Skip to content

Commit 0ad3d25

Browse files
authored
refactor(agents): share subagent cron fallback selection (#82328)
* fix(agents): honor subagent cron fallbacks * refactor(agents): share cron subagent model selection * test(agents): align subagent fallback policy expectation * fix(agents): keep subagent fallbacks on selected model * fix(agents): preserve subagent fallback-only overrides
1 parent b7e8f6d commit 0ad3d25

8 files changed

Lines changed: 351 additions & 29 deletions

src/agents/agent-scope.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
resolveAgentModelFallbacksOverride,
1717
resolveAgentModelPrimary,
1818
resolveRunModelFallbacksOverride,
19+
resolveSubagentModelConfigSelection,
20+
resolveSubagentModelFallbacksOverride,
1921
resolveAgentWorkspaceDir,
2022
resolveAgentIdByWorkspacePath,
2123
resolveAgentIdsByWorkspacePath,
@@ -514,6 +516,150 @@ describe("resolveAgentConfig", () => {
514516
).toBe(false);
515517
});
516518

519+
it("resolves subagent model fallbacks from the selected subagent model source", () => {
520+
const cfg: OpenClawConfig = {
521+
agents: {
522+
defaults: {
523+
model: {
524+
primary: "anthropic/claude-opus-4-6",
525+
fallbacks: ["openai/gpt-5.4"],
526+
},
527+
subagents: {
528+
model: {
529+
primary: "kimi/kimi-code",
530+
fallbacks: ["openai-codex/gpt-5.4", "zai/glm-5"],
531+
},
532+
},
533+
},
534+
list: [
535+
{
536+
id: "research",
537+
subagents: {
538+
model: {
539+
primary: "kimi/kimi-code",
540+
fallbacks: ["openai-codex/gpt-5.4", "zai/glm-5"],
541+
},
542+
},
543+
},
544+
{
545+
id: "agent-model",
546+
model: {
547+
primary: "anthropic/claude-sonnet-4-6",
548+
fallbacks: ["google/gemini-3-pro"],
549+
},
550+
},
551+
{
552+
id: "metadata-only-subagent",
553+
model: {
554+
primary: "anthropic/claude-sonnet-4-6",
555+
fallbacks: ["google/gemini-3-pro"],
556+
},
557+
subagents: {
558+
model: { timeoutMs: 1_000 },
559+
},
560+
},
561+
{
562+
id: "fallback-only-agent-model",
563+
model: {
564+
fallbacks: ["google/gemini-3-pro"],
565+
},
566+
},
567+
{
568+
id: "fallback-only-subagent-model",
569+
subagents: {
570+
model: {
571+
fallbacks: [],
572+
},
573+
},
574+
},
575+
{
576+
id: "default-subagent",
577+
},
578+
{
579+
id: "strict",
580+
subagents: {
581+
model: "kimi/kimi-code",
582+
},
583+
},
584+
],
585+
},
586+
};
587+
588+
expect(resolveSubagentModelFallbacksOverride(cfg, "research")).toEqual([
589+
"openai-codex/gpt-5.4",
590+
"zai/glm-5",
591+
]);
592+
expect(resolveSubagentModelFallbacksOverride(cfg, "agent-model")).toEqual([
593+
"google/gemini-3-pro",
594+
]);
595+
expect(resolveSubagentModelFallbacksOverride(cfg, "metadata-only-subagent")).toEqual([
596+
"google/gemini-3-pro",
597+
]);
598+
expect(resolveSubagentModelFallbacksOverride(cfg, "fallback-only-agent-model")).toEqual([
599+
"openai-codex/gpt-5.4",
600+
"zai/glm-5",
601+
]);
602+
expect(
603+
resolveSubagentModelFallbacksOverride(cfg, "fallback-only-subagent-model"),
604+
).toStrictEqual([]);
605+
expect(resolveSubagentModelFallbacksOverride(cfg, "default-subagent")).toEqual([
606+
"openai-codex/gpt-5.4",
607+
"zai/glm-5",
608+
]);
609+
expect(resolveSubagentModelFallbacksOverride(cfg, "strict")).toStrictEqual([]);
610+
});
611+
612+
it("resolves the subagent model config selected for isolated runs", () => {
613+
const cfg: OpenClawConfig = {
614+
agents: {
615+
defaults: {
616+
subagents: { model: "openai/gpt-5.4" },
617+
},
618+
list: [
619+
{
620+
id: "agent-model",
621+
model: {
622+
primary: "anthropic/claude-sonnet-4-6",
623+
fallbacks: ["google/gemini-3-pro"],
624+
},
625+
},
626+
{
627+
id: "subagent-model",
628+
model: "anthropic/claude-sonnet-4-6",
629+
subagents: {
630+
model: {
631+
primary: "kimi/kimi-code",
632+
fallbacks: ["openai-codex/gpt-5.4"],
633+
},
634+
},
635+
},
636+
{
637+
id: "metadata-only-subagent",
638+
model: "anthropic/claude-sonnet-4-6",
639+
subagents: {
640+
model: { timeoutMs: 1_000 },
641+
},
642+
},
643+
],
644+
},
645+
};
646+
647+
expect(resolveSubagentModelConfigSelection({ cfg, agentId: "agent-model" })).toEqual({
648+
primary: "anthropic/claude-sonnet-4-6",
649+
fallbacks: ["google/gemini-3-pro"],
650+
});
651+
expect(resolveSubagentModelConfigSelection({ cfg, agentId: "subagent-model" })).toEqual({
652+
primary: "kimi/kimi-code",
653+
fallbacks: ["openai-codex/gpt-5.4"],
654+
});
655+
expect(resolveSubagentModelConfigSelection({ cfg, agentId: "metadata-only-subagent" })).toBe(
656+
"anthropic/claude-sonnet-4-6",
657+
);
658+
expect(resolveSubagentModelConfigSelection({ cfg, agentId: "default-subagent" })).toBe(
659+
"openai/gpt-5.4",
660+
);
661+
});
662+
517663
it("should return agent-specific sandbox config", () => {
518664
const cfg = {
519665
agents: {

src/agents/agent-scope.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,12 @@ export function resolveAgentModelFallbacksOverride(
153153
cfg: OpenClawConfig,
154154
agentId: string,
155155
): string[] | undefined {
156-
const raw = resolveAgentConfig(cfg, agentId)?.model;
157-
return resolveModelFallbacksOverride(raw);
156+
return resolveSelectedModelFallbacksOverride(resolveAgentConfig(cfg, agentId)?.model);
158157
}
159158

160-
function resolveModelFallbacksOverride(raw: AgentModelConfig | undefined): string[] | undefined {
159+
function resolveSelectedModelFallbacksOverride(
160+
raw: AgentModelConfig | undefined,
161+
): string[] | undefined {
161162
if (!raw) {
162163
return undefined;
163164
}
@@ -171,15 +172,63 @@ function resolveModelFallbacksOverride(raw: AgentModelConfig | undefined): strin
171172
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
172173
}
173174

175+
export type SubagentModelConfigSelectionSource = "subagent" | "agent" | "default-subagent";
176+
177+
export type SubagentModelConfigSelectionResult = {
178+
raw: AgentModelConfig;
179+
source: SubagentModelConfigSelectionSource;
180+
};
181+
182+
export function resolveSubagentModelConfigSelectionResult(params: {
183+
cfg: OpenClawConfig;
184+
agentId?: string;
185+
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
186+
}): SubagentModelConfigSelectionResult | undefined {
187+
const agentConfig =
188+
params.agentConfigOverride ??
189+
(params.agentId ? resolveAgentConfig(params.cfg, params.agentId) : undefined);
190+
const candidates: SubagentModelConfigSelectionResult[] = [
191+
...(agentConfig?.subagents?.model
192+
? [{ raw: agentConfig.subagents.model, source: "subagent" as const }]
193+
: []),
194+
...(agentConfig?.model ? [{ raw: agentConfig.model, source: "agent" as const }] : []),
195+
...(params.cfg.agents?.defaults?.subagents?.model
196+
? [
197+
{
198+
raw: params.cfg.agents.defaults.subagents.model,
199+
source: "default-subagent" as const,
200+
},
201+
]
202+
: []),
203+
];
204+
return candidates.find((candidate) => resolvePrimaryStringValue(candidate.raw));
205+
}
206+
207+
export function resolveSubagentModelConfigSelection(params: {
208+
cfg: OpenClawConfig;
209+
agentId?: string;
210+
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
211+
}): AgentModelConfig | undefined {
212+
return resolveSubagentModelConfigSelectionResult(params)?.raw;
213+
}
214+
174215
export function resolveSubagentModelFallbacksOverride(
175216
cfg: OpenClawConfig,
176217
agentId: string,
177218
): string[] | undefined {
178219
const agentConfig = resolveAgentConfig(cfg, agentId);
179-
return (
180-
resolveModelFallbacksOverride(agentConfig?.subagents?.model) ??
181-
resolveModelFallbacksOverride(cfg.agents?.defaults?.subagents?.model)
182-
);
220+
const subagentFallbacks = resolveSelectedModelFallbacksOverride(agentConfig?.subagents?.model);
221+
if (subagentFallbacks !== undefined) {
222+
return subagentFallbacks;
223+
}
224+
const selection = resolveSubagentModelConfigSelectionResult({ cfg, agentId });
225+
if (selection?.source === "agent") {
226+
return resolveSelectedModelFallbacksOverride(agentConfig?.model);
227+
}
228+
if (selection?.source === "default-subagent") {
229+
return resolveSelectedModelFallbacksOverride(cfg.agents?.defaults?.subagents?.model);
230+
}
231+
return undefined;
183232
}
184233

185234
export function resolveFallbackAgentId(params: {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ vi.mock("./isolated-agent/run-model-selection.runtime.js", () => ({
3939
resolveAllowedModelRef: resolveAllowedModelRefMock,
4040
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
4141
resolveHooksGmailModel: resolveHooksGmailModelMock,
42+
resolveSubagentModelConfigSelectionResult: ({
43+
cfg,
44+
agentConfigOverride,
45+
}: {
46+
cfg?: { agents?: { defaults?: { subagents?: { model?: unknown } } } };
47+
agentConfigOverride?: { model?: unknown; subagents?: { model?: unknown } };
48+
}) => {
49+
for (const candidate of [
50+
{ raw: agentConfigOverride?.subagents?.model, source: "subagent" as const },
51+
{ raw: agentConfigOverride?.model, source: "agent" as const },
52+
{ raw: cfg?.agents?.defaults?.subagents?.model, source: "default-subagent" as const },
53+
]) {
54+
if (normalizeModelSelectionMock(candidate.raw)) {
55+
return candidate;
56+
}
57+
}
58+
return undefined;
59+
},
4260
}));
4361

4462
import { resolveCronModelSelection } from "./isolated-agent/model-selection.js";
@@ -615,6 +633,26 @@ describe("cron model formatting and precedence edge cases", () => {
615633
);
616634
});
617635

636+
it("falls through metadata-only subagents.model to the agent model", async () => {
637+
await expectSelectedModel(
638+
{
639+
cfg: {
640+
agents: {
641+
defaults: {
642+
model: "anthropic/claude-sonnet-4-6",
643+
subagents: { model: "ollama/llama3.2:3b" },
644+
},
645+
},
646+
},
647+
agentConfigOverride: {
648+
model: { primary: "anthropic/claude-opus-4-6" },
649+
subagents: { model: { timeoutMs: 1_000 } },
650+
},
651+
},
652+
{ provider: "anthropic", model: "claude-opus-4-6" },
653+
);
654+
});
655+
618656
it("job payload model override takes precedence over subagents.model", async () => {
619657
await expectSelectedModel(
620658
{

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AgentConfig } from "../../config/types.agents.js";
12
import type { OpenClawConfig } from "../../config/types.openclaw.js";
23
import type { CronJob } from "../types.js";
34
import {
@@ -9,6 +10,7 @@ import {
910
resolveAllowedModelRef,
1011
resolveConfiguredModelRef,
1112
resolveHooksGmailModel,
13+
resolveSubagentModelConfigSelectionResult,
1214
} from "./run-model-selection.runtime.js";
1315

1416
type CronSessionModelOverrides = {
@@ -21,12 +23,7 @@ type CronModelSelectionSource = "default" | "subagent" | "agent" | "hook" | "pay
2123
export type ResolveCronModelSelectionParams = {
2224
cfg: OpenClawConfig;
2325
cfgWithAgentDefaults: OpenClawConfig;
24-
agentConfigOverride?: {
25-
model?: unknown;
26-
subagents?: {
27-
model?: unknown;
28-
};
29-
};
26+
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
3027
sessionEntry: CronSessionModelOverrides;
3128
payload: CronJob["payload"];
3229
isGmailHook: boolean;
@@ -86,14 +83,14 @@ export async function resolveCronModelSelection(
8683
return catalog;
8784
};
8885

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;
86+
const subagentModelConfigSelection = resolveSubagentModelConfigSelectionResult({
87+
cfg: params.cfg,
88+
agentId: params.agentId,
89+
agentConfigOverride: params.agentConfigOverride,
90+
});
91+
const subagentModelRaw = normalizeModelSelection(subagentModelConfigSelection?.raw);
9592
const subagentModelSource: CronModelSelectionSource =
96-
agentSubagentModel !== undefined ? "subagent" : agentModel !== undefined ? "agent" : "subagent";
93+
subagentModelConfigSelection?.source === "agent" ? "agent" : "subagent";
9794
if (subagentModelRaw) {
9895
const resolvedSubagent = resolveAllowedModelRef({
9996
cfg: params.cfgWithAgentDefaults,

0 commit comments

Comments
 (0)