fix: support context-inheriting subagents in headless mode (#7378)

* fix(core): support headless fork subagents

* fix(desktop): avoid false background agent events

---------

Co-authored-by: Claude <[email protected]>
This commit is contained in:
DragonandClaude authored and GitHub committed 2026-07-21 12:33:13 +00:00
1 parent 957a93fc96
commit 1fec09423b
9 files changed
+308 -154

No files matched your search

@@ -0,0 +1,51 @@
# Headless context-inheriting subagents
## Problem
An explicit `subagent_type: "fork"` request is currently honored only when
`Config.isInteractive()` is true. Headless callers such as `qwen --prompt`,
the TypeScript SDK, and CI runners silently execute a fresh
`general-purpose` subagent instead. The requested and effective context modes
therefore differ, and the child does not receive the parent conversation.
## Design
Fork availability is independent of the presentation surface. A top-level
fork request always uses the existing fork construction path, which copies
the parent's history and cache-safe generation configuration.
Headless forks run through the existing background-agent registry even when
`run_in_background` is omitted or false. Forks are detached by definition, and
the registry gives non-interactive callers the lifecycle they need:
- one-shot headless execution waits for the fork to finish;
- stream consumers receive `task_started` and terminal task notifications;
- the effective `subagent_type: "fork"` is recorded in events, metadata, and
subagent telemetry;
- permission requests that cannot be shown in a non-interactive session are
denied by the existing background-agent policy instead of hanging.
Interactive fork behavior remains unchanged.
A fork request from a nested subagent is still unsupported, but it now fails
with an explicit tool error instead of silently running a fresh
`general-purpose` subagent.
## Scope
This change reuses the current full-history fork behavior. It does not add
partial history selection such as `fork_turns`; that can be introduced
separately without blocking correct headless inheritance.
## Verification
- Core dispatch tests cover interactive forks, headless forks, forced
background lifecycle, inherited history construction, permission behavior,
and explicit nested-fork rejection.
- The non-interactive CLI test covers the SDK-facing `task_started` event and
verifies that it exposes `subagent_type: "fork"`.
- The desktop SDK adapter test verifies that the runtime's background result
takes precedence over a caller-provided `run_in_background: false`.
- An end-to-end `qwen --prompt --output-format stream-json` check uses a parent
marker that is absent from the fork directive and verifies that the child
can still recover it from inherited history.
@@ -2823,6 +2823,68 @@ describe('runNonInteractive', () => {
});
});
it('emits the effective fork context mode in headless task events', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(
OutputFormat.STREAM_JSON,
);
(mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false);
setupMetricsMock();
const writes: string[] = [];
processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => {
writes.push(
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'),
);
return true;
});
mockBackgroundTaskRegistry.setRegisterCallback.mockImplementation(
(callback) => {
callback?.({
agentId: 'fork-tool-fork-1',
toolUseId: 'tool-fork-1',
description: 'Inherit parent context',
subagentType: 'fork',
});
},
);
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'Fork launched.' },
{
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata: { totalTokenCount: 2 },
},
},
]),
);
await runNonInteractive(
mockConfig,
mockSettings,
'Launch a context-inheriting fork',
'prompt-headless-fork',
);
const envelopes = writes
.join('')
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line));
expect(envelopes).toContainEqual(
expect.objectContaining({
type: 'system',
subtype: 'task_started',
data: expect.objectContaining({
task_id: 'fork-tool-fork-1',
tool_use_id: 'tool-fork-1',
subagent_type: 'fork',
}),
}),
);
});
it('flushes terminal monitor notifications before the final headless result', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(
OutputFormat.STREAM_JSON,
+93 -60
View File
@@ -199,7 +199,6 @@ describe('AgentTool', () => {
getMaxToolCalls: vi.fn().mockReturnValue(-1),
isTrustedFolder: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(false),
isForkSubagentEnabled: vi.fn().mockReturnValue(false),
getFileFilteringOptions: vi.fn().mockReturnValue({
respectGitIgnore: true,
respectQwenIgnore: true,
@@ -316,7 +315,7 @@ describe('AgentTool', () => {
);
});
it('omits fork discipline but keeps "Writing the prompt" when non-interactive', async () => {
it('includes fork discipline when non-interactive', async () => {
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(false);
@@ -324,21 +323,19 @@ describe('AgentTool', () => {
const nonInteractiveTool = new AgentTool(config);
await vi.runAllTimersAsync();
// Fork-specific sections must be absent
expect(nonInteractiveTool.description).not.toContain('When to fork');
expect(nonInteractiveTool.description).not.toContain("Don't peek");
expect(nonInteractiveTool.description).not.toContain("Don't race");
expect(nonInteractiveTool.description).not.toContain(
'Writing a fork prompt',
);
// "Writing the prompt" section is always present (useful for fresh agents too)
expect(nonInteractiveTool.description).toContain('When to fork');
expect(nonInteractiveTool.description).toContain("Don't peek");
expect(nonInteractiveTool.description).toContain("Don't race");
expect(nonInteractiveTool.description).toContain('Writing a fork prompt');
expect(nonInteractiveTool.description).toContain('Writing the prompt');
expect(nonInteractiveTool.description).toContain(
'Never delegate understanding',
);
// ...but its live fork reference is gated with the fork framework.
expect(nonInteractiveTool.description).not.toContain('forks inherit');
// Forks are now available in headless sessions too, so the fork
// inheritance guidance is present regardless of interactivity.
expect(nonInteractiveTool.description).toContain(
'forks inherit all or the selected recent window',
);
});
it('includes fork discipline when interactive', async () => {
@@ -1238,10 +1235,7 @@ describe('AgentTool', () => {
);
});
it('falls back to a regular general-purpose sub-agent when a nested sub-agent requests a fork', async () => {
// Fork is a top-level-only capability in v1. A nested sub-agent
// requesting `subagent_type: "fork"` must get the awaitable
// general-purpose sub-agent, not an error and not a nested fork.
it('rejects a nested fork request instead of changing its context mode', async () => {
vi.mocked(config.getMaxSubagentDepth).mockReturnValue(5);
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(null);
const invocation = agentTool.build({
@@ -1249,13 +1243,17 @@ describe('AgentTool', () => {
prompt: 'Do work',
subagent_type: 'fork',
});
await runWithAgentContext('sub-1', () =>
const result = await runWithAgentContext('sub-1', () =>
invocation.execute(new AbortController().signal),
);
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'general-purpose',
expect(partToString(result.llmContent)).toContain(
'subagent_type "fork" is not supported',
);
expect(result.error?.message).toContain(
'subagent_type "fork" is not supported',
);
expect(mockSubagentManager.loadSubagent).not.toHaveBeenCalled();
});
});
@@ -3012,57 +3010,88 @@ describe('AgentTool', () => {
expect(AgentHeadless.create).not.toHaveBeenCalled();
});
it('falls back to general-purpose when non-interactive', async () => {
it('runs a non-interactive fork through the background registry', async () => {
vi.mocked(
config.isInteractive as ReturnType<typeof vi.fn>,
).mockReturnValue(false);
// Non-interactive sessions cannot service fork progress or prompts.
const mockLoadedSubagent: SubagentConfig = {
name: 'general-purpose',
description: 'General-purpose agent',
systemPrompt: 'You are a general-purpose agent.',
level: 'builtin',
filePath: '<builtin:general-purpose>',
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockLoadedSubagent,
);
(mockAgent as unknown as Record<string, unknown>)['getCore'] = vi
.fn()
.mockReturnValue({
modelConfig: { model: 'subagent-model' },
getEventEmitter: () => ({ on: vi.fn(), off: vi.fn() }),
});
(mockAgent as unknown as Record<string, unknown>)[
'setExternalMessageProvider'
] = vi.fn();
(mockAgent as unknown as Record<string, unknown>)[
'setExternalMessageWaiter'
] = vi.fn();
(mockAgent as unknown as Record<string, unknown>)[
'setExternalMessageWaitPredicate'
] = vi.fn();
vi.mocked(config.getGeminiClient).mockReturnValue({
getHistory: vi.fn().mockReturnValue([
{
role: 'user',
parts: [{ text: 'parent marker: FORK7348_PARENT_7XQ9' }],
},
{ role: 'model', parts: [{ text: 'Ready.' }] },
]),
getChat: vi.fn().mockReturnValue({
getGenerationConfig: vi.fn().mockReturnValue({}),
}),
} as unknown as ReturnType<Config['getGeminiClient']>);
const stubRegistry = (
config as unknown as {
getBackgroundTaskRegistry: () => {
register: ReturnType<typeof vi.fn>;
};
}
).getBackgroundTaskRegistry();
const params: AgentParams = {
description: 'fork task',
prompt: 'do the thing',
subagent_type: 'fork',
// A fork is detached by definition. Headless execution must still
// register it so the process waits for completion.
run_in_background: false,
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
const result = await invocation.execute();
// Should fall back to general-purpose
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'general-purpose',
expect(partToString(result.llmContent)).toContain(
'Background agent launched',
);
expect(mockSubagentManager.loadSubagent).not.toHaveBeenCalled();
expect(AgentHeadless.create).toHaveBeenCalled();
expect(stubRegistry.register).toHaveBeenCalledWith(
expect.objectContaining({
isBackgrounded: true,
subagentType: 'fork',
}),
expect.anything(),
);
const createCalls = vi.mocked(AgentHeadless.create).mock.calls;
const headlessForkConfig = createCalls[createCalls.length - 1][1];
expect(headlessForkConfig.getShouldAvoidPermissionPrompts()).toBe(true);
expect(JSON.stringify(createCalls[createCalls.length - 1][2])).toContain(
'FORK7348_PARENT_7XQ9',
);
await vi.runAllTimersAsync();
expect(mockStartSubagentSpan).toHaveBeenCalledWith(
expect.objectContaining({
invocationKind: 'fork',
subagentName: 'fork',
}),
);
expect(AgentHeadless.create).not.toHaveBeenCalled();
});
it('falls back to general-purpose when a nested sub-agent requests a fork', async () => {
// v1: fork is a top-level-only capability. A sub-agent — which may
// carry the AgentTool via nesting — that requests a fork downgrades to
// the awaitable general-purpose subagent instead of opening a nested
// fork, even in interactive mode where fork is otherwise enabled.
const mockLoadedSubagent: SubagentConfig = {
name: 'general-purpose',
description: 'General-purpose agent',
systemPrompt: 'You are a general-purpose agent.',
level: 'builtin',
filePath: '<builtin:general-purpose>',
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockLoadedSubagent,
);
it('rejects a nested fork request', async () => {
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation({
@@ -3070,13 +3099,17 @@ describe('AgentTool', () => {
prompt: 'do the thing',
subagent_type: 'fork',
});
// One agent frame → the invoker is a level-1 sub-agent, not the
// top-level session, so the fork request must take the fallback path.
await runWithAgentContext('parent-sub', () => invocation.execute());
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'general-purpose',
const result = await runWithAgentContext('parent-sub', () =>
invocation.execute(),
);
expect(partToString(result.llmContent)).toContain(
'subagent_type "fork" is not supported',
);
expect(result.error?.message).toContain(
'subagent_type "fork" is not supported',
);
expect(mockSubagentManager.loadSubagent).not.toHaveBeenCalled();
expect(AgentHeadless.create).not.toHaveBeenCalled();
});
+45 -61
View File
@@ -44,7 +44,6 @@ import {
buildChildMessage,
buildPinnedWorktreeNotice,
buildWorktreeNotice,
isForkSubagentEnabled,
normalizeForkTurns,
runInForkContext,
selectForkHistory,
@@ -877,11 +876,7 @@ The Agent tool launches specialized agents (subprocesses) that autonomously hand
Available agent types and the tools they have access to:
${subagentDescriptions}
${
isForkSubagentEnabled(this.config)
? `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 parameter 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.`
}
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 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
@@ -908,9 +903,6 @@ Usage notes:
- If the user asks for agents "in parallel", group independent launches in a single message with multiple Agent tool use content blocks. Do not parallelize overlapping code changes.
- Top-level regular subagents run in the background by default. Set \`run_in_background: false\` when the current turn must wait for the result before continuing. Nested agent launches run in the foreground and return to their direct parent, so the main agent cannot independently address them as background tasks. Caller-owned \`working_dir\` launches default to foreground and cannot run in the background.
- 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.
${
isForkSubagentEnabled(this.config)
? `
## 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.
@@ -924,12 +916,10 @@ Forks are cheap because they share your prompt cache. Don't set \`model\` on a f
**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.
**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.
`
: ''
}
## Writing the prompt
Brief the agent like a smart colleague: make the delegated task, boundaries, and expected output explicit. Regular subagents have not seen this conversation${isForkSubagentEnabled(this.config) ? '; forks inherit all or the selected recent window' : '.'}
Brief the agent like a smart colleague: make the delegated task, boundaries, and expected output explicit. Regular subagents have not seen this conversation; forks inherit all or the selected recent window.
- Explain what you're trying to accomplish and why.
- Describe what you've already learned or ruled out.
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -1034,8 +1024,7 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent
return 'Parameter "subagent_type" must be a non-empty string.';
}
// `fork` is an explicit pseudo-type resolved by the dispatch logic (not
// a loadable subagent), so it never appears in the registered list; when
// forking is unavailable, dispatch falls back to general-purpose.
// a loadable subagent), so it never appears in the registered list.
const lowerType = params.subagent_type.toLowerCase();
if (lowerType !== FORK_SUBAGENT_TYPE) {
const subagentExists = this.availableSubagents.some(
@@ -2344,45 +2333,40 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
};
try {
// Forking is explicit: `subagent_type: "fork"` selects a fork, and only
// when forking is available (interactive session). Any other value — or
// an omitted subagent_type — resolves to a regular subagent
// Forking is explicit: `subagent_type: "fork"` selects a fork. Any other
// value — or an omitted subagent_type — resolves to a regular subagent
// (general-purpose by default). Its execution mode is decided separately
// below; a fork is opt-in, never the default.
const requestedType = this.params.subagent_type;
const isForkRequested =
requestedType?.toLowerCase() === FORK_SUBAGENT_TYPE;
const isFork =
isForkRequested &&
isForkSubagentEnabled(this.config) &&
// v1: fork is a top-level-only capability. A sub-agent — which may now
// carry the AgentTool via nesting — that requests a fork falls back to
// a general-purpose sub-agent (via effectiveSubagentType below) instead
// of opening a nested fork. Fork nesting is deferred past v1.
isTopLevelSession();
const effectiveSubagentType = isFork
? undefined
: isForkRequested
? // Explicit fork requested but unavailable (non-interactive) →
// fall back to the general-purpose subagent.
DEFAULT_BUILTIN_SUBAGENT_TYPE
: (requestedType ?? DEFAULT_BUILTIN_SUBAGENT_TYPE);
if (isForkRequested && !isFork) {
if (isForkRequested && !isTopLevelSession()) {
debugLogger.debug(
`[AgentTool] Fork request downgraded to a regular sub-agent (${
isTopLevelSession()
? 'forking unavailable in this session'
: 'forks do not nest'
'[AgentTool] Fork request rejected because forks do not nest.',
);
return this.buildSpawnBlockedResult(
'Error: subagent_type "fork" is not supported from within a sub-agent. Complete this task directly with your own tools instead of requesting a nested fork.',
'Nested forks are not supported',
);
}
const isFork = isForkRequested;
if (isFork) {
debugLogger.debug(
`[AgentTool] Fork request accepted with inherited context (${
this.config.isInteractive() ? 'interactive' : 'headless'
}).`,
);
}
const effectiveSubagentType = requestedType
? requestedType
: DEFAULT_BUILTIN_SUBAGENT_TYPE;
let subagentConfig: SubagentConfig;
if (isFork) {
subagentConfig = FORK_AGENT;
} else {
const loadedConfig = await this.subagentManager.loadSubagent(
effectiveSubagentType!,
effectiveSubagentType,
);
if (!loadedConfig) {
// loadSubagent() reads from disk, so reaching this point means the
@@ -2403,7 +2387,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
llmContent: notFoundMessage,
returnDisplay: {
type: 'task_execution' as const,
subagentName: effectiveSubagentType!,
subagentName: effectiveSubagentType,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'failed' as const,
@@ -2427,12 +2411,12 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
updateOutput(this.currentDisplay);
}
// An explicit tool parameter wins. Otherwise, an agent-level background
// flag retains its existing meaning, and safe ordinary one-shot launches
// default to background. Caller-owned working_dir launches stay
// foreground unless explicitly configured for background, which the
// incompatibility guard below rejects. Unavailable fork requests retain
// their existing foreground general-purpose fallback.
// Headless forks always use the background registry, even when
// run_in_background is false. Forks are detached by definition, and a
// short-lived non-interactive process must hold open until the inherited
// work completes. Otherwise, an explicit tool parameter wins. An
// agent-level background flag retains its existing meaning, and safe
// ordinary one-shot launches default to background.
//
// This is the source of truth for the background-classification rule. Two
// UI classifiers replicate it from tool-call args (they cannot see
@@ -2442,24 +2426,26 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
// - packages/desktop/packages/shared/src/agent/tool-matching.ts
// (detectBackgroundEvents)
//
// Background delegation is top-level-only in v1, mirroring the fork
// downgrade above: a nested launcher would be handed a completion
// contract it cannot honor — the success guidance names send_message
// and task_stop (both excluded from sub-agent toolsets), and
// Background delegation is top-level-only in v1. A nested launcher would
// be handed a completion contract it cannot honor — the success guidance
// names send_message and task_stop (both excluded from sub-agent
// toolsets), and
// BackgroundTaskRegistry's single session-level notification callback
// would inject the child's completion into the top-level conversation
// while the launcher (typically finished by then) never hears back.
// Downgrade to an awaited foreground run instead of orphaning the
// child's results.
const backgroundRequested =
this.params.run_in_background ??
(subagentConfig.background === true ||
(!isForkRequested &&
this.params.working_dir === undefined &&
// A `name` passed without an active team falls through to a regular
// one-shot agent above; keep it foreground so both UI classifiers
// (which exclude `name`) stay consistent with core dispatch.
this.params.name === undefined));
isFork && !this.config.isInteractive()
? true
: (this.params.run_in_background ??
(subagentConfig.background === true ||
(!isForkRequested &&
this.params.working_dir === undefined &&
// A `name` passed without an active team falls through to a regular
// one-shot agent above; keep it foreground so both UI classifiers
// (which exclude `name`) stay consistent with core dispatch.
this.params.name === undefined)));
const shouldRunInBackground = backgroundRequested && isTopLevelSession();
if (this.params.working_dir !== undefined && shouldRunInBackground) {
// A caller-owned worktree has no lifecycle coupling to a backgrounded
@@ -2751,9 +2737,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
const agentIdSuffix = this.callId ?? randomUUID().slice(0, 8);
const hookOpts = {
agentId: `${subagentConfig.name}-${agentIdSuffix}`,
// Resolved config name, not the raw requested type: a fork request
// that fell back to the awaitable path (nested / non-interactive)
// must report the agent that actually runs — hooks, spans, task
// Resolved config name, not the raw requested type. Hooks, spans, task
// rows, and the meta sidecar all read this field.
agentType: subagentConfig.name,
resolvedMode,
+4 -14
View File
@@ -1,6 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Content } from '@google/genai';
import type { Config } from '../../config/config.js';
import type { SubagentConfig } from '../../subagents/types.js';
import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js';
import {
@@ -11,30 +10,21 @@ import {
export const FORK_SUBAGENT_TYPE = 'fork';
/**
* Fork subagent availability gate.
*
* Fork is available in interactive sessions. Non-interactive sessions
* (e.g. `qwen -p`, SDK headless, CI/CD) lack a terminal UI for fork progress
* display and permission prompts, which can cause hangs or silent failures.
*
* Forking is an explicit choice — the caller selects it with
* `subagent_type: "fork"`. Omitting `subagent_type` always resolves to the
* general-purpose subagent, never a fork. Regular top-level subagents run in
* the background by default; callers can set `run_in_background: false` for an
* inline result. When fork is unavailable, an explicit `subagent_type: "fork"`
* falls back to the general-purpose subagent.
* inline result. Forks are available in both interactive and headless
* sessions; headless forks use the background registry so the caller waits for
* completion and non-interactive permission policy is applied.
*/
export function isForkSubagentEnabled(config: Config): boolean {
return config.isInteractive();
}
export const FORK_BOILERPLATE_TAG = 'fork-boilerplate';
export const FORK_DIRECTIVE_PREFIX = 'Directive: ';
export const FORK_AGENT = {
name: FORK_SUBAGENT_TYPE,
description:
'Fork yourself — inherits parent conversation context. Selected explicitly via `subagent_type: "fork"` (only in interactive sessions). Runs detached in the background; you are notified when it completes.',
'Fork yourself — inherits parent conversation context. Selected explicitly via `subagent_type: "fork"`. Runs detached in the background; you are notified when it completes.',
tools: ['*'],
systemPrompt:
'You are a forked worker process. Follow the directive in the conversation history. Execute tasks directly using available tools. Do not spawn sub-agents.',
@@ -677,7 +677,7 @@ describe('extractToolResults', () => {
})
})
it('does NOT emit task_backgrounded for an explicit foreground Agent with agentId in result', () => {
it('does NOT emit task_backgrounded for a foreground non-fork Agent with a launch-shaped result', () => {
toolIndex.register('toolu_agent', 'Agent', {
_intent: 'Explore codebase',
prompt: 'Find auth code',
@@ -685,7 +685,10 @@ describe('extractToolResults', () => {
})
const blocks: ContentBlock[] = [
makeToolResultBlock('toolu_agent', 'Found auth in /src/auth.ts\nagentId: fg_agent_xyz'),
makeToolResultBlock(
'toolu_agent',
'Background agent launched successfully.\nagentId: fg_agent_xyz',
),
]
const events = extractToolResults(blocks, null, undefined, toolIndex)
@@ -695,10 +698,9 @@ describe('extractToolResults', () => {
expect(events[0]).toMatchObject({ type: 'tool_result', toolUseId: 'toolu_agent' })
})
it('does NOT emit task_backgrounded for an omitted-flag fork Agent', () => {
// Mirrors core's `!isForkRequested` guard: a `subagent_type: "fork"`
// fallback stays foreground, so no background event even with an agentId
// in the result.
it('does not infer fork background status from args alone', () => {
// An arbitrary result containing an agentId is not sufficient evidence of
// the effective runtime mode.
toolIndex.register('toolu_fork_agent', 'Agent', {
_intent: 'Handle a detached chore',
prompt: 'Handle the detached chore',
@@ -718,6 +720,31 @@ describe('extractToolResults', () => {
})
})
it('detects a registry-backed headless fork from the runtime result', () => {
toolIndex.register('toolu_fork_agent', 'Agent', {
_intent: 'Handle a detached chore',
prompt: 'Handle the detached chore',
subagent_type: 'fork',
run_in_background: false,
})
const blocks: ContentBlock[] = [
makeToolResultBlock(
'toolu_fork_agent',
'Background agent launched successfully.\nagentId: fork_agent_xyz',
),
]
const events = extractToolResults(blocks, null, undefined, toolIndex)
expect(events).toHaveLength(2)
expect(events[1]).toMatchObject({
type: 'task_backgrounded',
toolUseId: 'toolu_fork_agent',
taskId: 'fork_agent_xyz',
})
})
it('does NOT emit task_backgrounded for a named teammate Agent', () => {
// Mirrors core dispatch: a top-level Agent with `name` set routes to a
// named teammate and stays foreground (the classifier excludes
@@ -418,22 +418,29 @@ function detectBackgroundEvents(
// (`backgroundRequested`/`shouldRunInBackground` in AgentTool.execute)
// - web-shell UI: packages/web-shell/client/adapters/toolClassification.ts
// (`isBackgroundSubAgentToolCall`)
// If the routing rule changes in core, update all three. This copy does not
// have the web-shell's `rawOutput.status === 'background'` fallback.
// If the routing rule changes in core, update all three. The web shell reads
// `rawOutput.status`; this adapter reads the serialized runtime result.
const normalizedToolName = entry.name.toLowerCase();
const isTopLevelQwenAgent =
normalizedToolName === 'agent' && parentToolUseId === undefined;
const isForkAgent =
typeof entry.input.subagent_type === 'string' &&
entry.input.subagent_type.toLowerCase() === 'fork';
const defaultsToBackground =
isTopLevelQwenAgent &&
entry.input.run_in_background === undefined &&
entry.input.working_dir === undefined &&
entry.input.name === undefined &&
// Core keeps fork fallbacks in the foreground (`!isForkRequested` in
// AgentTool.execute), so an omitted-flag `subagent_type: "fork"` call must
// not be classified as background here.
(typeof entry.input.subagent_type !== 'string' ||
entry.input.subagent_type.toLowerCase() !== 'fork');
// Args alone cannot distinguish an interactive detached fork from a
// headless registry-backed fork. The runtime result check below handles
// the latter.
!isForkAgent;
const runtimeReportedBackground =
isTopLevelQwenAgent &&
isForkAgent &&
resultStr.startsWith('Background agent launched successfully.');
const wasRunInBackground =
runtimeReportedBackground ||
(entry.input.run_in_background === true &&
(normalizedToolName !== 'agent' || isTopLevelQwenAgent)) ||
defaultsToBackground;
@@ -16,9 +16,9 @@ describe('isBackgroundSubAgentToolCall', () => {
expect(isBackgroundSubAgentToolCall(agentTool())).toBe(true);
});
it('keeps an omitted-flag fork request in the foreground', () => {
// Mirrors core's `!isForkRequested` guard: a `subagent_type: "fork"`
// fallback stays foreground even when run_in_background is omitted.
it('does not infer fork background status from args alone', () => {
// Args alone do not expose whether the runtime is interactive or headless.
// The effective background status is covered by the rawOutput test below.
expect(
isBackgroundSubAgentToolCall(agentTool({ subagent_type: 'fork' })),
).toBe(false);
@@ -41,9 +41,9 @@ export function isBackgroundSubAgentToolCall(tool: ACPToolCall): boolean {
args?.run_in_background === undefined &&
args?.working_dir === undefined &&
args?.name === undefined &&
// Core keeps fork fallbacks in the foreground (`!isForkRequested` in
// AgentTool.execute), so an omitted-flag `subagent_type: "fork"` call must
// not be classified as background here.
// Args alone cannot distinguish an interactive detached fork from a
// headless registry-backed fork. Keep the omitted-flag shape out of this
// heuristic and trust rawOutput.status for the effective runtime mode.
(typeof args?.subagent_type !== 'string' ||
args.subagent_type.toLowerCase() !== 'fork');
const explicitlyBackground =