From ba54c5a351f7ba7f6ffcc690be0e15d8e052d0d9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Feb 2026 23:52:21 -0500 Subject: [PATCH] refactor(subagents): share effective model selection resolver --- src/agents/model-selection.ts | 34 ++++++++++++++++++- ...subagents.sessions-spawn.model.e2e.test.ts | 34 +++++++++++++++++++ src/agents/subagent-spawn.ts | 11 ++---- src/gateway/sessions-patch.ts | 15 ++------ 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index ab7d9abbca..73286ad4f2 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentModelPrimary } from "./agent-scope.js"; +import { resolveAgentConfig, resolveAgentModelPrimary } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { normalizeGoogleModelId } from "./models-config.providers.js"; @@ -316,6 +316,38 @@ export function resolveDefaultModelForAgent(params: { }); } +export function resolveSubagentConfiguredModelSelection(params: { + cfg: OpenClawConfig; + agentId: string; +}): string | undefined { + const agentConfig = resolveAgentConfig(params.cfg, params.agentId); + return ( + normalizeModelSelection(agentConfig?.subagents?.model) ?? + normalizeModelSelection(params.cfg.agents?.defaults?.subagents?.model) ?? + normalizeModelSelection(agentConfig?.model) + ); +} + +export function resolveSubagentSpawnModelSelection(params: { + cfg: OpenClawConfig; + agentId: string; + modelOverride?: unknown; +}): string { + const runtimeDefault = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }); + return ( + normalizeModelSelection(params.modelOverride) ?? + resolveSubagentConfiguredModelSelection({ + cfg: params.cfg, + agentId: params.agentId, + }) ?? + normalizeModelSelection(params.cfg.agents?.defaults?.model?.primary) ?? + `${runtimeDefault.provider}/${runtimeDefault.model}` + ); +} + export function buildAllowedModelSet(params: { cfg: OpenClawConfig; catalog: ModelCatalogEntry[]; diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 61eb50c633..6427e7b6d4 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -255,6 +255,40 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { }); }); + it("sessions_spawn prefers target agent primary model over global default", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { model: { primary: "minimax/MiniMax-M2.1" } }, + list: [{ id: "research", model: { primary: "opencode/claude" } }], + }, + }); + const calls: GatewayCall[] = []; + mockPatchAndSingleAgentRun({ calls, runId: "run-agent-primary-model" }); + + const tool = await getSessionsSpawnTool({ + agentSessionKey: "agent:research:main", + agentChannel: "discord", + }); + + const result = await tool.execute("call-agent-primary-model", { + task: "do thing", + }); + expect(result.details).toMatchObject({ + status: "accepted", + modelApplied: true, + }); + + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); + expect(patchCall?.params).toMatchObject({ + model: "opencode/claude", + }); + }); + it("sessions_spawn fails when model patch is rejected", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index e4d1225780..00d9beaf88 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -6,7 +6,7 @@ import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.j import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; -import { normalizeModelSelection, resolveDefaultModelForAgent } from "./model-selection.js"; +import { resolveSubagentSpawnModelSelection } from "./model-selection.js"; import { buildSubagentSystemPrompt } from "./subagent-announce.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js"; @@ -149,16 +149,11 @@ export async function spawnSubagentDirect( const childDepth = callerDepth + 1; const spawnedByKey = requesterInternalKey; const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); - const runtimeDefaultModel = resolveDefaultModelForAgent({ + const resolvedModel = resolveSubagentSpawnModelSelection({ cfg, agentId: targetAgentId, + modelOverride, }); - const resolvedModel = - normalizeModelSelection(modelOverride) ?? - normalizeModelSelection(targetAgentConfig?.subagents?.model) ?? - normalizeModelSelection(cfg.agents?.defaults?.subagents?.model) ?? - normalizeModelSelection(cfg.agents?.defaults?.model?.primary) ?? - normalizeModelSelection(`${runtimeDefaultModel.provider}/${runtimeDefaultModel.model}`); const resolvedThinkingDefaultRaw = readStringParam(targetAgentConfig?.subagents ?? {}, "thinking") ?? diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 7690e6a66b..99e83a3bea 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -1,10 +1,10 @@ import { randomUUID } from "node:crypto"; -import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { - normalizeModelSelection, resolveAllowedModelRef, resolveDefaultModelForAgent, + resolveSubagentConfiguredModelSelection, } from "../agents/model-selection.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { @@ -62,15 +62,6 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } -function resolveSubagentModelHint(cfg: OpenClawConfig, agentId: string): string | undefined { - const agentConfig = resolveAgentConfig(cfg, agentId); - return ( - normalizeModelSelection(agentConfig?.subagents?.model) ?? - normalizeModelSelection(cfg.agents?.defaults?.subagents?.model) ?? - normalizeModelSelection(agentConfig?.model) - ); -} - export async function applySessionsPatchToStore(params: { cfg: OpenClawConfig; store: Record; @@ -84,7 +75,7 @@ export async function applySessionsPatchToStore(params: { const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId }); const subagentModelHint = isSubagentSessionKey(storeKey) - ? resolveSubagentModelHint(cfg, sessionAgentId) + ? resolveSubagentConfiguredModelSelection({ cfg, agentId: sessionAgentId }) : undefined; const existing = store[storeKey];