import { 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 = { 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 { 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: `Error: No such tool available: ${toolName}`, 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: `${detailedError}`, 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, ): AsyncIterable { // 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() 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, onToolProgress: ( progress: ToolProgress | ProgressMessage, ) => void, ): Promise { // 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: `InputValidationError: ${errorContent}`, 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: `${isValidCall.message}`, 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) 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 = {} 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 = {} 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) && (processedInput as Record).file_path === (backfilledClone as Record).file_path ) { callInput = { ...processedInput, file_path: (callInput as Record).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 = {} // 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 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 >[] = [] 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) } } }