import { 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 }> { // 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/ 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 { // 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//). 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() 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, resolvedAgentModel: string, additionalWorkingDirectories: string[], resolvedTools: readonly Tool[], ): Promise { 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 }