fix(cli): display correct model for sub-agents in sessions list (#18660)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ba54c5a351
Co-authored-by: robbyczgw-cla <239660374+robbyczgw-cla@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Robby
2026-02-18 05:59:20 +01:00
committed by GitHub
parent a69e7682c1
commit 5c69e625f5
13 changed files with 485 additions and 67 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof import("../config/config.js")>();
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");
});
});

View File

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

View File

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

View File

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

View File

@@ -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<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
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 };
}

View File

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