Skip to content

Commit e22a7e4

Browse files
committed
fix(cron): honor subagent model fallbacks
1 parent d0218d3 commit e22a7e4

15 files changed

Lines changed: 327 additions & 19 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414
### Fixes
1515

1616
- Release tooling: align the published launcher Node floor, `npm start`, package script checks, sharded lint locking, Vitest root project coverage, and plugin-SDK declaration build cache metadata so release/package validation does not silently skip or ship stale surfaces.
17+
- Cron/agents: honor configured subagent model fallbacks for isolated scheduled runs and forward that fallback policy into embedded agent timeout failover. Fixes #74985. Thanks @chrisgwynne.
1718
- Codex app-server/MCP: scope user MCP servers to specific OpenClaw agent ids through an optional `mcp.servers.<name>.codex.agents` list and accept `codex.defaultToolsApprovalMode` (`auto`/`prompt`/`approve`) for native Codex approval defaults; OpenClaw strips the `codex` block before handing `mcp_servers` config to Codex. (#82180) Thanks @sercada.
1819
- Agents/OpenAI Responses: clamp `input_tokens - cached_tokens` at zero and reconstruct `totalTokens` from input + output + cached components so Responses-API streams report consistent usage when providers under-report `input_tokens` relative to `cached_tokens`.
1920
- Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries.

src/agents/agent-scope.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ export function resolveAgentModelFallbacksOverride(
154154
agentId: string,
155155
): string[] | undefined {
156156
const raw = resolveAgentConfig(cfg, agentId)?.model;
157+
return resolveModelFallbacksOverride(raw);
158+
}
159+
160+
function resolveModelFallbacksOverride(raw: AgentModelConfig | undefined): string[] | undefined {
157161
if (!raw) {
158162
return undefined;
159163
}
@@ -167,6 +171,17 @@ export function resolveAgentModelFallbacksOverride(
167171
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
168172
}
169173

174+
export function resolveSubagentModelFallbacksOverride(
175+
cfg: OpenClawConfig,
176+
agentId: string,
177+
): string[] | undefined {
178+
const agentConfig = resolveAgentConfig(cfg, agentId);
179+
return (
180+
resolveModelFallbacksOverride(agentConfig?.subagents?.model) ??
181+
resolveModelFallbacksOverride(cfg.agents?.defaults?.subagents?.model)
182+
);
183+
}
184+
170185
export function resolveFallbackAgentId(params: {
171186
agentId?: string | null;
172187
sessionKey?: string | null;

src/agents/pi-embedded-runner/run.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { sanitizeForLog } from "../../terminal/ansi.js";
2222
import { resolveUserPath } from "../../utils.js";
2323
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
2424
import {
25-
hasConfiguredModelFallbacks,
2625
resolveAgentExecutionContract,
2726
resolveAgentDir,
2827
resolveSessionAgentIds,
@@ -120,6 +119,7 @@ import { resolveAuthProfileFailureReason } from "./run/auth-profile-failure-poli
120119
import { runEmbeddedAttemptWithBackend } from "./run/backend.js";
121120
import { createFailoverDecisionLogger } from "./run/failover-observation.js";
122121
import { mergeRetryFailoverReason, resolveRunFailoverDecision } from "./run/failover-policy.js";
122+
import { hasEmbeddedRunConfiguredModelFallbacks } from "./run/fallbacks.js";
123123
import {
124124
buildErrorAgentMeta,
125125
buildUsageAgentMetaFields,
@@ -479,10 +479,11 @@ export async function runEmbeddedPiAgent(
479479
const agentDir =
480480
params.agentDir ?? resolveAgentDir(params.config ?? {}, workspaceResolution.agentId);
481481
const normalizedSessionKey = params.sessionKey?.trim();
482-
const fallbackConfigured = hasConfiguredModelFallbacks({
482+
const fallbackConfigured = hasEmbeddedRunConfiguredModelFallbacks({
483483
cfg: params.config,
484484
agentId: params.agentId,
485485
sessionKey: normalizedSessionKey,
486+
modelFallbacksOverride: params.modelFallbacksOverride,
486487
});
487488
const resolvedSessionKey = normalizedSessionKey;
488489
const hookRunner = getGlobalHookRunner();
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
3+
import { hasEmbeddedRunConfiguredModelFallbacks } from "./fallbacks.js";
4+
5+
describe("hasEmbeddedRunConfiguredModelFallbacks", () => {
6+
it("uses explicit non-empty modelFallbacksOverride as configured", () => {
7+
expect(
8+
hasEmbeddedRunConfiguredModelFallbacks({
9+
cfg: {},
10+
modelFallbacksOverride: ["openai/gpt-5.4"],
11+
}),
12+
).toBe(true);
13+
});
14+
15+
it("treats explicit empty modelFallbacksOverride as disabling fallbacks", () => {
16+
const cfg: OpenClawConfig = {
17+
agents: {
18+
defaults: {
19+
model: {
20+
fallbacks: ["openai/gpt-5.4"],
21+
},
22+
},
23+
},
24+
};
25+
expect(
26+
hasEmbeddedRunConfiguredModelFallbacks({
27+
cfg,
28+
modelFallbacksOverride: [],
29+
}),
30+
).toBe(false);
31+
});
32+
33+
it("falls back to normal agent/default model fallback config when no override is provided", () => {
34+
const cfg: OpenClawConfig = {
35+
agents: {
36+
defaults: {
37+
model: {
38+
fallbacks: ["openai/gpt-5.4"],
39+
},
40+
},
41+
},
42+
};
43+
expect(hasEmbeddedRunConfiguredModelFallbacks({ cfg, agentId: "main" })).toBe(true);
44+
});
45+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
2+
import { hasConfiguredModelFallbacks } from "../../agent-scope.js";
3+
4+
export function hasEmbeddedRunConfiguredModelFallbacks(params: {
5+
cfg: OpenClawConfig | undefined;
6+
agentId?: string | null;
7+
sessionKey?: string | null;
8+
modelFallbacksOverride?: string[];
9+
}): boolean {
10+
if (params.modelFallbacksOverride !== undefined) {
11+
return params.modelFallbacksOverride.length > 0;
12+
}
13+
return hasConfiguredModelFallbacks({
14+
cfg: params.cfg,
15+
agentId: params.agentId,
16+
sessionKey: params.sessionKey,
17+
});
18+
}

src/cron/isolated-agent.model-formatting.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ async function expectSelectedModel(
136136
expected: { provider: string; model: string },
137137
) {
138138
const result = await selectModel(options);
139-
expect(result).toEqual({ ok: true, ...expected });
139+
expect(result).toMatchObject({ ok: true, ...expected });
140140
}
141141

142142
async function expectDefaultSelectedModel(options: SelectModelOptions = {}) {

src/cron/isolated-agent/model-selection.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type CronSessionModelOverrides = {
1616
providerOverride?: string;
1717
};
1818

19+
type CronModelSelectionSource = "default" | "subagent" | "agent" | "hook" | "payload" | "session";
20+
1921
export type ResolveCronModelSelectionParams = {
2022
cfg: OpenClawConfig;
2123
cfgWithAgentDefaults: OpenClawConfig;
@@ -36,6 +38,7 @@ export type ResolveCronModelSelectionResult =
3638
ok: true;
3739
provider: string;
3840
model: string;
41+
modelSource: CronModelSelectionSource;
3942
}
4043
| {
4144
ok: false;
@@ -73,6 +76,7 @@ export async function resolveCronModelSelection(
7376
});
7477
let provider = resolvedDefault.provider;
7578
let model = resolvedDefault.model;
79+
let modelSource: CronModelSelectionSource = "default";
7680

7781
let catalog: Awaited<ReturnType<typeof loadModelCatalog>> | undefined;
7882
const loadCatalogOnce = async () => {
@@ -82,10 +86,14 @@ export async function resolveCronModelSelection(
8286
return catalog;
8387
};
8488

85-
const subagentModelRaw =
86-
normalizeModelSelection(params.agentConfigOverride?.subagents?.model) ??
87-
normalizeModelSelection(params.agentConfigOverride?.model) ??
88-
normalizeModelSelection(params.cfg.agents?.defaults?.subagents?.model);
89+
const agentSubagentModel = normalizeModelSelection(params.agentConfigOverride?.subagents?.model);
90+
const agentModel = normalizeModelSelection(params.agentConfigOverride?.model);
91+
const defaultSubagentModel = normalizeModelSelection(
92+
params.cfg.agents?.defaults?.subagents?.model,
93+
);
94+
const subagentModelRaw = agentSubagentModel ?? agentModel ?? defaultSubagentModel;
95+
const subagentModelSource: CronModelSelectionSource =
96+
agentSubagentModel !== undefined ? "subagent" : agentModel !== undefined ? "agent" : "subagent";
8997
if (subagentModelRaw) {
9098
const resolvedSubagent = resolveAllowedModelRef({
9199
cfg: params.cfgWithAgentDefaults,
@@ -97,6 +105,7 @@ export async function resolveCronModelSelection(
97105
if (!("error" in resolvedSubagent)) {
98106
provider = resolvedSubagent.ref.provider;
99107
model = resolvedSubagent.ref.model;
108+
modelSource = subagentModelSource;
100109
}
101110
}
102111

@@ -119,6 +128,7 @@ export async function resolveCronModelSelection(
119128
provider = hooksGmailModelRef.provider;
120129
model = hooksGmailModelRef.model;
121130
hooksGmailModelApplied = true;
131+
modelSource = "hook";
122132
}
123133
}
124134

@@ -144,6 +154,7 @@ export async function resolveCronModelSelection(
144154
}
145155
provider = resolvedOverride.ref.provider;
146156
model = resolvedOverride.ref.model;
157+
modelSource = "payload";
147158
}
148159

149160
if (!modelOverride && !hooksGmailModelApplied) {
@@ -161,9 +172,10 @@ export async function resolveCronModelSelection(
161172
if (!("error" in resolvedSessionOverride)) {
162173
provider = resolvedSessionOverride.ref.provider;
163174
model = resolvedSessionOverride.ref.model;
175+
modelSource = "session";
164176
}
165177
}
166178
}
167179

168-
return { ok: true, provider, model };
180+
return { ok: true, provider, model, modelSource };
169181
}

src/cron/isolated-agent/run-execution.runtime.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
export { resolveEffectiveModelFallbacks } from "../../agents/agent-scope.js";
1+
export {
2+
resolveEffectiveModelFallbacks,
3+
resolveSubagentModelFallbacksOverride,
4+
} from "../../agents/agent-scope.js";
25
export { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
36
export { resolveCronAgentLane } from "../../agents/lanes.js";
47
export { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";

src/cron/isolated-agent/run-executor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export function createCronPromptExecutor(params: {
123123
};
124124
skillsSnapshot: SkillSnapshot;
125125
agentPayload: AgentTurnPayload;
126+
useSubagentFallbacks: boolean;
126127
liveSelection: CronLiveSelection;
127128
cronSession: MutableCronSession;
128129
abortSignal?: AbortSignal;
@@ -144,6 +145,7 @@ export function createCronPromptExecutor(params: {
144145
cfg: params.cfg,
145146
job: params.job,
146147
agentId: params.agentId,
148+
useSubagentFallbacks: params.useSubagentFallbacks,
147149
});
148150
let runResult: CronPromptRunResult | undefined;
149151
let fallbackProvider = params.liveSelection.provider;
@@ -246,6 +248,7 @@ export function createCronPromptExecutor(params: {
246248
lane: resolveCronAgentLane(params.lane),
247249
provider: providerOverride,
248250
model: modelOverride,
251+
modelFallbacksOverride: cronFallbacksOverride,
249252
authProfileId: params.liveSelection.authProfileId,
250253
authProfileIdSource: params.liveSelection.authProfileId
251254
? params.liveSelection.authProfileIdSource
@@ -330,6 +333,7 @@ export async function executeCronRun(params: {
330333
};
331334
skillsSnapshot: SkillSnapshot;
332335
agentPayload: AgentTurnPayload;
336+
useSubagentFallbacks: boolean;
333337
agentVerboseDefault: AgentDefaultsConfig["verboseDefault"];
334338
liveSelection: CronLiveSelection;
335339
cronSession: MutableCronSession;
@@ -379,6 +383,7 @@ export async function executeCronRun(params: {
379383
toolPolicy: params.toolPolicy,
380384
skillsSnapshot: params.skillsSnapshot,
381385
agentPayload: params.agentPayload,
386+
useSubagentFallbacks: params.useSubagentFallbacks,
382387
liveSelection: params.liveSelection,
383388
cronSession: params.cronSession,
384389
abortSignal: params.abortSignal,

src/cron/isolated-agent/run-fallback-policy.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,125 @@ describe("resolveCronFallbacksOverride", () => {
7171
).toStrictEqual([]);
7272
});
7373

74+
it("uses subagent model fallbacks when cron selects the configured subagent model", () => {
75+
expect(
76+
resolveCronFallbacksOverride({
77+
cfg: {
78+
agents: {
79+
defaults: {
80+
model: {
81+
primary: "anthropic/claude-opus-4-6",
82+
fallbacks: ["openai/gpt-5.4"],
83+
},
84+
subagents: {
85+
model: {
86+
primary: "kimi/kimi-code",
87+
fallbacks: ["openai-codex/gpt-5.2", "zai/glm-5"],
88+
},
89+
},
90+
},
91+
},
92+
},
93+
agentId: "main",
94+
useSubagentFallbacks: true,
95+
job: makeJob({
96+
kind: "agentTurn",
97+
message: "summarize",
98+
}),
99+
}),
100+
).toEqual(["openai-codex/gpt-5.2", "zai/glm-5"]);
101+
});
102+
103+
it("keeps default subagent fallbacks ahead of the agent primary fallback policy", () => {
104+
expect(
105+
resolveCronFallbacksOverride({
106+
cfg: {
107+
agents: {
108+
defaults: {
109+
subagents: {
110+
model: {
111+
primary: "kimi/kimi-code",
112+
fallbacks: ["openai-codex/gpt-5.2"],
113+
},
114+
},
115+
},
116+
list: [
117+
{
118+
id: "research",
119+
model: {
120+
primary: "anthropic/claude-opus-4-6",
121+
},
122+
},
123+
],
124+
},
125+
},
126+
agentId: "research",
127+
useSubagentFallbacks: true,
128+
job: makeJob({
129+
kind: "agentTurn",
130+
message: "summarize",
131+
}),
132+
}),
133+
).toEqual(["openai-codex/gpt-5.2"]);
134+
});
135+
136+
it("keeps explicit empty subagent fallbacks as a fallback override", () => {
137+
expect(
138+
resolveCronFallbacksOverride({
139+
cfg: {
140+
agents: {
141+
defaults: {
142+
model: {
143+
primary: "anthropic/claude-opus-4-6",
144+
fallbacks: ["openai/gpt-5.4"],
145+
},
146+
subagents: {
147+
model: {
148+
primary: "kimi/kimi-code",
149+
fallbacks: [],
150+
},
151+
},
152+
},
153+
},
154+
},
155+
agentId: "main",
156+
useSubagentFallbacks: true,
157+
job: makeJob({
158+
kind: "agentTurn",
159+
message: "summarize",
160+
}),
161+
}),
162+
).toStrictEqual([]);
163+
});
164+
165+
it("ignores subagent fallbacks when cron did not select the subagent model", () => {
166+
expect(
167+
resolveCronFallbacksOverride({
168+
cfg: {
169+
agents: {
170+
defaults: {
171+
model: {
172+
primary: "anthropic/claude-opus-4-6",
173+
},
174+
subagents: {
175+
model: {
176+
primary: "kimi/kimi-code",
177+
fallbacks: ["openai-codex/gpt-5.2"],
178+
},
179+
},
180+
},
181+
},
182+
},
183+
agentId: "main",
184+
useSubagentFallbacks: false,
185+
job: makeJob({
186+
kind: "agentTurn",
187+
message: "summarize",
188+
}),
189+
}),
190+
).toBeUndefined();
191+
});
192+
74193
it("leaves the default model path to the fallback runner when no payload model is set", () => {
75194
expect(
76195
resolveCronFallbacksOverride({

0 commit comments

Comments
 (0)