diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ebbde8f1f..6036ca9b599 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/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 579f72f1c7f..3c7e8fad6a9 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 @@ -254,7 +254,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { }); }); - it("sessions_spawn skips invalid model overrides and continues", async () => { + it("sessions_spawn fails when model patch is rejected", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const calls: GatewayCall[] = []; @@ -281,13 +281,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 a81592a0dc3..48457a17a3d 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -104,7 +104,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(); @@ -222,16 +221,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 +322,5 @@ export async function spawnSubagentDirect( runId: childRunId, note: SUBAGENT_SPAWN_ACCEPTED_NOTE, modelApplied: resolvedModel ? modelApplied : undefined, - warning: modelWarning, }; } diff --git a/src/commands/sessions.e2e.test.ts b/src/commands/sessions.e2e.test.ts index 61f89889022..2c1c6edda85 100644 --- a/src/commands/sessions.e2e.test.ts +++ b/src/commands/sessions.e2e.test.ts @@ -145,4 +145,54 @@ describe("sessionsCommand", () => { expect(group?.totalTokens).toBeNull(); expect(group?.totalTokensFresh).toBe(false); }); + + 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 3b71dbbe09b..0a0934b82b8 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,7 +1,6 @@ -import type { RuntimeEnv } from "../runtime.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { parseModelRef, resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, @@ -9,9 +8,11 @@ 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"; type SessionRow = { @@ -117,35 +118,6 @@ const formatModelCell = (model: string | null | undefined, rich: boolean) => { return rich ? theme.info(label) : label; }; -const resolveEntryModel = ( - entry: - | { - model?: string; - modelProvider?: string; - modelOverride?: string; - providerOverride?: string; - } - | undefined, - fallbackModel: string, - fallbackProvider: string, -): string => { - const runtimeModel = entry?.model?.trim(); - const runtimeProvider = entry?.modelProvider?.trim(); - if (runtimeModel) { - const parsedRuntime = parseModelRef(runtimeModel, runtimeProvider || fallbackProvider); - return parsedRuntime ? parsedRuntime.model : runtimeModel; - } - - const overrideModel = entry?.modelOverride?.trim(); - if (overrideModel) { - const overrideProvider = entry?.providerOverride?.trim() || fallbackProvider; - const parsedOverride = parseModelRef(overrideModel, overrideProvider); - return parsedOverride ? parsedOverride.model : overrideModel; - } - - return fallbackModel; -}; - const formatFlagsCell = (row: SessionRow, rich: boolean) => { const flags = [ row.thinkingLevel ? `think:${row.thinkingLevel}` : null, @@ -241,7 +213,12 @@ export async function sessionsCommand( count: rows.length, activeMinutes: activeMinutes ?? null, sessions: rows.map((r) => { - const model = resolveEntryModel(r, configModel, resolved.provider ?? DEFAULT_PROVIDER); + const resolvedModel = resolveSessionModelRef( + cfg, + r, + parseAgentSessionKey(r.key)?.agentId, + ); + const model = resolvedModel.model ?? configModel; return { ...r, totalTokens: resolveFreshSessionTotalTokens(r) ?? null, @@ -283,7 +260,8 @@ export async function sessionsCommand( runtime.log(rich ? theme.heading(header) : header); for (const row of rows) { - const model = resolveEntryModel(row, configModel, resolved.provider ?? DEFAULT_PROVIDER); + 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/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index f9ff9998a0b..fc1c37415be 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -199,4 +199,83 @@ describe("gateway sessions patch", () => { 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 5c7cbb1165c..05199a8e2d1 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -1,8 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { ModelCatalogEntry } from "../agents/model-catalog.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { SessionEntry } from "../config/sessions.js"; import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveAllowedModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { @@ -14,6 +12,8 @@ import { normalizeUsageDisplay, supportsXHighThinking, } from "../auto-reply/thinking.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; import { isSubagentSessionKey, normalizeAgentId, @@ -58,6 +58,31 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } +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; +} + +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; @@ -70,23 +95,8 @@ export async function applySessionsPatchToStore(params: { const parsedAgent = parseAgentSessionKey(storeKey); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId }); - const 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; - }; const subagentModelHint = isSubagentSessionKey(storeKey) - ? normalizeModelSelection(resolveAgentConfig(cfg, sessionAgentId)?.model) + ? resolveSubagentModelHint(cfg, sessionAgentId) : undefined; const existing = store[storeKey];