974 lines
35 KiB
TypeScript
974 lines
35 KiB
TypeScript
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
|
|
}
|