Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
23 kB · 686 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687import { 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<string, Tool>() for (const tool of allowedAvailableTools) { availableToolMap.set(tool.name, tool) }
const validTools: string[] = [] const invalidTools: string[] = [] const resolved: Tool[] = [] const resolvedToolsSet = new Set<Tool>() 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<ReturnType<typeof agentToolResultSchema>>
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<string | null> { 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<MessageType, void> metadata: Parameters<typeof finalizeAgentTool>[2] description: string toolUseContext: ToolUseContext rootSetAppState: SetAppState agentIdForCleanup: string enableSummarization: boolean getWorktreeResult: () => Promise<{ worktreePath?: string worktreeBranch?: string }>}): Promise<void> { 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) }}