/ services / tools / toolExecution.ts
toolExecution.ts
   1  import { feature } from 'bun:bundle'
   2  import type {
   3    ContentBlockParam,
   4    ToolResultBlockParam,
   5    ToolUseBlock,
   6  } from '@anthropic-ai/sdk/resources/index.mjs'
   7  import {
   8    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
   9    logEvent,
  10  } from 'src/services/analytics/index.js'
  11  import {
  12    extractMcpToolDetails,
  13    extractSkillName,
  14    extractToolInputForTelemetry,
  15    getFileExtensionForAnalytics,
  16    getFileExtensionsFromBashCommand,
  17    isToolDetailsLoggingEnabled,
  18    mcpToolDetailsForAnalytics,
  19    sanitizeToolNameForAnalytics,
  20  } from 'src/services/analytics/metadata.js'
  21  import {
  22    addToToolDuration,
  23    getCodeEditToolDecisionCounter,
  24    getStatsStore,
  25  } from '../../bootstrap/state.js'
  26  import {
  27    buildCodeEditToolAttributes,
  28    isCodeEditingTool,
  29  } from '../../hooks/toolPermission/permissionLogging.js'
  30  import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
  31  import {
  32    findToolByName,
  33    type Tool,
  34    type ToolProgress,
  35    type ToolProgressData,
  36    type ToolUseContext,
  37  } from '../../Tool.js'
  38  import type { BashToolInput } from '../../tools/BashTool/BashTool.js'
  39  import { startSpeculativeClassifierCheck } from '../../tools/BashTool/bashPermissions.js'
  40  import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
  41  import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js'
  42  import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
  43  import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js'
  44  import { NOTEBOOK_EDIT_TOOL_NAME } from '../../tools/NotebookEditTool/constants.js'
  45  import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
  46  import { parseGitCommitId } from '../../tools/shared/gitOperationTracking.js'
  47  import {
  48    isDeferredTool,
  49    TOOL_SEARCH_TOOL_NAME,
  50  } from '../../tools/ToolSearchTool/prompt.js'
  51  import { getAllBaseTools } from '../../tools.js'
  52  import type { HookProgress } from '../../types/hooks.js'
  53  import type {
  54    AssistantMessage,
  55    AttachmentMessage,
  56    Message,
  57    ProgressMessage,
  58    StopHookInfo,
  59  } from '../../types/message.js'
  60  import { count } from '../../utils/array.js'
  61  import { createAttachmentMessage } from '../../utils/attachments.js'
  62  import { logForDebugging } from '../../utils/debug.js'
  63  import {
  64    AbortError,
  65    errorMessage,
  66    getErrnoCode,
  67    ShellError,
  68    TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  69  } from '../../utils/errors.js'
  70  import { executePermissionDeniedHooks } from '../../utils/hooks.js'
  71  import { logError } from '../../utils/log.js'
  72  import {
  73    CANCEL_MESSAGE,
  74    createProgressMessage,
  75    createStopHookSummaryMessage,
  76    createToolResultStopMessage,
  77    createUserMessage,
  78    withMemoryCorrectionHint,
  79  } from '../../utils/messages.js'
  80  import type {
  81    PermissionDecisionReason,
  82    PermissionResult,
  83  } from '../../utils/permissions/PermissionResult.js'
  84  import {
  85    startSessionActivity,
  86    stopSessionActivity,
  87  } from '../../utils/sessionActivity.js'
  88  import { jsonStringify } from '../../utils/slowOperations.js'
  89  import { Stream } from '../../utils/stream.js'
  90  import { logOTelEvent } from '../../utils/telemetry/events.js'
  91  import {
  92    addToolContentEvent,
  93    endToolBlockedOnUserSpan,
  94    endToolExecutionSpan,
  95    endToolSpan,
  96    isBetaTracingEnabled,
  97    startToolBlockedOnUserSpan,
  98    startToolExecutionSpan,
  99    startToolSpan,
 100  } from '../../utils/telemetry/sessionTracing.js'
 101  import {
 102    formatError,
 103    formatZodValidationError,
 104  } from '../../utils/toolErrors.js'
 105  import {
 106    processPreMappedToolResultBlock,
 107    processToolResultBlock,
 108  } from '../../utils/toolResultStorage.js'
 109  import {
 110    extractDiscoveredToolNames,
 111    isToolSearchEnabledOptimistic,
 112    isToolSearchToolAvailable,
 113  } from '../../utils/toolSearch.js'
 114  import {
 115    McpAuthError,
 116    McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 117  } from '../mcp/client.js'
 118  import { mcpInfoFromString } from '../mcp/mcpStringUtils.js'
 119  import { normalizeNameForMCP } from '../mcp/normalization.js'
 120  import type { MCPServerConnection } from '../mcp/types.js'
 121  import {
 122    getLoggingSafeMcpBaseUrl,
 123    getMcpServerScopeFromToolName,
 124    isMcpTool,
 125  } from '../mcp/utils.js'
 126  import {
 127    resolveHookPermissionDecision,
 128    runPostToolUseFailureHooks,
 129    runPostToolUseHooks,
 130    runPreToolUseHooks,
 131  } from './toolHooks.js'
 132  
 133  /** Minimum total hook duration (ms) to show inline timing summary */
 134  export const HOOK_TIMING_DISPLAY_THRESHOLD_MS = 500
 135  /** Log a debug warning when hooks/permission-decision block for this long. Matches
 136   * BashTool's PROGRESS_THRESHOLD_MS — the collapsed view feels stuck past this. */
 137  const SLOW_PHASE_LOG_THRESHOLD_MS = 2000
 138  
 139  /**
 140   * Classify a tool execution error into a telemetry-safe string.
 141   *
 142   * In minified/external builds, `error.constructor.name` is mangled into
 143   * short identifiers like "nJT" or "Chq" — useless for diagnostics.
 144   * This function extracts structured, telemetry-safe information instead:
 145   * - TelemetrySafeError: use its telemetryMessage (already vetted)
 146   * - Node.js fs errors: log the error code (ENOENT, EACCES, etc.)
 147   * - Known error types: use their unminified name
 148   * - Fallback: "Error" (better than a mangled 3-char identifier)
 149   */
 150  export function classifyToolError(error: unknown): string {
 151    if (
 152      error instanceof TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
 153    ) {
 154      return error.telemetryMessage.slice(0, 200)
 155    }
 156    if (error instanceof Error) {
 157      // Node.js filesystem errors have a `code` property (ENOENT, EACCES, etc.)
 158      // These are safe to log and much more useful than the constructor name.
 159      const errnoCode = getErrnoCode(error)
 160      if (typeof errnoCode === 'string') {
 161        return `Error:${errnoCode}`
 162      }
 163      // ShellError, ImageSizeError, etc. have stable `.name` properties
 164      // that survive minification (they're set in the constructor).
 165      if (error.name && error.name !== 'Error' && error.name.length > 3) {
 166        return error.name.slice(0, 60)
 167      }
 168      return 'Error'
 169    }
 170    return 'UnknownError'
 171  }
 172  
 173  /**
 174   * Map a rule's origin to the documented OTel `source` vocabulary, matching
 175   * the interactive path's semantics (permissionLogging.ts:81): session-scoped
 176   * grants are temporary, on-disk grants are permanent, and user-authored
 177   * denies are user_reject regardless of persistence. Everything the user
 178   * didn't write (cliArg, policySettings, projectSettings, flagSettings) is
 179   * config.
 180   */
 181  function ruleSourceToOTelSource(
 182    ruleSource: string,
 183    behavior: 'allow' | 'deny',
 184  ): string {
 185    switch (ruleSource) {
 186      case 'session':
 187        return behavior === 'allow' ? 'user_temporary' : 'user_reject'
 188      case 'localSettings':
 189      case 'userSettings':
 190        return behavior === 'allow' ? 'user_permanent' : 'user_reject'
 191      default:
 192        return 'config'
 193    }
 194  }
 195  
 196  /**
 197   * Map a PermissionDecisionReason to the OTel `source` label for the
 198   * non-interactive tool_decision path, staying within the documented
 199   * vocabulary (config, hook, user_permanent, user_temporary, user_reject).
 200   *
 201   * For permissionPromptTool, the SDK host may set decisionClassification on
 202   * the PermissionResult to tell us exactly what happened (once vs always vs
 203   * cache hit — the host knows, we can't tell from {behavior:'allow'} alone).
 204   * Without it, we fall back conservatively: allow → user_temporary,
 205   * deny → user_reject.
 206   */
 207  function decisionReasonToOTelSource(
 208    reason: PermissionDecisionReason | undefined,
 209    behavior: 'allow' | 'deny',
 210  ): string {
 211    if (!reason) {
 212      return 'config'
 213    }
 214    switch (reason.type) {
 215      case 'permissionPromptTool': {
 216        // toolResult is typed `unknown` on PermissionDecisionReason but carries
 217        // the parsed Output from PermissionPromptToolResultSchema. Narrow at
 218        // runtime rather than widen the cross-file type.
 219        const toolResult = reason.toolResult as
 220          | { decisionClassification?: string }
 221          | undefined
 222        const classified = toolResult?.decisionClassification
 223        if (
 224          classified === 'user_temporary' ||
 225          classified === 'user_permanent' ||
 226          classified === 'user_reject'
 227        ) {
 228          return classified
 229        }
 230        return behavior === 'allow' ? 'user_temporary' : 'user_reject'
 231      }
 232      case 'rule':
 233        return ruleSourceToOTelSource(reason.rule.source, behavior)
 234      case 'hook':
 235        return 'hook'
 236      case 'mode':
 237      case 'classifier':
 238      case 'subcommandResults':
 239      case 'asyncAgent':
 240      case 'sandboxOverride':
 241      case 'workingDir':
 242      case 'safetyCheck':
 243      case 'other':
 244        return 'config'
 245      default: {
 246        const _exhaustive: never = reason
 247        return 'config'
 248      }
 249    }
 250  }
 251  
 252  function getNextImagePasteId(messages: Message[]): number {
 253    let maxId = 0
 254    for (const message of messages) {
 255      if (message.type === 'user' && message.imagePasteIds) {
 256        for (const id of message.imagePasteIds) {
 257          if (id > maxId) maxId = id
 258        }
 259      }
 260    }
 261    return maxId + 1
 262  }
 263  
 264  export type MessageUpdateLazy<M extends Message = Message> = {
 265    message: M
 266    contextModifier?: {
 267      toolUseID: string
 268      modifyContext: (context: ToolUseContext) => ToolUseContext
 269    }
 270  }
 271  
 272  export type McpServerType =
 273    | 'stdio'
 274    | 'sse'
 275    | 'http'
 276    | 'ws'
 277    | 'sdk'
 278    | 'sse-ide'
 279    | 'ws-ide'
 280    | 'claudeai-proxy'
 281    | undefined
 282  
 283  function findMcpServerConnection(
 284    toolName: string,
 285    mcpClients: MCPServerConnection[],
 286  ): MCPServerConnection | undefined {
 287    if (!toolName.startsWith('mcp__')) {
 288      return undefined
 289    }
 290  
 291    const mcpInfo = mcpInfoFromString(toolName)
 292    if (!mcpInfo) {
 293      return undefined
 294    }
 295  
 296    // mcpInfo.serverName is normalized (e.g., "claude_ai_Slack"), but client.name
 297    // is the original name (e.g., "claude.ai Slack"). Normalize both for comparison.
 298    return mcpClients.find(
 299      client => normalizeNameForMCP(client.name) === mcpInfo.serverName,
 300    )
 301  }
 302  
 303  /**
 304   * Extracts the MCP server transport type from a tool name.
 305   * Returns the server type (stdio, sse, http, ws, sdk, etc.) for MCP tools,
 306   * or undefined for built-in tools.
 307   */
 308  function getMcpServerType(
 309    toolName: string,
 310    mcpClients: MCPServerConnection[],
 311  ): McpServerType {
 312    const serverConnection = findMcpServerConnection(toolName, mcpClients)
 313  
 314    if (serverConnection?.type === 'connected') {
 315      // Handle stdio configs where type field is optional (defaults to 'stdio')
 316      return serverConnection.config.type ?? 'stdio'
 317    }
 318  
 319    return undefined
 320  }
 321  
 322  /**
 323   * Extracts the MCP server base URL for a tool by looking up its server connection.
 324   * Returns undefined for stdio servers, built-in tools, or if the server is not connected.
 325   */
 326  function getMcpServerBaseUrlFromToolName(
 327    toolName: string,
 328    mcpClients: MCPServerConnection[],
 329  ): string | undefined {
 330    const serverConnection = findMcpServerConnection(toolName, mcpClients)
 331    if (serverConnection?.type !== 'connected') {
 332      return undefined
 333    }
 334    return getLoggingSafeMcpBaseUrl(serverConnection.config)
 335  }
 336  
 337  export async function* runToolUse(
 338    toolUse: ToolUseBlock,
 339    assistantMessage: AssistantMessage,
 340    canUseTool: CanUseToolFn,
 341    toolUseContext: ToolUseContext,
 342  ): AsyncGenerator<MessageUpdateLazy, void> {
 343    const toolName = toolUse.name
 344    // First try to find in the available tools (what the model sees)
 345    let tool = findToolByName(toolUseContext.options.tools, toolName)
 346  
 347    // If not found, check if it's a deprecated tool being called by alias
 348    // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop")
 349    // Only fall back for tools where the name matches an alias, not the primary name
 350    if (!tool) {
 351      const fallbackTool = findToolByName(getAllBaseTools(), toolName)
 352      // Only use fallback if the tool was found via alias (deprecated name)
 353      if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
 354        tool = fallbackTool
 355      }
 356    }
 357    const messageId = assistantMessage.message.id
 358    const requestId = assistantMessage.requestId
 359    const mcpServerType = getMcpServerType(
 360      toolName,
 361      toolUseContext.options.mcpClients,
 362    )
 363    const mcpServerBaseUrl = getMcpServerBaseUrlFromToolName(
 364      toolName,
 365      toolUseContext.options.mcpClients,
 366    )
 367  
 368    // Check if the tool exists
 369    if (!tool) {
 370      const sanitizedToolName = sanitizeToolNameForAnalytics(toolName)
 371      logForDebugging(`Unknown tool ${toolName}: ${toolUse.id}`)
 372      logEvent('tengu_tool_use_error', {
 373        error:
 374          `No such tool available: ${sanitizedToolName}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 375        toolName: sanitizedToolName,
 376        toolUseID:
 377          toolUse.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 378        isMcp: toolName.startsWith('mcp__'),
 379        queryChainId: toolUseContext.queryTracking
 380          ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 381        queryDepth: toolUseContext.queryTracking?.depth,
 382        ...(mcpServerType && {
 383          mcpServerType:
 384            mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 385        }),
 386        ...(mcpServerBaseUrl && {
 387          mcpServerBaseUrl:
 388            mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 389        }),
 390        ...(requestId && {
 391          requestId:
 392            requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 393        }),
 394        ...mcpToolDetailsForAnalytics(toolName, mcpServerType, mcpServerBaseUrl),
 395      })
 396      yield {
 397        message: createUserMessage({
 398          content: [
 399            {
 400              type: 'tool_result',
 401              content: `<tool_use_error>Error: No such tool available: ${toolName}</tool_use_error>`,
 402              is_error: true,
 403              tool_use_id: toolUse.id,
 404            },
 405          ],
 406          toolUseResult: `Error: No such tool available: ${toolName}`,
 407          sourceToolAssistantUUID: assistantMessage.uuid,
 408        }),
 409      }
 410      return
 411    }
 412  
 413    const toolInput = toolUse.input as { [key: string]: string }
 414    try {
 415      if (toolUseContext.abortController.signal.aborted) {
 416        logEvent('tengu_tool_use_cancelled', {
 417          toolName: sanitizeToolNameForAnalytics(tool.name),
 418          toolUseID:
 419            toolUse.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 420          isMcp: tool.isMcp ?? false,
 421  
 422          queryChainId: toolUseContext.queryTracking
 423            ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 424          queryDepth: toolUseContext.queryTracking?.depth,
 425          ...(mcpServerType && {
 426            mcpServerType:
 427              mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 428          }),
 429          ...(mcpServerBaseUrl && {
 430            mcpServerBaseUrl:
 431              mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 432          }),
 433          ...(requestId && {
 434            requestId:
 435              requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 436          }),
 437          ...mcpToolDetailsForAnalytics(
 438            tool.name,
 439            mcpServerType,
 440            mcpServerBaseUrl,
 441          ),
 442        })
 443        const content = createToolResultStopMessage(toolUse.id)
 444        content.content = withMemoryCorrectionHint(CANCEL_MESSAGE)
 445        yield {
 446          message: createUserMessage({
 447            content: [content],
 448            toolUseResult: CANCEL_MESSAGE,
 449            sourceToolAssistantUUID: assistantMessage.uuid,
 450          }),
 451        }
 452        return
 453      }
 454  
 455      for await (const update of streamedCheckPermissionsAndCallTool(
 456        tool,
 457        toolUse.id,
 458        toolInput,
 459        toolUseContext,
 460        canUseTool,
 461        assistantMessage,
 462        messageId,
 463        requestId,
 464        mcpServerType,
 465        mcpServerBaseUrl,
 466      )) {
 467        yield update
 468      }
 469    } catch (error) {
 470      logError(error)
 471      const errorMessage = error instanceof Error ? error.message : String(error)
 472      const toolInfo = tool ? ` (${tool.name})` : ''
 473      const detailedError = `Error calling tool${toolInfo}: ${errorMessage}`
 474  
 475      yield {
 476        message: createUserMessage({
 477          content: [
 478            {
 479              type: 'tool_result',
 480              content: `<tool_use_error>${detailedError}</tool_use_error>`,
 481              is_error: true,
 482              tool_use_id: toolUse.id,
 483            },
 484          ],
 485          toolUseResult: detailedError,
 486          sourceToolAssistantUUID: assistantMessage.uuid,
 487        }),
 488      }
 489    }
 490  }
 491  
 492  function streamedCheckPermissionsAndCallTool(
 493    tool: Tool,
 494    toolUseID: string,
 495    input: { [key: string]: boolean | string | number },
 496    toolUseContext: ToolUseContext,
 497    canUseTool: CanUseToolFn,
 498    assistantMessage: AssistantMessage,
 499    messageId: string,
 500    requestId: string | undefined,
 501    mcpServerType: McpServerType,
 502    mcpServerBaseUrl: ReturnType<typeof getLoggingSafeMcpBaseUrl>,
 503  ): AsyncIterable<MessageUpdateLazy> {
 504    // This is a bit of a hack to get progress events and final results
 505    // into a single async iterable.
 506    //
 507    // Ideally the progress reporting and tool call reporting would
 508    // be via separate mechanisms.
 509    const stream = new Stream<MessageUpdateLazy>()
 510    checkPermissionsAndCallTool(
 511      tool,
 512      toolUseID,
 513      input,
 514      toolUseContext,
 515      canUseTool,
 516      assistantMessage,
 517      messageId,
 518      requestId,
 519      mcpServerType,
 520      mcpServerBaseUrl,
 521      progress => {
 522        logEvent('tengu_tool_use_progress', {
 523          messageID:
 524            messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 525          toolName: sanitizeToolNameForAnalytics(tool.name),
 526          isMcp: tool.isMcp ?? false,
 527  
 528          queryChainId: toolUseContext.queryTracking
 529            ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 530          queryDepth: toolUseContext.queryTracking?.depth,
 531          ...(mcpServerType && {
 532            mcpServerType:
 533              mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 534          }),
 535          ...(mcpServerBaseUrl && {
 536            mcpServerBaseUrl:
 537              mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 538          }),
 539          ...(requestId && {
 540            requestId:
 541              requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 542          }),
 543          ...mcpToolDetailsForAnalytics(
 544            tool.name,
 545            mcpServerType,
 546            mcpServerBaseUrl,
 547          ),
 548        })
 549        stream.enqueue({
 550          message: createProgressMessage({
 551            toolUseID: progress.toolUseID,
 552            parentToolUseID: toolUseID,
 553            data: progress.data,
 554          }),
 555        })
 556      },
 557    )
 558      .then(results => {
 559        for (const result of results) {
 560          stream.enqueue(result)
 561        }
 562      })
 563      .catch(error => {
 564        stream.error(error)
 565      })
 566      .finally(() => {
 567        stream.done()
 568      })
 569    return stream
 570  }
 571  
 572  /**
 573   * Appended to Zod errors when a deferred tool wasn't in the discovered-tool
 574   * set — re-runs the claude.ts schema-filter scan dispatch-time to detect the
 575   * mismatch. The raw Zod error ("expected array, got string") doesn't tell the
 576   * model to re-load the tool; this hint does. Null if the schema was sent.
 577   */
 578  export function buildSchemaNotSentHint(
 579    tool: Tool,
 580    messages: Message[],
 581    tools: readonly { name: string }[],
 582  ): string | null {
 583    // Optimistic gating — reconstructing claude.ts's full useToolSearch
 584    // computation is fragile. These two gates prevent pointing at a ToolSearch
 585    // that isn't callable; occasional misfires (Haiku, tst-auto below threshold)
 586    // cost one extra round-trip on an already-failing path.
 587    if (!isToolSearchEnabledOptimistic()) return null
 588    if (!isToolSearchToolAvailable(tools)) return null
 589    if (!isDeferredTool(tool)) return null
 590    const discovered = extractDiscoveredToolNames(messages)
 591    if (discovered.has(tool.name)) return null
 592    return (
 593      `\n\nThis tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. ` +
 594      `Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. ` +
 595      `Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.`
 596    )
 597  }
 598  
 599  async function checkPermissionsAndCallTool(
 600    tool: Tool,
 601    toolUseID: string,
 602    input: { [key: string]: boolean | string | number },
 603    toolUseContext: ToolUseContext,
 604    canUseTool: CanUseToolFn,
 605    assistantMessage: AssistantMessage,
 606    messageId: string,
 607    requestId: string | undefined,
 608    mcpServerType: McpServerType,
 609    mcpServerBaseUrl: ReturnType<typeof getLoggingSafeMcpBaseUrl>,
 610    onToolProgress: (
 611      progress: ToolProgress<ToolProgressData> | ProgressMessage<HookProgress>,
 612    ) => void,
 613  ): Promise<MessageUpdateLazy[]> {
 614    // Validate input types with zod (surprisingly, the model is not great at generating valid input)
 615    const parsedInput = tool.inputSchema.safeParse(input)
 616    if (!parsedInput.success) {
 617      let errorContent = formatZodValidationError(tool.name, parsedInput.error)
 618  
 619      const schemaHint = buildSchemaNotSentHint(
 620        tool,
 621        toolUseContext.messages,
 622        toolUseContext.options.tools,
 623      )
 624      if (schemaHint) {
 625        logEvent('tengu_deferred_tool_schema_not_sent', {
 626          toolName: sanitizeToolNameForAnalytics(tool.name),
 627          isMcp: tool.isMcp ?? false,
 628        })
 629        errorContent += schemaHint
 630      }
 631  
 632      logForDebugging(
 633        `${tool.name} tool input error: ${errorContent.slice(0, 200)}`,
 634      )
 635      logEvent('tengu_tool_use_error', {
 636        error:
 637          'InputValidationError' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 638        errorDetails: errorContent.slice(
 639          0,
 640          2000,
 641        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 642        messageID:
 643          messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 644        toolName: sanitizeToolNameForAnalytics(tool.name),
 645        isMcp: tool.isMcp ?? false,
 646  
 647        queryChainId: toolUseContext.queryTracking
 648          ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 649        queryDepth: toolUseContext.queryTracking?.depth,
 650        ...(mcpServerType && {
 651          mcpServerType:
 652            mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 653        }),
 654        ...(mcpServerBaseUrl && {
 655          mcpServerBaseUrl:
 656            mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 657        }),
 658        ...(requestId && {
 659          requestId:
 660            requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 661        }),
 662        ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl),
 663      })
 664      return [
 665        {
 666          message: createUserMessage({
 667            content: [
 668              {
 669                type: 'tool_result',
 670                content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
 671                is_error: true,
 672                tool_use_id: toolUseID,
 673              },
 674            ],
 675            toolUseResult: `InputValidationError: ${parsedInput.error.message}`,
 676            sourceToolAssistantUUID: assistantMessage.uuid,
 677          }),
 678        },
 679      ]
 680    }
 681  
 682    // Validate input values. Each tool has its own validation logic
 683    const isValidCall = await tool.validateInput?.(
 684      parsedInput.data,
 685      toolUseContext,
 686    )
 687    if (isValidCall?.result === false) {
 688      logForDebugging(
 689        `${tool.name} tool validation error: ${isValidCall.message?.slice(0, 200)}`,
 690      )
 691      logEvent('tengu_tool_use_error', {
 692        messageID:
 693          messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 694        toolName: sanitizeToolNameForAnalytics(tool.name),
 695        error:
 696          isValidCall.message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 697        errorCode: isValidCall.errorCode,
 698        isMcp: tool.isMcp ?? false,
 699  
 700        queryChainId: toolUseContext.queryTracking
 701          ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 702        queryDepth: toolUseContext.queryTracking?.depth,
 703        ...(mcpServerType && {
 704          mcpServerType:
 705            mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 706        }),
 707        ...(mcpServerBaseUrl && {
 708          mcpServerBaseUrl:
 709            mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 710        }),
 711        ...(requestId && {
 712          requestId:
 713            requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 714        }),
 715        ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl),
 716      })
 717      return [
 718        {
 719          message: createUserMessage({
 720            content: [
 721              {
 722                type: 'tool_result',
 723                content: `<tool_use_error>${isValidCall.message}</tool_use_error>`,
 724                is_error: true,
 725                tool_use_id: toolUseID,
 726              },
 727            ],
 728            toolUseResult: `Error: ${isValidCall.message}`,
 729            sourceToolAssistantUUID: assistantMessage.uuid,
 730          }),
 731        },
 732      ]
 733    }
 734    // Speculatively start the bash allow classifier check early so it runs in
 735    // parallel with pre-tool hooks, deny/ask classifiers, and permission dialog
 736    // setup. The UI indicator (setClassifierChecking) is NOT set here — it's
 737    // set in interactiveHandler.ts only when the permission check returns `ask`
 738    // with a pendingClassifierCheck. This avoids flashing "classifier running"
 739    // for commands that auto-allow via prefix rules.
 740    if (
 741      tool.name === BASH_TOOL_NAME &&
 742      parsedInput.data &&
 743      'command' in parsedInput.data
 744    ) {
 745      const appState = toolUseContext.getAppState()
 746      startSpeculativeClassifierCheck(
 747        (parsedInput.data as BashToolInput).command,
 748        appState.toolPermissionContext,
 749        toolUseContext.abortController.signal,
 750        toolUseContext.options.isNonInteractiveSession,
 751      )
 752    }
 753  
 754    const resultingMessages = []
 755  
 756    // Defense-in-depth: strip _simulatedSedEdit from model-provided Bash input.
 757    // This field is internal-only — it must only be injected by the permission
 758    // system (SedEditPermissionRequest) after user approval. If the model supplies
 759    // it, the schema's strictObject should already reject it, but we strip here
 760    // as a safeguard against future regressions.
 761    let processedInput = parsedInput.data
 762    if (
 763      tool.name === BASH_TOOL_NAME &&
 764      processedInput &&
 765      typeof processedInput === 'object' &&
 766      '_simulatedSedEdit' in processedInput
 767    ) {
 768      const { _simulatedSedEdit: _, ...rest } =
 769        processedInput as typeof processedInput & {
 770          _simulatedSedEdit: unknown
 771        }
 772      processedInput = rest as typeof processedInput
 773    }
 774  
 775    // Backfill legacy/derived fields on a shallow clone so hooks/canUseTool see
 776    // them without affecting tool.call(). SendMessageTool adds fields; file
 777    // tools overwrite file_path with expandPath — that mutation must not reach
 778    // call() because tool results embed the input path verbatim (e.g. "File
 779    // created successfully at: {path}"), and changing it alters the serialized
 780    // transcript and VCR fixture hashes. If a hook/permission later returns a
 781    // fresh updatedInput, callInput converges on it below — that replacement
 782    // is intentional and should reach call().
 783    let callInput = processedInput
 784    const backfilledClone =
 785      tool.backfillObservableInput &&
 786      typeof processedInput === 'object' &&
 787      processedInput !== null
 788        ? ({ ...processedInput } as typeof processedInput)
 789        : null
 790    if (backfilledClone) {
 791      tool.backfillObservableInput!(backfilledClone as Record<string, unknown>)
 792      processedInput = backfilledClone
 793    }
 794  
 795    let shouldPreventContinuation = false
 796    let stopReason: string | undefined
 797    let hookPermissionResult: PermissionResult | undefined
 798    const preToolHookInfos: StopHookInfo[] = []
 799    const preToolHookStart = Date.now()
 800    for await (const result of runPreToolUseHooks(
 801      toolUseContext,
 802      tool,
 803      processedInput,
 804      toolUseID,
 805      assistantMessage.message.id,
 806      requestId,
 807      mcpServerType,
 808      mcpServerBaseUrl,
 809    )) {
 810      switch (result.type) {
 811        case 'message':
 812          if (result.message.message.type === 'progress') {
 813            onToolProgress(result.message.message)
 814          } else {
 815            resultingMessages.push(result.message)
 816            const att = result.message.message.attachment
 817            if (
 818              att &&
 819              'command' in att &&
 820              att.command !== undefined &&
 821              'durationMs' in att &&
 822              att.durationMs !== undefined
 823            ) {
 824              preToolHookInfos.push({
 825                command: att.command,
 826                durationMs: att.durationMs,
 827              })
 828            }
 829          }
 830          break
 831        case 'hookPermissionResult':
 832          hookPermissionResult = result.hookPermissionResult
 833          break
 834        case 'hookUpdatedInput':
 835          // Hook provided updatedInput without making a permission decision (passthrough)
 836          // Update processedInput so it's used in the normal permission flow
 837          processedInput = result.updatedInput
 838          break
 839        case 'preventContinuation':
 840          shouldPreventContinuation = result.shouldPreventContinuation
 841          break
 842        case 'stopReason':
 843          stopReason = result.stopReason
 844          break
 845        case 'additionalContext':
 846          resultingMessages.push(result.message)
 847          break
 848        case 'stop':
 849          getStatsStore()?.observe(
 850            'pre_tool_hook_duration_ms',
 851            Date.now() - preToolHookStart,
 852          )
 853          resultingMessages.push({
 854            message: createUserMessage({
 855              content: [createToolResultStopMessage(toolUseID)],
 856              toolUseResult: `Error: ${stopReason}`,
 857              sourceToolAssistantUUID: assistantMessage.uuid,
 858            }),
 859          })
 860          return resultingMessages
 861      }
 862    }
 863    const preToolHookDurationMs = Date.now() - preToolHookStart
 864    getStatsStore()?.observe('pre_tool_hook_duration_ms', preToolHookDurationMs)
 865    if (preToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) {
 866      logForDebugging(
 867        `Slow PreToolUse hooks: ${preToolHookDurationMs}ms for ${tool.name} (${preToolHookInfos.length} hooks)`,
 868        { level: 'info' },
 869      )
 870    }
 871  
 872    // Emit PreToolUse summary immediately so it's visible while the tool executes.
 873    // Use wall-clock time (not sum of individual durations) since hooks run in parallel.
 874    if (process.env.USER_TYPE === 'ant' && preToolHookInfos.length > 0) {
 875      if (preToolHookDurationMs > HOOK_TIMING_DISPLAY_THRESHOLD_MS) {
 876        resultingMessages.push({
 877          message: createStopHookSummaryMessage(
 878            preToolHookInfos.length,
 879            preToolHookInfos,
 880            [],
 881            false,
 882            undefined,
 883            false,
 884            'suggestion',
 885            undefined,
 886            'PreToolUse',
 887            preToolHookDurationMs,
 888          ),
 889        })
 890      }
 891    }
 892  
 893    const toolAttributes: Record<string, string | number | boolean> = {}
 894    if (processedInput && typeof processedInput === 'object') {
 895      if (tool.name === FILE_READ_TOOL_NAME && 'file_path' in processedInput) {
 896        toolAttributes.file_path = String(processedInput.file_path)
 897      } else if (
 898        (tool.name === FILE_EDIT_TOOL_NAME ||
 899          tool.name === FILE_WRITE_TOOL_NAME) &&
 900        'file_path' in processedInput
 901      ) {
 902        toolAttributes.file_path = String(processedInput.file_path)
 903      } else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
 904        const bashInput = processedInput as BashToolInput
 905        toolAttributes.full_command = bashInput.command
 906      }
 907    }
 908  
 909    startToolSpan(
 910      tool.name,
 911      toolAttributes,
 912      isBetaTracingEnabled() ? jsonStringify(processedInput) : undefined,
 913    )
 914    startToolBlockedOnUserSpan()
 915  
 916    // Check whether we have permission to use the tool,
 917    // and ask the user for permission if we don't
 918    const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode
 919    const permissionStart = Date.now()
 920  
 921    const resolved = await resolveHookPermissionDecision(
 922      hookPermissionResult,
 923      tool,
 924      processedInput,
 925      toolUseContext,
 926      canUseTool,
 927      assistantMessage,
 928      toolUseID,
 929    )
 930    const permissionDecision = resolved.decision
 931    processedInput = resolved.input
 932    const permissionDurationMs = Date.now() - permissionStart
 933    // In auto mode, canUseTool awaits the classifier (side_query) — if that's
 934    // slow the collapsed view shows "Running…" with no (Ns) tick since
 935    // bash_progress hasn't started yet. Auto-only: in default mode this timer
 936    // includes interactive-dialog wait (user think time), which is just noise.
 937    if (
 938      permissionDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS &&
 939      permissionMode === 'auto'
 940    ) {
 941      logForDebugging(
 942        `Slow permission decision: ${permissionDurationMs}ms for ${tool.name} ` +
 943          `(mode=${permissionMode}, behavior=${permissionDecision.behavior})`,
 944        { level: 'info' },
 945      )
 946    }
 947  
 948    // Emit tool_decision OTel event and code-edit counter if the interactive
 949    // permission path didn't already log it (headless mode bypasses permission
 950    // logging, so we need to emit both the generic event and the code-edit
 951    // counter here)
 952    if (
 953      permissionDecision.behavior !== 'ask' &&
 954      !toolUseContext.toolDecisions?.has(toolUseID)
 955    ) {
 956      const decision =
 957        permissionDecision.behavior === 'allow' ? 'accept' : 'reject'
 958      const source = decisionReasonToOTelSource(
 959        permissionDecision.decisionReason,
 960        permissionDecision.behavior,
 961      )
 962      void logOTelEvent('tool_decision', {
 963        decision,
 964        source,
 965        tool_name: sanitizeToolNameForAnalytics(tool.name),
 966      })
 967  
 968      // Increment code-edit tool decision counter for headless mode
 969      if (isCodeEditingTool(tool.name)) {
 970        void buildCodeEditToolAttributes(
 971          tool,
 972          processedInput,
 973          decision,
 974          source,
 975        ).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes))
 976      }
 977    }
 978  
 979    // Add message if permission was granted/denied by PermissionRequest hook
 980    if (
 981      permissionDecision.decisionReason?.type === 'hook' &&
 982      permissionDecision.decisionReason.hookName === 'PermissionRequest' &&
 983      permissionDecision.behavior !== 'ask'
 984    ) {
 985      resultingMessages.push({
 986        message: createAttachmentMessage({
 987          type: 'hook_permission_decision',
 988          decision: permissionDecision.behavior,
 989          toolUseID,
 990          hookEvent: 'PermissionRequest',
 991        }),
 992      })
 993    }
 994  
 995    if (permissionDecision.behavior !== 'allow') {
 996      logForDebugging(`${tool.name} tool permission denied`)
 997      const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID)
 998      endToolBlockedOnUserSpan('reject', decisionInfo?.source || 'unknown')
 999      endToolSpan()
1000  
1001      logEvent('tengu_tool_use_can_use_tool_rejected', {
1002        messageID:
1003          messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1004        toolName: sanitizeToolNameForAnalytics(tool.name),
1005  
1006        queryChainId: toolUseContext.queryTracking
1007          ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1008        queryDepth: toolUseContext.queryTracking?.depth,
1009        ...(mcpServerType && {
1010          mcpServerType:
1011            mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1012        }),
1013        ...(mcpServerBaseUrl && {
1014          mcpServerBaseUrl:
1015            mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1016        }),
1017        ...(requestId && {
1018          requestId:
1019            requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1020        }),
1021        ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl),
1022      })
1023      let errorMessage = permissionDecision.message
1024      // Only use generic "Execution stopped" message if we don't have a detailed hook message
1025      if (shouldPreventContinuation && !errorMessage) {
1026        errorMessage = `Execution stopped by PreToolUse hook${stopReason ? `: ${stopReason}` : ''}`
1027      }
1028  
1029      // Build top-level content: tool_result (text-only for is_error compatibility) + images alongside
1030      const messageContent: ContentBlockParam[] = [
1031        {
1032          type: 'tool_result',
1033          content: errorMessage,
1034          is_error: true,
1035          tool_use_id: toolUseID,
1036        },
1037      ]
1038  
1039      // Add image blocks at top level (not inside tool_result, which rejects non-text with is_error)
1040      const rejectContentBlocks =
1041        permissionDecision.behavior === 'ask'
1042          ? permissionDecision.contentBlocks
1043          : undefined
1044      if (rejectContentBlocks?.length) {
1045        messageContent.push(...rejectContentBlocks)
1046      }
1047  
1048      // Generate sequential imagePasteIds so each image renders with a distinct label
1049      let rejectImageIds: number[] | undefined
1050      if (rejectContentBlocks?.length) {
1051        const imageCount = count(
1052          rejectContentBlocks,
1053          (b: ContentBlockParam) => b.type === 'image',
1054        )
1055        if (imageCount > 0) {
1056          const startId = getNextImagePasteId(toolUseContext.messages)
1057          rejectImageIds = Array.from(
1058            { length: imageCount },
1059            (_, i) => startId + i,
1060          )
1061        }
1062      }
1063  
1064      resultingMessages.push({
1065        message: createUserMessage({
1066          content: messageContent,
1067          imagePasteIds: rejectImageIds,
1068          toolUseResult: `Error: ${errorMessage}`,
1069          sourceToolAssistantUUID: assistantMessage.uuid,
1070        }),
1071      })
1072  
1073      // Run PermissionDenied hooks for auto mode classifier denials.
1074      // If a hook returns {retry: true}, tell the model it may retry.
1075      if (
1076        feature('TRANSCRIPT_CLASSIFIER') &&
1077        permissionDecision.decisionReason?.type === 'classifier' &&
1078        permissionDecision.decisionReason.classifier === 'auto-mode'
1079      ) {
1080        let hookSaysRetry = false
1081        for await (const result of executePermissionDeniedHooks(
1082          tool.name,
1083          toolUseID,
1084          processedInput,
1085          permissionDecision.decisionReason.reason ?? 'Permission denied',
1086          toolUseContext,
1087          permissionMode,
1088          toolUseContext.abortController.signal,
1089        )) {
1090          if (result.retry) hookSaysRetry = true
1091        }
1092        if (hookSaysRetry) {
1093          resultingMessages.push({
1094            message: createUserMessage({
1095              content:
1096                'The PermissionDenied hook indicated this command is now approved. You may retry it if you would like.',
1097              isMeta: true,
1098            }),
1099          })
1100        }
1101      }
1102  
1103      return resultingMessages
1104    }
1105    logEvent('tengu_tool_use_can_use_tool_allowed', {
1106      messageID:
1107        messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1108      toolName: sanitizeToolNameForAnalytics(tool.name),
1109  
1110      queryChainId: toolUseContext.queryTracking
1111        ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1112      queryDepth: toolUseContext.queryTracking?.depth,
1113      ...(mcpServerType && {
1114        mcpServerType:
1115          mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1116      }),
1117      ...(mcpServerBaseUrl && {
1118        mcpServerBaseUrl:
1119          mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1120      }),
1121      ...(requestId && {
1122        requestId:
1123          requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1124      }),
1125      ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl),
1126    })
1127  
1128    // Use the updated input from permissions if provided
1129    // (Don't overwrite if undefined - processedInput may have been modified by passthrough hooks)
1130    if (permissionDecision.updatedInput !== undefined) {
1131      processedInput = permissionDecision.updatedInput
1132    }
1133  
1134    // Prepare tool parameters for logging in tool_result event.
1135    // Gated by OTEL_LOG_TOOL_DETAILS — tool parameters can contain sensitive
1136    // content (bash commands, MCP server names, etc.) so they're opt-in only.
1137    const telemetryToolInput = extractToolInputForTelemetry(processedInput)
1138    let toolParameters: Record<string, unknown> = {}
1139    if (isToolDetailsLoggingEnabled()) {
1140      if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
1141        const bashInput = processedInput as BashToolInput
1142        const commandParts = bashInput.command.trim().split(/\s+/)
1143        const bashCommand = commandParts[0] || ''
1144  
1145        toolParameters = {
1146          bash_command: bashCommand,
1147          full_command: bashInput.command,
1148          ...(bashInput.timeout !== undefined && {
1149            timeout: bashInput.timeout,
1150          }),
1151          ...(bashInput.description !== undefined && {
1152            description: bashInput.description,
1153          }),
1154          ...('dangerouslyDisableSandbox' in bashInput && {
1155            dangerouslyDisableSandbox: bashInput.dangerouslyDisableSandbox,
1156          }),
1157        }
1158      }
1159  
1160      const mcpDetails = extractMcpToolDetails(tool.name)
1161      if (mcpDetails) {
1162        toolParameters.mcp_server_name = mcpDetails.serverName
1163        toolParameters.mcp_tool_name = mcpDetails.mcpToolName
1164      }
1165      const skillName = extractSkillName(tool.name, processedInput)
1166      if (skillName) {
1167        toolParameters.skill_name = skillName
1168      }
1169    }
1170  
1171    const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID)
1172    endToolBlockedOnUserSpan(
1173      decisionInfo?.decision || 'unknown',
1174      decisionInfo?.source || 'unknown',
1175    )
1176    startToolExecutionSpan()
1177  
1178    const startTime = Date.now()
1179  
1180    startSessionActivity('tool_exec')
1181    // If processedInput still points at the backfill clone, no hook/permission
1182    // replaced it — pass the pre-backfill callInput so call() sees the model's
1183    // original field values. Otherwise converge on the hook-supplied input.
1184    // Permission/hook flows may return a fresh object derived from the
1185    // backfilled clone (e.g. via inputSchema.parse). If its file_path matches
1186    // the backfill-expanded value, restore the model's original so the tool
1187    // result string embeds the path the model emitted — keeps transcript/VCR
1188    // hashes stable. Other hook modifications flow through unchanged.
1189    if (
1190      backfilledClone &&
1191      processedInput !== callInput &&
1192      typeof processedInput === 'object' &&
1193      processedInput !== null &&
1194      'file_path' in processedInput &&
1195      'file_path' in (callInput as Record<string, unknown>) &&
1196      (processedInput as Record<string, unknown>).file_path ===
1197        (backfilledClone as Record<string, unknown>).file_path
1198    ) {
1199      callInput = {
1200        ...processedInput,
1201        file_path: (callInput as Record<string, unknown>).file_path,
1202      } as typeof processedInput
1203    } else if (processedInput !== backfilledClone) {
1204      callInput = processedInput
1205    }
1206    try {
1207      const result = await tool.call(
1208        callInput,
1209        {
1210          ...toolUseContext,
1211          toolUseId: toolUseID,
1212          userModified: permissionDecision.userModified ?? false,
1213        },
1214        canUseTool,
1215        assistantMessage,
1216        progress => {
1217          onToolProgress({
1218            toolUseID: progress.toolUseID,
1219            data: progress.data,
1220          })
1221        },
1222      )
1223      const durationMs = Date.now() - startTime
1224      addToToolDuration(durationMs)
1225  
1226      // Log tool content/output as span event if enabled
1227      if (result.data && typeof result.data === 'object') {
1228        const contentAttributes: Record<string, string | number | boolean> = {}
1229  
1230        // Read tool: capture file_path and content
1231        if (tool.name === FILE_READ_TOOL_NAME && 'content' in result.data) {
1232          if ('file_path' in processedInput) {
1233            contentAttributes.file_path = String(processedInput.file_path)
1234          }
1235          contentAttributes.content = String(result.data.content)
1236        }
1237  
1238        // Edit/Write tools: capture file_path and diff
1239        if (
1240          (tool.name === FILE_EDIT_TOOL_NAME ||
1241            tool.name === FILE_WRITE_TOOL_NAME) &&
1242          'file_path' in processedInput
1243        ) {
1244          contentAttributes.file_path = String(processedInput.file_path)
1245  
1246          // For Edit, capture the actual changes made
1247          if (tool.name === FILE_EDIT_TOOL_NAME && 'diff' in result.data) {
1248            contentAttributes.diff = String(result.data.diff)
1249          }
1250          // For Write, capture the written content
1251          if (tool.name === FILE_WRITE_TOOL_NAME && 'content' in processedInput) {
1252            contentAttributes.content = String(processedInput.content)
1253          }
1254        }
1255  
1256        // Bash tool: capture command
1257        if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
1258          const bashInput = processedInput as BashToolInput
1259          contentAttributes.bash_command = bashInput.command
1260          // Also capture output if available
1261          if ('output' in result.data) {
1262            contentAttributes.output = String(result.data.output)
1263          }
1264        }
1265  
1266        if (Object.keys(contentAttributes).length > 0) {
1267          addToolContentEvent('tool.output', contentAttributes)
1268        }
1269      }
1270  
1271      // Capture structured output from tool result if present
1272      if (typeof result === 'object' && 'structured_output' in result) {
1273        // Store the structured output in an attachment message
1274        resultingMessages.push({
1275          message: createAttachmentMessage({
1276            type: 'structured_output',
1277            data: result.structured_output,
1278          }),
1279        })
1280      }
1281  
1282      endToolExecutionSpan({ success: true })
1283      // Pass tool result for new_context logging
1284      const toolResultStr =
1285        result.data && typeof result.data === 'object'
1286          ? jsonStringify(result.data)
1287          : String(result.data ?? '')
1288      endToolSpan(toolResultStr)
1289  
1290      // Map the tool result to API format once and cache it. This block is reused
1291      // by addToolResult (skipping the remap) and measured here for analytics.
1292      const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(
1293        result.data,
1294        toolUseID,
1295      )
1296      const mappedContent = mappedToolResultBlock.content
1297      const toolResultSizeBytes = !mappedContent
1298        ? 0
1299        : typeof mappedContent === 'string'
1300          ? mappedContent.length
1301          : jsonStringify(mappedContent).length
1302  
1303      // Extract file extension for file-related tools
1304      let fileExtension: ReturnType<typeof getFileExtensionForAnalytics>
1305      if (processedInput && typeof processedInput === 'object') {
1306        if (
1307          (tool.name === FILE_READ_TOOL_NAME ||
1308            tool.name === FILE_EDIT_TOOL_NAME ||
1309            tool.name === FILE_WRITE_TOOL_NAME) &&
1310          'file_path' in processedInput
1311        ) {
1312          fileExtension = getFileExtensionForAnalytics(
1313            String(processedInput.file_path),
1314          )
1315        } else if (
1316          tool.name === NOTEBOOK_EDIT_TOOL_NAME &&
1317          'notebook_path' in processedInput
1318        ) {
1319          fileExtension = getFileExtensionForAnalytics(
1320            String(processedInput.notebook_path),
1321          )
1322        } else if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
1323          const bashInput = processedInput as BashToolInput
1324          fileExtension = getFileExtensionsFromBashCommand(
1325            bashInput.command,
1326            bashInput._simulatedSedEdit?.filePath,
1327          )
1328        }
1329      }
1330  
1331      logEvent('tengu_tool_use_success', {
1332        messageID:
1333          messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1334        toolName: sanitizeToolNameForAnalytics(tool.name),
1335        isMcp: tool.isMcp ?? false,
1336        durationMs,
1337        preToolHookDurationMs,
1338        toolResultSizeBytes,
1339        ...(fileExtension !== undefined && { fileExtension }),
1340  
1341        queryChainId: toolUseContext.queryTracking
1342          ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1343        queryDepth: toolUseContext.queryTracking?.depth,
1344        ...(mcpServerType && {
1345          mcpServerType:
1346            mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1347        }),
1348        ...(mcpServerBaseUrl && {
1349          mcpServerBaseUrl:
1350            mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1351        }),
1352        ...(requestId && {
1353          requestId:
1354            requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1355        }),
1356        ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl),
1357      })
1358  
1359      // Enrich tool parameters with git commit ID from successful git commit output
1360      if (
1361        isToolDetailsLoggingEnabled() &&
1362        (tool.name === BASH_TOOL_NAME || tool.name === POWERSHELL_TOOL_NAME) &&
1363        'command' in processedInput &&
1364        typeof processedInput.command === 'string' &&
1365        processedInput.command.match(/\bgit\s+commit\b/) &&
1366        result.data &&
1367        typeof result.data === 'object' &&
1368        'stdout' in result.data
1369      ) {
1370        const gitCommitId = parseGitCommitId(String(result.data.stdout))
1371        if (gitCommitId) {
1372          toolParameters.git_commit_id = gitCommitId
1373        }
1374      }
1375  
1376      // Log tool result event for OTLP with tool parameters and decision context
1377      const mcpServerScope = isMcpTool(tool)
1378        ? getMcpServerScopeFromToolName(tool.name)
1379        : null
1380  
1381      void logOTelEvent('tool_result', {
1382        tool_name: sanitizeToolNameForAnalytics(tool.name),
1383        success: 'true',
1384        duration_ms: String(durationMs),
1385        ...(Object.keys(toolParameters).length > 0 && {
1386          tool_parameters: jsonStringify(toolParameters),
1387        }),
1388        ...(telemetryToolInput && { tool_input: telemetryToolInput }),
1389        tool_result_size_bytes: String(toolResultSizeBytes),
1390        ...(decisionInfo && {
1391          decision_source: decisionInfo.source,
1392          decision_type: decisionInfo.decision,
1393        }),
1394        ...(mcpServerScope && { mcp_server_scope: mcpServerScope }),
1395      })
1396  
1397      // Run PostToolUse hooks
1398      let toolOutput = result.data
1399      const hookResults = []
1400      const toolContextModifier = result.contextModifier
1401      const mcpMeta = result.mcpMeta
1402  
1403      async function addToolResult(
1404        toolUseResult: unknown,
1405        preMappedBlock?: ToolResultBlockParam,
1406      ) {
1407        // Use the pre-mapped block when available (non-MCP tools where hooks
1408        // don't modify the output), otherwise map from scratch.
1409        const toolResultBlock = preMappedBlock
1410          ? await processPreMappedToolResultBlock(
1411              preMappedBlock,
1412              tool.name,
1413              tool.maxResultSizeChars,
1414            )
1415          : await processToolResultBlock(tool, toolUseResult, toolUseID)
1416  
1417        // Build content blocks - tool result first, then optional feedback
1418        const contentBlocks: ContentBlockParam[] = [toolResultBlock]
1419        // Add accept feedback if user provided feedback when approving
1420        // (acceptFeedback only exists on PermissionAllowDecision, which is guaranteed here)
1421        if (
1422          'acceptFeedback' in permissionDecision &&
1423          permissionDecision.acceptFeedback
1424        ) {
1425          contentBlocks.push({
1426            type: 'text',
1427            text: permissionDecision.acceptFeedback,
1428          })
1429        }
1430  
1431        // Add content blocks (e.g., pasted images) from the permission decision
1432        const allowContentBlocks =
1433          'contentBlocks' in permissionDecision
1434            ? permissionDecision.contentBlocks
1435            : undefined
1436        if (allowContentBlocks?.length) {
1437          contentBlocks.push(...allowContentBlocks)
1438        }
1439  
1440        // Generate sequential imagePasteIds so each image renders with a distinct label
1441        let allowImageIds: number[] | undefined
1442        if (allowContentBlocks?.length) {
1443          const imageCount = count(
1444            allowContentBlocks,
1445            (b: ContentBlockParam) => b.type === 'image',
1446          )
1447          if (imageCount > 0) {
1448            const startId = getNextImagePasteId(toolUseContext.messages)
1449            allowImageIds = Array.from(
1450              { length: imageCount },
1451              (_, i) => startId + i,
1452            )
1453          }
1454        }
1455  
1456        resultingMessages.push({
1457          message: createUserMessage({
1458            content: contentBlocks,
1459            imagePasteIds: allowImageIds,
1460            toolUseResult:
1461              toolUseContext.agentId && !toolUseContext.preserveToolUseResults
1462                ? undefined
1463                : toolUseResult,
1464            mcpMeta: toolUseContext.agentId ? undefined : mcpMeta,
1465            sourceToolAssistantUUID: assistantMessage.uuid,
1466          }),
1467          contextModifier: toolContextModifier
1468            ? {
1469                toolUseID: toolUseID,
1470                modifyContext: toolContextModifier,
1471              }
1472            : undefined,
1473        })
1474      }
1475  
1476      // TOOD(hackyon): refactor so we don't have different experiences for MCP tools
1477      if (!isMcpTool(tool)) {
1478        await addToolResult(toolOutput, mappedToolResultBlock)
1479      }
1480  
1481      const postToolHookInfos: StopHookInfo[] = []
1482      const postToolHookStart = Date.now()
1483      for await (const hookResult of runPostToolUseHooks(
1484        toolUseContext,
1485        tool,
1486        toolUseID,
1487        assistantMessage.message.id,
1488        processedInput,
1489        toolOutput,
1490        requestId,
1491        mcpServerType,
1492        mcpServerBaseUrl,
1493      )) {
1494        if ('updatedMCPToolOutput' in hookResult) {
1495          if (isMcpTool(tool)) {
1496            toolOutput = hookResult.updatedMCPToolOutput
1497          }
1498        } else if (isMcpTool(tool)) {
1499          hookResults.push(hookResult)
1500          if (hookResult.message.type === 'attachment') {
1501            const att = hookResult.message.attachment
1502            if (
1503              'command' in att &&
1504              att.command !== undefined &&
1505              'durationMs' in att &&
1506              att.durationMs !== undefined
1507            ) {
1508              postToolHookInfos.push({
1509                command: att.command,
1510                durationMs: att.durationMs,
1511              })
1512            }
1513          }
1514        } else {
1515          resultingMessages.push(hookResult)
1516          if (hookResult.message.type === 'attachment') {
1517            const att = hookResult.message.attachment
1518            if (
1519              'command' in att &&
1520              att.command !== undefined &&
1521              'durationMs' in att &&
1522              att.durationMs !== undefined
1523            ) {
1524              postToolHookInfos.push({
1525                command: att.command,
1526                durationMs: att.durationMs,
1527              })
1528            }
1529          }
1530        }
1531      }
1532      const postToolHookDurationMs = Date.now() - postToolHookStart
1533      if (postToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) {
1534        logForDebugging(
1535          `Slow PostToolUse hooks: ${postToolHookDurationMs}ms for ${tool.name} (${postToolHookInfos.length} hooks)`,
1536          { level: 'info' },
1537        )
1538      }
1539  
1540      if (isMcpTool(tool)) {
1541        await addToolResult(toolOutput)
1542      }
1543  
1544      // Show PostToolUse hook timing inline below tool result when > 500ms.
1545      // Use wall-clock time (not sum of individual durations) since hooks run in parallel.
1546      if (process.env.USER_TYPE === 'ant' && postToolHookInfos.length > 0) {
1547        if (postToolHookDurationMs > HOOK_TIMING_DISPLAY_THRESHOLD_MS) {
1548          resultingMessages.push({
1549            message: createStopHookSummaryMessage(
1550              postToolHookInfos.length,
1551              postToolHookInfos,
1552              [],
1553              false,
1554              undefined,
1555              false,
1556              'suggestion',
1557              undefined,
1558              'PostToolUse',
1559              postToolHookDurationMs,
1560            ),
1561          })
1562        }
1563      }
1564  
1565      // If the tool provided new messages, add them to the list to return.
1566      if (result.newMessages && result.newMessages.length > 0) {
1567        for (const message of result.newMessages) {
1568          resultingMessages.push({ message })
1569        }
1570      }
1571      // If hook indicated to prevent continuation after successful execution, yield a stop reason message
1572      if (shouldPreventContinuation) {
1573        resultingMessages.push({
1574          message: createAttachmentMessage({
1575            type: 'hook_stopped_continuation',
1576            message: stopReason || 'Execution stopped by hook',
1577            hookName: `PreToolUse:${tool.name}`,
1578            toolUseID: toolUseID,
1579            hookEvent: 'PreToolUse',
1580          }),
1581        })
1582      }
1583  
1584      // Yield the remaining hook results after the other messages are sent
1585      for (const hookResult of hookResults) {
1586        resultingMessages.push(hookResult)
1587      }
1588      return resultingMessages
1589    } catch (error) {
1590      const durationMs = Date.now() - startTime
1591      addToToolDuration(durationMs)
1592  
1593      endToolExecutionSpan({
1594        success: false,
1595        error: errorMessage(error),
1596      })
1597      endToolSpan()
1598  
1599      // Handle MCP auth errors by updating the client status to 'needs-auth'
1600      // This updates the /mcp display to show the server needs re-authorization
1601      if (error instanceof McpAuthError) {
1602        toolUseContext.setAppState(prevState => {
1603          const serverName = error.serverName
1604          const existingClientIndex = prevState.mcp.clients.findIndex(
1605            c => c.name === serverName,
1606          )
1607          if (existingClientIndex === -1) {
1608            return prevState
1609          }
1610          const existingClient = prevState.mcp.clients[existingClientIndex]
1611          // Only update if client was connected (don't overwrite other states)
1612          if (!existingClient || existingClient.type !== 'connected') {
1613            return prevState
1614          }
1615          const updatedClients = [...prevState.mcp.clients]
1616          updatedClients[existingClientIndex] = {
1617            name: serverName,
1618            type: 'needs-auth' as const,
1619            config: existingClient.config,
1620          }
1621          return {
1622            ...prevState,
1623            mcp: {
1624              ...prevState.mcp,
1625              clients: updatedClients,
1626            },
1627          }
1628        })
1629      }
1630  
1631      if (!(error instanceof AbortError)) {
1632        const errorMsg = errorMessage(error)
1633        logForDebugging(
1634          `${tool.name} tool error (${durationMs}ms): ${errorMsg.slice(0, 200)}`,
1635        )
1636        if (!(error instanceof ShellError)) {
1637          logError(error)
1638        }
1639        logEvent('tengu_tool_use_error', {
1640          messageID:
1641            messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1642          toolName: sanitizeToolNameForAnalytics(tool.name),
1643          error: classifyToolError(
1644            error,
1645          ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1646          isMcp: tool.isMcp ?? false,
1647  
1648          queryChainId: toolUseContext.queryTracking
1649            ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1650          queryDepth: toolUseContext.queryTracking?.depth,
1651          ...(mcpServerType && {
1652            mcpServerType:
1653              mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1654          }),
1655          ...(mcpServerBaseUrl && {
1656            mcpServerBaseUrl:
1657              mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1658          }),
1659          ...(requestId && {
1660            requestId:
1661              requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1662          }),
1663          ...mcpToolDetailsForAnalytics(
1664            tool.name,
1665            mcpServerType,
1666            mcpServerBaseUrl,
1667          ),
1668        })
1669        // Log tool result error event for OTLP with tool parameters and decision context
1670        const mcpServerScope = isMcpTool(tool)
1671          ? getMcpServerScopeFromToolName(tool.name)
1672          : null
1673  
1674        void logOTelEvent('tool_result', {
1675          tool_name: sanitizeToolNameForAnalytics(tool.name),
1676          use_id: toolUseID,
1677          success: 'false',
1678          duration_ms: String(durationMs),
1679          error: errorMessage(error),
1680          ...(Object.keys(toolParameters).length > 0 && {
1681            tool_parameters: jsonStringify(toolParameters),
1682          }),
1683          ...(telemetryToolInput && { tool_input: telemetryToolInput }),
1684          ...(decisionInfo && {
1685            decision_source: decisionInfo.source,
1686            decision_type: decisionInfo.decision,
1687          }),
1688          ...(mcpServerScope && { mcp_server_scope: mcpServerScope }),
1689        })
1690      }
1691      const content = formatError(error)
1692  
1693      // Determine if this was a user interrupt
1694      const isInterrupt = error instanceof AbortError
1695  
1696      // Run PostToolUseFailure hooks
1697      const hookMessages: MessageUpdateLazy<
1698        AttachmentMessage | ProgressMessage<HookProgress>
1699      >[] = []
1700      for await (const hookResult of runPostToolUseFailureHooks(
1701        toolUseContext,
1702        tool,
1703        toolUseID,
1704        messageId,
1705        processedInput,
1706        content,
1707        isInterrupt,
1708        requestId,
1709        mcpServerType,
1710        mcpServerBaseUrl,
1711      )) {
1712        hookMessages.push(hookResult)
1713      }
1714  
1715      return [
1716        {
1717          message: createUserMessage({
1718            content: [
1719              {
1720                type: 'tool_result',
1721                content,
1722                is_error: true,
1723                tool_use_id: toolUseID,
1724              },
1725            ],
1726            toolUseResult: `Error: ${content}`,
1727            mcpMeta: toolUseContext.agentId
1728              ? undefined
1729              : error instanceof
1730                  McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
1731                ? error.mcpMeta
1732                : undefined,
1733            sourceToolAssistantUUID: assistantMessage.uuid,
1734          }),
1735        },
1736        ...hookMessages,
1737      ]
1738    } finally {
1739      stopSessionActivity('tool_exec')
1740      // Clean up decision info after logging
1741      if (decisionInfo) {
1742        toolUseContext.toolDecisions?.delete(toolUseID)
1743      }
1744    }
1745  }