mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
108
src/commands/sessions.model-resolution.test.ts
Normal file
108
src/commands/sessions.model-resolution.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user