/ tools / AgentTool / forkSubagent.ts
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  }