import { feature } from 'bun:bundle' import { z } from 'zod/v4' import { clearInvokedSkillsForAgent } from '../../bootstrap/state.js' import { ALL_AGENT_DISALLOWED_TOOLS, ASYNC_AGENT_ALLOWED_TOOLS, CUSTOM_AGENT_DISALLOWED_TOOLS, IN_PROCESS_TEAMMATE_ALLOWED_TOOLS, } from '../../constants/tools.js' import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../../services/analytics/index.js' import { clearDumpState } from '../../services/api/dumpPrompts.js' import type { AppState } from '../../state/AppState.js' import type { Tool, ToolPermissionContext, Tools, ToolUseContext, } from '../../Tool.js' import { toolMatchesName } from '../../Tool.js' import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, type ProgressTracker, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage, } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import { asAgentId } from '../../types/ids.js' import type { Message as MessageType } from '../../types/message.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { logForDebugging } from '../../utils/debug.js' import { isInProtectedNamespace } from '../../utils/envUtils.js' import { AbortError, errorMessage } from '../../utils/errors.js' import type { CacheSafeParams } from '../../utils/forkedAgent.js' import { lazySchema } from '../../utils/lazySchema.js' import { extractTextContent, getLastAssistantMessage, } from '../../utils/messages.js' import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' import { permissionRuleValueFromString } from '../../utils/permissions/permissionRuleParser.js' import { buildTranscriptForClassifier, classifyYoloAction, } from '../../utils/permissions/yoloClassifier.js' import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js' import { isInProcessTeammate } from '../../utils/teammateContext.js' import { getTokenCountFromUsage } from '../../utils/tokens.js' import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js' import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js' import type { AgentDefinition } from './loadAgentsDir.js' export type ResolvedAgentTools = { hasWildcard: boolean validTools: string[] invalidTools: string[] resolvedTools: Tools allowedAgentTypes?: string[] } export function filterToolsForAgent({ tools, isBuiltIn, isAsync = false, permissionMode, }: { tools: Tools isBuiltIn: boolean isAsync?: boolean permissionMode?: PermissionMode }): Tools { return tools.filter(tool => { // Allow MCP tools for all agents if (tool.name.startsWith('mcp__')) { return true } // Allow ExitPlanMode for agents in plan mode (e.g., in-process teammates) // This bypasses both the ALL_AGENT_DISALLOWED_TOOLS and async tool filters if ( toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) && permissionMode === 'plan' ) { return true } if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) { return false } if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) { return false } if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) { if (isAgentSwarmsEnabled() && isInProcessTeammate()) { // Allow AgentTool for in-process teammates to spawn sync subagents. // Validation in AgentTool.call() prevents background agents and teammate spawning. if (toolMatchesName(tool, AGENT_TOOL_NAME)) { return true } // Allow task tools for in-process teammates to coordinate via shared task list if (IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.has(tool.name)) { return true } } return false } return true }) } /** * Resolves and validates agent tools against available tools * Handles wildcard expansion and validation in one place */ export function resolveAgentTools( agentDefinition: Pick< AgentDefinition, 'tools' | 'disallowedTools' | 'source' | 'permissionMode' >, availableTools: Tools, isAsync = false, isMainThread = false, ): ResolvedAgentTools { const { tools: agentTools, disallowedTools, source, permissionMode, } = agentDefinition // When isMainThread is true, skip filterToolsForAgent entirely — the main // thread's tool pool is already properly assembled by useMergedTools(), so // the sub-agent disallow lists shouldn't apply. const filteredAvailableTools = isMainThread ? availableTools : filterToolsForAgent({ tools: availableTools, isBuiltIn: source === 'built-in', isAsync, permissionMode, }) // Create a set of disallowed tool names for quick lookup const disallowedToolSet = new Set( disallowedTools?.map(toolSpec => { const { toolName } = permissionRuleValueFromString(toolSpec) return toolName }) ?? [], ) // Filter available tools based on disallowed list const allowedAvailableTools = filteredAvailableTools.filter( tool => !disallowedToolSet.has(tool.name), ) // If tools is undefined or ['*'], allow all tools (after filtering disallowed) const hasWildcard = agentTools === undefined || (agentTools.length === 1 && agentTools[0] === '*') if (hasWildcard) { return { hasWildcard: true, validTools: [], invalidTools: [], resolvedTools: allowedAvailableTools, } } const availableToolMap = new Map() for (const tool of allowedAvailableTools) { availableToolMap.set(tool.name, tool) } const validTools: string[] = [] const invalidTools: string[] = [] const resolved: Tool[] = [] const resolvedToolsSet = new Set() let allowedAgentTypes: string[] | undefined for (const toolSpec of agentTools) { // Parse the tool spec to extract the base tool name and any permission pattern const { toolName, ruleContent } = permissionRuleValueFromString(toolSpec) // Special case: Agent tool carries allowedAgentTypes metadata in its spec if (toolName === AGENT_TOOL_NAME) { if (ruleContent) { // Parse comma-separated agent types: "worker, researcher" → ["worker", "researcher"] allowedAgentTypes = ruleContent.split(',').map(s => s.trim()) } // For sub-agents, Agent is excluded by filterToolsForAgent — mark the spec // valid for allowedAgentTypes tracking but skip tool resolution. if (!isMainThread) { validTools.push(toolSpec) continue } // For main thread, filtering was skipped so Agent is in availableToolMap — // fall through to normal resolution below. } const tool = availableToolMap.get(toolName) if (tool) { validTools.push(toolSpec) if (!resolvedToolsSet.has(tool)) { resolved.push(tool) resolvedToolsSet.add(tool) } } else { invalidTools.push(toolSpec) } } return { hasWildcard: false, validTools, invalidTools, resolvedTools: resolved, allowedAgentTypes, } } export const agentToolResultSchema = lazySchema(() => z.object({ agentId: z.string(), // Optional: older persisted sessions won't have this (resume replays // results verbatim without re-validation). Used to gate the sync // result trailer — one-shot built-ins skip the SendMessage hint. agentType: z.string().optional(), content: z.array(z.object({ type: z.literal('text'), text: z.string() })), totalToolUseCount: z.number(), totalDurationMs: z.number(), totalTokens: z.number(), usage: z.object({ input_tokens: z.number(), output_tokens: z.number(), cache_creation_input_tokens: z.number().nullable(), cache_read_input_tokens: z.number().nullable(), server_tool_use: z .object({ web_search_requests: z.number(), web_fetch_requests: z.number(), }) .nullable(), service_tier: z.enum(['standard', 'priority', 'batch']).nullable(), cache_creation: z .object({ ephemeral_1h_input_tokens: z.number(), ephemeral_5m_input_tokens: z.number(), }) .nullable(), }), }), ) export type AgentToolResult = z.input> export function countToolUses(messages: MessageType[]): number { let count = 0 for (const m of messages) { if (m.type === 'assistant') { for (const block of m.message.content) { if (block.type === 'tool_use') { count++ } } } } return count } export function finalizeAgentTool( agentMessages: MessageType[], agentId: string, metadata: { prompt: string resolvedAgentModel: string isBuiltInAgent: boolean startTime: number agentType: string isAsync: boolean }, ): AgentToolResult { const { prompt, resolvedAgentModel, isBuiltInAgent, startTime, agentType, isAsync, } = metadata const lastAssistantMessage = getLastAssistantMessage(agentMessages) if (lastAssistantMessage === undefined) { throw new Error('No assistant messages found') } // Extract text content from the agent's response. If the final assistant // message is a pure tool_use block (loop exited mid-turn), fall back to // the most recent assistant message that has text content. let content = lastAssistantMessage.message.content.filter( _ => _.type === 'text', ) if (content.length === 0) { for (let i = agentMessages.length - 1; i >= 0; i--) { const m = agentMessages[i]! if (m.type !== 'assistant') continue const textBlocks = m.message.content.filter(_ => _.type === 'text') if (textBlocks.length > 0) { content = textBlocks break } } } const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message.usage) const totalToolUseCount = countToolUses(agentMessages) logEvent('tengu_agent_tool_completed', { agent_type: agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, prompt_char_count: prompt.length, response_char_count: content.length, assistant_message_count: agentMessages.length, total_tool_uses: totalToolUseCount, duration_ms: Date.now() - startTime, total_tokens: totalTokens, is_built_in_agent: isBuiltInAgent, is_async: isAsync, }) // Signal to inference that this subagent's cache chain can be evicted. const lastRequestId = lastAssistantMessage.requestId if (lastRequestId) { logEvent('tengu_cache_eviction_hint', { scope: 'subagent_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, last_request_id: lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } return { agentId, agentType, content, totalDurationMs: Date.now() - startTime, totalTokens, totalToolUseCount, usage: lastAssistantMessage.message.usage, } } /** * Returns the name of the last tool_use block in an assistant message, * or undefined if the message is not an assistant message with tool_use. */ export function getLastToolUseName(message: MessageType): string | undefined { if (message.type !== 'assistant') return undefined const block = message.message.content.findLast(b => b.type === 'tool_use') return block?.type === 'tool_use' ? block.name : undefined } export function emitTaskProgress( tracker: ProgressTracker, taskId: string, toolUseId: string | undefined, description: string, startTime: number, lastToolName: string, ): void { const progress = getProgressUpdate(tracker) emitTaskProgressEvent({ taskId, toolUseId, description: progress.lastActivity?.activityDescription ?? description, startTime, totalTokens: progress.tokenCount, toolUses: progress.toolUseCount, lastToolName, }) } export async function classifyHandoffIfNeeded({ agentMessages, tools, toolPermissionContext, abortSignal, subagentType, totalToolUseCount, }: { agentMessages: MessageType[] tools: Tools toolPermissionContext: AppState['toolPermissionContext'] abortSignal: AbortSignal subagentType: string totalToolUseCount: number }): Promise { if (feature('TRANSCRIPT_CLASSIFIER')) { if (toolPermissionContext.mode !== 'auto') return null const agentTranscript = buildTranscriptForClassifier(agentMessages, tools) if (!agentTranscript) return null const classifierResult = await classifyYoloAction( agentMessages, { role: 'user', content: [ { type: 'text', text: "Sub-agent has finished and is handing back control to the main agent. Review the sub-agent's work based on the block rules and let the main agent know if any file is dangerous (the main agent will see the reason).", }, ], }, tools, toolPermissionContext as ToolPermissionContext, abortSignal, ) const handoffDecision = classifierResult.unavailable ? 'unavailable' : classifierResult.shouldBlock ? 'blocked' : 'allowed' logEvent('tengu_auto_mode_decision', { decision: handoffDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: // Use legacy name for analytics continuity across the Task→Agent rename LEGACY_AGENT_TOOL_NAME as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, inProtectedNamespace: isInProtectedNamespace(), classifierModel: classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, agentType: subagentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolUseCount: totalToolUseCount, isHandoff: true, // For handoff, the relevant agent completion is the subagent's final // assistant message — the last thing the classifier transcript shows // before the handoff review prompt. agentMsgId: getLastAssistantMessage(agentMessages)?.message .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage: classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage1RequestId: classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage1MsgId: classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage2RequestId: classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage2MsgId: classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (classifierResult.shouldBlock) { // When classifier is unavailable, still propagate the sub-agent's // results but with a warning so the parent agent can verify the work. if (classifierResult.unavailable) { logForDebugging( 'Handoff classifier unavailable, allowing sub-agent output with warning', { level: 'warn' }, ) return `Note: The safety classifier was unavailable when reviewing this sub-agent's work. Please carefully verify the sub-agent's actions and output before acting on them.` } logForDebugging( `Handoff classifier flagged sub-agent output: ${classifierResult.reason}`, { level: 'warn' }, ) return `SECURITY WARNING: This sub-agent performed actions that may violate security policy. Reason: ${classifierResult.reason}. Review the sub-agent's actions carefully before acting on its output.` } } return null } /** * Extract a partial result string from an agent's accumulated messages. * Used when an async agent is killed to preserve what it accomplished. * Returns undefined if no text content is found. */ export function extractPartialResult( messages: MessageType[], ): string | undefined { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]! if (m.type !== 'assistant') continue const text = extractTextContent(m.message.content, '\n') if (text) { return text } } return undefined } type SetAppState = (f: (prev: AppState) => AppState) => void /** * Drives a background agent from spawn to terminal notification. * Shared between AgentTool's async-from-start path and resumeAgentBackground. */ export async function runAsyncAgentLifecycle({ taskId, abortController, makeStream, metadata, description, toolUseContext, rootSetAppState, agentIdForCleanup, enableSummarization, getWorktreeResult, }: { taskId: string abortController: AbortController makeStream: ( onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined, ) => AsyncGenerator metadata: Parameters[2] description: string toolUseContext: ToolUseContext rootSetAppState: SetAppState agentIdForCleanup: string enableSummarization: boolean getWorktreeResult: () => Promise<{ worktreePath?: string worktreeBranch?: string }> }): Promise { let stopSummarization: (() => void) | undefined const agentMessages: MessageType[] = [] try { const tracker = createProgressTracker() const resolveActivity = createActivityDescriptionResolver( toolUseContext.options.tools, ) const onCacheSafeParams = enableSummarization ? (params: CacheSafeParams) => { const { stop } = startAgentSummarization( taskId, asAgentId(taskId), params, rootSetAppState, ) stopSummarization = stop } : undefined for await (const message of makeStream(onCacheSafeParams)) { agentMessages.push(message) // Append immediately when UI holds the task (retain). Bootstrap reads // disk in parallel and UUID-merges the prefix — disk-write-before-yield // means live is always a suffix of disk, so merge is order-correct. rootSetAppState(prev => { const t = prev.tasks[taskId] if (!isLocalAgentTask(t) || !t.retain) return prev const base = t.messages ?? [] return { ...prev, tasks: { ...prev.tasks, [taskId]: { ...t, messages: [...base, message] }, }, } }) updateProgressFromMessage( tracker, message, resolveActivity, toolUseContext.options.tools, ) updateAsyncAgentProgress( taskId, getProgressUpdate(tracker), rootSetAppState, ) const lastToolName = getLastToolUseName(message) if (lastToolName) { emitTaskProgress( tracker, taskId, toolUseContext.toolUseId, description, metadata.startTime, lastToolName, ) } } stopSummarization?.() const agentResult = finalizeAgentTool(agentMessages, taskId, metadata) // Mark task completed FIRST so TaskOutput(block=true) unblocks // immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult // (git exec) are notification embellishments that can hang — they must // not gate the status transition (gh-20236). completeAsyncAgent(agentResult, rootSetAppState) let finalMessage = extractTextContent(agentResult.content, '\n') if (feature('TRANSCRIPT_CLASSIFIER')) { const handoffWarning = await classifyHandoffIfNeeded({ agentMessages, tools: toolUseContext.options.tools, toolPermissionContext: toolUseContext.getAppState().toolPermissionContext, abortSignal: abortController.signal, subagentType: metadata.agentType, totalToolUseCount: agentResult.totalToolUseCount, }) if (handoffWarning) { finalMessage = `${handoffWarning}\n\n${finalMessage}` } } const worktreeResult = await getWorktreeResult() enqueueAgentNotification({ taskId, description, status: 'completed', setAppState: rootSetAppState, finalMessage, usage: { totalTokens: getTokenCountFromTracker(tracker), toolUses: agentResult.totalToolUseCount, durationMs: agentResult.totalDurationMs, }, toolUseId: toolUseContext.toolUseId, ...worktreeResult, }) } catch (error) { stopSummarization?.() if (error instanceof AbortError) { // killAsyncAgent is a no-op if TaskStop already set status='killed' — // but only this catch handler has agentMessages, so the notification // must fire unconditionally. Transition status BEFORE worktree cleanup // so TaskOutput unblocks even if git hangs (gh-20236). killAsyncAgent(taskId, rootSetAppState) logEvent('tengu_agent_tool_terminated', { agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, is_async: true, is_built_in_agent: metadata.isBuiltInAgent, reason: 'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) const worktreeResult = await getWorktreeResult() const partialResult = extractPartialResult(agentMessages) enqueueAgentNotification({ taskId, description, status: 'killed', setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, finalMessage: partialResult, ...worktreeResult, }) return } const msg = errorMessage(error) failAsyncAgent(taskId, msg, rootSetAppState) const worktreeResult = await getWorktreeResult() enqueueAgentNotification({ taskId, description, status: 'failed', error: msg, setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, ...worktreeResult, }) } finally { clearInvokedSkillsForAgent(agentIdForCleanup) clearDumpState(agentIdForCleanup) } }