Handoff-style /model switch command and per-turn thinking level (#64)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-26 16:31:51 +08:00
1 parent 816a823b75
commit 1e23452f48
58 files changed
+1064 -336

No files matched your search

+16 -11
View File
@@ -70,7 +70,7 @@ import type { ModelEntry } from "./state/index.js";
*/
const MAX_SUBAGENT_DEPTH = 1;
/** The five valid thinking level names (session_meta additionally records the literal "default" for "no level"). */
/** The five valid thinking level names (legacy session_meta additionally recorded the literal "default" for "no level"). */
const THINKING_LEVEL_NAMES: readonly ThinkingLevelName[] = [
"none",
"low",
@@ -80,9 +80,9 @@ const THINKING_LEVEL_NAMES: readonly ThinkingLevelName[] = [
];
/**
* Narrows a recorded session_meta `thinking_level` back to a ThinkingLevelName; the literal
* "default" (no level recorded), a missing field (legacy Trace), and anything unknown are
* not levels and yield undefined.
* Narrows a legacy session_meta `thinking_level` back to a ThinkingLevelName; the literal
* "default" (no level recorded), a missing field (current Traces no longer record one), and
* anything unknown are not levels and yield undefined.
*/
function asThinkingLevelName(value: unknown): ThinkingLevelName | undefined {
return typeof value === "string" && (THINKING_LEVEL_NAMES as readonly string[]).includes(value)
@@ -291,6 +291,8 @@ export class Agent {
});
return new Session({
// session_meta holds per-session invariants only: the thinking level is a per-turn
// run parameter (RunOptions.thinkingLevel) and is deliberately not recorded here.
meta: {
session_id: sessionId,
provider: modelEntry.provider,
@@ -298,7 +300,6 @@ export class Agent {
model_context_window: modelEntry.context_window ?? "unknown",
system_prompt: systemPrompt,
tools: rt.tools,
thinking_level: thinkingLevel ?? "default",
agent_state: this.state.stateDir,
workspace: workspaceDir,
...(opts.source !== undefined ? { source: opts.source } : {}),
@@ -393,12 +394,15 @@ export class Agent {
// original history); the vault uses current values (it's injected into the
// subprocess environment, not the history, so a resumed Session should get the
// latest keys too).
// The recorded thinking level is restored with the rest of session_meta (like model,
// Workspace, and system prompt): a resumed subagent session keeps its inherited level
// instead of re-reading this Agent's config. The literal "default" (no level recorded)
// — and a legacy Trace without the field — falls back to the Agent's current config.
// Back-compat: session_meta no longer records a thinking level (it became a per-turn
// run parameter), but OLD Traces still carry `thinking_level` in their meta JSON — when
// present, keep honoring it as this Session's default level (a resumed legacy subagent
// session keeps its inherited level instead of re-reading this Agent's config). The
// field is read loosely (it's gone from SessionMetaPayload); the legacy literal
// "default" — and any current Trace without the field — falls back to the Agent config.
const thinkingLevel =
asThinkingLevelName(meta.thinking_level) ?? this.state.systemConfig.model?.thinking_level;
asThinkingLevelName((meta as unknown as Record<string, unknown>).thinking_level) ??
this.state.systemConfig.model?.thinking_level;
const rt = await this.buildRuntime({
workspaceDir,
@@ -438,6 +442,8 @@ export class Agent {
});
return new Session({
// Invariants only (the legacy thinking_level, when honored above, feeds the LLM default
// but is not re-recorded — new meta writes never contain it).
meta: {
session_id: sessionId,
provider: modelEntry.provider,
@@ -445,7 +451,6 @@ export class Agent {
model_context_window: modelEntry.context_window ?? "unknown",
system_prompt: meta.system_prompt,
tools: rt.tools,
thinking_level: thinkingLevel ?? "default",
agent_state: this.state.stateDir,
workspace: workspaceDir,
// The origin carries over from the original session_meta (a resumed scheduled/subagent
+19 -2
View File
@@ -58,7 +58,13 @@ import type {
ToolCallOutputPayload,
ToolCallPayload,
} from "../omnimessage/index.js";
import type { ApproveFn, EnvironmentInterface, LLMInterface, LLMOutcome } from "../interfaces.js";
import type {
ApproveFn,
EnvironmentInterface,
LLMInterface,
LLMOutcome,
ThinkingLevelName,
} from "../interfaces.js";
/** Trace sink: `write` a complete/event/meta message; `rotate` starts a new file (compaction splits files). */
export interface TraceSink {
@@ -96,6 +102,12 @@ export interface RunOptions {
signal?: AbortSignal;
/** Per-tool approval callback; defaults to denying everything (conservative, to avoid accidental approval when unattended). */
approve?: ApproveFn;
/**
* Thinking level for this run's LLM requests (a per-turn parameter): forwarded to every
* `streamGenerate` of this run — reconnect retries included; compaction requests keep the
* construction-time default (no override). Omitted = the LLM object's default.
*/
thinkingLevel?: ThinkingLevelName;
}
/**
@@ -260,6 +272,9 @@ export class ContextEngine {
const signal = opts?.signal;
// Default approval policy: deny (conservative). CLI/Web will inject a real callback (interactive or permission-mode based).
const approve: ApproveFn = opts?.approve ?? (async () => "deny");
// Per-turn thinking level: applies to each of this run's LLM requests (reconnects included);
// compaction requests are out of scope and keep the LLM default.
const thinkingLevel = opts?.thinkingLevel;
// Merge the Task-boundary compaction summary (the new context's first input, merged with
// this Prompt), the carry-over left over from the last interruption, and this call's new
@@ -329,7 +344,7 @@ export class ContextEngine {
// Both LLM and Environment handle errors internally and guarantee a complete, closed
// output with no thrown exceptions; the engine doesn't handle exceptions —
// it decides retry/resend purely from `outcome`.
turn = yield* this.runTurn(attemptInput, approve, signal);
turn = yield* this.runTurn(attemptInput, approve, signal, thinkingLevel);
// User interruption (the LLM stream was aborted, outcome=aborted, or `signal` fired
// during tool execution): stop and hand control back to the user.
@@ -517,6 +532,7 @@ export class ContextEngine {
input: OmniMessage[],
approve: ApproveFn,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevelName,
): AsyncGenerator<OmniMessage, TurnResult> {
const queue = new MergeQueue();
// Tool outputs are collected in **completion order** (for streaming yield to the frontend);
@@ -547,6 +563,7 @@ export class ContextEngine {
const gen = this.llm.streamGenerate({
newMessages: input,
...(signal ? { signal } : {}),
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
});
for (;;) {
const res = await gen.next();
+7
View File
@@ -103,6 +103,7 @@ export interface GenerativeModelConfig {
contextWindow?: number;
/** Output token cap per Request; non-positive (-1) means no explicit cap (omitted from the request). */
maxTokens?: number;
/** Construction-time default thinking level; a per-request `GenerativeModelParameters.thinkingLevel` overrides it for that request. */
thinkingLevel?: ThinkingLevelName;
/** LLM Request timeout (ms): from system_config.model.timeoutMs; <=0 disables it. Defaults to 120000. */
requestTimeoutMs?: number;
@@ -118,6 +119,12 @@ export interface GenerativeModelParameters {
/** OmniMessage array for the input newly added this turn; implementations must merge it into a single UniMessage (multiple roles not accepted). */
newMessages: OmniMessage[];
signal?: AbortSignal;
/**
* Per-request thinking level override: applied to **this request only**; omitted falls back
* to the construction-time default (`GenerativeModelConfig.thinkingLevel`). The thinking
* level is a per-turn parameter, not a Session invariant.
*/
thinkingLevel?: ThinkingLevelName;
}
/**
+4 -4
View File
@@ -29,11 +29,11 @@ const TITLE_MAX_CHARS = 30;
/**
* Special message markers that must never leak into a title. These are machine-inserted
* XML-ish blocks (a skill invocation wraps the body in `<use_skills>…</use_skills>`, a
* subagent handoff / scheduled task prepend their own blocks) — meaningful to the runtime,
* noise in a title. The list is a fixed allowlist so ordinary angle-bracket text (e.g. a
* user pasting `<div>`) is left untouched.
* subagent handoff / scheduled task / model switch prepend their own blocks) — meaningful
* to the runtime, noise in a title. The list is a fixed allowlist so ordinary angle-bracket
* text (e.g. a user pasting `<div>`) is left untouched.
*/
const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task"];
const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task", "model_switch_from"];
/**
* Strips machine-inserted marker blocks (see MARKER_TAGS) from conversation text so titles are
+35 -8
View File
@@ -863,6 +863,13 @@ export function isRetryableError(error: unknown): boolean {
export class GenerativeModel implements LLMInterface {
private readonly client: AutoLLMClient;
private readonly uniConfig: UniConfig;
/**
* Construction-time default thinking level. Kept **out of the frozen uniConfig**: the
* effective level is resolved per request (`params.thinkingLevel ?? default`), so a turn can
* override it without rebuilding the model object — the thinking level is a per-turn
* parameter, not a Session invariant.
*/
private readonly defaultThinkingLevel: ThinkingLevelName | undefined;
/** Streaming idle timeout (milliseconds); <= 0 disables it. A timeout is treated as needing reconnection. */
private readonly requestTimeoutMs: number;
/**
@@ -889,10 +896,23 @@ export class GenerativeModel implements LLMInterface {
});
this.uniConfig = buildUniConfig(config);
this.defaultThinkingLevel = config.thinkingLevel;
this.requestTimeoutMs = config.requestTimeoutMs ?? 120000;
this.toolCallIds = config.toolCallIds ?? new ToolCallIdAllocator();
}
/**
* The UniConfig for one request: the shared frozen config plus this request's effective
* thinking level (per-request override, else the construction-time default; neither → the
* key stays off the wire, preserving the provider default).
*/
private requestConfig(override: ThinkingLevelName | undefined): UniConfig {
const thinking = mapThinkingLevel(override ?? this.defaultThinkingLevel);
return thinking === undefined
? this.uniConfig
: { ...this.uniConfig, thinking_level: thinking };
}
/**
* Streaming generation (a single attempt, no internal retry). Merges
* `params.newMessages` into one UniMessage to issue a stateful request, translating streamed
@@ -967,7 +987,9 @@ export class GenerativeModel implements LLMInterface {
// parse error) / aborted (user) / failed (other). null means it ended normally.
let outcome: LLMOutcome | null = null;
try {
const it = this.openStream(uniMessage, ac.signal)[Symbol.asyncIterator]();
const it = this.openStream(uniMessage, ac.signal, this.requestConfig(params.thinkingLevel))[
Symbol.asyncIterator
]();
for (;;) {
// The interruption check must happen **before pulling from upstream**: the user may
// interrupt while this generator is suspended at the `yield` below (the typical case —
@@ -1087,12 +1109,17 @@ export class GenerativeModel implements LLMInterface {
* Opens the underlying AgentHub stream (a testing seam): defaults to
* `streamingResponseStateful`; unit tests can subclass and override this method, feeding in a
* controlled UniEvent stream to verify the outcome classification for timeout/network
* drop/interruption/error (without a real API).
* drop/interruption/error (without a real API). `config` is this request's resolved
* UniConfig (the shared frozen config plus the per-request thinking level).
*/
protected openStream(uniMessage: UniMessage, signal: AbortSignal): AsyncIterable<UniEvent> {
protected openStream(
uniMessage: UniMessage,
signal: AbortSignal,
config: UniConfig = this.uniConfig,
): AsyncIterable<UniEvent> {
return this.client.streamingResponseStateful({
message: uniMessage,
config: this.uniConfig,
config,
signal,
});
}
@@ -1133,6 +1160,10 @@ export function toolDefinitionsToSchemas(tools: ToolDefinition[]): ToolSchema[]
* protocol equivalent. `tool_choice` is likewise never set — AgentHub only puts it on the wire
* when UniConfig defines it, and leaving it off preserves the protocol default ("auto" when
* tools are present).
*
* `thinkingLevel` is deliberately **not** baked in here: the effective level is resolved per
* request (`GenerativeModelParameters.thinkingLevel ?? the construction default`, see
* `GenerativeModel.requestConfig`), so a turn can override it on a live session.
*/
export function buildUniConfig(config: GenerativeModelConfig): UniConfig {
const uniConfig: UniConfig = {};
@@ -1148,9 +1179,5 @@ export function buildUniConfig(config: GenerativeModelConfig): UniConfig {
if (config.maxTokens !== undefined && config.maxTokens > 0) {
uniConfig.max_tokens = config.maxTokens;
}
const thinking = mapThinkingLevel(config.thinkingLevel);
if (thinking !== undefined) {
uniConfig.thinking_level = thinking;
}
return uniConfig;
}
+6 -2
View File
@@ -75,6 +75,12 @@ export interface ToolDefinition {
parameters?: Record<string, unknown>;
}
/**
* Session metadata. Holds **per-session invariants only** — values fixed for the Session's
* lifetime (model reference, assembled system prompt, tool schemas, paths, origin). Per-turn
* parameters (e.g. the thinking level, passed with each run) never belong here; legacy Traces
* may still carry a `thinking_level` field, which resume reads loosely for back-compat.
*/
export interface SessionMetaPayload {
session_id: string;
/** The session model's provider group (paired with `model_id` to form a model reference). */
@@ -86,8 +92,6 @@ export interface SessionMetaPayload {
system_prompt: string;
/** The list of tool definitions this Session exposes to the model (full schema, matching what's sent to the LLM). */
tools: ToolDefinition[];
/** The Session's effective thinking level (an explicit createSession option — e.g. subagent inheritance — else system_config.model.thinking_level; "default" when unconfigured). */
thinking_level: string;
/** Absolute path to the Agent State. */
agent_state: string;
/** Absolute path to the Workspace. */
+1 -1
View File
@@ -33,7 +33,7 @@ import type {
} from "./engine/context-engine.js";
export interface SessionConfig {
/** Session metadata (session_id / provider / model_id / model_context_window / system_prompt / tools / thinking_level / agent_state / workspace). */
/** Session metadata — per-session invariants only (session_id / provider / model_id / model_context_window / system_prompt / tools / agent_state / workspace / source). */
meta: SessionMetaPayload;
llm: LLMInterface;
environment: EnvironmentInterface;
+48 -28
View File
@@ -44,6 +44,21 @@ vi.mock("../src/environment/index.js", async (importOriginal) => {
return { ...mod, Environment: CapturingEnvironment };
});
// Captures every GenerativeModelConfig buildRuntime constructs: the effective thinking level
// is no longer observable through session_meta (it holds invariants only) — assertions read
// the construction default from the captured config instead.
const capturedLLMConfigs = vi.hoisted(() => ({ list: [] as { thinkingLevel?: string }[] }));
vi.mock("../src/llm/index.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../src/llm/index.js")>();
class CapturingGenerativeModel extends mod.GenerativeModel {
constructor(config: ConstructorParameters<typeof mod.GenerativeModel>[0]) {
super(config);
capturedLLMConfigs.list.push(config as { thinkingLevel?: string });
}
}
return { ...mod, GenerativeModel: CapturingGenerativeModel };
});
let tmpRoot: string;
let prevHome: string | undefined;
let restoreKeys: () => void;
@@ -247,12 +262,13 @@ describe("Agent.createSession session source (session_meta origin marker)", () =
});
describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => {
const uniThinkingOf = (llm: unknown): unknown =>
((llm as { uniConfig?: { thinking_level?: unknown } }).uniConfig ?? {}).thinking_level;
const llmOf = (session: unknown): unknown =>
(session as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
// The session's default level lives on the LLM object (per-request overrides fall back to
// it); session_meta holds per-session invariants only and never records a thinking level.
const defaultLevelOf = (session: unknown): unknown =>
(session as { engine: { deps: { llm: { defaultThinkingLevel?: unknown } } } }).engine.deps.llm
.defaultThinkingLevel;
it("falls back to the Agent config for both the meta echo and the llm config", async () => {
it("falls back to the Agent config for the llm default; session_meta records no level", async () => {
const agent = await createAgent();
// The seeded Agent config pins thinking_level "medium" — the only source when no option is given.
expect(agent.state.systemConfig.model?.thinking_level).toBe("medium");
@@ -260,10 +276,12 @@ describe("Agent.createSession thinking level (explicit option wins over the Agen
await fs.mkdir(ws, { recursive: true });
const session = await agent.createSession({ workspaceDir: ws });
try {
expect((session.metaMessage.payload as { thinking_level: string }).thinking_level).toBe(
"medium",
);
expect(uniThinkingOf(llmOf(session))).toBe(mapThinkingLevel("medium"));
// session_meta contains ONLY per-session invariants: the thinking level is per-turn.
expect(
"thinking_level" in (session.metaMessage.payload as unknown as Record<string, unknown>),
).toBe(false);
expect(defaultLevelOf(session)).toBe("medium");
expect(mapThinkingLevel("medium")).toBeDefined(); // the name maps onto the wire enum
} finally {
session.dispose();
}
@@ -275,10 +293,7 @@ describe("Agent.createSession thinking level (explicit option wins over the Agen
await fs.mkdir(ws, { recursive: true });
const session = await agent.createSession({ workspaceDir: ws, thinkingLevel: "high" });
try {
expect((session.metaMessage.payload as { thinking_level: string }).thinking_level).toBe(
"high",
);
expect(uniThinkingOf(llmOf(session))).toBe(mapThinkingLevel("high"));
expect(defaultLevelOf(session)).toBe("high");
} finally {
session.dispose();
}
@@ -297,7 +312,9 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
/**
* Spawns through the real runner and reads the child session_meta — the first message
* handle.run yields, emitted before any LLM request; the run generator is closed right
* after, so nothing is ever sent upstream.
* after, so nothing is ever sent upstream. The child's effective thinking level is no
* longer in session_meta (invariants only): it's read from the captured LLM config the
* spawn constructed (the last one pushed).
*/
async function spawnedChildMeta(
runner: SubagentRunner,
@@ -305,11 +322,12 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
): Promise<{
provider: string;
model_id: string;
thinking_level: string;
workspace: string;
source?: string;
llm: { thinkingLevel?: string };
}> {
const handle = await runner.spawn(input);
const llm = capturedLLMConfigs.list.at(-1)!;
try {
const gen = handle.run({ prompt: "noop" });
const first = await gen.next();
@@ -319,12 +337,14 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
// Child messages are stamped with the child Session id as the origin hop.
expect(msg.origin?.[0]).toBe(handle.sessionId);
await gen.return(undefined);
return msg.payload as {
provider: string;
model_id: string;
thinking_level: string;
workspace: string;
source?: string;
return {
...(msg.payload as {
provider: string;
model_id: string;
workspace: string;
source?: string;
}),
llm,
};
} finally {
handle.dispose();
@@ -350,7 +370,7 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
const child = await spawnedChildMeta(runner, {});
expect(child.provider).toBe("anthropic");
expect(child.model_id).toBe("claude-sonnet-4-6");
expect(child.thinking_level).toBe("high");
expect(child.llm.thinkingLevel).toBe("high");
// Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
expect(child.workspace).toBe(ws);
// The spawn site marks the child's own session_meta as subagent-created — the single
@@ -379,7 +399,7 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
expect(child.provider).toBe("deepseek");
expect(child.model_id).toBe("deepseek-v4-pro");
// Thinking level and workspace are inherited implicitly even with an explicit model.
expect(child.thinking_level).toBe("medium");
expect(child.llm.thinkingLevel).toBe("medium");
expect(child.workspace).toBe(ws);
} finally {
parent.dispose();
@@ -396,13 +416,13 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
const ws = path.join(tmpRoot, "ws-inherit-none");
await fs.mkdir(ws, { recursive: true });
const parent = await agent.createSession({ workspaceDir: ws });
const parentLLM = capturedLLMConfigs.list.at(-1)!;
const runner = lastSpawnedRunner();
try {
expect((parent.metaMessage.payload as { thinking_level: string }).thinking_level).toBe(
"default",
);
expect("thinkingLevel" in parentLLM).toBe(false);
const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
expect(child.thinking_level).toBe("default");
// The tri-state null reached the child: no level at all, not helper_agent's "medium".
expect("thinkingLevel" in child.llm).toBe(false);
} finally {
parent.dispose();
}
@@ -447,7 +467,7 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
expect(child.provider).toBe("anthropic");
expect(child.model_id).toBe("claude-sonnet-4-6");
expect(child.thinking_level).toBe("xhigh");
expect(child.llm.thinkingLevel).toBe("xhigh");
expect(child.workspace).toBe(ws);
} finally {
parent.dispose();
-1
View File
@@ -115,7 +115,6 @@ const metaMessage = sessionMeta({
model_context_window: 200000,
system_prompt: "sp",
tools: [],
thinking_level: "default",
agent_state: "/tmp/state",
workspace: "/tmp/ws",
});
+60 -1
View File
@@ -580,7 +580,6 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
model_context_window: 1000,
system_prompt: "sys",
tools: [],
thinking_level: "medium",
agent_state: "/root/p/worker/agent_state",
workspace: "/tmp/w",
});
@@ -666,6 +665,66 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
).toBe(false);
expect(recorded.filter((m) => (m.payload as { text?: string }).text === "go")).toHaveLength(1);
});
it("forwards RunOptions.thinkingLevel to every LLM request of the run (reconnects included); compaction keeps the default", async () => {
const levels: (string | undefined)[] = [];
let calls = 0;
const llm: LLMInterface = {
async *streamGenerate(params) {
calls += 1;
levels.push(params.thinkingLevel);
if (calls === 1) {
// First attempt drops: the reconnect retry must carry the same per-turn level.
yield assistantText("half", "timeout");
return { status: "timeout" };
}
if (calls === 2) {
// Retry completes with usage above the compaction threshold → a Task-boundary
// summarize compaction issues one more request (the engine's, not this run's turn):
// it must NOT carry the per-turn override.
yield assistantText("recovered");
yield tokenUsage(emptyTokenCounts(), {
cache_read: 0,
cache_write: 0,
output: 1,
total: 100,
});
return { status: "completed" };
}
yield assistantText("<summary>s</summary>");
yield tokenUsage(emptyTokenCounts(), {
cache_read: 0,
cache_write: 0,
output: 1,
total: 5,
});
return { status: "completed" };
},
};
const environment = new Environment({
workspaceDir: workspace,
toolConfig: execCommandToolConfig(),
});
const engine = new ContextEngine({
llm,
environment,
maxReconnects: 1,
reconnectBackoffMs: 1,
createLLM: () => llm,
compaction: { maxContextLength: 10, maxSessionTurns: -1, mode: "summarize", prompt: "SUM" },
});
const all: OmniMessage[] = [];
for await (const msg of engine.run([userText("go")], {
approve: allowAll,
thinkingLevel: "high",
})) {
all.push(msg);
}
expect(calls).toBe(3);
// Turn attempt + reconnect retry carry the run's level; the compaction request does not.
expect(levels).toEqual(["high", "high", undefined]);
});
});
describe("ContextEngine async/incremental tool calls (overlapping execution)", () => {
+72 -4
View File
@@ -15,8 +15,8 @@ import {
ThinkingLevel,
ToolCallArgumentParseError,
} from "@prismshadow/agenthub";
import type { UniEvent, UniMessage, UsageMetadata } from "@prismshadow/agenthub";
import type { LLMOutcome } from "../src/interfaces.js";
import type { UniConfig, UniEvent, UniMessage, UsageMetadata } from "@prismshadow/agenthub";
import type { LLMOutcome, ThinkingLevelName } from "../src/interfaces.js";
import {
EventTranslator,
@@ -1034,7 +1034,7 @@ describe("config helpers", () => {
expect("parameters" in schemas[1]!).toBe(false);
});
it("builds UniConfig with only provided fields", () => {
it("builds UniConfig with only provided fields (thinking level stays out — it is per-request)", () => {
const cfg = buildUniConfig({
modelId: "claude-sonnet-4-6",
tools: [{ name: "t", description: "d" }],
@@ -1044,7 +1044,9 @@ describe("config helpers", () => {
});
expect(cfg.system_prompt).toBe("You are concise.");
expect(cfg.max_tokens).toBe(256);
expect(cfg.thinking_level).toBe(ThinkingLevel.HIGH);
// The thinking level is applied per request (override ?? construction default), never
// baked into the frozen config — see the request-config test below.
expect("thinking_level" in cfg).toBe(false);
expect(cfg.tools).toEqual([{ name: "t", description: "d" }]);
const minimal = buildUniConfig({ modelId: "m", tools: [] });
@@ -1181,6 +1183,72 @@ describe("isIncompleteStreamError", () => {
});
});
describe("GenerativeModel per-request thinking level", () => {
// Captures the UniConfig each request goes out with (the openStream seam now receives the
// per-request resolved config): the effective level = params.thinkingLevel ?? the
// construction default, mapped onto the wire enum; neither → the key stays off the wire.
function capturingModel(defaultLevel?: ThinkingLevelName): {
model: GenerativeModel;
configs: (UniConfig | undefined)[];
} {
const configs: (UniConfig | undefined)[] = [];
class CapturingModel extends GenerativeModel {
protected override openStream(
_uni: UniMessage,
_signal: AbortSignal,
config?: UniConfig,
): AsyncIterable<UniEvent> {
configs.push(config);
return (async function* () {
yield ev({
content_items: [{ type: "text", text: "ok" }],
finish_reason: "stop",
usage_metadata: {
cached_tokens: 0,
prompt_tokens: 1,
thoughts_tokens: 0,
response_tokens: 1,
},
});
})();
}
}
const model = new CapturingModel({
modelId: "claude-sonnet-4-6",
tools: [],
...(defaultLevel !== undefined ? { thinkingLevel: defaultLevel } : {}),
});
return { model, configs };
}
async function drainAll(gen: AsyncGenerator<OmniMessage, LLMOutcome | void>): Promise<void> {
let res = await gen.next();
while (!res.done) res = await gen.next();
}
it("applies the construction default when no override is given, per request", async () => {
const { model, configs } = capturingModel("medium");
await drainAll(model.streamGenerate({ newMessages: [userText("hi")] }));
expect(configs[0]?.thinking_level).toBe(ThinkingLevel.MEDIUM);
});
it("a per-request override wins for that request only; the default returns afterwards", async () => {
const { model, configs } = capturingModel("medium");
await drainAll(model.streamGenerate({ newMessages: [userText("a")], thinkingLevel: "high" }));
await drainAll(model.streamGenerate({ newMessages: [userText("b")] }));
expect(configs[0]?.thinking_level).toBe(ThinkingLevel.HIGH);
expect(configs[1]?.thinking_level).toBe(ThinkingLevel.MEDIUM);
});
it("no default and no override: thinking_level stays off the wire; an override still applies", async () => {
const { model, configs } = capturingModel();
await drainAll(model.streamGenerate({ newMessages: [userText("a")] }));
await drainAll(model.streamGenerate({ newMessages: [userText("b")], thinkingLevel: "xhigh" }));
expect(configs[0] !== undefined && "thinking_level" in configs[0]).toBe(false);
expect(configs[1]?.thinking_level).toBe(ThinkingLevel.XHIGH);
});
});
describe("GenerativeModel.streamGenerate outcome classification (PRN-013)", () => {
// Injects a controlled UniEvent stream through the protected openStream seam to verify the
// outcome classification of timeout/network-drop/interrupt/error, without needing a real API.
-1
View File
@@ -50,7 +50,6 @@ function meta(): OmniMessage {
model_context_window: 1000000,
system_prompt: "SP",
tools: [],
thinking_level: "default",
agent_state: "/agent/state",
workspace: "/ws",
});
+24 -20
View File
@@ -24,7 +24,7 @@ import {
userText,
} from "../src/omnimessage/index.js";
import type { OmniMessage, TokenCounts } from "../src/omnimessage/index.js";
import { GenerativeModel, groupHistoryToUniMessages, mapThinkingLevel } from "../src/llm/index.js";
import { GenerativeModel, groupHistoryToUniMessages } from "../src/llm/index.js";
import { readTrace } from "../src/trace/index.js";
import { tracesDir } from "../src/state/paths.js";
import { stubProviderKeys } from "./provider-keys.js";
@@ -90,7 +90,6 @@ function metaFor(
model_context_window: 1000000,
system_prompt: "ORIGINAL SYSTEM PROMPT",
tools: [],
thinking_level: "default",
agent_state: "/agent/state",
workspace: workspaceDir,
...(source !== undefined ? { source } : {}),
@@ -176,35 +175,40 @@ describe("agent.resumeSession", () => {
).toEqual(["text", "text", "abort"]);
});
it("restores the recorded thinking level; the literal 'default' falls back to the Agent config", async () => {
// A resumed subagent session must keep the level it inherited from its parent (recorded
// in session_meta, like model/Workspace/system prompt) — never silently re-read this
// Agent's own config. The seeded Agent config here pins "medium".
it("honors a legacy trace's recorded thinking_level; new meta never re-records it", async () => {
// session_meta no longer carries a thinking level (it became a per-turn run parameter),
// but OLD traces still have it in their meta JSON: resume must keep honoring it as the
// session's default level (a legacy subagent session keeps its inherited level instead of
// re-reading this Agent's config). The seeded Agent config here pins "medium".
const agent = await createAgent({});
expect(agent.state.systemConfig.model?.thinking_level).toBe("medium");
const levelOf = (session: unknown): unknown =>
(
(session as { engine: { deps: { llm: { uniConfig?: { thinking_level?: unknown } } } } })
.engine.deps.llm.uniConfig ?? {}
).thinking_level;
(session as { engine: { deps: { llm: { defaultThinkingLevel?: unknown } } } }).engine.deps.llm
.defaultThinkingLevel;
const recorded = metaFor(SID, workspace);
(recorded.payload as { thinking_level: string }).thinking_level = "xhigh";
// A legacy trace: inject the retired field loosely into the on-disk meta JSON.
(recorded.payload as unknown as Record<string, unknown>).thinking_level = "xhigh";
await writeTraceFile(tmpRoot, SID, [recorded, userText("hello")]);
const inherited = await agent.resumeSession({ sessionId: SID });
expect((inherited.metaMessage.payload as { thinking_level: string }).thinking_level).toBe(
"xhigh",
);
expect(levelOf(inherited)).toBe(mapThinkingLevel("xhigh"));
expect(levelOf(inherited)).toBe("xhigh");
// The rebuilt meta holds invariants only: the legacy field is honored but never re-recorded.
expect(
"thinking_level" in (inherited.metaMessage.payload as unknown as Record<string, unknown>),
).toBe(false);
// "default" records "no level": resume falls back to the Agent's current config, as before.
// A current trace (no field) — and the legacy literal "default" — fall back to the Agent config.
const SID2 = "session-2026-07-06-11-00-00-abcdef02";
await writeTraceFile(tmpRoot, SID2, [metaFor(SID2, workspace), userText("hi")]);
const fallback = await agent.resumeSession({ sessionId: SID2 });
expect((fallback.metaMessage.payload as { thinking_level: string }).thinking_level).toBe(
"medium",
);
expect(levelOf(fallback)).toBe(mapThinkingLevel("medium"));
expect(levelOf(fallback)).toBe("medium");
const SID3 = "session-2026-07-06-12-00-00-abcdef03";
const legacyDefault = metaFor(SID3, workspace);
(legacyDefault.payload as unknown as Record<string, unknown>).thinking_level = "default";
await writeTraceFile(tmpRoot, SID3, [legacyDefault, userText("hi")]);
const viaDefault = await agent.resumeSession({ sessionId: SID3 });
expect(levelOf(viaDefault)).toBe("medium");
});
it("does not write pairing placeholders to the trace file (resume is side-effect free)", async () => {
+6 -1
View File
@@ -55,7 +55,6 @@ const META: SessionMetaPayload = {
model_context_window: 1000,
system_prompt: "sp",
tools: [],
thinking_level: "default",
agent_state: "/tmp/state",
workspace: "/tmp/w",
};
@@ -137,6 +136,12 @@ describe("session-title", () => {
expect(stripConversationMarkers("<handoff_from>data_analyst</handoff_from>继续分析")).toBe(
"继续分析",
);
// The /model switch origin block (the new session's first message) must not leak into the title either.
expect(
stripConversationMarkers(
"<model_switch_from>\nsession: session-01\ntrace: /t/x_001.jsonl\n</model_switch_from>\n继续这个任务",
),
).toBe("继续这个任务");
expect(stripConversationMarkers("render a <div> element")).toBe("render a <div> element");
});
-2
View File
@@ -25,7 +25,6 @@ function meta() {
model_context_window: 200000,
system_prompt: "test system prompt",
tools: [{ name: "exec_command", description: "test tool" }],
thinking_level: "medium",
agent_state: "/tmp/agent_state",
workspace: "/tmp/workspace",
});
@@ -87,7 +86,6 @@ describe("Writer", () => {
model_context_window: 200000,
system_prompt: "child prompt",
tools: [],
thinking_level: "medium",
agent_state: "/tmp/child_agent/agent_state",
workspace: "/tmp/workspace",
});