Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
36 kB · 973 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974import { feature } from 'bun:bundle'import type { UUID } from 'crypto'import { randomUUID } from 'crypto'import uniqBy from 'lodash-es/uniqBy.js'import { logForDebugging } from 'src/utils/debug.js'import { getProjectRoot, getSessionId } from '../../bootstrap/state.js'import { getCommand, getSkillToolCommands, hasCommand } from '../../commands.js'import { DEFAULT_AGENT_PROMPT, enhanceSystemPromptWithEnvDetails,} from '../../constants/prompts.js'import type { QuerySource } from '../../constants/querySource.js'import { getSystemContext, getUserContext } from '../../context.js'import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'import { query } from '../../query.js'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'import { cleanupAgentTracking } from '../../services/api/promptCacheBreakDetection.js'import { connectToServer, fetchToolsForClient,} from '../../services/mcp/client.js'import { getMcpConfigByName } from '../../services/mcp/config.js'import type { MCPServerConnection, ScopedMcpServerConfig,} from '../../services/mcp/types.js'import type { Tool, Tools, ToolUseContext } from '../../Tool.js'import { killShellTasksForAgent } from '../../tasks/LocalShellTask/killShellTasks.js'import type { Command } from '../../types/command.js'import type { AgentId } from '../../types/ids.js'import type { AssistantMessage, Message, ProgressMessage, RequestStartEvent, StreamEvent, SystemCompactBoundaryMessage, TombstoneMessage, ToolUseSummaryMessage, UserMessage,} from '../../types/message.js'import { createAttachmentMessage } from '../../utils/attachments.js'import { AbortError } from '../../utils/errors.js'import { getDisplayPath } from '../../utils/file.js'import { cloneFileStateCache, createFileStateCacheWithSizeLimit, READ_FILE_STATE_CACHE_SIZE,} from '../../utils/fileStateCache.js'import { type CacheSafeParams, createSubagentContext,} from '../../utils/forkedAgent.js'import { registerFrontmatterHooks } from '../../utils/hooks/registerFrontmatterHooks.js'import { clearSessionHooks } from '../../utils/hooks/sessionHooks.js'import { executeSubagentStartHooks } from '../../utils/hooks.js'import { createUserMessage } from '../../utils/messages.js'import { getAgentModel } from '../../utils/model/agent.js'import type { ModelAlias } from '../../utils/model/aliases.js'import { clearAgentTranscriptSubdir, recordSidechainTranscript, setAgentTranscriptSubdir, writeAgentMetadata,} from '../../utils/sessionStorage.js'import { isRestrictedToPluginOnly, isSourceAdminTrusted,} from '../../utils/settings/pluginOnlyPolicy.js'import { asSystemPrompt, type SystemPrompt,} from '../../utils/systemPromptType.js'import { isPerfettoTracingEnabled, registerAgent as registerPerfettoAgent, unregisterAgent as unregisterPerfettoAgent,} from '../../utils/telemetry/perfettoTracing.js'import type { ContentReplacementState } from '../../utils/toolResultStorage.js'import { createAgentId } from '../../utils/uuid.js'import { resolveAgentTools } from './agentToolUtils.js'import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
/** * Initialize agent-specific MCP servers * Agents can define their own MCP servers in their frontmatter that are additive * to the parent's MCP clients. These servers are connected when the agent starts * and cleaned up when the agent finishes. * * @param agentDefinition The agent definition with optional mcpServers * @param parentClients MCP clients inherited from parent context * @returns Merged clients (parent + agent-specific), agent MCP tools, and cleanup function */async function initializeAgentMcpServers( agentDefinition: AgentDefinition, parentClients: MCPServerConnection[],): Promise<{ clients: MCPServerConnection[] tools: Tools cleanup: () => Promise<void>}> { // If no agent-specific servers defined, return parent clients as-is if (!agentDefinition.mcpServers?.length) { return { clients: parentClients, tools: [], cleanup: async () => {}, } }
// When MCP is locked to plugin-only, skip frontmatter MCP servers for // USER-CONTROLLED agents only. Plugin, built-in, and policySettings agents // are admin-trusted — their frontmatter MCP is part of the admin-approved // surface. Blocking them (as the first cut did) breaks plugin agents that // legitimately need MCP, contradicting "plugin-provided always loads." const agentIsAdminTrusted = isSourceAdminTrusted(agentDefinition.source) if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) { logForDebugging( `[Agent: ${agentDefinition.agentType}] Skipping MCP servers: strictPluginOnlyCustomization locks MCP to plugin-only (agent source: ${agentDefinition.source})`, ) return { clients: parentClients, tools: [], cleanup: async () => {}, } }
const agentClients: MCPServerConnection[] = [] // Track which clients were newly created (inline definitions) vs. shared from parent // Only newly created clients should be cleaned up when the agent finishes const newlyCreatedClients: MCPServerConnection[] = [] const agentTools: Tool[] = []
for (const spec of agentDefinition.mcpServers) { let config: ScopedMcpServerConfig | null = null let name: string let isNewlyCreated = false
if (typeof spec === 'string') { // Reference by name - look up in existing MCP configs // This uses the memoized connectToServer, so we may get a shared client name = spec config = getMcpConfigByName(spec) if (!config) { logForDebugging( `[Agent: ${agentDefinition.agentType}] MCP server not found: ${spec}`, { level: 'warn' }, ) continue } } else { // Inline definition as { [name]: config } // These are agent-specific servers that should be cleaned up const entries = Object.entries(spec) if (entries.length !== 1) { logForDebugging( `[Agent: ${agentDefinition.agentType}] Invalid MCP server spec: expected exactly one key`, { level: 'warn' }, ) continue } const [serverName, serverConfig] = entries[0]! name = serverName config = { ...serverConfig, scope: 'dynamic' as const, } as ScopedMcpServerConfig isNewlyCreated = true }
// Connect to the server const client = await connectToServer(name, config) agentClients.push(client) if (isNewlyCreated) { newlyCreatedClients.push(client) }
// Fetch tools if connected if (client.type === 'connected') { const tools = await fetchToolsForClient(client) agentTools.push(...tools) logForDebugging( `[Agent: ${agentDefinition.agentType}] Connected to MCP server '${name}' with ${tools.length} tools`, ) } else { logForDebugging( `[Agent: ${agentDefinition.agentType}] Failed to connect to MCP server '${name}': ${client.type}`, { level: 'warn' }, ) } }
// Create cleanup function for agent-specific servers // Only clean up newly created clients (inline definitions), not shared/referenced ones // Shared clients (referenced by string name) are memoized and used by the parent context const cleanup = async () => { for (const client of newlyCreatedClients) { if (client.type === 'connected') { try { await client.cleanup() } catch (error) { logForDebugging( `[Agent: ${agentDefinition.agentType}] Error cleaning up MCP server '${client.name}': ${error}`, { level: 'warn' }, ) } } } }
// Return merged clients (parent + agent-specific) and agent tools return { clients: [...parentClients, ...agentClients], tools: agentTools, cleanup, }}
type QueryMessage = | StreamEvent | RequestStartEvent | Message | ToolUseSummaryMessage | TombstoneMessage
/** * Type guard to check if a message from query() is a recordable Message type. * Matches the types we want to record: assistant, user, progress, or system compact_boundary. */function isRecordableMessage( msg: QueryMessage,): msg is | AssistantMessage | UserMessage | ProgressMessage | SystemCompactBoundaryMessage { return ( msg.type === 'assistant' || msg.type === 'user' || msg.type === 'progress' || (msg.type === 'system' && 'subtype' in msg && msg.subtype === 'compact_boundary') )}
export async function* runAgent({ agentDefinition, promptMessages, toolUseContext, canUseTool, isAsync, canShowPermissionPrompts, forkContextMessages, querySource, override, model, maxTurns, preserveToolUseResults, availableTools, allowedTools, onCacheSafeParams, contentReplacementState, useExactTools, worktreePath, description, transcriptSubdir, onQueryProgress,}: { agentDefinition: AgentDefinition promptMessages: Message[] toolUseContext: ToolUseContext canUseTool: CanUseToolFn isAsync: boolean /** Whether this agent can show permission prompts. Defaults to !isAsync. * Set to true for in-process teammates that run async but share the terminal. */ canShowPermissionPrompts?: boolean forkContextMessages?: Message[] querySource: QuerySource override?: { userContext?: { [k: string]: string } systemContext?: { [k: string]: string } systemPrompt?: SystemPrompt abortController?: AbortController agentId?: AgentId } model?: ModelAlias maxTurns?: number /** Preserve toolUseResult on messages for subagents with viewable transcripts */ preserveToolUseResults?: boolean /** Precomputed tool pool for the worker agent. Computed by the caller * (AgentTool.tsx) to avoid a circular dependency between runAgent and tools.ts. * Always contains the full tool pool assembled with the worker's own permission * mode, independent of the parent's tool restrictions. */ availableTools: Tools /** Tool permission rules to add to the agent's session allow rules. * When provided, replaces ALL allow rules so the agent only has what's * explicitly listed (parent approvals don't leak through). */ allowedTools?: string[] /** Optional callback invoked with CacheSafeParams after constructing the agent's * system prompt, context, and tools. Used by background summarization to fork * the agent's conversation for periodic progress summaries. */ onCacheSafeParams?: (params: CacheSafeParams) => void /** Replacement state reconstructed from a resumed sidechain transcript so * the same tool results are re-replaced (prompt cache stability). When * omitted, createSubagentContext clones the parent's state. */ contentReplacementState?: ContentReplacementState /** When true, use availableTools directly without filtering through * resolveAgentTools(). Also inherits the parent's thinkingConfig and * isNonInteractiveSession instead of overriding them. Used by the fork * subagent path to produce byte-identical API request prefixes for * prompt cache hits. */ useExactTools?: boolean /** Worktree path if the agent was spawned with isolation: "worktree". * Persisted to metadata so resume can restore the correct cwd. */ worktreePath?: string /** Original task description from AgentTool input. Persisted to metadata * so a resumed agent's notification can show the original description. */ description?: string /** Optional subdirectory under subagents/ to group this agent's transcript * with related ones (e.g. workflows/<runId> for workflow subagents). */ transcriptSubdir?: string /** Optional callback fired on every message yielded by query() — including * stream_event deltas that runAgent otherwise drops. Use to detect liveness * during long single-block streams (e.g. thinking) where no assistant * message is yielded for >60s. */ onQueryProgress?: () => void}): AsyncGenerator<Message, void> { // Track subagent usage for feature discovery
const appState = toolUseContext.getAppState() const permissionMode = appState.toolPermissionContext.mode // Always-shared channel to the root AppState store. toolUseContext.setAppState // is a no-op when the *parent* is itself an async agent (nested async→async), // so session-scoped writes (hooks, bash tasks) must go through this instead. const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState
const resolvedAgentModel = getAgentModel( agentDefinition.model, toolUseContext.options.mainLoopModel, model, permissionMode, )
const agentId = override?.agentId ? override.agentId : createAgentId()
// Route this agent's transcript into a grouping subdirectory if requested // (e.g. workflow subagents write to subagents/workflows/<runId>/). if (transcriptSubdir) { setAgentTranscriptSubdir(agentId, transcriptSubdir) }
// Register agent in Perfetto trace for hierarchy visualization if (isPerfettoTracingEnabled()) { const parentId = toolUseContext.agentId ?? getSessionId() registerPerfettoAgent(agentId, agentDefinition.agentType, parentId) }
// Log API calls path for subagents (ant-only) if (process.env.USER_TYPE === 'ant') { logForDebugging( `[Subagent ${agentDefinition.agentType}] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}`, ) }
// Handle message forking for context sharing // Filter out incomplete tool calls from parent messages to avoid API errors const contextMessages: Message[] = forkContextMessages ? filterIncompleteToolCalls(forkContextMessages) : [] const initialMessages: Message[] = [...contextMessages, ...promptMessages]
const agentReadFileState = forkContextMessages !== undefined ? cloneFileStateCache(toolUseContext.readFileState) : createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)
const [baseUserContext, baseSystemContext] = await Promise.all([ override?.userContext ?? getUserContext(), override?.systemContext ?? getSystemContext(), ])
// Read-only agents (Explore, Plan) don't act on commit/PR/lint rules from // CLAUDE.md — the main agent has full context and interprets their output. // Dropping claudeMd here saves ~5-15 Gtok/week across 34M+ Explore spawns. // Explicit override.userContext from callers is preserved untouched. // Kill-switch defaults true; flip tengu_slim_subagent_claudemd=false to revert. const shouldOmitClaudeMd = agentDefinition.omitClaudeMd && !override?.userContext && getFeatureValue_CACHED_MAY_BE_STALE('tengu_slim_subagent_claudemd', true) const { claudeMd: _omittedClaudeMd, ...userContextNoClaudeMd } = baseUserContext const resolvedUserContext = shouldOmitClaudeMd ? userContextNoClaudeMd : baseUserContext
// Explore/Plan are read-only search agents — the parent-session-start // gitStatus (up to 40KB, explicitly labeled stale) is dead weight. If they // need git info they run `git status` themselves and get fresh data. // Saves ~1-3 Gtok/week fleet-wide. const { gitStatus: _omittedGitStatus, ...systemContextNoGit } = baseSystemContext const resolvedSystemContext = agentDefinition.agentType === 'Explore' || agentDefinition.agentType === 'Plan' ? systemContextNoGit : baseSystemContext
// Override permission mode if agent defines one // However, don't override if parent is in bypassPermissions or acceptEdits mode - those should always take precedence // For async agents, also set shouldAvoidPermissionPrompts since they can't show UI const agentPermissionMode = agentDefinition.permissionMode const agentGetAppState = () => { const state = toolUseContext.getAppState() let toolPermissionContext = state.toolPermissionContext
// Override permission mode if agent defines one (unless parent is bypassPermissions, acceptEdits, or auto) if ( agentPermissionMode && state.toolPermissionContext.mode !== 'bypassPermissions' && state.toolPermissionContext.mode !== 'acceptEdits' && !( feature('TRANSCRIPT_CLASSIFIER') && state.toolPermissionContext.mode === 'auto' ) ) { toolPermissionContext = { ...toolPermissionContext, mode: agentPermissionMode, } }
// Set flag to auto-deny prompts for agents that can't show UI // Use explicit canShowPermissionPrompts if provided, otherwise: // - bubble mode: always show prompts (bubbles to parent terminal) // - default: !isAsync (sync agents show prompts, async agents don't) const shouldAvoidPrompts = canShowPermissionPrompts !== undefined ? !canShowPermissionPrompts : agentPermissionMode === 'bubble' ? false : isAsync if (shouldAvoidPrompts) { toolPermissionContext = { ...toolPermissionContext, shouldAvoidPermissionPrompts: true, } }
// For background agents that can show prompts, await automated checks // (classifier, permission hooks) before showing the permission dialog. // Since these are background agents, waiting is fine — the user should // only be interrupted when automated checks can't resolve the permission. // This applies to bubble mode (always) and explicit canShowPermissionPrompts. if (isAsync && !shouldAvoidPrompts) { toolPermissionContext = { ...toolPermissionContext, awaitAutomatedChecksBeforeDialog: true, } }
// Scope tool permissions: when allowedTools is provided, use them as session rules. // IMPORTANT: Preserve cliArg rules (from SDK's --allowedTools) since those are // explicit permissions from the SDK consumer that should apply to all agents. // Only clear session-level rules from the parent to prevent unintended leakage. if (allowedTools !== undefined) { toolPermissionContext = { ...toolPermissionContext, alwaysAllowRules: { // Preserve SDK-level permissions from --allowedTools cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, // Use the provided allowedTools as session-level permissions session: [...allowedTools], }, } }
// Override effort level if agent defines one const effortValue = agentDefinition.effort !== undefined ? agentDefinition.effort : state.effortValue
if ( toolPermissionContext === state.toolPermissionContext && effortValue === state.effortValue ) { return state } return { ...state, toolPermissionContext, effortValue, } }
const resolvedTools = useExactTools ? availableTools : resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools
const additionalWorkingDirectories = Array.from( appState.toolPermissionContext.additionalWorkingDirectories.keys(), )
const agentSystemPrompt = override?.systemPrompt ? override.systemPrompt : asSystemPrompt( await getAgentSystemPrompt( agentDefinition, toolUseContext, resolvedAgentModel, additionalWorkingDirectories, resolvedTools, ), )
// Determine abortController: // - Override takes precedence // - Async agents get a new unlinked controller (runs independently) // - Sync agents share parent's controller const agentAbortController = override?.abortController ? override.abortController : isAsync ? new AbortController() : toolUseContext.abortController
// Execute SubagentStart hooks and collect additional context const additionalContexts: string[] = [] for await (const hookResult of executeSubagentStartHooks( agentId, agentDefinition.agentType, agentAbortController.signal, )) { if ( hookResult.additionalContexts && hookResult.additionalContexts.length > 0 ) { additionalContexts.push(...hookResult.additionalContexts) } }
// Add SubagentStart hook context as a user message (consistent with SessionStart/UserPromptSubmit) if (additionalContexts.length > 0) { const contextMessage = createAttachmentMessage({ type: 'hook_additional_context', content: additionalContexts, hookName: 'SubagentStart', toolUseID: randomUUID(), hookEvent: 'SubagentStart', }) initialMessages.push(contextMessage) }
// Register agent's frontmatter hooks (scoped to agent lifecycle) // Pass isAgent=true to convert Stop hooks to SubagentStop (since subagents trigger SubagentStop) // Same admin-trusted gate for frontmatter hooks: under ["hooks"] alone // (skills/agents not locked), user agents still load — block their // frontmatter-hook REGISTRATION here where source is known, rather than // blanket-blocking all session hooks at execution time (which would // also kill plugin agents' hooks). const hooksAllowedForThisAgent = !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(agentDefinition.source) if (agentDefinition.hooks && hooksAllowedForThisAgent) { registerFrontmatterHooks( rootSetAppState, agentId, agentDefinition.hooks, `agent '${agentDefinition.agentType}'`, true, // isAgent - converts Stop to SubagentStop ) }
// Preload skills from agent frontmatter const skillsToPreload = agentDefinition.skills ?? [] if (skillsToPreload.length > 0) { const allSkills = await getSkillToolCommands(getProjectRoot())
// Filter valid skills and warn about missing ones const validSkills: Array<{ skillName: string skill: (typeof allSkills)[0] & { type: 'prompt' } }> = []
for (const skillName of skillsToPreload) { // Resolve the skill name, trying multiple strategies: // 1. Exact match (hasCommand checks name, userFacingName, aliases) // 2. Fully-qualified with agent's plugin prefix (e.g., "my-skill" → "plugin:my-skill") // 3. Suffix match on ":skillName" for plugin-namespaced skills const resolvedName = resolveSkillName( skillName, allSkills, agentDefinition, ) if (!resolvedName) { logForDebugging( `[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' specified in frontmatter was not found`, { level: 'warn' }, ) continue }
const skill = getCommand(resolvedName, allSkills) if (skill.type !== 'prompt') { logForDebugging( `[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' is not a prompt-based skill`, { level: 'warn' }, ) continue } validSkills.push({ skillName, skill }) }
// Load all skill contents concurrently and add to initial messages const { formatSkillLoadingMetadata } = await import( '../../utils/processUserInput/processSlashCommand.js' ) const loaded = await Promise.all( validSkills.map(async ({ skillName, skill }) => ({ skillName, skill, content: await skill.getPromptForCommand('', toolUseContext), })), ) for (const { skillName, skill, content } of loaded) { logForDebugging( `[Agent: ${agentDefinition.agentType}] Preloaded skill '${skillName}'`, )
// Add command-message metadata so the UI shows which skill is loading const metadata = formatSkillLoadingMetadata( skillName, skill.progressMessage, )
initialMessages.push( createUserMessage({ content: [{ type: 'text', text: metadata }, ...content], isMeta: true, }), ) } }
// Initialize agent-specific MCP servers (additive to parent's servers) const { clients: mergedMcpClients, tools: agentMcpTools, cleanup: mcpCleanup, } = await initializeAgentMcpServers( agentDefinition, toolUseContext.options.mcpClients, )
// Merge agent MCP tools with resolved agent tools, deduplicating by name. // resolvedTools is already deduplicated (see resolveAgentTools), so skip // the spread + uniqBy overhead when there are no agent-specific MCP tools. const allTools = agentMcpTools.length > 0 ? uniqBy([...resolvedTools, ...agentMcpTools], 'name') : resolvedTools
// Build agent-specific options const agentOptions: ToolUseContext['options'] = { isNonInteractiveSession: useExactTools ? toolUseContext.options.isNonInteractiveSession : isAsync ? true : (toolUseContext.options.isNonInteractiveSession ?? false), appendSystemPrompt: toolUseContext.options.appendSystemPrompt, tools: allTools, commands: [], debug: toolUseContext.options.debug, verbose: toolUseContext.options.verbose, mainLoopModel: resolvedAgentModel, // For fork children (useExactTools), inherit thinking config to match the // parent's API request prefix for prompt cache hits. For regular // sub-agents, disable thinking to control output token costs. thinkingConfig: useExactTools ? toolUseContext.options.thinkingConfig : { type: 'disabled' as const }, mcpClients: mergedMcpClients, mcpResources: toolUseContext.options.mcpResources, agentDefinitions: toolUseContext.options.agentDefinitions, // Fork children (useExactTools path) need querySource on context.options // for the recursive-fork guard at AgentTool.tsx call() — it checks // options.querySource === 'agent:builtin:fork'. This survives autocompact // (which rewrites messages, not context.options). Without this, the guard // reads undefined and only the message-scan fallback fires — which // autocompact defeats by replacing the fork-boilerplate message. ...(useExactTools && { querySource }), }
// Create subagent context using shared helper // - Sync agents share setAppState, setResponseLength, abortController with parent // - Async agents are fully isolated (but with explicit unlinked abortController) const agentToolUseContext = createSubagentContext(toolUseContext, { options: agentOptions, agentId, agentType: agentDefinition.agentType, messages: initialMessages, readFileState: agentReadFileState, abortController: agentAbortController, getAppState: agentGetAppState, // Sync agents share these callbacks with parent shareSetAppState: !isAsync, shareSetResponseLength: true, // Both sync and async contribute to response metrics criticalSystemReminder_EXPERIMENTAL: agentDefinition.criticalSystemReminder_EXPERIMENTAL, contentReplacementState, })
// Preserve tool use results for subagents with viewable transcripts (in-process teammates) if (preserveToolUseResults) { agentToolUseContext.preserveToolUseResults = true }
// Expose cache-safe params for background summarization (prompt cache sharing) if (onCacheSafeParams) { onCacheSafeParams({ systemPrompt: agentSystemPrompt, userContext: resolvedUserContext, systemContext: resolvedSystemContext, toolUseContext: agentToolUseContext, forkContextMessages: initialMessages, }) }
// Record initial messages before the query loop starts, plus the agentType // so resume can route correctly when subagent_type is omitted. Both writes // are fire-and-forget — persistence failure shouldn't block the agent. void recordSidechainTranscript(initialMessages, agentId).catch(_err => logForDebugging(`Failed to record sidechain transcript: ${_err}`), ) void writeAgentMetadata(agentId, { agentType: agentDefinition.agentType, ...(worktreePath && { worktreePath }), ...(description && { description }), }).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`))
// Track the last recorded message UUID for parent chain continuity let lastRecordedUuid: UUID | null = initialMessages.at(-1)?.uuid ?? null
try { for await (const message of query({ messages: initialMessages, systemPrompt: agentSystemPrompt, userContext: resolvedUserContext, systemContext: resolvedSystemContext, canUseTool, toolUseContext: agentToolUseContext, querySource, maxTurns: maxTurns ?? agentDefinition.maxTurns, })) { onQueryProgress?.() // Forward subagent API request starts to parent's metrics display // so TTFT/OTPS update during subagent execution. if ( message.type === 'stream_event' && message.event.type === 'message_start' && message.ttftMs != null ) { toolUseContext.pushApiMetricsEntry?.(message.ttftMs) continue }
// Yield attachment messages (e.g., structured_output) without recording them if (message.type === 'attachment') { // Handle max turns reached signal from query.ts if (message.attachment.type === 'max_turns_reached') { logForDebugging( `[Agent: ${ agentDefinition.agentType}] Reached max turns limit (${ message.attachment.maxTurns})`, ) break } yield message continue }
if (isRecordableMessage(message)) { // Record only the new message with correct parent (O(1) per message) await recordSidechainTranscript( [message], agentId, lastRecordedUuid, ).catch(err => logForDebugging(`Failed to record sidechain transcript: ${err}`), ) if (message.type !== 'progress') { lastRecordedUuid = message.uuid } yield message } }
if (agentAbortController.signal.aborted) { throw new AbortError() }
// Run callback if provided (only built-in agents have callbacks) if (isBuiltInAgent(agentDefinition) && agentDefinition.callback) { agentDefinition.callback() } } finally { // Clean up agent-specific MCP servers (runs on normal completion, abort, or error) await mcpCleanup() // Clean up agent's session hooks if (agentDefinition.hooks) { clearSessionHooks(rootSetAppState, agentId) } // Clean up prompt cache tracking state for this agent if (feature('PROMPT_CACHE_BREAK_DETECTION')) { cleanupAgentTracking(agentId) } // Release cloned file state cache memory agentToolUseContext.readFileState.clear() // Release the cloned fork context messages initialMessages.length = 0 // Release perfetto agent registry entry unregisterPerfettoAgent(agentId) // Release transcript subdir mapping clearAgentTranscriptSubdir(agentId) // Release this agent's todos entry. Without this, every subagent that // called TodoWrite leaves a key in AppState.todos forever (even after all // items complete, the value is [] but the key stays). Whale sessions // spawn hundreds of agents; each orphaned key is a small leak that adds up. rootSetAppState(prev => { if (!(agentId in prev.todos)) return prev const { [agentId]: _removed, ...todos } = prev.todos return { ...prev, todos } }) // Kill any background bash tasks this agent spawned. Without this, a // `run_in_background` shell loop (e.g. test fixture fake-logs.sh) outlives // the agent as a PPID=1 zombie once the main session eventually exits. killShellTasksForAgent(agentId, toolUseContext.getAppState, rootSetAppState) /* eslint-disable @typescript-eslint/no-require-imports */ if (feature('MONITOR_TOOL')) { const mcpMod = require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js') mcpMod.killMonitorMcpTasksForAgent( agentId, toolUseContext.getAppState, rootSetAppState, ) } /* eslint-enable @typescript-eslint/no-require-imports */ }}
/** * Filters out assistant messages with incomplete tool calls (tool uses without results). * This prevents API errors when sending messages with orphaned tool calls. */export function filterIncompleteToolCalls(messages: Message[]): Message[] { // Build a set of tool use IDs that have results const toolUseIdsWithResults = new Set<string>()
for (const message of messages) { if (message?.type === 'user') { const userMessage = message as UserMessage const content = userMessage.message.content if (Array.isArray(content)) { for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id) { toolUseIdsWithResults.add(block.tool_use_id) } } } } }
// Filter out assistant messages that contain tool calls without results return messages.filter(message => { if (message?.type === 'assistant') { const assistantMessage = message as AssistantMessage const content = assistantMessage.message.content if (Array.isArray(content)) { // Check if this assistant message has any tool uses without results const hasIncompleteToolCall = content.some( block => block.type === 'tool_use' && block.id && !toolUseIdsWithResults.has(block.id), ) // Exclude messages with incomplete tool calls return !hasIncompleteToolCall } } // Keep all non-assistant messages and assistant messages without tool calls return true })}
async function getAgentSystemPrompt( agentDefinition: AgentDefinition, toolUseContext: Pick<ToolUseContext, 'options'>, resolvedAgentModel: string, additionalWorkingDirectories: string[], resolvedTools: readonly Tool[],): Promise<string[]> { const enabledToolNames = new Set(resolvedTools.map(t => t.name)) try { const agentPrompt = agentDefinition.getSystemPrompt({ toolUseContext }) const prompts = [agentPrompt]
return await enhanceSystemPromptWithEnvDetails( prompts, resolvedAgentModel, additionalWorkingDirectories, enabledToolNames, ) } catch (_error) { return enhanceSystemPromptWithEnvDetails( [DEFAULT_AGENT_PROMPT], resolvedAgentModel, additionalWorkingDirectories, enabledToolNames, ) }}
/** * Resolve a skill name from agent frontmatter to a registered command name. * * Plugin skills are registered with namespaced names (e.g., "my-plugin:my-skill") * but agents reference them with bare names (e.g., "my-skill"). This function * tries multiple resolution strategies: * * 1. Exact match via hasCommand (name, userFacingName, aliases) * 2. Prefix with agent's plugin name (e.g., "my-skill" → "my-plugin:my-skill") * 3. Suffix match — find any command whose name ends with ":skillName" */function resolveSkillName( skillName: string, allSkills: Command[], agentDefinition: AgentDefinition,): string | null { // 1. Direct match if (hasCommand(skillName, allSkills)) { return skillName }
// 2. Try prefixing with the agent's plugin name // Plugin agents have agentType like "pluginName:agentName" const pluginPrefix = agentDefinition.agentType.split(':')[0] if (pluginPrefix) { const qualifiedName = `${pluginPrefix}:${skillName}` if (hasCommand(qualifiedName, allSkills)) { return qualifiedName } }
// 3. Suffix match — find a skill whose name ends with ":skillName" const suffix = `:${skillName}` const match = allSkills.find(cmd => cmd.name.endsWith(suffix)) if (match) { return match.name }
return null}