Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
60 kB · 1745 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746import { feature } from 'bun:bundle'import type { ContentBlockParam, ToolResultBlockParam, ToolUseBlock,} from '@anthropic-ai/sdk/resources/index.mjs'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent,} from 'src/services/analytics/index.js'import { extractMcpToolDetails, extractSkillName, extractToolInputForTelemetry, getFileExtensionForAnalytics, getFileExtensionsFromBashCommand, isToolDetailsLoggingEnabled, mcpToolDetailsForAnalytics, sanitizeToolNameForAnalytics,} from 'src/services/analytics/metadata.js'import { addToToolDuration, getCodeEditToolDecisionCounter, getStatsStore,} from '../../bootstrap/state.js'import { buildCodeEditToolAttributes, isCodeEditingTool,} from '../../hooks/toolPermission/permissionLogging.js'import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'import { findToolByName, type Tool, type ToolProgress, type ToolProgressData, type ToolUseContext,} from '../../Tool.js'import type { BashToolInput } from '../../tools/BashTool/BashTool.js'import { startSpeculativeClassifierCheck } from '../../tools/BashTool/bashPermissions.js'import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'import { NOTEBOOK_EDIT_TOOL_NAME } from '../../tools/NotebookEditTool/constants.js'import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'import { parseGitCommitId } from '../../tools/shared/gitOperationTracking.js'import { isDeferredTool, TOOL_SEARCH_TOOL_NAME,} from '../../tools/ToolSearchTool/prompt.js'import { getAllBaseTools } from '../../tools.js'import type { HookProgress } from '../../types/hooks.js'import type { AssistantMessage, AttachmentMessage, Message, ProgressMessage, StopHookInfo,} from '../../types/message.js'import { count } from '../../utils/array.js'import { createAttachmentMessage } from '../../utils/attachments.js'import { logForDebugging } from '../../utils/debug.js'import { AbortError, errorMessage, getErrnoCode, ShellError, TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,} from '../../utils/errors.js'import { executePermissionDeniedHooks } from '../../utils/hooks.js'import { logError } from '../../utils/log.js'import { CANCEL_MESSAGE, createProgressMessage, createStopHookSummaryMessage, createToolResultStopMessage, createUserMessage, withMemoryCorrectionHint,} from '../../utils/messages.js'import type { PermissionDecisionReason, PermissionResult,} from '../../utils/permissions/PermissionResult.js'import { startSessionActivity, stopSessionActivity,} from '../../utils/sessionActivity.js'import { jsonStringify } from '../../utils/slowOperations.js'import { Stream } from '../../utils/stream.js'import { logOTelEvent } from '../../utils/telemetry/events.js'import { addToolContentEvent, endToolBlockedOnUserSpan, endToolExecutionSpan, endToolSpan, isBetaTracingEnabled, startToolBlockedOnUserSpan, startToolExecutionSpan, startToolSpan,} from '../../utils/telemetry/sessionTracing.js'import { formatError, formatZodValidationError,} from '../../utils/toolErrors.js'import { processPreMappedToolResultBlock, processToolResultBlock,} from '../../utils/toolResultStorage.js'import { extractDiscoveredToolNames, isToolSearchEnabledOptimistic, isToolSearchToolAvailable,} from '../../utils/toolSearch.js'import { McpAuthError, McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,} from '../mcp/client.js'import { mcpInfoFromString } from '../mcp/mcpStringUtils.js'import { normalizeNameForMCP } from '../mcp/normalization.js'import type { MCPServerConnection } from '../mcp/types.js'import { getLoggingSafeMcpBaseUrl, getMcpServerScopeFromToolName, isMcpTool,} from '../mcp/utils.js'import { resolveHookPermissionDecision, runPostToolUseFailureHooks, runPostToolUseHooks, runPreToolUseHooks,} from './toolHooks.js'
/** Minimum total hook duration (ms) to show inline timing summary */export const HOOK_TIMING_DISPLAY_THRESHOLD_MS = 500/** Log a debug warning when hooks/permission-decision block for this long. Matches * BashTool's PROGRESS_THRESHOLD_MS — the collapsed view feels stuck past this. */const SLOW_PHASE_LOG_THRESHOLD_MS = 2000
/** * Classify a tool execution error into a telemetry-safe string. * * In minified/external builds, `error.constructor.name` is mangled into * short identifiers like "nJT" or "Chq" — useless for diagnostics. * This function extracts structured, telemetry-safe information instead: * - TelemetrySafeError: use its telemetryMessage (already vetted) * - Node.js fs errors: log the error code (ENOENT, EACCES, etc.) * - Known error types: use their unminified name * - Fallback: "Error" (better than a mangled 3-char identifier) */export function classifyToolError(error: unknown): string { if ( error instanceof TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ) { return error.telemetryMessage.slice(0, 200) } if (error instanceof Error) { // Node.js filesystem errors have a `code` property (ENOENT, EACCES, etc.) // These are safe to log and much more useful than the constructor name. const errnoCode = getErrnoCode(error) if (typeof errnoCode === 'string') { return `Error:${errnoCode}` } // ShellError, ImageSizeError, etc. have stable `.name` properties // that survive minification (they're set in the constructor). if (error.name && error.name !== 'Error' && error.name.length > 3) { return error.name.slice(0, 60) } return 'Error' } return 'UnknownError'}
/** * Map a rule's origin to the documented OTel `source` vocabulary, matching * the interactive path's semantics (permissionLogging.ts:81): session-scoped * grants are temporary, on-disk grants are permanent, and user-authored * denies are user_reject regardless of persistence. Everything the user * didn't write (cliArg, policySettings, projectSettings, flagSettings) is * config. */function ruleSourceToOTelSource( ruleSource: string, behavior: 'allow' | 'deny',): string { switch (ruleSource) { case 'session': return behavior === 'allow' ? 'user_temporary' : 'user_reject' case 'localSettings': case 'userSettings': return behavior === 'allow' ? 'user_permanent' : 'user_reject' default: return 'config' }}
/** * Map a PermissionDecisionReason to the OTel `source` label for the * non-interactive tool_decision path, staying within the documented * vocabulary (config, hook, user_permanent, user_temporary, user_reject). * * For permissionPromptTool, the SDK host may set decisionClassification on * the PermissionResult to tell us exactly what happened (once vs always vs * cache hit — the host knows, we can't tell from {behavior:'allow'} alone). * Without it, we fall back conservatively: allow → user_temporary, * deny → user_reject. */function decisionReasonToOTelSource( reason: PermissionDecisionReason | undefined, behavior: 'allow' | 'deny',): string { if (!reason) { return 'config' } switch (reason.type) { case 'permissionPromptTool': { // toolResult is typed `unknown` on PermissionDecisionReason but carries // the parsed Output from PermissionPromptToolResultSchema. Narrow at // runtime rather than widen the cross-file type. const toolResult = reason.toolResult as | { decisionClassification?: string } | undefined const classified = toolResult?.decisionClassification if ( classified === 'user_temporary' || classified === 'user_permanent' || classified === 'user_reject' ) { return classified } return behavior === 'allow' ? 'user_temporary' : 'user_reject' } case 'rule': return ruleSourceToOTelSource(reason.rule.source, behavior) case 'hook': return 'hook' case 'mode': case 'classifier': case 'subcommandResults': case 'asyncAgent': case 'sandboxOverride': case 'workingDir': case 'safetyCheck': case 'other': return 'config' default: { const _exhaustive: never = reason return 'config' } }}
function getNextImagePasteId(messages: Message[]): number { let maxId = 0 for (const message of messages) { if (message.type === 'user' && message.imagePasteIds) { for (const id of message.imagePasteIds) { if (id > maxId) maxId = id } } } return maxId + 1}
export type MessageUpdateLazy<M extends Message = Message> = { message: M contextModifier?: { toolUseID: string modifyContext: (context: ToolUseContext) => ToolUseContext }}
export type McpServerType = | 'stdio' | 'sse' | 'http' | 'ws' | 'sdk' | 'sse-ide' | 'ws-ide' | 'claudeai-proxy' | undefined
function findMcpServerConnection( toolName: string, mcpClients: MCPServerConnection[],): MCPServerConnection | undefined { if (!toolName.startsWith('mcp__')) { return undefined }
const mcpInfo = mcpInfoFromString(toolName) if (!mcpInfo) { return undefined }
// mcpInfo.serverName is normalized (e.g., "claude_ai_Slack"), but client.name // is the original name (e.g., "claude.ai Slack"). Normalize both for comparison. return mcpClients.find( client => normalizeNameForMCP(client.name) === mcpInfo.serverName, )}
/** * Extracts the MCP server transport type from a tool name. * Returns the server type (stdio, sse, http, ws, sdk, etc.) for MCP tools, * or undefined for built-in tools. */function getMcpServerType( toolName: string, mcpClients: MCPServerConnection[],): McpServerType { const serverConnection = findMcpServerConnection(toolName, mcpClients)
if (serverConnection?.type === 'connected') { // Handle stdio configs where type field is optional (defaults to 'stdio') return serverConnection.config.type ?? 'stdio' }
return undefined}
/** * Extracts the MCP server base URL for a tool by looking up its server connection. * Returns undefined for stdio servers, built-in tools, or if the server is not connected. */function getMcpServerBaseUrlFromToolName( toolName: string, mcpClients: MCPServerConnection[],): string | undefined { const serverConnection = findMcpServerConnection(toolName, mcpClients) if (serverConnection?.type !== 'connected') { return undefined } return getLoggingSafeMcpBaseUrl(serverConnection.config)}
export async function* runToolUse( toolUse: ToolUseBlock, assistantMessage: AssistantMessage, canUseTool: CanUseToolFn, toolUseContext: ToolUseContext,): AsyncGenerator<MessageUpdateLazy, void> { const toolName = toolUse.name // First try to find in the available tools (what the model sees) let tool = findToolByName(toolUseContext.options.tools, toolName)
// If not found, check if it's a deprecated tool being called by alias // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop") // Only fall back for tools where the name matches an alias, not the primary name if (!tool) { const fallbackTool = findToolByName(getAllBaseTools(), toolName) // Only use fallback if the tool was found via alias (deprecated name) if (fallbackTool && fallbackTool.aliases?.includes(toolName)) { tool = fallbackTool } } const messageId = assistantMessage.message.id const requestId = assistantMessage.requestId const mcpServerType = getMcpServerType( toolName, toolUseContext.options.mcpClients, ) const mcpServerBaseUrl = getMcpServerBaseUrlFromToolName( toolName, toolUseContext.options.mcpClients, )
// Check if the tool exists if (!tool) { const sanitizedToolName = sanitizeToolNameForAnalytics(toolName) logForDebugging(`Unknown tool ${toolName}: ${toolUse.id}`) logEvent('tengu_tool_use_error', { error: `No such tool available: ${sanitizedToolName}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizedToolName, toolUseID: toolUse.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: toolName.startsWith('mcp__'), queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics(toolName, mcpServerType, mcpServerBaseUrl), }) yield { message: createUserMessage({ content: [ { type: 'tool_result', content: `<tool_use_error>Error: No such tool available: ${toolName}</tool_use_error>`, is_error: true, tool_use_id: toolUse.id, }, ], toolUseResult: `Error: No such tool available: ${toolName}`, sourceToolAssistantUUID: assistantMessage.uuid, }), } return }
const toolInput = toolUse.input as { [key: string]: string } try { if (toolUseContext.abortController.signal.aborted) { logEvent('tengu_tool_use_cancelled', { toolName: sanitizeToolNameForAnalytics(tool.name), toolUseID: toolUse.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: tool.isMcp ?? false,
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics( tool.name, mcpServerType, mcpServerBaseUrl, ), }) const content = createToolResultStopMessage(toolUse.id) content.content = withMemoryCorrectionHint(CANCEL_MESSAGE) yield { message: createUserMessage({ content: [content], toolUseResult: CANCEL_MESSAGE, sourceToolAssistantUUID: assistantMessage.uuid, }), } return }
for await (const update of streamedCheckPermissionsAndCallTool( tool, toolUse.id, toolInput, toolUseContext, canUseTool, assistantMessage, messageId, requestId, mcpServerType, mcpServerBaseUrl, )) { yield update } } catch (error) { logError(error) const errorMessage = error instanceof Error ? error.message : String(error) const toolInfo = tool ? ` (${tool.name})` : '' const detailedError = `Error calling tool${toolInfo}: ${errorMessage}`
yield { message: createUserMessage({ content: [ { type: 'tool_result', content: `<tool_use_error>${detailedError}</tool_use_error>`, is_error: true, tool_use_id: toolUse.id, }, ], toolUseResult: detailedError, sourceToolAssistantUUID: assistantMessage.uuid, }), } }}
function streamedCheckPermissionsAndCallTool( tool: Tool, toolUseID: string, input: { [key: string]: boolean | string | number }, toolUseContext: ToolUseContext, canUseTool: CanUseToolFn, assistantMessage: AssistantMessage, messageId: string, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: ReturnType<typeof getLoggingSafeMcpBaseUrl>,): AsyncIterable<MessageUpdateLazy> { // This is a bit of a hack to get progress events and final results // into a single async iterable. // // Ideally the progress reporting and tool call reporting would // be via separate mechanisms. const stream = new Stream<MessageUpdateLazy>() checkPermissionsAndCallTool( tool, toolUseID, input, toolUseContext, canUseTool, assistantMessage, messageId, requestId, mcpServerType, mcpServerBaseUrl, progress => { logEvent('tengu_tool_use_progress', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false,
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics( tool.name, mcpServerType, mcpServerBaseUrl, ), }) stream.enqueue({ message: createProgressMessage({ toolUseID: progress.toolUseID, parentToolUseID: toolUseID, data: progress.data, }), }) }, ) .then(results => { for (const result of results) { stream.enqueue(result) } }) .catch(error => { stream.error(error) }) .finally(() => { stream.done() }) return stream}
/** * Appended to Zod errors when a deferred tool wasn't in the discovered-tool * set — re-runs the claude.ts schema-filter scan dispatch-time to detect the * mismatch. The raw Zod error ("expected array, got string") doesn't tell the * model to re-load the tool; this hint does. Null if the schema was sent. */export function buildSchemaNotSentHint( tool: Tool, messages: Message[], tools: readonly { name: string }[],): string | null { // Optimistic gating — reconstructing claude.ts's full useToolSearch // computation is fragile. These two gates prevent pointing at a ToolSearch // that isn't callable; occasional misfires (Haiku, tst-auto below threshold) // cost one extra round-trip on an already-failing path. if (!isToolSearchEnabledOptimistic()) return null if (!isToolSearchToolAvailable(tools)) return null if (!isDeferredTool(tool)) return null const discovered = extractDiscoveredToolNames(messages) if (discovered.has(tool.name)) return null return ( `\n\nThis tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. ` + `Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. ` + `Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.` )}
async function checkPermissionsAndCallTool( tool: Tool, toolUseID: string, input: { [key: string]: boolean | string | number }, toolUseContext: ToolUseContext, canUseTool: CanUseToolFn, assistantMessage: AssistantMessage, messageId: string, requestId: string | undefined, mcpServerType: McpServerType, mcpServerBaseUrl: ReturnType<typeof getLoggingSafeMcpBaseUrl>, onToolProgress: ( progress: ToolProgress<ToolProgressData> | ProgressMessage<HookProgress>, ) => void,): Promise<MessageUpdateLazy[]> { // Validate input types with zod (surprisingly, the model is not great at generating valid input) const parsedInput = tool.inputSchema.safeParse(input) if (!parsedInput.success) { let errorContent = formatZodValidationError(tool.name, parsedInput.error)
const schemaHint = buildSchemaNotSentHint( tool, toolUseContext.messages, toolUseContext.options.tools, ) if (schemaHint) { logEvent('tengu_deferred_tool_schema_not_sent', { toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false, }) errorContent += schemaHint }
logForDebugging( `${tool.name} tool input error: ${errorContent.slice(0, 200)}`, ) logEvent('tengu_tool_use_error', { error: 'InputValidationError' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, errorDetails: errorContent.slice( 0, 2000, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false,
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), }) return [ { message: createUserMessage({ content: [ { type: 'tool_result', content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`, is_error: true, tool_use_id: toolUseID, }, ], toolUseResult: `InputValidationError: ${parsedInput.error.message}`, sourceToolAssistantUUID: assistantMessage.uuid, }), }, ] }
// Validate input values. Each tool has its own validation logic const isValidCall = await tool.validateInput?.( parsedInput.data, toolUseContext, ) if (isValidCall?.result === false) { logForDebugging( `${tool.name} tool validation error: ${isValidCall.message?.slice(0, 200)}`, ) logEvent('tengu_tool_use_error', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), error: isValidCall.message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, errorCode: isValidCall.errorCode, isMcp: tool.isMcp ?? false,
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), }) return [ { message: createUserMessage({ content: [ { type: 'tool_result', content: `<tool_use_error>${isValidCall.message}</tool_use_error>`, is_error: true, tool_use_id: toolUseID, }, ], toolUseResult: `Error: ${isValidCall.message}`, sourceToolAssistantUUID: assistantMessage.uuid, }), }, ] } // Speculatively start the bash allow classifier check early so it runs in // parallel with pre-tool hooks, deny/ask classifiers, and permission dialog // setup. The UI indicator (setClassifierChecking) is NOT set here — it's // set in interactiveHandler.ts only when the permission check returns `ask` // with a pendingClassifierCheck. This avoids flashing "classifier running" // for commands that auto-allow via prefix rules. if ( tool.name === BASH_TOOL_NAME && parsedInput.data && 'command' in parsedInput.data ) { const appState = toolUseContext.getAppState() startSpeculativeClassifierCheck( (parsedInput.data as BashToolInput).command, appState.toolPermissionContext, toolUseContext.abortController.signal, toolUseContext.options.isNonInteractiveSession, ) }
const resultingMessages = []
// Defense-in-depth: strip _simulatedSedEdit from model-provided Bash input. // This field is internal-only — it must only be injected by the permission // system (SedEditPermissionRequest) after user approval. If the model supplies // it, the schema's strictObject should already reject it, but we strip here // as a safeguard against future regressions. let processedInput = parsedInput.data if ( tool.name === BASH_TOOL_NAME && processedInput && typeof processedInput === 'object' && '_simulatedSedEdit' in processedInput ) { const { _simulatedSedEdit: _, ...rest } = processedInput as typeof processedInput & { _simulatedSedEdit: unknown } processedInput = rest as typeof processedInput }
// Backfill legacy/derived fields on a shallow clone so hooks/canUseTool see // them without affecting tool.call(). SendMessageTool adds fields; file // tools overwrite file_path with expandPath — that mutation must not reach // call() because tool results embed the input path verbatim (e.g. "File // created successfully at: {path}"), and changing it alters the serialized // transcript and VCR fixture hashes. If a hook/permission later returns a // fresh updatedInput, callInput converges on it below — that replacement // is intentional and should reach call(). let callInput = processedInput const backfilledClone = tool.backfillObservableInput && typeof processedInput === 'object' && processedInput !== null ? ({ ...processedInput } as typeof processedInput) : null if (backfilledClone) { tool.backfillObservableInput!(backfilledClone as Record<string, unknown>) processedInput = backfilledClone }
let shouldPreventContinuation = false let stopReason: string | undefined let hookPermissionResult: PermissionResult | undefined const preToolHookInfos: StopHookInfo[] = [] const preToolHookStart = Date.now() for await (const result of runPreToolUseHooks( toolUseContext, tool, processedInput, toolUseID, assistantMessage.message.id, requestId, mcpServerType, mcpServerBaseUrl, )) { switch (result.type) { case 'message': if (result.message.message.type === 'progress') { onToolProgress(result.message.message) } else { resultingMessages.push(result.message) const att = result.message.message.attachment if ( att && 'command' in att && att.command !== undefined && 'durationMs' in att && att.durationMs !== undefined ) { preToolHookInfos.push({ command: att.command, durationMs: att.durationMs, }) } } break case 'hookPermissionResult': hookPermissionResult = result.hookPermissionResult break case 'hookUpdatedInput': // Hook provided updatedInput without making a permission decision (passthrough) // Update processedInput so it's used in the normal permission flow processedInput = result.updatedInput break case 'preventContinuation': shouldPreventContinuation = result.shouldPreventContinuation break case 'stopReason': stopReason = result.stopReason break case 'additionalContext': resultingMessages.push(result.message) break case 'stop': getStatsStore()?.observe( 'pre_tool_hook_duration_ms', Date.now() - preToolHookStart, ) resultingMessages.push({ message: createUserMessage({ content: [createToolResultStopMessage(toolUseID)], toolUseResult: `Error: ${stopReason}`, sourceToolAssistantUUID: assistantMessage.uuid, }), }) return resultingMessages } } const preToolHookDurationMs = Date.now() - preToolHookStart getStatsStore()?.observe('pre_tool_hook_duration_ms', preToolHookDurationMs) if (preToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) { logForDebugging( `Slow PreToolUse hooks: ${preToolHookDurationMs}ms for ${tool.name} (${preToolHookInfos.length} hooks)`, { level: 'info' }, ) }
// Emit PreToolUse summary immediately so it's visible while the tool executes. // Use wall-clock time (not sum of individual durations) since hooks run in parallel. if (process.env.USER_TYPE === 'ant' && preToolHookInfos.length > 0) { if (preToolHookDurationMs > HOOK_TIMING_DISPLAY_THRESHOLD_MS) { resultingMessages.push({ message: createStopHookSummaryMessage( preToolHookInfos.length, preToolHookInfos, [], false, undefined, false, 'suggestion', undefined, 'PreToolUse', preToolHookDurationMs, ), }) } }
const toolAttributes: Record<string, string | number | boolean> = {} if (processedInput && typeof processedInput === 'object') { if (tool.name === FILE_READ_TOOL_NAME && 'file_path' in processedInput) { toolAttributes.file_path = String(processedInput.file_path) } else if ( (tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME) && 'file_path' in processedInput ) { toolAttributes.file_path = String(processedInput.file_path) } else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { const bashInput = processedInput as BashToolInput toolAttributes.full_command = bashInput.command } }
startToolSpan( tool.name, toolAttributes, isBetaTracingEnabled() ? jsonStringify(processedInput) : undefined, ) startToolBlockedOnUserSpan()
// Check whether we have permission to use the tool, // and ask the user for permission if we don't const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode const permissionStart = Date.now()
const resolved = await resolveHookPermissionDecision( hookPermissionResult, tool, processedInput, toolUseContext, canUseTool, assistantMessage, toolUseID, ) const permissionDecision = resolved.decision processedInput = resolved.input const permissionDurationMs = Date.now() - permissionStart // In auto mode, canUseTool awaits the classifier (side_query) — if that's // slow the collapsed view shows "Running…" with no (Ns) tick since // bash_progress hasn't started yet. Auto-only: in default mode this timer // includes interactive-dialog wait (user think time), which is just noise. if ( permissionDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS && permissionMode === 'auto' ) { logForDebugging( `Slow permission decision: ${permissionDurationMs}ms for ${tool.name} ` + `(mode=${permissionMode}, behavior=${permissionDecision.behavior})`, { level: 'info' }, ) }
// Emit tool_decision OTel event and code-edit counter if the interactive // permission path didn't already log it (headless mode bypasses permission // logging, so we need to emit both the generic event and the code-edit // counter here) if ( permissionDecision.behavior !== 'ask' && !toolUseContext.toolDecisions?.has(toolUseID) ) { const decision = permissionDecision.behavior === 'allow' ? 'accept' : 'reject' const source = decisionReasonToOTelSource( permissionDecision.decisionReason, permissionDecision.behavior, ) void logOTelEvent('tool_decision', { decision, source, tool_name: sanitizeToolNameForAnalytics(tool.name), })
// Increment code-edit tool decision counter for headless mode if (isCodeEditingTool(tool.name)) { void buildCodeEditToolAttributes( tool, processedInput, decision, source, ).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes)) } }
// Add message if permission was granted/denied by PermissionRequest hook if ( permissionDecision.decisionReason?.type === 'hook' && permissionDecision.decisionReason.hookName === 'PermissionRequest' && permissionDecision.behavior !== 'ask' ) { resultingMessages.push({ message: createAttachmentMessage({ type: 'hook_permission_decision', decision: permissionDecision.behavior, toolUseID, hookEvent: 'PermissionRequest', }), }) }
if (permissionDecision.behavior !== 'allow') { logForDebugging(`${tool.name} tool permission denied`) const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID) endToolBlockedOnUserSpan('reject', decisionInfo?.source || 'unknown') endToolSpan()
logEvent('tengu_tool_use_can_use_tool_rejected', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name),
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), }) let errorMessage = permissionDecision.message // Only use generic "Execution stopped" message if we don't have a detailed hook message if (shouldPreventContinuation && !errorMessage) { errorMessage = `Execution stopped by PreToolUse hook${stopReason ? `: ${stopReason}` : ''}` }
// Build top-level content: tool_result (text-only for is_error compatibility) + images alongside const messageContent: ContentBlockParam[] = [ { type: 'tool_result', content: errorMessage, is_error: true, tool_use_id: toolUseID, }, ]
// Add image blocks at top level (not inside tool_result, which rejects non-text with is_error) const rejectContentBlocks = permissionDecision.behavior === 'ask' ? permissionDecision.contentBlocks : undefined if (rejectContentBlocks?.length) { messageContent.push(...rejectContentBlocks) }
// Generate sequential imagePasteIds so each image renders with a distinct label let rejectImageIds: number[] | undefined if (rejectContentBlocks?.length) { const imageCount = count( rejectContentBlocks, (b: ContentBlockParam) => b.type === 'image', ) if (imageCount > 0) { const startId = getNextImagePasteId(toolUseContext.messages) rejectImageIds = Array.from( { length: imageCount }, (_, i) => startId + i, ) } }
resultingMessages.push({ message: createUserMessage({ content: messageContent, imagePasteIds: rejectImageIds, toolUseResult: `Error: ${errorMessage}`, sourceToolAssistantUUID: assistantMessage.uuid, }), })
// Run PermissionDenied hooks for auto mode classifier denials. // If a hook returns {retry: true}, tell the model it may retry. if ( feature('TRANSCRIPT_CLASSIFIER') && permissionDecision.decisionReason?.type === 'classifier' && permissionDecision.decisionReason.classifier === 'auto-mode' ) { let hookSaysRetry = false for await (const result of executePermissionDeniedHooks( tool.name, toolUseID, processedInput, permissionDecision.decisionReason.reason ?? 'Permission denied', toolUseContext, permissionMode, toolUseContext.abortController.signal, )) { if (result.retry) hookSaysRetry = true } if (hookSaysRetry) { resultingMessages.push({ message: createUserMessage({ content: 'The PermissionDenied hook indicated this command is now approved. You may retry it if you would like.', isMeta: true, }), }) } }
return resultingMessages } logEvent('tengu_tool_use_can_use_tool_allowed', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name),
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), })
// Use the updated input from permissions if provided // (Don't overwrite if undefined - processedInput may have been modified by passthrough hooks) if (permissionDecision.updatedInput !== undefined) { processedInput = permissionDecision.updatedInput }
// Prepare tool parameters for logging in tool_result event. // Gated by OTEL_LOG_TOOL_DETAILS — tool parameters can contain sensitive // content (bash commands, MCP server names, etc.) so they're opt-in only. const telemetryToolInput = extractToolInputForTelemetry(processedInput) let toolParameters: Record<string, unknown> = {} if (isToolDetailsLoggingEnabled()) { if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { const bashInput = processedInput as BashToolInput const commandParts = bashInput.command.trim().split(/\s+/) const bashCommand = commandParts[0] || ''
toolParameters = { bash_command: bashCommand, full_command: bashInput.command, ...(bashInput.timeout !== undefined && { timeout: bashInput.timeout, }), ...(bashInput.description !== undefined && { description: bashInput.description, }), ...('dangerouslyDisableSandbox' in bashInput && { dangerouslyDisableSandbox: bashInput.dangerouslyDisableSandbox, }), } }
const mcpDetails = extractMcpToolDetails(tool.name) if (mcpDetails) { toolParameters.mcp_server_name = mcpDetails.serverName toolParameters.mcp_tool_name = mcpDetails.mcpToolName } const skillName = extractSkillName(tool.name, processedInput) if (skillName) { toolParameters.skill_name = skillName } }
const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID) endToolBlockedOnUserSpan( decisionInfo?.decision || 'unknown', decisionInfo?.source || 'unknown', ) startToolExecutionSpan()
const startTime = Date.now()
startSessionActivity('tool_exec') // If processedInput still points at the backfill clone, no hook/permission // replaced it — pass the pre-backfill callInput so call() sees the model's // original field values. Otherwise converge on the hook-supplied input. // Permission/hook flows may return a fresh object derived from the // backfilled clone (e.g. via inputSchema.parse). If its file_path matches // the backfill-expanded value, restore the model's original so the tool // result string embeds the path the model emitted — keeps transcript/VCR // hashes stable. Other hook modifications flow through unchanged. if ( backfilledClone && processedInput !== callInput && typeof processedInput === 'object' && processedInput !== null && 'file_path' in processedInput && 'file_path' in (callInput as Record<string, unknown>) && (processedInput as Record<string, unknown>).file_path === (backfilledClone as Record<string, unknown>).file_path ) { callInput = { ...processedInput, file_path: (callInput as Record<string, unknown>).file_path, } as typeof processedInput } else if (processedInput !== backfilledClone) { callInput = processedInput } try { const result = await tool.call( callInput, { ...toolUseContext, toolUseId: toolUseID, userModified: permissionDecision.userModified ?? false, }, canUseTool, assistantMessage, progress => { onToolProgress({ toolUseID: progress.toolUseID, data: progress.data, }) }, ) const durationMs = Date.now() - startTime addToToolDuration(durationMs)
// Log tool content/output as span event if enabled if (result.data && typeof result.data === 'object') { const contentAttributes: Record<string, string | number | boolean> = {}
// Read tool: capture file_path and content if (tool.name === FILE_READ_TOOL_NAME && 'content' in result.data) { if ('file_path' in processedInput) { contentAttributes.file_path = String(processedInput.file_path) } contentAttributes.content = String(result.data.content) }
// Edit/Write tools: capture file_path and diff if ( (tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME) && 'file_path' in processedInput ) { contentAttributes.file_path = String(processedInput.file_path)
// For Edit, capture the actual changes made if (tool.name === FILE_EDIT_TOOL_NAME && 'diff' in result.data) { contentAttributes.diff = String(result.data.diff) } // For Write, capture the written content if (tool.name === FILE_WRITE_TOOL_NAME && 'content' in processedInput) { contentAttributes.content = String(processedInput.content) } }
// Bash tool: capture command if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { const bashInput = processedInput as BashToolInput contentAttributes.bash_command = bashInput.command // Also capture output if available if ('output' in result.data) { contentAttributes.output = String(result.data.output) } }
if (Object.keys(contentAttributes).length > 0) { addToolContentEvent('tool.output', contentAttributes) } }
// Capture structured output from tool result if present if (typeof result === 'object' && 'structured_output' in result) { // Store the structured output in an attachment message resultingMessages.push({ message: createAttachmentMessage({ type: 'structured_output', data: result.structured_output, }), }) }
endToolExecutionSpan({ success: true }) // Pass tool result for new_context logging const toolResultStr = result.data && typeof result.data === 'object' ? jsonStringify(result.data) : String(result.data ?? '') endToolSpan(toolResultStr)
// Map the tool result to API format once and cache it. This block is reused // by addToolResult (skipping the remap) and measured here for analytics. const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam( result.data, toolUseID, ) const mappedContent = mappedToolResultBlock.content const toolResultSizeBytes = !mappedContent ? 0 : typeof mappedContent === 'string' ? mappedContent.length : jsonStringify(mappedContent).length
// Extract file extension for file-related tools let fileExtension: ReturnType<typeof getFileExtensionForAnalytics> if (processedInput && typeof processedInput === 'object') { if ( (tool.name === FILE_READ_TOOL_NAME || tool.name === FILE_EDIT_TOOL_NAME || tool.name === FILE_WRITE_TOOL_NAME) && 'file_path' in processedInput ) { fileExtension = getFileExtensionForAnalytics( String(processedInput.file_path), ) } else if ( tool.name === NOTEBOOK_EDIT_TOOL_NAME && 'notebook_path' in processedInput ) { fileExtension = getFileExtensionForAnalytics( String(processedInput.notebook_path), ) } else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) { const bashInput = processedInput as BashToolInput fileExtension = getFileExtensionsFromBashCommand( bashInput.command, bashInput._simulatedSedEdit?.filePath, ) } }
logEvent('tengu_tool_use_success', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false, durationMs, preToolHookDurationMs, toolResultSizeBytes, ...(fileExtension !== undefined && { fileExtension }),
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), })
// Enrich tool parameters with git commit ID from successful git commit output if ( isToolDetailsLoggingEnabled() && (tool.name === BASH_TOOL_NAME || tool.name === POWERSHELL_TOOL_NAME) && 'command' in processedInput && typeof processedInput.command === 'string' && processedInput.command.match(/\bgit\s+commit\b/) && result.data && typeof result.data === 'object' && 'stdout' in result.data ) { const gitCommitId = parseGitCommitId(String(result.data.stdout)) if (gitCommitId) { toolParameters.git_commit_id = gitCommitId } }
// Log tool result event for OTLP with tool parameters and decision context const mcpServerScope = isMcpTool(tool) ? getMcpServerScopeFromToolName(tool.name) : null
void logOTelEvent('tool_result', { tool_name: sanitizeToolNameForAnalytics(tool.name), success: 'true', duration_ms: String(durationMs), ...(Object.keys(toolParameters).length > 0 && { tool_parameters: jsonStringify(toolParameters), }), ...(telemetryToolInput && { tool_input: telemetryToolInput }), tool_result_size_bytes: String(toolResultSizeBytes), ...(decisionInfo && { decision_source: decisionInfo.source, decision_type: decisionInfo.decision, }), ...(mcpServerScope && { mcp_server_scope: mcpServerScope }), })
// Run PostToolUse hooks let toolOutput = result.data const hookResults = [] const toolContextModifier = result.contextModifier const mcpMeta = result.mcpMeta
async function addToolResult( toolUseResult: unknown, preMappedBlock?: ToolResultBlockParam, ) { // Use the pre-mapped block when available (non-MCP tools where hooks // don't modify the output), otherwise map from scratch. const toolResultBlock = preMappedBlock ? await processPreMappedToolResultBlock( preMappedBlock, tool.name, tool.maxResultSizeChars, ) : await processToolResultBlock(tool, toolUseResult, toolUseID)
// Build content blocks - tool result first, then optional feedback const contentBlocks: ContentBlockParam[] = [toolResultBlock] // Add accept feedback if user provided feedback when approving // (acceptFeedback only exists on PermissionAllowDecision, which is guaranteed here) if ( 'acceptFeedback' in permissionDecision && permissionDecision.acceptFeedback ) { contentBlocks.push({ type: 'text', text: permissionDecision.acceptFeedback, }) }
// Add content blocks (e.g., pasted images) from the permission decision const allowContentBlocks = 'contentBlocks' in permissionDecision ? permissionDecision.contentBlocks : undefined if (allowContentBlocks?.length) { contentBlocks.push(...allowContentBlocks) }
// Generate sequential imagePasteIds so each image renders with a distinct label let allowImageIds: number[] | undefined if (allowContentBlocks?.length) { const imageCount = count( allowContentBlocks, (b: ContentBlockParam) => b.type === 'image', ) if (imageCount > 0) { const startId = getNextImagePasteId(toolUseContext.messages) allowImageIds = Array.from( { length: imageCount }, (_, i) => startId + i, ) } }
resultingMessages.push({ message: createUserMessage({ content: contentBlocks, imagePasteIds: allowImageIds, toolUseResult: toolUseContext.agentId && !toolUseContext.preserveToolUseResults ? undefined : toolUseResult, mcpMeta: toolUseContext.agentId ? undefined : mcpMeta, sourceToolAssistantUUID: assistantMessage.uuid, }), contextModifier: toolContextModifier ? { toolUseID: toolUseID, modifyContext: toolContextModifier, } : undefined, }) }
// TOOD(hackyon): refactor so we don't have different experiences for MCP tools if (!isMcpTool(tool)) { await addToolResult(toolOutput, mappedToolResultBlock) }
const postToolHookInfos: StopHookInfo[] = [] const postToolHookStart = Date.now() for await (const hookResult of runPostToolUseHooks( toolUseContext, tool, toolUseID, assistantMessage.message.id, processedInput, toolOutput, requestId, mcpServerType, mcpServerBaseUrl, )) { if ('updatedMCPToolOutput' in hookResult) { if (isMcpTool(tool)) { toolOutput = hookResult.updatedMCPToolOutput } } else if (isMcpTool(tool)) { hookResults.push(hookResult) if (hookResult.message.type === 'attachment') { const att = hookResult.message.attachment if ( 'command' in att && att.command !== undefined && 'durationMs' in att && att.durationMs !== undefined ) { postToolHookInfos.push({ command: att.command, durationMs: att.durationMs, }) } } } else { resultingMessages.push(hookResult) if (hookResult.message.type === 'attachment') { const att = hookResult.message.attachment if ( 'command' in att && att.command !== undefined && 'durationMs' in att && att.durationMs !== undefined ) { postToolHookInfos.push({ command: att.command, durationMs: att.durationMs, }) } } } } const postToolHookDurationMs = Date.now() - postToolHookStart if (postToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) { logForDebugging( `Slow PostToolUse hooks: ${postToolHookDurationMs}ms for ${tool.name} (${postToolHookInfos.length} hooks)`, { level: 'info' }, ) }
if (isMcpTool(tool)) { await addToolResult(toolOutput) }
// Show PostToolUse hook timing inline below tool result when > 500ms. // Use wall-clock time (not sum of individual durations) since hooks run in parallel. if (process.env.USER_TYPE === 'ant' && postToolHookInfos.length > 0) { if (postToolHookDurationMs > HOOK_TIMING_DISPLAY_THRESHOLD_MS) { resultingMessages.push({ message: createStopHookSummaryMessage( postToolHookInfos.length, postToolHookInfos, [], false, undefined, false, 'suggestion', undefined, 'PostToolUse', postToolHookDurationMs, ), }) } }
// If the tool provided new messages, add them to the list to return. if (result.newMessages && result.newMessages.length > 0) { for (const message of result.newMessages) { resultingMessages.push({ message }) } } // If hook indicated to prevent continuation after successful execution, yield a stop reason message if (shouldPreventContinuation) { resultingMessages.push({ message: createAttachmentMessage({ type: 'hook_stopped_continuation', message: stopReason || 'Execution stopped by hook', hookName: `PreToolUse:${tool.name}`, toolUseID: toolUseID, hookEvent: 'PreToolUse', }), }) }
// Yield the remaining hook results after the other messages are sent for (const hookResult of hookResults) { resultingMessages.push(hookResult) } return resultingMessages } catch (error) { const durationMs = Date.now() - startTime addToToolDuration(durationMs)
endToolExecutionSpan({ success: false, error: errorMessage(error), }) endToolSpan()
// Handle MCP auth errors by updating the client status to 'needs-auth' // This updates the /mcp display to show the server needs re-authorization if (error instanceof McpAuthError) { toolUseContext.setAppState(prevState => { const serverName = error.serverName const existingClientIndex = prevState.mcp.clients.findIndex( c => c.name === serverName, ) if (existingClientIndex === -1) { return prevState } const existingClient = prevState.mcp.clients[existingClientIndex] // Only update if client was connected (don't overwrite other states) if (!existingClient || existingClient.type !== 'connected') { return prevState } const updatedClients = [...prevState.mcp.clients] updatedClients[existingClientIndex] = { name: serverName, type: 'needs-auth' as const, config: existingClient.config, } return { ...prevState, mcp: { ...prevState.mcp, clients: updatedClients, }, } }) }
if (!(error instanceof AbortError)) { const errorMsg = errorMessage(error) logForDebugging( `${tool.name} tool error (${durationMs}ms): ${errorMsg.slice(0, 200)}`, ) if (!(error instanceof ShellError)) { logError(error) } logEvent('tengu_tool_use_error', { messageID: messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), error: classifyToolError( error, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: tool.isMcp ?? false,
queryChainId: toolUseContext.queryTracking ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, queryDepth: toolUseContext.queryTracking?.depth, ...(mcpServerType && { mcpServerType: mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(mcpServerBaseUrl && { mcpServerBaseUrl: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(requestId && { requestId: requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...mcpToolDetailsForAnalytics( tool.name, mcpServerType, mcpServerBaseUrl, ), }) // Log tool result error event for OTLP with tool parameters and decision context const mcpServerScope = isMcpTool(tool) ? getMcpServerScopeFromToolName(tool.name) : null
void logOTelEvent('tool_result', { tool_name: sanitizeToolNameForAnalytics(tool.name), use_id: toolUseID, success: 'false', duration_ms: String(durationMs), error: errorMessage(error), ...(Object.keys(toolParameters).length > 0 && { tool_parameters: jsonStringify(toolParameters), }), ...(telemetryToolInput && { tool_input: telemetryToolInput }), ...(decisionInfo && { decision_source: decisionInfo.source, decision_type: decisionInfo.decision, }), ...(mcpServerScope && { mcp_server_scope: mcpServerScope }), }) } const content = formatError(error)
// Determine if this was a user interrupt const isInterrupt = error instanceof AbortError
// Run PostToolUseFailure hooks const hookMessages: MessageUpdateLazy< AttachmentMessage | ProgressMessage<HookProgress> >[] = [] for await (const hookResult of runPostToolUseFailureHooks( toolUseContext, tool, toolUseID, messageId, processedInput, content, isInterrupt, requestId, mcpServerType, mcpServerBaseUrl, )) { hookMessages.push(hookResult) }
return [ { message: createUserMessage({ content: [ { type: 'tool_result', content, is_error: true, tool_use_id: toolUseID, }, ], toolUseResult: `Error: ${content}`, mcpMeta: toolUseContext.agentId ? undefined : error instanceof McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ? error.mcpMeta : undefined, sourceToolAssistantUUID: assistantMessage.uuid, }), }, ...hookMessages, ] } finally { stopSessionActivity('tool_exec') // Clean up decision info after logging if (decisionInfo) { toolUseContext.toolDecisions?.delete(toolUseID) } }}