Download src/tools/AgentTool/runAgent.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 35.8 kB
-
https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/AgentTool/runAgent.ts
- Command line
-
hf download hf://chenbhao/codev@e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/AgentTool/runAgent.ts
-
curl -L -o runAgent.ts https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/AgentTool/runAgent.ts
35.8 kB
| 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<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 | |
| } | |