Skip to content

Commit ba74e09

Browse files
feat: fork-agent-redesign — 新增 AgentTool fork 参数与 spec 设计文档
为 AgentTool 引入 fork 布尔参数,支持子代理从父对话上下文中 fork 出独立分支, 继承完整历史、系统提示和模型配置。重构 inputSchema 条件逻辑以适配 fork 模式。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 86df024 commit ba74e09

7 files changed

Lines changed: 720 additions & 40 deletions

File tree

packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ const baseInputSchema = lazySchema(() =>
148148
.boolean()
149149
.optional()
150150
.describe('Set to true to run this agent in the background. You will be notified when it completes.'),
151+
fork: z
152+
.boolean()
153+
.optional()
154+
.describe(
155+
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
156+
),
151157
}),
152158
);
153159

@@ -191,24 +197,23 @@ const fullInputSchema = lazySchema(() => {
191197
// type, but call() destructures via the explicit AgentToolInput type below
192198
// which always includes all optional fields.
193199
export const inputSchema = lazySchema(() => {
194-
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
195-
196-
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
197-
// was removed in 906da6c723): the divergence window is one-session-per-
198-
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
199-
// "schema shows a no-op param" (gate flips on mid-session: param ignored
200-
// by forceAsync) or "schema hides a param that would've worked" (gate
201-
// flips off mid-session: everything still runs async via memoized
202-
// forceAsync). No Zod rejection, no crash — unlike required→optional.
203-
return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema;
200+
const base = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
201+
return isBackgroundTasksDisabled
202+
? !isForkSubagentEnabled()
203+
? base.omit({ run_in_background: true, fork: true })
204+
: base.omit({ run_in_background: true })
205+
: !isForkSubagentEnabled()
206+
? base.omit({ fork: true })
207+
: base;
204208
});
205209
type InputSchema = ReturnType<typeof inputSchema>;
206210

207211
// Explicit type widens the schema inference to always include all optional
208212
// fields even when .omit() strips them for gating (cwd, run_in_background).
209-
// subagent_type is optional; call() defaults it to general-purpose when the
210-
// fork gate is off, or routes to the fork path when the gate is on.
213+
// subagent_type is optional; call() defaults it to general-purpose.
214+
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
211215
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
216+
fork?: boolean;
212217
name?: string;
213218
team_name?: string;
214219
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
@@ -322,6 +327,7 @@ export const AgentTool = buildTool({
322327
{
323328
prompt,
324329
subagent_type,
330+
fork,
325331
description,
326332
model: modelParam,
327333
run_in_background,
@@ -406,12 +412,11 @@ export const AgentTool = buildTool({
406412
return { data: spawnResult } as unknown as { data: Output };
407413
}
408414

409-
// Fork subagent experiment routing:
410-
// - subagent_type set: use it (explicit wins)
411-
// - subagent_type omitted, gate on: fork path (undefined)
412-
// - subagent_type omitted, gate off: default general-purpose
413-
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
414-
const isForkPath = effectiveType === undefined;
415+
// Fork routing: explicit `fork: true` parameter triggers the fork path
416+
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
417+
// subagent_type is ignored when fork takes effect.
418+
const isForkPath = fork === true && isForkSubagentEnabled();
419+
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
415420

416421
let selectedAgent: AgentDefinition;
417422
if (isForkPath) {
@@ -692,10 +697,6 @@ export const AgentTool = buildTool({
692697
// dependency issues during test module loading.
693698
const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false;
694699

695-
// Fork subagent experiment: force ALL spawns async for a unified
696-
// <task-notification> interaction model (not just fork spawns — all of them).
697-
const forceAsync = isForkSubagentEnabled();
698-
699700
// Assistant mode: force all agents async. Synchronous subagents hold the
700701
// main loop's turn open until they complete — the daemon's inputQueue
701702
// backs up, and the first overdue cron catch-up on spawn becomes N
@@ -709,7 +710,6 @@ export const AgentTool = buildTool({
709710
(run_in_background === true ||
710711
selectedAgent.background === true ||
711712
isCoordinator ||
712-
forceAsync ||
713713
assistantForceAsync ||
714714
(proactiveModule?.isProactiveActive() ?? false)) &&
715715
!isBackgroundTasksDisabled;
@@ -889,7 +889,7 @@ export const AgentTool = buildTool({
889889
toolUseContext,
890890
rootSetAppState,
891891
agentIdForCleanup: asyncAgentId,
892-
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
892+
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
893893
getWorktreeResult: cleanupWorktreeIfNeeded,
894894
}),
895895
),
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { readFileSync } from 'fs'
3+
import { join, dirname } from 'path'
4+
import { fileURLToPath } from 'url'
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url))
7+
const promptSource = readFileSync(join(__dirname, '..', 'prompt.ts'), 'utf-8')
8+
9+
describe('prompt.ts fork-related text verification', () => {
10+
test('does not contain "omit `subagent_type`" guidance', () => {
11+
expect(promptSource).not.toMatch(/omit.*subagent_type/)
12+
})
13+
14+
test('contains `fork: true` in at least 3 locations (shared + whenToFork + forkExamples)', () => {
15+
const matches = promptSource.match(/fork: true/g)
16+
expect(matches).not.toBeNull()
17+
expect(matches!.length).toBeGreaterThanOrEqual(3)
18+
})
19+
20+
test('all forkEnabled references are ternary conditions, not negated', () => {
21+
const lines = promptSource.split('\n')
22+
for (const line of lines) {
23+
if (
24+
line.includes('forkEnabled') &&
25+
!line.includes('const forkEnabled') &&
26+
!line.includes('forkEnabled =')
27+
) {
28+
expect(line).not.toContain('!forkEnabled')
29+
}
30+
}
31+
})
32+
33+
test('uses "non-fork" terminology instead of "fresh agent"', () => {
34+
expect(promptSource).toContain('non-fork')
35+
// "fresh agent" should not appear in fork-aware conditional text
36+
const freshAgentMatches = promptSource.match(/fresh agent/g)
37+
if (freshAgentMatches) {
38+
// Only allowed in comments explaining behavior, not in prompt text
39+
const linesWithFreshAgent = promptSource
40+
.split('\n')
41+
.filter(line => line.includes('fresh agent'))
42+
.map(line => line.trim())
43+
for (const line of linesWithFreshAgent) {
44+
// "fresh agent" in the context of "starts fresh" (not fork-aware) is ok
45+
// but "fresh agent" in forkEnabled conditional should not appear
46+
expect(line).not.toMatch(/fresh agent.*subagent_type/)
47+
}
48+
}
49+
})
50+
51+
test('background task condition does not include !forkEnabled', () => {
52+
// The condition for showing background task instructions should not exclude fork
53+
const bgCondition = promptSource.match(
54+
/!isEnvTruthy.*isInProcessTeammate[\s\S]*?run_in_background/,
55+
)
56+
if (bgCondition) {
57+
expect(bgCondition[0]).not.toContain('!forkEnabled')
58+
}
59+
})
60+
61+
test('fork example includes fork: true parameter', () => {
62+
// The first fork example should have fork: true
63+
const forkExampleBlock = promptSource.match(
64+
/name: "ship-audit"[\s\S]*?Under 200 words/,
65+
)
66+
expect(forkExampleBlock).not.toBeNull()
67+
expect(forkExampleBlock![0]).toContain('fork: true')
68+
})
69+
})

packages/builtin-tools/src/tools/AgentTool/prompt.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,7 @@ export async function getPrompt(
8282
8383
## When to fork
8484
85-
Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size.
86-
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache.
87-
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.
88-
89-
Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run.
85+
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
9086
9187
**Don't peek.** The tool result includes an \`output_file\` path — 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.
9288
@@ -100,14 +96,14 @@ Forks are cheap because they share your prompt cache. Don't set \`model\` on a f
10096
10197
## Writing the prompt
10298
103-
${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
99+
${forkEnabled ? 'When spawning an agent without `fork: true`, it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
104100
- Explain what you're trying to accomplish and why.
105101
- Describe what you've already learned or ruled out.
106102
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
107103
- If you need a short response, say so ("report in under 200 words").
108104
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
109105
110-
${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
106+
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
111107
112108
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
113109
`
@@ -120,6 +116,7 @@ assistant: <thinking>Forking this \u2014 it's a survey question. I want the punc
120116
${AGENT_TOOL_NAME}({
121117
name: "ship-audit",
122118
description: "Branch ship-readiness audit",
119+
fork: true,
123120
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
124121
})
125122
assistant: Ship-readiness audit running.
@@ -205,11 +202,7 @@ The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that auto
205202
206203
${agentListSection}
207204
208-
${
209-
forkEnabled
210-
? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
211-
: `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
212-
}`
205+
When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.${forkEnabled ? ` Set \`fork: true\` to fork from the parent conversation context, inheriting full history and model.` : ''}`
213206

214207
// Coordinator mode gets the slim prompt -- the coordinator system prompt
215208
// already covers usage notes, examples, and when-not-to-use guidance.
@@ -257,14 +250,13 @@ Usage notes:
257250
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${
258251
// eslint-disable-next-line custom-rules/no-process-env-top-level
259252
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
260-
!isInProcessTeammate() &&
261-
!forkEnabled
253+
!isInProcessTeammate()
262254
? `
263255
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
264256
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.`
265257
: ''
266258
}
267-
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
259+
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each non-fork Agent invocation starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
268260
- The agent's outputs should generally be trusted
269261
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
270262
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.

scripts/defines.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const DEFAULT_BUILD_FEATURES = [
5252
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
5353
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
5454
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
55-
// 'FORK_SUBAGENT', // 已禁用:启用后 prompt 引导模型用 fork(继承父模型)替代 Explore(haiku),导致探索任务使用同等级模型
55+
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
5656
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
5757
'KAIROS', // Kairos 定时任务系统核心
5858
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因

0 commit comments

Comments
 (0)