mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 23:41:07 +00:00
fix(subagents): enforce model patch consistency
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user