Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 30 additions & 29 deletions packages/core/src/tools/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ describe('AgentTool', () => {
expect(interactiveTool.description).toContain("Don't peek");
expect(interactiveTool.description).toContain("Don't race");
expect(interactiveTool.description).toContain('Writing a fork prompt');
expect(interactiveTool.description).toContain(
'result arrives through a completion notification',
);
expect(interactiveTool.description).not.toContain(
'does NOT come back to you',
);
expect(interactiveTool.description).not.toContain(
"won't need the result back",
);
expect(interactiveTool.description).toContain(
'forks inherit all or the selected recent window',
);
Expand Down Expand Up @@ -358,6 +367,9 @@ describe('AgentTool', () => {

expect(tool.description).toContain('background by default');
expect(tool.description).toContain('run_in_background: false');
expect(tool.description).toContain(
'foreground regular agent returns its result inline',
);
});

it('explains how to continue reusable background agents', async () => {
Expand Down Expand Up @@ -404,19 +416,22 @@ describe('AgentTool', () => {
});

describe('schema generation', () => {
it('should generate schema with subagent names as enum', () => {
it('keeps subagent_type open when named subagents are available', () => {
const schema = agentTool.schema;
const properties = schema.parametersJsonSchema as {
properties: {
subagent_type: {
type?: string;
description?: string;
enum?: string[];
};
};
};
expect(properties.properties.subagent_type.enum).toEqual([
'file-search',
'code-review',
]);
expect(properties.properties.subagent_type.type).toBe('string');
expect(properties.properties.subagent_type.description).toContain(
'"fork" to inherit',
);
expect(properties.properties.subagent_type.enum).toBeUndefined();
});

it('declares the background default and foreground opt-out', () => {
Expand All @@ -433,6 +448,9 @@ describe('AgentTool', () => {
expect(properties.properties.run_in_background.description).toContain(
'Set to false',
);
expect(properties.properties.run_in_background.description).toContain(
'interactive fork',
);
});

it('declares fork_turns for fork agents without a none option', () => {
Expand Down Expand Up @@ -473,30 +491,6 @@ describe('AgentTool', () => {
);
});

it('does not advertise "fork" in the enum, even when interactive', async () => {
// `fork` is intentionally omitted from the enum so the model is not
// steered to fork result-bearing work; it stays valid via validation.
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(true);
const interactiveTool = new AgentTool(config);
await vi.runAllTimersAsync();

const schema = interactiveTool.schema;
const properties = schema.parametersJsonSchema as {
properties: {
subagent_type: {
enum?: string[];
};
};
};
expect(properties.properties.subagent_type.enum).toEqual([
'file-search',
'code-review',
]);
expect(properties.properties.subagent_type.enum).not.toContain('fork');
});

it('does not expose teammate name when teams are disabled', () => {
const schema = agentTool.schema;
const parameters = schema.parametersJsonSchema as {
Expand Down Expand Up @@ -3014,6 +3008,7 @@ describe('AgentTool', () => {
vi.mocked(
config.isInteractive as ReturnType<typeof vi.fn>,
).mockReturnValue(false);
vi.mocked(mockAgent.getFinalText).mockReturnValue('headless fork result');
(mockAgent as unknown as Record<string, unknown>)['getCore'] = vi
.fn()
.mockReturnValue({
Expand Down Expand Up @@ -3045,6 +3040,7 @@ describe('AgentTool', () => {
config as unknown as {
getBackgroundTaskRegistry: () => {
register: ReturnType<typeof vi.fn>;
complete: ReturnType<typeof vi.fn>;
};
}
).getBackgroundTaskRegistry();
Expand Down Expand Up @@ -3083,6 +3079,11 @@ describe('AgentTool', () => {
);

await vi.runAllTimersAsync();
expect(stubRegistry.complete).toHaveBeenCalledWith(
expect.any(String),
'headless fork result',
expect.anything(),
);
expect(mockStartSubagentSpan).toHaveBeenCalledWith(
expect.objectContaining({
invocationKind: 'fork',
Expand Down
36 changes: 9 additions & 27 deletions packages/core/src/tools/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,8 @@ export class AgentTool extends BaseDeclarativeTool<AgentParams, ToolResult> {
},
subagent_type: {
type: 'string',
description: 'The type of specialized agent to use for this task',
description:
'The named agent type to use, or "fork" to inherit the parent conversation context',
},
fork_turns: {
oneOf: [
Expand All @@ -780,7 +781,7 @@ export class AgentTool extends BaseDeclarativeTool<AgentParams, ToolResult> {
type: 'boolean',
default: true,
description:
'Defaults to true for top-level regular subagents. Set to false to run in the foreground and return the result inline. Nested agents run in the foreground. Caller-owned working_dir launches default to foreground and cannot run in the background.',
'Defaults to true for top-level regular subagents. Set to false to run a regular agent in the foreground and return its result inline. Set to true for an interactive fork to receive its completion notification; headless forks always run in the background. Nested agents run in the foreground. Caller-owned working_dir launches default to foreground and cannot run in the background.',
},
...(config.isAgentTeamEnabled()
? {
Expand Down Expand Up @@ -876,7 +877,7 @@ The Agent tool launches specialized agents (subprocesses) that autonomously hand
Available agent types and the tools they have access to:
${subagentDescriptions}

When using the Agent tool, specify a subagent_type to select which agent type to use. If omitted, the general-purpose agent is used. Top-level regular subagents run in the background by default and report their results through a completion notification; set \`run_in_background: false\` when you need the result inline before continuing. A fork (\`subagent_type: "fork"\`) runs detached and fire-and-forget — its result does NOT come back to you, so use it ONLY for work whose output you won't need. Forks inherit the full parent conversation by default; set \`fork_turns\` to a positive integer string to limit inheritance to that many recent real user turns. When you need the agent's findings back (review, audit, aggregation, verification), use a regular subagent, never a fork.
When using the Agent tool, specify a subagent_type to select which agent type to use. If omitted, the general-purpose agent is used. Top-level regular subagents run in the background by default and report their results through a completion notification; set \`run_in_background: false\` when you need a regular subagent's result inline before continuing. A fork (\`subagent_type: "fork"\`) inherits the parent conversation context. A background fork's result arrives through a completion notification. Forks inherit the full parent conversation by default; set \`fork_turns\` to a positive integer string to limit inheritance to that many recent real user turns.

When NOT to use the Agent tool:
- If you want to read a specific file path, use the ${ToolNames.READ_FILE} tool or the ${ToolNames.GLOB} tool instead of the ${ToolNames.AGENT} tool, to find the match more quickly
Expand All @@ -892,7 +893,7 @@ Usage notes:
- Keep immediate critical-path work local when your next action depends on it.
- Do not duplicate work between the parent and subagents.
- Run agents concurrently only when their tasks are independent. For code changes, give concurrent agents disjoint write scopes; launch them in a single message with multiple tool uses.
- A background agent reports its result through a completion notification in a later turn. A foreground agent returns its result inline. Agent results are not visible to the user, so relay the relevant outcome in your response.
- A background agent reports its result through a completion notification in a later turn. A foreground regular agent returns its result inline. Agent results are not visible to the user, so relay the relevant outcome in your response.
- While background agents run, continue meaningful non-overlapping work. Wait for an agent only when its result blocks the next required step.
- Reuse an existing background agent for related follow-up work instead of launching a duplicate: call ${ToolNames.SEND_MESSAGE} with the \`agentId\` from its launch result as its \`task_id\`. Running agents receive the message at the next tool-round boundary; paused agents resume with it as their first continuation instruction; completed agents are revived from their retained transcript. If the task is no longer retained or cannot be resumed or revived, launch a new agent.
- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.
Expand All @@ -905,15 +906,15 @@ Usage notes:
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result so you can review or merge them.
## When to fork

A fork (\`subagent_type: "fork"\`) runs detached and fire-and-forget: it inherits your full context by default, but its findings do NOT come back to you in a form you can act on. Set \`fork_turns\` to a positive integer string only when a bounded recent window is sufficient. **Never fork work whose output you need** — reviews, audits, parallel investigations you must aggregate, verification, anything where you have to read or combine the results. For all of that, launch regular subagents instead (omit \`subagent_type\` for general-purpose, or name a specific type). Regular top-level subagents report through background completion notifications by default; set \`run_in_background: false\` when you need the result inline in the current turn. Omitting \`subagent_type\` does NOT fork.
A fork (\`subagent_type: "fork"\`) inherits your full context by default. Set \`fork_turns\` to a positive integer string only when a bounded recent window is sufficient. A background fork reports its result through a completion notification; set \`run_in_background: true\` in interactive sessions when you need that result. Headless forks always use this background path. Omitting \`subagent_type\` does NOT fork.

Fork only when you genuinely won't need the result back — a detached background chore the user asked you to kick off and move on from. The criterion is qualitative: "will I need to read this output?" If yes, don't fork.
Choose a fork when the task needs substantial context from the parent conversation. Use a regular subagent when a fresh prompt provides enough context.

Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork — a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can track the fork.

**Don't peek.** The tool result includes an output — do not read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
**Don't peek.** For a background fork, do not read or tail its output unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.

**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
**Don't race.** After launching a background fork, you know nothing about what it found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.

**Writing a fork prompt.** With the default full history, the prompt is a *directive* — what to do, not what the situation is. When \`fork_turns\` limits history, include any older context the fork still needs. Be specific about scope: what's in, what's out, what another agent is handling.

Expand Down Expand Up @@ -960,32 +961,13 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent
// Update description using object property assignment since it's readonly
(this as { description: string }).description = baseDescription;

// Generate dynamic schema with enum of available subagent names
const subagentNames = this.availableSubagents.map((s) => s.name);

// Update the parameter schema by modifying the existing object
const schema = this.parameterSchema as {
properties?: {
subagent_type?: {
enum?: string[];
};
name?: typeof TEAM_AGENT_NAME_PROPERTY;
plan_mode_required?: typeof TEAM_AGENT_PLAN_REQUIRED_PROPERTY;
};
};
if (schema.properties && schema.properties.subagent_type) {
// Only real, loadable subagents are advertised in the enum. `fork` is a
// deliberate pseudo-type, NOT listed here: dangling it as a casual option
// led the model to fork result-bearing work (e.g. review agents), whose
// findings a fork never returns. Forking stays reachable for intentional
// use — validation accepts `subagent_type: "fork"` and `/fork` passes it
// directly — it just isn't offered as a default pick.
if (subagentNames.length > 0) {
schema.properties.subagent_type.enum = subagentNames;
} else {
delete schema.properties.subagent_type.enum;
}
}
if (schema.properties) {
if (this.config.isAgentTeamEnabled()) {
schema.properties.name = TEAM_AGENT_NAME_PROPERTY;
Expand Down
Loading