fix(subagents): enforce model patch consistency

This commit is contained in:
Gustavo Madeira Santana 2026-02-17 20:11:50 -05:00
parent 7c7b6acdb0
commit e75638aa5f
7 changed files with 180 additions and 72 deletions

View File

@ -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.

View File

@ -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 () => {

View File

@ -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,
};
}

View File

@ -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");
});
});

View File

@ -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);

View File

@ -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<string, SessionEntry> = {};
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<string, SessionEntry> = {};
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");
});
});

View File

@ -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<string, SessionEntry>;
@ -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];