From 1514cd66b27c294f8eb1e0a935c135775b996d8f Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Thu, 23 Jul 2026 00:27:58 +0800 Subject: [PATCH] feat(core): subagents inherit the parent session's model and thinking level (#38) Co-authored-by: Alice Co-authored-by: Claude Fable 5 --- packages/core/src/agent.ts | 100 ++++++-- .../src/environment/tools/run-subagent.ts | 4 +- packages/core/src/interfaces.ts | 4 +- packages/core/src/omnimessage/types.ts | 2 +- packages/core/src/state/default-config.ts | 2 +- packages/core/test/agent.test.ts | 220 +++++++++++++++++- packages/core/test/resume.test.ts | 33 ++- packages/core/test/subagent.test.ts | 6 +- packages/docs/content/interfaces.en.md | 2 +- packages/docs/content/interfaces.zh.md | 2 +- packages/docs/content/tools.en.md | 3 +- packages/docs/content/tools.zh.md | 3 +- 12 files changed, 353 insertions(+), 28 deletions(-) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7d0b6b8..6b9a6d8 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -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; }): 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 } : {}), diff --git a/packages/core/src/environment/tools/run-subagent.ts b/packages/core/src/environment/tools/run-subagent.ts index 328c296..fd21cdd 100644 --- a/packages/core/src/environment/tools/run-subagent.ts +++ b/packages/core/src/environment/tools/run-subagent.ts @@ -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" }; } diff --git a/packages/core/src/interfaces.ts b/packages/core/src/interfaces.ts index fab5ea1..486fa5c 100644 --- a/packages/core/src/interfaces.ts +++ b/packages/core/src/interfaces.ts @@ -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. */ diff --git a/packages/core/src/omnimessage/types.ts b/packages/core/src/omnimessage/types.ts index 39c4a62..7cdd613 100644 --- a/packages/core/src/omnimessage/types.ts +++ b/packages/core/src/omnimessage/types.ts @@ -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; diff --git a/packages/core/src/state/default-config.ts b/packages/core/src/state/default-config.ts index 1068265..7486c14 100644 --- a/packages/core/src/state/default-config.ts +++ b/packages/core/src/state/default-config.ts @@ -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", diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 5539e96..8cc5c7c 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -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(); + 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[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) => diff --git a/packages/core/test/resume.test.ts b/packages/core/test/resume.test.ts index 8cc28e5..f8fc271 100644 --- a/packages/core/test/resume.test.ts +++ b/packages/core/test/resume.test.ts @@ -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, [ diff --git a/packages/core/test/subagent.test.ts b/packages/core/test/subagent.test.ts index 3089025..ee2adb7 100644 --- a/packages/core/test/subagent.test.ts +++ b/packages/core/test/subagent.test.ts @@ -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(); }); diff --git a/packages/docs/content/interfaces.en.md b/packages/docs/content/interfaces.en.md index d732428..6c74917 100644 --- a/packages/docs/content/interfaces.en.md +++ b/packages/docs/content/interfaces.en.md @@ -202,7 +202,7 @@ interface SubagentRunner { // Precheck errors (depth limit, unknown agent) are thrown — Environment collapses them to failed spawn(input: { agentId?: string; // defaults to the current Agent (self-spawn) - modelId?: string; // defaults to the Project default model + modelId?: string; // omitted = inherit the parent Session's model }): Promise; } diff --git a/packages/docs/content/interfaces.zh.md b/packages/docs/content/interfaces.zh.md index 5478052..2042825 100644 --- a/packages/docs/content/interfaces.zh.md +++ b/packages/docs/content/interfaces.zh.md @@ -202,7 +202,7 @@ interface SubagentRunner { // 深度超限、目标 Agent 不存在等前置错误以抛出表达(由 Environment 收敛为 failed) spawn(input: { agentId?: string; // 缺省复用当前 Agent(自派生) - modelId?: string; // 缺省用 Project 默认模型 + modelId?: string; // 缺省继承父 Session 的模型 }): Promise; } diff --git a/packages/docs/content/tools.en.md b/packages/docs/content/tools.en.md index b9816bd..8d356e4 100644 --- a/packages/docs/content/tools.en.md +++ b/packages/docs/content/tools.en.md @@ -113,7 +113,7 @@ Both tools' arguments (explicit keys): { prompt: string; // required: the complete subtask (all context + the exact final output expected) agent_id?: string; // the child Agent; defaults to the current Agent - model_id?: string; // the child Session's model; defaults to the Project default model + model_id?: string; // the child Session's model; inherits the parent Session's model when omitted yield_time_ms?: number; // foreground wait; default 300000 } @@ -126,6 +126,7 @@ Both tools' arguments (explicit keys): ``` - Depth is capped at 1: a subagent cannot spawn another subagent. +- The child Session follows the parent Session — its model (unless `model_id`/`provider` pick another), thinking level, and Workspace — never the Project defaults. - The child Session inherits the parent Agent's approval callback, so the approval mode follows the parent. - The child Session gets its own Trace, linked from the parent by a `subagent` pointer event; child messages stream back into the parent flow tagged with `origin`. See [Sessions & Traces](/sessions-and-traces). diff --git a/packages/docs/content/tools.zh.md b/packages/docs/content/tools.zh.md index 06b7570..095f976 100644 --- a/packages/docs/content/tools.zh.md +++ b/packages/docs/content/tools.zh.md @@ -112,7 +112,7 @@ exec_command(cmd) { prompt: string; // 必填:完整的子任务(含全部上下文与期望的最终产出) agent_id?: string; // 子 Agent;缺省复用当前 Agent - model_id?: string; // 子 Session 模型;缺省用 Project 默认模型 + model_id?: string; // 子 Session 模型;缺省继承父 Session 的模型 yield_time_ms?: number; // 前台等待时长;默认 300000 } @@ -125,6 +125,7 @@ exec_command(cmd) ``` - 深度上限为 1:Subagent 不能再派生 Subagent。 +- 子 Session 跟随父 Session:模型(除非以 `model_id`/`provider` 显式指定)、thinking level 与 Workspace 均继承父级,而非 Project 默认值。 - 子 Session 继承父 Agent 的审批回调,审批模式随父生效。 - 子 Session 拥有独立 Trace,父 Trace 以 `subagent` 指针事件链接;子消息带 `origin` 标记回流到父级消息流。见 [Session 与 Trace](/sessions-and-traces)。