Skip to content

Commit 51728dc

Browse files
committed
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
1 parent 7b15dfe commit 51728dc

File tree

6 files changed

+305
-49
lines changed

6 files changed

+305
-49
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+
})
50+
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: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,49 @@ 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+
1544
## Key Benefits
1645

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

2354
## How Subagents Work
2455

2556
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
57+
2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents — or implicitly fork when no specific subagent type is needed
2758
3. **Execution**: Subagents work independently, using their configured tools to complete tasks
2859
4. **Results**: They return results and execution summaries back to the main conversation
2960

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ export interface CreateChatOptions {
101101
* conversational context (e.g., from the main session that spawned it).
102102
*/
103103
extraHistory?: Content[];
104+
/**
105+
* When provided, replaces the auto-built generationConfig
106+
* (systemInstruction, temperature, etc.) with this exact config.
107+
* Used by fork subagents to share the parent conversation's cache
108+
* prefix for DashScope prompt caching.
109+
*/
110+
generationConfigOverride?: GenerateContentConfig & {
111+
systemInstruction?: string | Content;
112+
};
104113
}
105114

106115
/**
@@ -230,22 +239,30 @@ export class AgentCore {
230239
...(this.promptConfig.initialMessages ?? []),
231240
];
232241

233-
const systemInstruction = this.promptConfig.systemPrompt
234-
? this.buildChatSystemPrompt(context, options)
235-
: undefined;
242+
// If an override is provided (fork path), use it directly for cache
243+
// sharing. Otherwise, build the config from this agent's promptConfig.
244+
// Note: buildChatSystemPrompt is called OUTSIDE the try/catch so template
245+
// errors propagate to the caller (not swallowed by reportError).
246+
let generationConfig: GenerateContentConfig & {
247+
systemInstruction?: string | Content;
248+
};
236249

237-
try {
238-
const generationConfig: GenerateContentConfig & {
239-
systemInstruction?: string | Content;
240-
} = {
250+
if (options?.generationConfigOverride) {
251+
generationConfig = options.generationConfigOverride;
252+
} else {
253+
const systemInstruction = this.promptConfig.systemPrompt
254+
? this.buildChatSystemPrompt(context, options)
255+
: undefined;
256+
generationConfig = {
241257
temperature: this.modelConfig.temp,
242258
topP: this.modelConfig.top_p,
243259
};
244-
245260
if (systemInstruction) {
246261
generationConfig.systemInstruction = systemInstruction;
247262
}
263+
}
248264

265+
try {
249266
return new GeminiChat(
250267
this.runtimeContext,
251268
generationConfig,

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,17 @@ export class AgentHeadless {
192192
async execute(
193193
context: ContextState,
194194
externalSignal?: AbortSignal,
195-
options?: { extraHistory?: Array<import('@google/genai').Content> },
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+
},
196202
): Promise<void> {
197203
const chat = await this.core.createChat(context, {
198204
extraHistory: options?.extraHistory,
205+
generationConfigOverride: options?.generationConfigOverride,
199206
});
200207

201208
if (!chat) {
@@ -215,7 +222,7 @@ export class AgentHeadless {
215222
abortController.abort();
216223
}
217224

218-
const toolsList = this.core.prepareTools();
225+
const toolsList = options?.toolsOverride ?? this.core.prepareTools();
219226

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

packages/core/src/agents/runtime/forkSubagent.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export const FORK_AGENT = {
1010
description:
1111
'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type.',
1212
tools: ['*'],
13-
systemPrompt: '',
13+
systemPrompt:
14+
'You are a forked worker process. Follow the directive in the conversation history. Execute tasks directly using available tools. Do not spawn sub-agents.',
1415
level: 'session' as const,
1516
};
1617

@@ -26,33 +27,49 @@ export function isInForkChild(messages: Content[]): boolean {
2627
export const FORK_PLACEHOLDER_RESULT =
2728
'Fork started — processing in background';
2829

30+
/**
31+
* Build extra history messages for a forked subagent.
32+
*
33+
* When the last model message has function calls, we must include matching
34+
* function responses in a user message (Gemini API requirement). The
35+
* directive is embedded in this same user message to avoid consecutive
36+
* user messages.
37+
*
38+
* When there are no function calls, we return [] — the parent history
39+
* already ends with a model text message and the directive will be sent
40+
* as the task_prompt by agent-headless (model → user alternation is OK).
41+
*
42+
* @param directive - The fork directive text (user's prompt)
43+
* @param assistantMessage - The last model message from the parent history
44+
* @returns Extra messages to append to history (may be empty)
45+
*/
2946
export function buildForkedMessages(
3047
directive: string,
3148
assistantMessage: Content,
3249
): Content[] {
33-
// Clone the assistant message to avoid mutating the original
34-
const fullAssistantMessage: Content = {
35-
role: assistantMessage.role,
36-
parts: [...(assistantMessage.parts || [])],
37-
};
38-
3950
const toolUseParts =
4051
assistantMessage.parts?.filter((part) => part.functionCall) || [];
4152

4253
if (toolUseParts.length === 0) {
43-
return [
44-
{
45-
role: 'user',
46-
parts: [{ text: buildChildMessage(directive) }],
47-
},
48-
];
54+
// No function calls — no extra messages needed.
55+
// The parent history already ends with this model message.
56+
return [];
4957
}
5058

51-
// Build tool_result blocks for every tool_use, all with identical placeholder text
59+
// Clone the assistant message to avoid mutating the original
60+
const fullAssistantMessage: Content = {
61+
role: assistantMessage.role,
62+
parts: [...(assistantMessage.parts || [])],
63+
};
64+
65+
// Build tool_result blocks for every tool_use, all with identical placeholder text.
66+
// Include the directive text in the same user message to maintain
67+
// proper user/model alternation.
5268
const toolResultParts = toolUseParts.map((part) => ({
5369
functionResponse: {
70+
id: part.functionCall!.id,
5471
name: part.functionCall!.name,
55-
response: { result: FORK_PLACEHOLDER_RESULT },
72+
response: { output: FORK_PLACEHOLDER_RESULT },
5673
},
5774
}));
5875

@@ -76,7 +93,7 @@ STOP. READ THIS FIRST.
7693
You are a forked worker process. You are NOT the main agent.
7794
7895
RULES (non-negotiable):
79-
1. Your system prompt says "default to forking." IGNORE IT — that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
96+
1. You ARE the fork. Do NOT spawn sub-agents; execute directly.
8097
2. Do NOT converse, ask questions, or suggest next steps
8198
3. Do NOT editorialize or add meta-commentary
8299
4. USE your tools directly: Bash, Read, Write, etc.

0 commit comments

Comments
 (0)