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
48 changes: 24 additions & 24 deletions packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ const baseInputSchema = lazySchema(() =>
.boolean()
.optional()
.describe('Set to true to run this agent in the background. You will be notified when it completes.'),
fork: z
.boolean()
.optional()
.describe(
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
),
}),
);

Expand Down Expand Up @@ -191,24 +197,23 @@ const fullInputSchema = lazySchema(() => {
// type, but call() destructures via the explicit AgentToolInput type below
// which always includes all optional fields.
export const inputSchema = lazySchema(() => {
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });

// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
// was removed in 906da6c723): the divergence window is one-session-per-
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
// "schema shows a no-op param" (gate flips on mid-session: param ignored
// by forceAsync) or "schema hides a param that would've worked" (gate
// flips off mid-session: everything still runs async via memoized
// forceAsync). No Zod rejection, no crash — unlike required→optional.
return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema;
const base = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
return isBackgroundTasksDisabled
? !isForkSubagentEnabled()
? base.omit({ run_in_background: true, fork: true })
: base.omit({ run_in_background: true })
: !isForkSubagentEnabled()
? base.omit({ fork: true })
: base;
});
type InputSchema = ReturnType<typeof inputSchema>;

// Explicit type widens the schema inference to always include all optional
// fields even when .omit() strips them for gating (cwd, run_in_background).
// subagent_type is optional; call() defaults it to general-purpose when the
// fork gate is off, or routes to the fork path when the gate is on.
// subagent_type is optional; call() defaults it to general-purpose.
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
fork?: boolean;
name?: string;
team_name?: string;
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
Expand Down Expand Up @@ -322,6 +327,7 @@ export const AgentTool = buildTool({
{
prompt,
subagent_type,
fork,
description,
model: modelParam,
run_in_background,
Expand Down Expand Up @@ -406,12 +412,11 @@ export const AgentTool = buildTool({
return { data: spawnResult } as unknown as { data: Output };
}

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

Comment on lines +415 to 420
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check the routing logic in AgentTool.tsx around lines 415-420
cat -n packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | sed -n '405,430p'

Repository: claude-code-best/claude-code

Length of output: 1544


🏁 Script executed:

# Find and examine the fork.tsx caller
fd -t f fork.tsx | head -5

Repository: claude-code-best/claude-code

Length of output: 99


🏁 Script executed:

# Also check for any inputSchema definitions to understand the implicit behavior
rg -n "inputSchema|subagent_type" packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx -A 2 -B 2 | head -100

Repository: claude-code-best/claude-code

Length of output: 2893


🏁 Script executed:

# Examine the AgentTool.call() invocation in fork.tsx
cat -n src/commands/fork/fork.tsx | sed -n '1,100p'

Repository: claude-code-best/claude-code

Length of output: 3289


🏁 Script executed:

# Look for the actual AgentTool.call() invocation
rg -n "AgentTool|call\(" src/commands/fork/fork.tsx -A 5 -B 5 | head -80

Repository: claude-code-best/claude-code

Length of output: 1801


Add fork: true to the fork command's AgentTool invocation.

The routing logic in AgentTool.tsx changed from implicit (omit subagent_type to trigger fork) to explicit (fork: true parameter). The /fork command in src/commands/fork/fork.tsx (lines 42–48) has not been updated and still relies on the old implicit behavior. Without fork: true, the fork command will spawn a general-purpose agent instead of a fork, silently producing incorrect behavior.

Required fix
 const input = {
   prompt: directive,
+  fork: true,
   run_in_background: true,
   description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` around lines 415 -
420, The /fork command still relies on the old implicit fork behavior, so update
the AgentTool invocation in the fork command handler to include fork: true in
the options/payload so the routing logic (isForkPath checks fork === true) takes
the fork path; specifically, add fork: true alongside or within the object that
currently passes subagent_type/effectiveType to AgentTool (the call site in the
fork command), ensuring the payload contains fork: true so AgentTool's
isForkPath and effectiveType logic selects the fork route.

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

// Fork subagent experiment: force ALL spawns async for a unified
// <task-notification> interaction model (not just fork spawns — all of them).
const forceAsync = isForkSubagentEnabled();

// Assistant mode: force all agents async. Synchronous subagents hold the
// main loop's turn open until they complete — the daemon's inputQueue
// backs up, and the first overdue cron catch-up on spawn becomes N
Expand All @@ -709,7 +710,6 @@ export const AgentTool = buildTool({
(run_in_background === true ||
selectedAgent.background === true ||
isCoordinator ||
forceAsync ||
assistantForceAsync ||
(proactiveModule?.isProactiveActive() ?? false)) &&
!isBackgroundTasksDisabled;
Expand Down Expand Up @@ -889,7 +889,7 @@ export const AgentTool = buildTool({
toolUseContext,
rootSetAppState,
agentIdForCleanup: asyncAgentId,
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
getWorktreeResult: cleanupWorktreeIfNeeded,
}),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

const __dirname = dirname(fileURLToPath(import.meta.url))
const promptSource = readFileSync(join(__dirname, '..', 'prompt.ts'), 'utf-8')

describe('prompt.ts fork-related text verification', () => {
test('does not contain "omit `subagent_type`" guidance', () => {
expect(promptSource).not.toMatch(/omit.*subagent_type/)
})

test('contains `fork: true` in at least 3 locations (shared + whenToFork + forkExamples)', () => {
const matches = promptSource.match(/fork: true/g)
expect(matches).not.toBeNull()
expect(matches!.length).toBeGreaterThanOrEqual(3)
})

test('all forkEnabled references are ternary conditions, not negated', () => {
const lines = promptSource.split('\n')
for (const line of lines) {
if (
line.includes('forkEnabled') &&
!line.includes('const forkEnabled') &&
!line.includes('forkEnabled =')
) {
expect(line).not.toContain('!forkEnabled')
}
}
})

test('uses "non-fork" terminology instead of "fresh agent"', () => {
expect(promptSource).toContain('non-fork')
// "fresh agent" should not appear in fork-aware conditional text
const freshAgentMatches = promptSource.match(/fresh agent/g)
if (freshAgentMatches) {
// Only allowed in comments explaining behavior, not in prompt text
const linesWithFreshAgent = promptSource
.split('\n')
.filter(line => line.includes('fresh agent'))
.map(line => line.trim())
for (const line of linesWithFreshAgent) {
// "fresh agent" in the context of "starts fresh" (not fork-aware) is ok
// but "fresh agent" in forkEnabled conditional should not appear
expect(line).not.toMatch(/fresh agent.*subagent_type/)
}
}
})

test('background task condition does not include !forkEnabled', () => {
// The condition for showing background task instructions should not exclude fork
const bgCondition = promptSource.match(
/!isEnvTruthy.*isInProcessTeammate[\s\S]*?run_in_background/,
)
if (bgCondition) {
expect(bgCondition[0]).not.toContain('!forkEnabled')
}
})
Comment on lines +51 to +59
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fail if the background-note block disappears.

If promptSource.match(...) returns null, this test still passes, so deleting the run_in_background guidance entirely would not be caught. Assert the match exists before checking that it does not contain !forkEnabled.

Suggested fix
   test('background task condition does not include !forkEnabled', () => {
     // The condition for showing background task instructions should not exclude fork
     const bgCondition = promptSource.match(
       /!isEnvTruthy.*isInProcessTeammate[\s\S]*?run_in_background/,
     )
-    if (bgCondition) {
-      expect(bgCondition[0]).not.toContain('!forkEnabled')
-    }
+    expect(bgCondition).not.toBeNull()
+    expect(bgCondition![0]).not.toContain('!forkEnabled')
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts` around
lines 51 - 59, The test currently allows promptSource.match(...) to be null so
deletion of the run_in_background guidance would pass undetected; update the
test in the 'background task condition does not include !forkEnabled' case to
first assert that the regex match (bgCondition returned from
promptSource.match(/!isEnvTruthy.*isInProcessTeammate[\s\S]*?run_in_background/))
is not null (e.g., expect(bgCondition).toBeTruthy() or
expect(bgCondition).not.toBeNull()) before calling
expect(bgCondition[0]).not.toContain('!forkEnabled'); this ensures the presence
of the run_in_background block is enforced before checking it does not contain
'!forkEnabled'.


test('fork example includes fork: true parameter', () => {
// The first fork example should have fork: true
const forkExampleBlock = promptSource.match(
/name: "ship-audit"[\s\S]*?Under 200 words/,
)
expect(forkExampleBlock).not.toBeNull()
expect(forkExampleBlock![0]).toContain('fork: true')
})
})
22 changes: 7 additions & 15 deletions packages/builtin-tools/src/tools/AgentTool/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,7 @@ export async function getPrompt(

## When to fork

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.
- **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.
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.

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.
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).

**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.

Expand All @@ -100,14 +96,14 @@ Forks are cheap because they share your prompt cache. Don't set \`model\` on a f

## Writing the prompt

${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.
${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.
- 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.
- If you need a short response, say so ("report in under 200 words").
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.

${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.

**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.
`
Expand All @@ -120,6 +116,7 @@ assistant: <thinking>Forking this \u2014 it's a survey question. I want the punc
${AGENT_TOOL_NAME}({
name: "ship-audit",
description: "Branch ship-readiness audit",
fork: true,
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."
})
assistant: Ship-readiness audit running.
Expand Down Expand Up @@ -205,11 +202,7 @@ The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that auto

${agentListSection}

${
forkEnabled
? `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.`
: `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.`
}`
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.` : ''}`

// Coordinator mode gets the slim prompt -- the coordinator system prompt
// already covers usage notes, examples, and when-not-to-use guidance.
Expand Down Expand Up @@ -257,14 +250,13 @@ Usage notes:
- 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.${
// eslint-disable-next-line custom-rules/no-process-env-top-level
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
!isInProcessTeammate() &&
!forkEnabled
!isInProcessTeammate()
? `
- 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.
- **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.`
: ''
}
- 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.'}
- 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.'}
- The agent's outputs should generally be trusted
- 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"}
- 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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/defines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const DEFAULT_BUILD_FEATURES = [
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
// 'FORK_SUBAGENT', // 已禁用:启用后 prompt 引导模型用 fork(继承父模型)替代 Explore(haiku),导致探索任务使用同等级模型
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
'KAIROS', // Kairos 定时任务系统核心
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
Expand Down
Loading
Loading