π File detail
tools/AgentTool/runAgent.ts
π― Use case
This module implements the βAgentToolβ tool (Agent) β something the model can call at runtime alongside other agent tools. On the API surface it exposes filterIncompleteToolCalls β mainly functions, hooks, or classes. Dependencies touch bun:bundle, crypto, lodash-es, and src. It composes internal code from bootstrap, commands, constants, context, and hooks (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
filterIncompleteToolCalls
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundlecryptolodash-essrc
π₯οΈ Source preview
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
}