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:
12 files changed
+353
-28
No files matched your search
+85
-15
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user