Skip to content

Commit 83b394e

Browse files
authored
feat(core): implement fork subagent for context sharing (#2936)
* feat(core): implement fork subagent for context sharing - Make subagent_type optional in AgentTool - Add forkSubagent.ts to build identical tool result prefixes - Run fork processes in the background to preserve UX * fix(core): fix test failures related to root execution and optional subagent_type - Skip pathReader and edit tool permission tests when running as root - Fix agent.test.ts to correctly mock execute call with extraHistory - Remove unused imports in forkSubagent.ts * fix(core): fix fork subagent bugs and add CacheSafeParams integration Bug fixes: - Fix AgentParams.subagent_type type: string -> string? (match schema) - Fix undefined agentType passed to hook system (fallback to subagentConfig.name) - Fix hook continuation missing extraHistory parameter - Fix functionResponse missing id field (match coreToolScheduler pattern) - Fix consecutive user messages in Gemini API (ensure history ends with model) - Fix duplicate task_prompt when directive already in extraHistory - Fix FORK_AGENT.systemPrompt empty string causing createChat to throw - Fix redundant dynamic import of forkSubagent.js (merge into single import) - Fix non-fork agent returning empty string on execution failure - Fix misleading fork child rule referencing non-existent system prompt config - Fix functionResponse.response key from {result:} to {output:} for consistency CacheSafeParams integration: - Retrieve parent's generationConfig via getCacheSafeParams() for cache sharing - Add generationConfigOverride to CreateChatOptions and AgentHeadless.execute() - Add toolsOverride to AgentHeadless.execute() for parent tool declarations - Fork API requests now share byte-identical prefix with parent (DashScope cache hits) - Graceful degradation when CacheSafeParams unavailable (first turn) Docs: - Add Fork Subagent section to sub-agents.md user manual - Add fork-subagent-design.md design document * fix(core): apply subagent tool exclusion to forked agents Fork children were inheriting parent's cached tool declarations directly, bypassing prepareTools() filtering and gaining access to AgentTool and cron tools. Extract EXCLUDED_TOOLS_FOR_SUBAGENTS as a shared constant and apply it to forkToolsOverride. * fix(core): skip env history whenever extraHistory is provided Previously gated on generationConfigOverride, which meant the no-cache fallback path (CacheSafeParams unavailable) still ran getInitialChatHistory and duplicated env bootstrap messages already present in the parent's history. Gate on extraHistory instead so both fork paths skip env init. * fix(core): use explicit skipEnvHistory flag for fork env handling The previous fix gated env-init skipping on the presence of extraHistory, but agent-interactive (arena) also passes extraHistory — its chatHistory is env-stripped by stripStartupContext() and DOES need fresh env init for the child's working directory. Skipping env there broke the interactive path. Replace the implicit gate with an explicit skipEnvHistory option that only fork sets (when extraHistory is present, since fork's history comes from getHistory(true) and already contains env). * fix(core): defend skipEnvHistory gate against empty extraHistory Edge case: when the parent's rawHistory ends with a user message and has length 1, extraHistory becomes []. The previous gate (extraHistory !== undefined) would set skipEnvHistory: true, leaving the fork with neither env bootstrap nor parent history. Check length > 0 so empty arrays fall through to the normal env-init path. * fix(core): apply skipEnvHistory to stop-hook retry execute The second subagent.execute() call in the SubagentStop retry loop was missing skipEnvHistory, so on retry the fork's env context would be duplicated — same bug as the initial tanzhenxin report, just on a less common code path.
1 parent 7103c90 commit 83b394e

File tree

9 files changed

+665
-176
lines changed

9 files changed

+665
-176
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Fork Subagent Design
2+
3+
> Implicit fork subagent that inherits the parent's full conversation context and shares prompt cache for cost-efficient parallel task execution.
4+
5+
## Overview
6+
7+
When the Agent tool is called without `subagent_type`, it triggers an implicit **fork** — a background subagent that inherits the parent's conversation history, system prompt, and tool definitions. The fork uses `CacheSafeParams` to ensure its API requests share the same prefix as the parent's, enabling DashScope prompt cache hits.
8+
9+
## Architecture
10+
11+
```
12+
Parent conversation: [SystemPrompt | Tools | Msg1 | Msg2 | ... | MsgN (model)]
13+
↑ identical prefix for all forks ↑
14+
15+
Fork A: [...MsgN | placeholder results | "Research A"] ← shared cache
16+
Fork B: [...MsgN | placeholder results | "Modify B"] ← shared cache
17+
Fork C: [...MsgN | placeholder results | "Test C"] ← shared cache
18+
```
19+
20+
## Key Components
21+
22+
### 1. FORK_AGENT (`forkSubagent.ts`)
23+
24+
Synthetic agent config, not registered in `builtInAgents`. Has a fallback `systemPrompt` but in practice uses the parent's rendered system prompt via `generationConfigOverride`.
25+
26+
### 2. CacheSafeParams Integration (`agent.ts` + `forkedQuery.ts`)
27+
28+
```
29+
agent.ts (fork path)
30+
31+
├── getCacheSafeParams() ← parent's generationConfig snapshot
32+
│ ├── generationConfig ← systemInstruction + tools + temp/topP
33+
│ └── history ← (not used — we build extraHistory instead)
34+
35+
├── forkGenerationConfig ← passed as generationConfigOverride
36+
└── forkToolsOverride ← FunctionDeclaration[] extracted from tools
37+
38+
39+
AgentHeadless.execute(context, signal, {
40+
extraHistory, ← parent conversation history
41+
generationConfigOverride, ← parent's exact systemInstruction + tools
42+
toolsOverride, ← parent's exact tool declarations
43+
})
44+
45+
46+
AgentCore.createChat(context, {
47+
extraHistory,
48+
generationConfigOverride, ← bypasses buildChatSystemPrompt()
49+
}) AND skips getInitialChatHistory()
50+
│ (extraHistory already has env context)
51+
52+
new GeminiChat(config, generationConfig, startHistory)
53+
↑ byte-identical to parent's config
54+
```
55+
56+
### 3. History Construction (`agent.ts` + `forkSubagent.ts`)
57+
58+
The fork's `extraHistory` must end with a model message to maintain Gemini API's user/model alternation when `agent-headless` sends the `task_prompt`.
59+
60+
Three cases:
61+
62+
| Parent history ends with | extraHistory construction | task_prompt |
63+
| ----------------------------- | ---------------------------------------------------------------------- | ------------------------------ |
64+
| `model` (no function calls) | `[...rawHistory]` (unchanged) | `buildChildMessage(directive)` |
65+
| `model` (with function calls) | `[...rawHistory, model(clone), user(responses+directive), model(ack)]` | `'Begin.'` |
66+
| `user` (unusual) | `rawHistory.slice(0, -1)` (drop trailing user) | `buildChildMessage(directive)` |
67+
68+
### 4. Recursive Fork Prevention (`forkSubagent.ts`)
69+
70+
`isInForkChild()` scans conversation history for the `<fork-boilerplate>` tag. If found, the fork attempt is rejected with an error message.
71+
72+
### 5. Background Execution (`agent.ts`)
73+
74+
Fork uses `void executeSubagent()` (fire-and-forget) and returns `FORK_PLACEHOLDER_RESULT` immediately to the parent. Errors in the background task are caught, logged, and reflected in the display state.
75+
76+
## Data Flow
77+
78+
```
79+
1. Model calls Agent tool (no subagent_type)
80+
2. agent.ts: import forkSubagent.js
81+
3. agent.ts: getCacheSafeParams() → forkGenerationConfig + forkToolsOverride
82+
4. agent.ts: build extraHistory from parent's getHistory(true)
83+
5. agent.ts: build forkTaskPrompt (directive or 'Begin.')
84+
6. agent.ts: createAgentHeadless(FORK_AGENT, ...)
85+
7. agent.ts: void executeSubagent() — background
86+
8. agent.ts: return FORK_PLACEHOLDER_RESULT to parent immediately
87+
9. Background:
88+
a. AgentHeadless.execute(context, signal, {extraHistory, generationConfigOverride, toolsOverride})
89+
b. AgentCore.createChat() — uses parent's generationConfig (cache-shared)
90+
c. runReasoningLoop() — uses parent's tool declarations
91+
d. Fork executes tools, produces result
92+
e. updateDisplay() with final status
93+
```
94+
95+
## Graceful Degradation
96+
97+
If `getCacheSafeParams()` returns null (first turn, no history yet), the fork falls back to:
98+
99+
- `FORK_AGENT.systemPrompt` for system instruction
100+
- `prepareTools()` for tool declarations
101+
102+
This ensures the fork always works, even without cache sharing.
103+
104+
## Files
105+
106+
| File | Role |
107+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
108+
| `packages/core/src/agents/runtime/forkSubagent.ts` | FORK_AGENT config, buildForkedMessages(), isInForkChild(), buildChildMessage() |
109+
| `packages/core/src/tools/agent.ts` | Fork path: CacheSafeParams retrieval, extraHistory construction, background execution |
110+
| `packages/core/src/agents/runtime/agent-headless.ts` | execute() options: generationConfigOverride, toolsOverride |
111+
| `packages/core/src/agents/runtime/agent-core.ts` | CreateChatOptions.generationConfigOverride |
112+
| `packages/core/src/followup/forkedQuery.ts` | CacheSafeParams infrastructure (existing, no changes) |

docs/users/features/sub-agents.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,54 @@ Subagents are independent AI assistants that:
1212
- **Work autonomously** - Once given a task, they work independently until completion or failure
1313
- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time
1414

15+
## Fork Subagent (Implicit Fork)
16+
17+
In addition to named subagents, Qwen Code supports **implicit forking** — when the AI omits the `subagent_type` parameter, it triggers a fork that inherits the parent's full conversation context.
18+
19+
### How Fork Differs from Named Subagents
20+
21+
| | Named Subagent | Fork Subagent |
22+
| ------------- | --------------------------------- | ----------------------------------------------------- |
23+
| Context | Starts fresh, no parent history | Inherits parent's full conversation history |
24+
| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) |
25+
| Execution | Blocks the parent until done | Runs in background, parent continues immediately |
26+
| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context |
27+
28+
### When Fork is Used
29+
30+
The AI automatically uses fork when it needs to:
31+
32+
- Run multiple research tasks in parallel (e.g., "investigate module A, B, and C")
33+
- Perform background work while continuing the main conversation
34+
- Delegate tasks that require understanding of the current conversation context
35+
36+
### Prompt Cache Sharing
37+
38+
All forks share the parent's exact API request prefix (system prompt, tools, conversation history), enabling DashScope prompt cache hits. When 3 forks run in parallel, the shared prefix is cached once and reused — saving 80%+ token costs compared to independent subagents.
39+
40+
### Recursive Fork Prevention
41+
42+
Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it receives an error instructing it to execute tasks directly.
43+
44+
### Current Limitations
45+
46+
- **No result feedback**: Fork results are reflected in the UI progress display but are not automatically fed back into the main conversation. The parent AI sees a placeholder message and cannot act on the fork's output.
47+
- **No worktree isolation**: Forks share the parent's working directory. Concurrent file modifications from multiple forks may conflict.
48+
1549
## Key Benefits
1650

1751
- **Task Specialization**: Create agents optimized for specific workflows (testing, documentation, refactoring, etc.)
1852
- **Context Isolation**: Keep specialized work separate from your main conversation
53+
- **Context Inheritance**: Fork subagents inherit the full conversation for context-heavy parallel tasks
54+
- **Prompt Cache Sharing**: Fork subagents share the parent's cache prefix, reducing token costs
1955
- **Reusability**: Save and reuse agent configurations across projects and sessions
2056
- **Controlled Access**: Limit which tools each agent can use for security and focus
2157
- **Progress Visibility**: Monitor agent execution with real-time progress updates
2258

2359
## How Subagents Work
2460

2561
1. **Configuration**: You create Subagents configurations that define their behavior, tools, and system prompts
26-
2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents
62+
2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents — or implicitly fork when no specific subagent type is needed
2763
3. **Execution**: Subagents work independently, using their configured tools to complete tasks
2864
4. **Results**: They return results and execution summaries back to the main conversation
2965

packages/core/src/agents/runtime/agent-core.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,25 @@ import type {
5858
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
5959
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
6060
import { matchesMcpPattern } from '../../permissions/rule-parser.js';
61-
import { AgentTool } from '../../tools/agent.js';
6261
import { ToolNames } from '../../tools/tool-names.js';
6362
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
6463
import { type ContextState, templateString } from './agent-headless.js';
6564

6665
/**
6766
* Result of a single reasoning loop invocation.
6867
*/
68+
/**
69+
* Tools that must never be available to subagents (including forked agents).
70+
* - AgentTool prevents recursive subagent spawning.
71+
* - Cron tools are session-scoped and should only run from the main session.
72+
*/
73+
export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
74+
ToolNames.AGENT,
75+
ToolNames.CRON_CREATE,
76+
ToolNames.CRON_LIST,
77+
ToolNames.CRON_DELETE,
78+
]);
79+
6980
export interface ReasoningLoopResult {
7081
/** The final model text response (empty if terminated by abort/limits). */
7182
text: string;
@@ -102,6 +113,26 @@ export interface CreateChatOptions {
102113
* conversational context (e.g., from the main session that spawned it).
103114
*/
104115
extraHistory?: Content[];
116+
/**
117+
* When provided, replaces the auto-built generationConfig
118+
* (systemInstruction, temperature, etc.) with this exact config.
119+
* Used by fork subagents to share the parent conversation's cache
120+
* prefix for DashScope prompt caching.
121+
*/
122+
generationConfigOverride?: GenerateContentConfig & {
123+
systemInstruction?: string | Content;
124+
};
125+
/**
126+
* When true, skip injecting the env bootstrap messages from
127+
* `getInitialChatHistory()`. Set by fork subagents because their
128+
* `extraHistory` is the full parent history that already contains
129+
* those env messages — re-injecting would duplicate them.
130+
*
131+
* Other callers (e.g. arena interactive agents) pass an
132+
* env-stripped history and DO need fresh env init for their own
133+
* working directory, so they must leave this unset.
134+
*/
135+
skipEnvHistory?: boolean;
105136
}
106137

107138
/**
@@ -223,30 +254,43 @@ export class AgentCore {
223254
);
224255
}
225256

226-
const envHistory = await getInitialChatHistory(this.runtimeContext);
257+
// Skip env bootstrap when the caller (fork) explicitly says its
258+
// extraHistory already contains those messages. Other callers that
259+
// provide an env-stripped history (e.g. arena) still get fresh env init.
260+
const envHistory = options?.skipEnvHistory
261+
? []
262+
: await getInitialChatHistory(this.runtimeContext);
227263

228264
const startHistory = [
229265
...envHistory,
230266
...(options?.extraHistory ?? []),
231267
...(this.promptConfig.initialMessages ?? []),
232268
];
233269

234-
const systemInstruction = this.promptConfig.systemPrompt
235-
? this.buildChatSystemPrompt(context, options)
236-
: undefined;
270+
// If an override is provided (fork path), use it directly for cache
271+
// sharing. Otherwise, build the config from this agent's promptConfig.
272+
// Note: buildChatSystemPrompt is called OUTSIDE the try/catch so template
273+
// errors propagate to the caller (not swallowed by reportError).
274+
let generationConfig: GenerateContentConfig & {
275+
systemInstruction?: string | Content;
276+
};
237277

238-
try {
239-
const generationConfig: GenerateContentConfig & {
240-
systemInstruction?: string | Content;
241-
} = {
278+
if (options?.generationConfigOverride) {
279+
generationConfig = options.generationConfigOverride;
280+
} else {
281+
const systemInstruction = this.promptConfig.systemPrompt
282+
? this.buildChatSystemPrompt(context, options)
283+
: undefined;
284+
generationConfig = {
242285
temperature: this.modelConfig.temp,
243286
topP: this.modelConfig.top_p,
244287
};
245-
246288
if (systemInstruction) {
247289
generationConfig.systemInstruction = systemInstruction;
248290
}
291+
}
249292

293+
try {
250294
return new GeminiChat(
251295
this.runtimeContext,
252296
generationConfig,
@@ -275,14 +319,7 @@ export class AgentCore {
275319
const toolRegistry = this.runtimeContext.getToolRegistry();
276320
const toolsList: FunctionDeclaration[] = [];
277321

278-
// Tools excluded from subagents: AgentTool (prevent recursion) and
279-
// cron tools (session-scoped, should only be used by the main session).
280-
const excludedFromSubagents = new Set<string>([
281-
AgentTool.Name,
282-
ToolNames.CRON_CREATE,
283-
ToolNames.CRON_LIST,
284-
ToolNames.CRON_DELETE,
285-
]);
322+
const excludedFromSubagents = EXCLUDED_TOOLS_FOR_SUBAGENTS;
286323

287324
if (this.toolConfig) {
288325
const asStrings = this.toolConfig.tools.filter(

packages/core/src/agents/runtime/agent-headless.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,21 @@ export class AgentHeadless {
192192
async execute(
193193
context: ContextState,
194194
externalSignal?: AbortSignal,
195+
options?: {
196+
extraHistory?: Array<import('@google/genai').Content>;
197+
/** Override generationConfig for cache sharing (fork subagent). */
198+
generationConfigOverride?: import('@google/genai').GenerateContentConfig;
199+
/** Override tool declarations for cache sharing (fork subagent). */
200+
toolsOverride?: Array<import('@google/genai').FunctionDeclaration>;
201+
/** Skip env bootstrap injection (fork already inherits parent env). */
202+
skipEnvHistory?: boolean;
203+
},
195204
): Promise<void> {
196-
const chat = await this.core.createChat(context);
205+
const chat = await this.core.createChat(context, {
206+
extraHistory: options?.extraHistory,
207+
generationConfigOverride: options?.generationConfigOverride,
208+
skipEnvHistory: options?.skipEnvHistory,
209+
});
197210

198211
if (!chat) {
199212
this.terminateMode = AgentTerminateMode.ERROR;
@@ -212,7 +225,7 @@ export class AgentHeadless {
212225
abortController.abort();
213226
}
214227

215-
const toolsList = this.core.prepareTools();
228+
const toolsList = options?.toolsOverride ?? this.core.prepareTools();
216229

217230
const initialTaskText = String(
218231
(context.get('task_prompt') as string) ?? 'Get Started!',

0 commit comments

Comments
 (0)