forkSubagent.ts
1 import { feature } from 'bun:bundle' 2 import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 3 import { randomUUID } from 'crypto' 4 import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 5 import { 6 FORK_BOILERPLATE_TAG, 7 FORK_DIRECTIVE_PREFIX, 8 } from '../../constants/xml.js' 9 import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' 10 import type { 11 AssistantMessage, 12 Message as MessageType, 13 } from '../../types/message.js' 14 import { logForDebugging } from '../../utils/debug.js' 15 import { createUserMessage } from '../../utils/messages.js' 16 import type { BuiltInAgentDefinition } from './loadAgentsDir.js' 17 18 /** 19 * Fork subagent feature gate. 20 * 21 * When enabled: 22 * - `subagent_type` becomes optional on the Agent tool schema 23 * - Omitting `subagent_type` triggers an implicit fork: the child inherits 24 * the parent's full conversation context and system prompt 25 * - All agent spawns run in the background (async) for a unified 26 * `<task-notification>` interaction model 27 * - `/fork <directive>` slash command is available 28 * 29 * Mutually exclusive with coordinator mode — coordinator already owns the 30 * orchestration role and has its own delegation model. 31 */ 32 export function isForkSubagentEnabled(): boolean { 33 if (feature('FORK_SUBAGENT')) { 34 if (isCoordinatorMode()) return false 35 if (getIsNonInteractiveSession()) return false 36 return true 37 } 38 return false 39 } 40 41 /** Synthetic agent type name used for analytics when the fork path fires. */ 42 export const FORK_SUBAGENT_TYPE = 'fork' 43 44 /** 45 * Synthetic agent definition for the fork path. 46 * 47 * Not registered in builtInAgents — used only when `!subagent_type` and the 48 * experiment is active. `tools: ['*']` with `useExactTools` means the fork 49 * child receives the parent's exact tool pool (for cache-identical API 50 * prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the 51 * parent terminal. `model: 'inherit'` keeps the parent's model for context 52 * length parity. 53 * 54 * The getSystemPrompt here is unused: the fork path passes 55 * `override.systemPrompt` with the parent's already-rendered system prompt 56 * bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing 57 * by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and 58 * bust the prompt cache; threading the rendered bytes is byte-exact. 59 */ 60 export const FORK_AGENT = { 61 agentType: FORK_SUBAGENT_TYPE, 62 whenToUse: 63 'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.', 64 tools: ['*'], 65 maxTurns: 200, 66 model: 'inherit', 67 permissionMode: 'bubble', 68 source: 'built-in', 69 baseDir: 'built-in', 70 getSystemPrompt: () => '', 71 } satisfies BuiltInAgentDefinition 72 73 /** 74 * Guard against recursive forking. Fork children keep the Agent tool in their 75 * tool pool for cache-identical tool definitions, so we reject fork attempts 76 * at call time by detecting the fork boilerplate tag in conversation history. 77 */ 78 export function isInForkChild(messages: MessageType[]): boolean { 79 return messages.some(m => { 80 if (m.type !== 'user') return false 81 const content = m.message.content 82 if (!Array.isArray(content)) return false 83 return content.some( 84 block => 85 block.type === 'text' && 86 block.text.includes(`<${FORK_BOILERPLATE_TAG}>`), 87 ) 88 }) 89 } 90 91 /** Placeholder text used for all tool_result blocks in the fork prefix. 92 * Must be identical across all fork children for prompt cache sharing. */ 93 const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background' 94 95 /** 96 * Build the forked conversation messages for the child agent. 97 * 98 * For prompt cache sharing, all fork children must produce byte-identical 99 * API request prefixes. This function: 100 * 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text) 101 * 2. Builds a single user message with tool_results for every tool_use block 102 * using an identical placeholder, then appends a per-child directive text block 103 * 104 * Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)] 105 * Only the final text block differs per child, maximizing cache hits. 106 */ 107 export function buildForkedMessages( 108 directive: string, 109 assistantMessage: AssistantMessage, 110 ): MessageType[] { 111 // Clone the assistant message to avoid mutating the original, keeping all 112 // content blocks (thinking, text, and every tool_use) 113 const fullAssistantMessage: AssistantMessage = { 114 ...assistantMessage, 115 uuid: randomUUID(), 116 message: { 117 ...assistantMessage.message, 118 content: [...assistantMessage.message.content], 119 }, 120 } 121 122 // Collect all tool_use blocks from the assistant message 123 const toolUseBlocks = assistantMessage.message.content.filter( 124 (block): block is BetaToolUseBlock => block.type === 'tool_use', 125 ) 126 127 if (toolUseBlocks.length === 0) { 128 logForDebugging( 129 `No tool_use blocks found in assistant message for fork directive: ${directive.slice(0, 50)}...`, 130 { level: 'error' }, 131 ) 132 return [ 133 createUserMessage({ 134 content: [ 135 { type: 'text' as const, text: buildChildMessage(directive) }, 136 ], 137 }), 138 ] 139 } 140 141 // Build tool_result blocks for every tool_use, all with identical placeholder text 142 const toolResultBlocks = toolUseBlocks.map(block => ({ 143 type: 'tool_result' as const, 144 tool_use_id: block.id, 145 content: [ 146 { 147 type: 'text' as const, 148 text: FORK_PLACEHOLDER_RESULT, 149 }, 150 ], 151 })) 152 153 // Build a single user message: all placeholder tool_results + the per-child directive 154 // TODO(smoosh): this text sibling creates a [tool_result, text] pattern on the wire 155 // (renders as </function_results>\n\nHuman:<text>). One-off per-child construction, 156 // not a repeated teacher, so low-priority. If we ever care, use smooshIntoToolResult 157 // from src/utils/messages.ts to fold the directive into the last tool_result.content. 158 const toolResultMessage = createUserMessage({ 159 content: [ 160 ...toolResultBlocks, 161 { 162 type: 'text' as const, 163 text: buildChildMessage(directive), 164 }, 165 ], 166 }) 167 168 return [fullAssistantMessage, toolResultMessage] 169 } 170 171 export function buildChildMessage(directive: string): string { 172 return `<${FORK_BOILERPLATE_TAG}> 173 STOP. READ THIS FIRST. 174 175 You are a forked worker process. You are NOT the main agent. 176 177 RULES (non-negotiable): 178 1. Your system prompt says "default to forking." IGNORE IT \u2014 that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly. 179 2. Do NOT converse, ask questions, or suggest next steps 180 3. Do NOT editorialize or add meta-commentary 181 4. USE your tools directly: Bash, Read, Write, etc. 182 5. If you modify files, commit your changes before reporting. Include the commit hash in your report. 183 6. Do NOT emit text between tool calls. Use tools silently, then report once at the end. 184 7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas. 185 8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise. 186 9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud. 187 10. REPORT structured facts, then stop 188 189 Output format (plain text labels, not markdown headers): 190 Scope: <echo back your assigned scope in one sentence> 191 Result: <the answer or key findings, limited to the scope above> 192 Key files: <relevant file paths — include for research tasks> 193 Files changed: <list with commit hash — include only if you modified files> 194 Issues: <list — include only if there are issues to flag> 195 </${FORK_BOILERPLATE_TAG}> 196 197 ${FORK_DIRECTIVE_PREFIX}${directive}` 198 } 199 200 /** 201 * Notice injected into fork children running in an isolated worktree. 202 * Tells the child to translate paths from the inherited context, re-read 203 * potentially stale files, and that its changes are isolated. 204 */ 205 export function buildWorktreeNotice( 206 parentCwd: string, 207 worktreeCwd: string, 208 ): string { 209 return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.` 210 }