diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ebbde8f1..6036ca9b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Tests/Telegram: add regression coverage for command-menu sync that asserts all `setMyCommands` entries are Telegram-safe and hyphen-normalized across native/custom/plugin command sources. (#19703) Thanks @obviyus. - Agents/Image: collapse resize diagnostics to one line per image and include visible pixel/byte size details in the log message for faster triage. - Agents/Subagents: preemptively guard accumulated tool-result context before model calls by truncating oversized outputs and compacting oldest tool-result messages to avoid context-window overflow crashes. Thanks @tyler6204. +- Agents/Subagents/CLI: fail `sessions_spawn` when subagent model patching is rejected, allow subagent model patch defaults from `subagents.model`, and keep `sessions list`/`status` model reporting aligned to runtime model resolution. (#18660) Thanks @robbyczgw-cla. - Agents/Subagents: add explicit subagent guidance to recover from `[compacted: tool output removed to free context]` / `[truncated: output exceeded context limit]` markers by re-reading with smaller chunks instead of full-file `cat`. Thanks @tyler6204. - Agents/Tools: make `read` auto-page across chunks (when no explicit `limit` is provided) and scale its per-call output budget from model `contextWindow`, so larger contexts can read more before context guards kick in. Thanks @tyler6204. - Agents/Tools: strip duplicated `read` truncation payloads from tool-result `details` and make pre-call context guarding account for heavy tool-result metadata, so repeated `read` calls no longer bypass compaction and overflow model context windows. Thanks @tyler6204. diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 1912d6048a..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"; @@ -146,6 +146,22 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef | return normalizeModelRef(providerRaw, model); } +export function normalizeModelSelection(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (!value || typeof value !== "object") { + return undefined; + } + const primary = (value as { primary?: unknown }).primary; + if (typeof primary === "string") { + const trimmed = primary.trim(); + return trimmed || undefined; + } + return undefined; +} + export function resolveAllowlistModelKey(raw: string, defaultProvider: string): string | null { const parsed = parseModelRef(raw, defaultProvider); if (!parsed) { @@ -300,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[]; @@ -316,10 +364,11 @@ export function buildAllowedModelSet(params: { })(); const allowAny = rawAllowlist.length === 0; const defaultModel = params.defaultModel?.trim(); - const defaultKey = + const defaultRef = defaultModel && params.defaultProvider - ? modelKey(params.defaultProvider, defaultModel) - : undefined; + ? parseModelRef(defaultModel, params.defaultProvider) + : null; + const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; const catalogKeys = new Set(params.catalog.map((entry) => modelKey(entry.provider, entry.id))); if (allowAny) { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts index c541e03161..0cb5b62c83 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -238,4 +238,37 @@ describe("sessions_spawn depth + child limits", () => { runId: "run-depth", }); }); + + it("fails spawn when sessions.patch rejects the model", async () => { + setSubagentLimits({ maxSpawnDepth: 2 }); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string; params?: { model?: string } }; + if (req.method === "sessions.patch" && req.params?.model === "bad-model") { + throw new Error("invalid model: bad-model"); + } + if (req.method === "agent") { + return { runId: "run-depth" }; + } + if (req.method === "agent.wait") { + return { status: "running" }; + } + return {}; + }); + + const tool = createSessionsSpawnTool({ agentSessionKey: "main" }); + const result = await tool.execute("call-model-reject", { + task: "hello", + model: "bad-model", + }); + + expect(result.details).toMatchObject({ + status: "error", + }); + expect(String((result.details as { error?: string }).error ?? "")).toContain("invalid model"); + expect( + callGatewayMock.mock.calls.some( + (call) => (call[0] as { method?: string }).method === "agent", + ), + ).toBe(false); + }); }); 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 579f72f1c7..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 @@ -8,6 +8,7 @@ import { setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +import { SUBAGENT_SPAWN_ACCEPTED_NOTE } from "./subagent-spawn.js"; const callGatewayMock = getCallGatewayMock(); type GatewayCall = { method?: string; params?: unknown }; @@ -83,7 +84,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { }); expect(result.details).toMatchObject({ status: "accepted", - note: "auto-announces on completion, do not poll/sleep. The response will be sent back as an agent message.", + note: SUBAGENT_SPAWN_ACCEPTED_NOTE, modelApplied: true, }); @@ -254,7 +255,41 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { }); }); - it("sessions_spawn skips invalid model overrides and continues", async () => { + 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(); const calls: GatewayCall[] = []; @@ -281,13 +316,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { model: "bad-model", }); expect(result.details).toMatchObject({ - status: "accepted", - modelApplied: false, + status: "error", }); - expect(String((result.details as { warning?: string }).warning ?? "")).toContain( - "invalid model", - ); - expect(calls.some((call) => call.method === "agent")).toBe(true); + expect(String((result.details as { error?: string }).error ?? "")).toContain("invalid model"); + expect(calls.some((call) => call.method === "agent")).toBe(false); }); it("sessions_spawn supports legacy timeoutSeconds alias", async () => { diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index a81592a0dc..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 { 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"; @@ -49,7 +49,6 @@ export type SpawnSubagentResult = { runId?: string; note?: string; modelApplied?: boolean; - warning?: string; error?: string; }; @@ -68,21 +67,6 @@ export function splitModelRef(ref?: string) { return { provider: undefined, model: trimmed }; } -export function normalizeModelSelection(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (!value || typeof value !== "object") { - return undefined; - } - const primary = (value as { primary?: unknown }).primary; - if (typeof primary === "string" && primary.trim()) { - return primary.trim(); - } - return undefined; -} - export async function spawnSubagentDirect( params: SpawnSubagentParams, ctx: SpawnSubagentContext, @@ -104,7 +88,6 @@ export async function spawnSubagentDirect( typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) : 0; - let modelWarning: string | undefined; let modelApplied = false; const cfg = loadConfig(); @@ -166,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") ?? @@ -222,16 +200,11 @@ export async function spawnSubagentDirect( } catch (err) { const messageText = err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - const recoverable = - messageText.includes("invalid model") || messageText.includes("model not allowed"); - if (!recoverable) { - return { - status: "error", - error: messageText, - childSessionKey, - }; - } - modelWarning = messageText; + return { + status: "error", + error: messageText, + childSessionKey, + }; } } if (thinkingOverride !== undefined) { @@ -328,6 +301,5 @@ export async function spawnSubagentDirect( runId: childRunId, note: SUBAGENT_SPAWN_ACCEPTED_NOTE, modelApplied: resolvedModel ? modelApplied : undefined, - warning: modelWarning, }; } diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index fdc043c4eb..25cc41bdff 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -707,7 +707,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo return { shouldContinue: false, reply: { - text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).${result.warning ? ` Warning: ${result.warning}` : ""}`, + text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`, }, }; } diff --git a/src/commands/sessions.model-resolution.test.ts b/src/commands/sessions.model-resolution.test.ts new file mode 100644 index 0000000000..41bb354688 --- /dev/null +++ b/src/commands/sessions.model-resolution.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + model: { primary: "pi:opus" }, + models: { "pi:opus": {} }, + contextTokens: 32000, + }, + }, + }), + }; +}); + +import { sessionsCommand } from "./sessions.js"; + +const makeRuntime = () => { + const logs: string[] = []; + return { + runtime: { + log: (msg: unknown) => logs.push(String(msg)), + error: (msg: unknown) => { + throw new Error(String(msg)); + }, + exit: (code: number) => { + throw new Error(`exit ${code}`); + }, + }, + logs, + } as const; +}; + +const writeStore = (data: unknown) => { + const file = path.join( + os.tmpdir(), + `sessions-model-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(file, JSON.stringify(data, null, 2)); + return file; +}; + +describe("sessionsCommand model resolution", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-06T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("prefers runtime model fields for subagent sessions in JSON output", async () => { + const store = writeStore({ + "agent:research:subagent:demo": { + sessionId: "subagent-1", + updatedAt: Date.now() - 2 * 60_000, + modelProvider: "openai-codex", + model: "gpt-5.3-codex", + modelOverride: "pi:opus", + }, + }); + + const { runtime, logs } = makeRuntime(); + await sessionsCommand({ store, json: true }, runtime); + + fs.rmSync(store); + + const payload = JSON.parse(logs[0] ?? "{}") as { + sessions?: Array<{ + key: string; + model?: string | null; + }>; + }; + const subagent = payload.sessions?.find((row) => row.key === "agent:research:subagent:demo"); + expect(subagent?.model).toBe("gpt-5.3-codex"); + }); + + it("falls back to modelOverride when runtime model is missing", async () => { + const store = writeStore({ + "agent:research:subagent:demo": { + sessionId: "subagent-2", + updatedAt: Date.now() - 2 * 60_000, + modelOverride: "openai-codex/gpt-5.3-codex", + }, + }); + + const { runtime, logs } = makeRuntime(); + await sessionsCommand({ store, json: true }, runtime); + + fs.rmSync(store); + + const payload = JSON.parse(logs[0] ?? "{}") as { + sessions?: Array<{ + key: string; + model?: string | null; + }>; + }; + const subagent = payload.sessions?.find((row) => row.key === "agent:research:subagent:demo"); + expect(subagent?.model).toBe("gpt-5.3-codex"); + }); +}); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index a75d8ec711..0a0934b82b 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -8,9 +8,10 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { classifySessionKey } from "../gateway/session-utils.js"; +import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; import { info } from "../globals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -33,6 +34,9 @@ type SessionRow = { totalTokens?: number; totalTokensFresh?: boolean; model?: string; + modelProvider?: string; + providerOverride?: string; + modelOverride?: string; contextTokens?: number; }; @@ -153,6 +157,9 @@ function toRows(store: Record): SessionRow[] { totalTokens: entry?.totalTokens, totalTokensFresh: entry?.totalTokensFresh, model: entry?.model, + modelProvider: entry?.modelProvider, + providerOverride: entry?.providerOverride, + modelOverride: entry?.modelOverride, contextTokens: entry?.contextTokens, } satisfies SessionRow; }) @@ -205,15 +212,23 @@ export async function sessionsCommand( path: storePath, count: rows.length, activeMinutes: activeMinutes ?? null, - sessions: rows.map((r) => ({ - ...r, - totalTokens: resolveFreshSessionTotalTokens(r) ?? null, - totalTokensFresh: - typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false, - contextTokens: - r.contextTokens ?? lookupContextTokens(r.model) ?? configContextTokens ?? null, - model: r.model ?? configModel ?? null, - })), + sessions: rows.map((r) => { + const resolvedModel = resolveSessionModelRef( + cfg, + r, + parseAgentSessionKey(r.key)?.agentId, + ); + const model = resolvedModel.model ?? configModel; + return { + ...r, + totalTokens: resolveFreshSessionTotalTokens(r) ?? null, + totalTokensFresh: + typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false, + contextTokens: + r.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null, + model, + }; + }), }, null, 2, @@ -245,7 +260,8 @@ export async function sessionsCommand( runtime.log(rich ? theme.heading(header) : header); for (const row of rows) { - const model = row.model ?? configModel; + const resolvedModel = resolveSessionModelRef(cfg, row, parseAgentSessionKey(row.key)?.agentId); + const model = resolvedModel.model ?? configModel; const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens; const total = resolveFreshSessionTotalTokens(row); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 6cee0d7de8..cf27cca633 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -9,7 +9,11 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { classifySessionKey, listAgentsForGateway } from "../gateway/session-utils.js"; +import { + classifySessionKey, + listAgentsForGateway, + resolveSessionModelRef, +} from "../gateway/session-utils.js"; import { buildChannelSummary } from "../infra/channel-summary.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; import { peekSystemEvents } from "../infra/system-events.js"; @@ -125,7 +129,8 @@ export async function getStatusSummary( .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; const age = updatedAt ? now - updatedAt : null; - const model = entry?.model ?? configModel ?? null; + const resolvedModel = resolveSessionModelRef(cfg, entry, opts.agentIdOverride); + const model = resolvedModel.model ?? configModel ?? null; const contextTokens = entry?.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null; const total = resolveFreshSessionTotalTokens(entry); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index e57ea027a3..4da01bdb8b 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -12,6 +12,7 @@ import { parseGroupKey, pruneLegacyStoreKeys, resolveGatewaySessionStoreTarget, + resolveSessionModelRef, resolveSessionStoreKey, } from "./session-utils.js"; @@ -218,6 +219,47 @@ describe("gateway session utils", () => { }); }); +describe("resolveSessionModelRef", () => { + test("prefers runtime model/provider from session entry", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "s1", + updatedAt: Date.now(), + modelProvider: "openai-codex", + model: "gpt-5.3-codex", + modelOverride: "claude-opus-4-6", + providerOverride: "anthropic", + }); + + expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + }); + + test("falls back to override when runtime model is not recorded yet", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "s2", + updatedAt: Date.now(), + modelOverride: "openai-codex/gpt-5.3-codex", + }); + + expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + }); +}); + describe("deriveSessionTitle", () => { test("returns undefined for undefined entry", () => { expect(deriveSessionTitle(undefined)).toBeUndefined(); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index d0198fc26c..3180b65ad6 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { + parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, } from "../agents/model-selection.js"; @@ -643,7 +644,9 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults export function resolveSessionModelRef( cfg: OpenClawConfig, - entry?: SessionEntry, + entry?: + | SessionEntry + | Pick, agentId?: string, ): { provider: string; model: string } { const resolved = agentId @@ -653,12 +656,41 @@ export function resolveSessionModelRef( defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); + + // Prefer the last runtime model recorded on the session entry. + // This is the actual model used by the latest run and must win over defaults. let provider = resolved.provider; let model = resolved.model; + const runtimeModel = entry?.model?.trim(); + const runtimeProvider = entry?.modelProvider?.trim(); + if (runtimeModel) { + const parsedRuntime = parseModelRef( + runtimeModel, + runtimeProvider || provider || DEFAULT_PROVIDER, + ); + if (parsedRuntime) { + provider = parsedRuntime.provider; + model = parsedRuntime.model; + } else { + provider = runtimeProvider || provider; + model = runtimeModel; + } + return { provider, model }; + } + + // Fall back to explicit per-session override (set at spawn/model-patch time), + // then finally to configured defaults. const storedModelOverride = entry?.modelOverride?.trim(); if (storedModelOverride) { - provider = entry?.providerOverride?.trim() || provider; - model = storedModelOverride; + const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER; + const parsedOverride = parseModelRef(storedModelOverride, overrideProvider); + if (parsedOverride) { + provider = parsedOverride.provider; + model = parsedOverride.model; + } else { + provider = overrideProvider; + model = storedModelOverride; + } } return { provider, model }; } diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index cc54ceacd5..fc1c37415b 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -157,4 +157,125 @@ describe("gateway sessions patch", () => { } expect(res.error.message).toContain("spawnDepth is only supported"); }); + + test("allows target agent own model for subagent session even when missing from global allowlist", async () => { + const store: Record = {}; + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "default" }, + }, + }, + list: [ + { + id: "kimi", + model: { primary: "synthetic/hf:moonshotai/Kimi-K2.5" }, + }, + ], + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:kimi:subagent:child", + patch: { + key: "agent:kimi:subagent:child", + model: "synthetic/hf:moonshotai/Kimi-K2.5", + }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, + { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + // Selected model matches the target agent default, so no override is stored. + expect(res.entry.providerOverride).toBeUndefined(); + expect(res.entry.modelOverride).toBeUndefined(); + }); + + test("allows target agent subagents.model for subagent session even when missing from global allowlist", async () => { + const store: Record = {}; + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "default" }, + }, + }, + list: [ + { + id: "kimi", + model: { primary: "anthropic/claude-sonnet-4-6" }, + subagents: { model: "synthetic/hf:moonshotai/Kimi-K2.5" }, + }, + ], + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:kimi:subagent:child", + patch: { + key: "agent:kimi:subagent:child", + model: "synthetic/hf:moonshotai/Kimi-K2.5", + }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, + { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.providerOverride).toBe("synthetic"); + expect(res.entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5"); + }); + + test("allows global defaults.subagents.model for subagent session even when missing from global allowlist", async () => { + const store: Record = {}; + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + subagents: { model: "synthetic/hf:moonshotai/Kimi-K2.5" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "default" }, + }, + }, + list: [{ id: "kimi", model: { primary: "anthropic/claude-sonnet-4-6" } }], + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:kimi:subagent:child", + patch: { + key: "agent:kimi:subagent:child", + model: "synthetic/hf:moonshotai/Kimi-K2.5", + }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, + { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.providerOverride).toBe("synthetic"); + expect(res.entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5"); + }); }); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 2d98bbdee3..99e83a3bea 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -1,7 +1,11 @@ import { randomUUID } from "node:crypto"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; -import { resolveAllowedModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js"; +import { + resolveAllowedModelRef, + resolveDefaultModelForAgent, + resolveSubagentConfiguredModelSelection, +} from "../agents/model-selection.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { formatThinkingLevels, @@ -70,6 +74,9 @@ export async function applySessionsPatchToStore(params: { const parsedAgent = parseAgentSessionKey(storeKey); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId }); + const subagentModelHint = isSubagentSessionKey(storeKey) + ? resolveSubagentConfiguredModelSelection({ cfg, agentId: sessionAgentId }) + : undefined; const existing = store[storeKey]; const next: SessionEntry = existing @@ -298,7 +305,7 @@ export async function applySessionsPatchToStore(params: { catalog, raw: trimmed, defaultProvider: resolvedDefault.provider, - defaultModel: resolvedDefault.model, + defaultModel: subagentModelHint ?? resolvedDefault.model, }); if ("error" in resolved) { return invalid(resolved.error);