feat(core): subagents inherit the parent session's model and thinking level (#38)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-23 00:27:58 +08:00
1 parent cf27b13880
commit 1514cd66b2
12 files changed
+353 -28

No files matched your search

+85 -15
View File
@@ -57,6 +57,7 @@ import type { CompactionSettings } from "./engine/context-engine.js";
import type {
GenerativeModelConfig,
SubagentRunner,
ThinkingLevelName,
ToolDefinition,
VisionDescriberService,
} from "./interfaces.js";
@@ -69,6 +70,26 @@ 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"). */
const THINKING_LEVEL_NAMES: readonly ThinkingLevelName[] = [
"none",
"low",
"medium",
"high",
"xhigh",
];
/**
* 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.
*/
function asThinkingLevelName(value: unknown): ThinkingLevelName | undefined {
return typeof value === "string" && (THINKING_LEVEL_NAMES as readonly string[]).includes(value)
? (value as ThinkingLevelName)
: undefined;
}
export interface CreateAgentOptions {
agentId?: string;
projectId?: string;
@@ -89,6 +110,17 @@ export interface CreateSessionOptions {
* inferred, so it's "both or neither" — either half alone is an error, not a lookup.
*/
provider?: string;
/**
* Thinking level for this Session — a tri-state:
* - a `ThinkingLevelName` pins the level;
* - `undefined` (omitted) falls back to the Agent config's `model.thinking_level`;
* - `null` means "no thinking level": the config fallback is suppressed entirely
* (nothing goes into the LLM config; session_meta echoes `"default"`).
* Subagent spawning always passes the parent Session's effective level (its level, or
* `null` when the parent has none), so a child Session thinks at the parent's level and
* never falls back to the child Agent's own config.
*/
thinkingLevel?: ThinkingLevelName | null;
/** Explicit credentials; if unspecified, falls back to credentials in the Project config, then to AgentHub reading environment variables. */
apiKey?: string;
baseUrl?: string;
@@ -206,6 +238,13 @@ export class Agent {
}
const sessionId = formatSessionId();
const subagentDepth = opts.subagentDepth ?? 0;
// Effective thinking level (tri-state option): a value wins over this Agent's own
// system_config; `null` — how subagent spawning says "the parent has none" — suppresses
// the config fallback entirely; only `undefined` (no option) reads the Agent config.
const thinkingLevel =
opts.thinkingLevel === null
? undefined
: (opts.thinkingLevel ?? this.state.systemConfig.model?.thinking_level);
// Agent-level vault (agent_state/.vault.toml) and installed Skills: read the current values each time a Session is created.
const vault = await loadAgentVault(this.state.root, this.state.projectId, this.state.agentId);
@@ -238,6 +277,7 @@ export class Agent {
apiKey,
baseUrl,
systemPrompt,
thinkingLevel,
subagentDepth,
vault,
});
@@ -255,7 +295,7 @@ export class Agent {
model_context_window: modelEntry.context_window ?? "unknown",
system_prompt: systemPrompt,
tools: rt.tools,
thinking_level: this.state.systemConfig.model?.thinking_level ?? "default",
thinking_level: thinkingLevel ?? "default",
agent_state: this.state.stateDir,
workspace: workspaceDir,
},
@@ -349,12 +389,20 @@ 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.
const thinkingLevel =
asThinkingLevelName(meta.thinking_level) ?? this.state.systemConfig.model?.thinking_level;
const rt = await this.buildRuntime({
workspaceDir,
modelEntry,
apiKey,
baseUrl,
systemPrompt: meta.system_prompt,
thinkingLevel,
subagentDepth: 0,
vault: await loadAgentVault(this.state.root, this.state.projectId, this.state.agentId),
});
@@ -393,7 +441,7 @@ export class Agent {
model_context_window: modelEntry.context_window ?? "unknown",
system_prompt: meta.system_prompt,
tools: rt.tools,
thinking_level: this.state.systemConfig.model?.thinking_level ?? "default",
thinking_level: thinkingLevel ?? "default",
agent_state: this.state.stateDir,
workspace: workspaceDir,
},
@@ -448,6 +496,8 @@ export class Agent {
apiKey: string | undefined;
baseUrl: string | undefined;
systemPrompt: string;
/** The Session's effective thinking level: the caller resolves the explicit option against the Agent config. */
thinkingLevel: ThinkingLevelName | undefined;
subagentDepth: number;
vault: Record<string, string>;
}): Promise<{
@@ -458,15 +508,24 @@ export class Agent {
createBareLLM: () => GenerativeModel;
compaction: CompactionSettings;
}> {
const { workspaceDir, modelEntry, apiKey, baseUrl, systemPrompt, subagentDepth, vault } = args;
const {
workspaceDir,
modelEntry,
apiKey,
baseUrl,
systemPrompt,
thinkingLevel,
subagentDepth,
vault,
} = args;
// Child-Agent runner: injected into the run_subagent tool so it doesn't need to
// depend on Agent/Session (breaking a circular dependency). The model can
// optionally choose agentId (omitted = call the current Agent) and the child
// Session's model (omitted = Project default); the model reference is forwarded to
// createSession as-is and must be a complete (provider, model_id) pair — half a
// reference is rejected there rather than being guessed here. Precheck errors (depth
// limit exceeded / agent doesn't exist) are expressed as throws, which the
// Environment collapses to failed.
// Session's model (omitted = the parent Session's model); an explicit model reference
// is forwarded to createSession as-is and must be a complete (provider, model_id)
// pair — half a reference is rejected there rather than being guessed here. Precheck
// errors (depth limit exceeded / agent doesn't exist) are expressed as throws, which
// the Environment collapses to failed.
// Docs: /docs/interfaces § "Subagent interfaces"
const parentAgent = this;
const { root, projectId, agentId: parentAgentId } = this.state;
@@ -494,12 +553,25 @@ export class Agent {
agentId !== undefined && agentId !== parentAgentId
? await createAgent({ root, projectId, agentId })
: parentAgent;
// The model reference is forwarded as a whole: createSession rejects half a pair, so a
// caller that named only one side gets the same error the CLI and HTTP layers give.
// The child Session follows the PARENT Session, never the Project default: with the
// model pair fully omitted it reuses the parent's resolved (provider, model_id) —
// the same Project-config entry, so max_tokens / context_window / vision follow
// automatically. An explicit pair still wins, and half a pair is forwarded as-is so
// createSession rejects it (never silently completed from the parent's here).
const childModel =
modelId === undefined && provider === undefined
? { modelId: modelEntry.model_id, provider: modelEntry.provider }
: {
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
};
// The parent Session's effective thinking level (`thinkingLevel` in scope) is passed
// down too, as a tri-state: `null` when the parent has none, so the child never falls
// back to its own Agent config (which would otherwise apply on a cross-agent spawn).
const childSession = await childAgent.createSession({
workspaceDir,
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
...childModel,
thinkingLevel: thinkingLevel ?? null,
subagentDepth: subagentDepth + 1,
});
// All child-session messages are tagged with an origin (the child Session id,
@@ -630,9 +702,7 @@ export class Agent {
? { contextWindow: modelEntry.context_window }
: {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(this.state.systemConfig.model?.thinking_level !== undefined
? { thinkingLevel: this.state.systemConfig.model.thinking_level }
: {}),
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
...(this.state.systemConfig.model?.timeoutMs !== undefined
? { requestTimeoutMs: this.state.systemConfig.model.timeoutMs }
: {}),
@@ -5,7 +5,7 @@
* The tool itself doesn't depend on Agent/Session, only holding an injected `SubagentRunner`
* (breaking the circular dependency). The model may freely choose the child Agent (`agent_id`)
* and model (`model_id`) via arguments; if omitted, it falls back to reusing the current Agent
* and the Project's default Model respectively. The spawned child session is managed by
* and inheriting the parent session's model respectively. The spawned child session is managed by
* `ManagedSubagentSession` (sharing the `SubagentSessionManager` injected by Environment with
* `input_subagent`).
*
@@ -87,7 +87,7 @@ export function createSubagentTool(
// Caught here rather than in createSession so the model is told which half it left out.
if ((modelId === undefined) !== (provider === undefined)) {
yield* fail(
"[run_subagent error: `model_id` and `provider` must be given together (a model reference is the pair), or both omitted to use the Project default model]",
"[run_subagent error: `model_id` and `provider` must be given together (a model reference is the pair), or both omitted to inherit the parent session's model]",
);
return { stopReason: "failed" };
}
+2 -2
View File
@@ -202,8 +202,8 @@ export interface SubagentRunner {
agentId?: string;
/**
* Upstream model id for the child Session, paired with `provider` — a model reference is
* always the complete pair. Omit both to use the Project's default Model; supplying one
* half without the other is rejected.
* always the complete pair. Omit both to inherit the parent Session's model; supplying
* one half without the other is rejected.
*/
modelId?: string;
/** Provider group for `modelId`; required whenever `modelId` is given. */
+1 -1
View File
@@ -86,7 +86,7 @@ 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 model's thinking level (from system_config.model.thinking_level; "default" when unconfigured). */
/** 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;
+1 -1
View File
@@ -253,7 +253,7 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
model_id: {
type: "string",
description:
"Which model the subagent should use, as the upstream model id. Must be given together with provider — a model is always referenced by the pair. Omit both to use the Project default model.",
"Which model the subagent should use, as the upstream model id. Must be given together with provider — a model is always referenced by the pair. Omit both to inherit the parent session's model.",
},
provider: {
type: "string",
+219 -1
View File
@@ -13,7 +13,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addModel,
createAgent,
@@ -23,7 +23,26 @@ import {
setVaultEntry,
} from "../src/index.js";
import { effectiveMaxContextLength, metaMaxTokens } from "../src/agent.js";
import { mapThinkingLevel } from "../src/llm/index.js";
import { stubProviderKeys } from "./provider-keys.js";
import type { EnvironmentConfig, EnvironmentServices, SubagentRunner } from "../src/interfaces.js";
// Captures the services buildRuntime hands to each Environment, so tests can drive the REAL
// subagent runner (the spawn closure in agent.ts). Spawning only constructs the child Session,
// and pulling a single message from handle.run yields the child session_meta before any LLM
// request is issued — no network is ever touched. The wrapper is otherwise transparent, so
// every other test in this file behaves as with the real class.
const capturedEnvServices = vi.hoisted(() => ({ list: [] as unknown[] }));
vi.mock("../src/environment/index.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../src/environment/index.js")>();
class CapturingEnvironment extends mod.Environment {
constructor(config: EnvironmentConfig) {
super(config);
capturedEnvServices.list.push(config.services);
}
}
return { ...mod, Environment: CapturingEnvironment };
});
let tmpRoot: string;
let prevHome: string | undefined;
@@ -201,6 +220,205 @@ describe("Agent.createSession model reference ((provider, model_id) pair)", () =
});
});
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;
it("falls back to the Agent config for both the meta echo and the llm config", 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");
const ws = path.join(tmpRoot, "ws-thinking-default");
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"));
} finally {
session.dispose();
}
});
it("uses an explicit thinkingLevel over the Agent config (subagent inheritance rides on this)", async () => {
const agent = await createAgent();
const ws = path.join(tmpRoot, "ws-thinking-explicit");
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"));
} finally {
session.dispose();
}
});
});
describe("run_subagent spawning follows the PARENT session (never the Project default)", () => {
/** The subagent runner captured from the most recently constructed real Environment. */
function lastSpawnedRunner(): SubagentRunner {
const services = capturedEnvServices.list.at(-1) as EnvironmentServices | undefined;
const runner = services?.subagentRunner;
expect(runner).toBeDefined();
return runner!;
}
/**
* 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.
*/
async function spawnedChildMeta(
runner: SubagentRunner,
input: Parameters<SubagentRunner["spawn"]>[0],
): Promise<{ provider: string; model_id: string; thinking_level: string; workspace: string }> {
const handle = await runner.spawn(input);
try {
const gen = handle.run({ prompt: "noop" });
const first = await gen.next();
expect(first.done).toBe(false);
const msg = first.value!;
expect(msg.type).toBe("session_meta");
// 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;
};
} finally {
handle.dispose();
}
}
it("inherits the parent's model pair, thinking level, and workspace when the args omit them", async () => {
const agent = await createAgent();
agent.state.systemConfig.model = {
...(agent.state.systemConfig.model ?? {}),
thinking_level: "high",
};
const ws = path.join(tmpRoot, "ws-inherit");
await fs.mkdir(ws, { recursive: true });
// The parent runs a NON-default model: the Project default (deepseek pair) must not leak in.
const parent = await agent.createSession({
workspaceDir: ws,
modelId: "claude-sonnet-4-6",
provider: "anthropic",
});
const runner = lastSpawnedRunner();
try {
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");
// Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
expect(child.workspace).toBe(ws);
} finally {
parent.dispose();
}
});
it("still honors an explicit (provider, model_id) pair over inheritance", async () => {
const agent = await createAgent();
const ws = path.join(tmpRoot, "ws-inherit-explicit");
await fs.mkdir(ws, { recursive: true });
const parent = await agent.createSession({
workspaceDir: ws,
modelId: "claude-sonnet-4-6",
provider: "anthropic",
});
const runner = lastSpawnedRunner();
try {
const child = await spawnedChildMeta(runner, {
modelId: "deepseek-v4-pro",
provider: "deepseek",
});
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.workspace).toBe(ws);
} finally {
parent.dispose();
}
});
it("passes 'no thinking level' down when the parent has none (the child config never applies)", async () => {
// helper_agent's own seeded config pins "medium"; the parent's optional key is removed
// (hand-edited config). The tri-state null must reach the child: falling back to the
// child's own config here was the fallback hole — the child must show no level at all.
await createAgent({ agentId: "helper_agent" });
const agent = await createAgent();
delete agent.state.systemConfig.model?.thinking_level;
const ws = path.join(tmpRoot, "ws-inherit-none");
await fs.mkdir(ws, { recursive: true });
const parent = await agent.createSession({ workspaceDir: ws });
const runner = lastSpawnedRunner();
try {
expect((parent.metaMessage.payload as { thinking_level: string }).thinking_level).toBe(
"default",
);
const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
expect(child.thinking_level).toBe("default");
} finally {
parent.dispose();
}
});
it("rejects half a model reference at the runner layer (never completed from the parent's half)", async () => {
const agent = await createAgent();
const ws = path.join(tmpRoot, "ws-inherit-half");
await fs.mkdir(ws, { recursive: true });
const parent = await agent.createSession({ workspaceDir: ws });
const runner = lastSpawnedRunner();
try {
await expect(runner.spawn({ modelId: "claude-sonnet-4-6" })).rejects.toThrow(
/must be given as a \(provider, model_id\) pair/,
);
await expect(runner.spawn({ provider: "anthropic" })).rejects.toThrow(
/must be given as a \(provider, model_id\) pair/,
);
} finally {
parent.dispose();
}
});
it("makes a cross-agent child follow the parent session, not its own Agent config", async () => {
// Seed the second Agent first (spawn verifies its system_config exists); its own seeded
// config (thinking "medium") and the Project default model must both lose to the parent.
await createAgent({ agentId: "helper_agent" });
const agent = await createAgent();
agent.state.systemConfig.model = {
...(agent.state.systemConfig.model ?? {}),
thinking_level: "xhigh",
};
const ws = path.join(tmpRoot, "ws-inherit-cross");
await fs.mkdir(ws, { recursive: true });
const parent = await agent.createSession({
workspaceDir: ws,
modelId: "claude-sonnet-4-6",
provider: "anthropic",
});
const runner = lastSpawnedRunner();
try {
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.workspace).toBe(ws);
} finally {
parent.dispose();
}
});
});
describe("Agent.createSession max output tokens (per-model cap wins over the Agent config)", () => {
// Reads a constructed GenerativeModel's request config (private; runtime-accessible for assertion).
const uniConfigOf = (llm: unknown) =>
+32 -1
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 } from "../src/llm/index.js";
import { GenerativeModel, groupHistoryToUniMessages, mapThinkingLevel } 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";
@@ -133,6 +133,37 @@ 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".
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;
const recorded = metaFor(SID, workspace);
(recorded.payload as { thinking_level: string }).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"));
// "default" records "no level": resume falls back to the Agent's current config, as before.
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"));
});
it("does not write pairing placeholders to the trace file (resume is side-effect free)", async () => {
const agent = await createAgent({});
const file = await writeTraceFile(tmpRoot, SID, [
+5 -1
View File
@@ -196,7 +196,10 @@ describe("run_subagent tool (foreground)", () => {
}
});
it("uses the Project default model when neither half of the reference is given", async () => {
it("leaves an omitted model reference to the runner, which inherits the parent session's model", async () => {
// With neither half given, the tool forwards NO pair — it never invents one; the real
// runner (the spawn closure in agent.ts) then fills in the PARENT session's model, not
// the Project default (that inheritance is locked in agent.test.ts).
const seen: Array<{ modelId?: string; provider?: string }> = [];
const runner = runnerOf(
async function* () {
@@ -208,6 +211,7 @@ describe("run_subagent tool (foreground)", () => {
const tool = createSubagentTool(DEF, services);
const { result } = await collectWithReturn(tool.execute({ prompt: "x" }, CTX));
expect(result?.stopReason).toBe("completed");
expect(seen).toHaveLength(1);
expect(seen[0]?.modelId).toBeUndefined();
expect(seen[0]?.provider).toBeUndefined();
});