source dump of claude code
at main 1745 lines 60 kB view raw
1import { feature } from 'bun:bundle' 2import type { 3 ContentBlockParam, 4 ToolResultBlockParam, 5 ToolUseBlock, 6} from '@anthropic-ai/sdk/resources/index.mjs' 7import { 8 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9 logEvent, 10} from 'src/services/analytics/index.js' 11import { 12 extractMcpToolDetails, 13 extractSkillName, 14 extractToolInputForTelemetry, 15 getFileExtensionForAnalytics, 16 getFileExtensionsFromBashCommand, 17 isToolDetailsLoggingEnabled, 18 mcpToolDetailsForAnalytics, 19 sanitizeToolNameForAnalytics, 20} from 'src/services/analytics/metadata.js' 21import { 22 addToToolDuration, 23 getCodeEditToolDecisionCounter, 24 getStatsStore, 25} from '../../bootstrap/state.js' 26import { 27 buildCodeEditToolAttributes, 28 isCodeEditingTool, 29} from '../../hooks/toolPermission/permissionLogging.js' 30import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 31import { 32 findToolByName, 33 type Tool, 34 type ToolProgress, 35 type ToolProgressData, 36 type ToolUseContext, 37} from '../../Tool.js' 38import type { BashToolInput } from '../../tools/BashTool/BashTool.js' 39import { startSpeculativeClassifierCheck } from '../../tools/BashTool/bashPermissions.js' 40import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 41import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 42import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' 43import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' 44import { NOTEBOOK_EDIT_TOOL_NAME } from '../../tools/NotebookEditTool/constants.js' 45import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' 46import { parseGitCommitId } from '../../tools/shared/gitOperationTracking.js' 47import { 48 isDeferredTool, 49 TOOL_SEARCH_TOOL_NAME, 50} from '../../tools/ToolSearchTool/prompt.js' 51import { getAllBaseTools } from '../../tools.js' 52import type { HookProgress } from '../../types/hooks.js' 53import type { 54 AssistantMessage, 55 AttachmentMessage, 56 Message, 57 ProgressMessage, 58 StopHookInfo, 59} from '../../types/message.js' 60import { count } from '../../utils/array.js' 61import { createAttachmentMessage } from '../../utils/attachments.js' 62import { logForDebugging } from '../../utils/debug.js' 63import { 64 AbortError, 65 errorMessage, 66 getErrnoCode, 67 ShellError, 68 TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 69} from '../../utils/errors.js' 70import { executePermissionDeniedHooks } from '../../utils/hooks.js' 71import { logError } from '../../utils/log.js' 72import { 73 CANCEL_MESSAGE, 74 createProgressMessage, 75 createStopHookSummaryMessage, 76 createToolResultStopMessage, 77 createUserMessage, 78 withMemoryCorrectionHint, 79} from '../../utils/messages.js' 80import type { 81 PermissionDecisionReason, 82 PermissionResult, 83} from '../../utils/permissions/PermissionResult.js' 84import { 85 startSessionActivity, 86 stopSessionActivity, 87} from '../../utils/sessionActivity.js' 88import { jsonStringify } from '../../utils/slowOperations.js' 89import { Stream } from '../../utils/stream.js' 90import { logOTelEvent } from '../../utils/telemetry/events.js' 91import { 92 addToolContentEvent, 93 endToolBlockedOnUserSpan, 94 endToolExecutionSpan, 95 endToolSpan, 96 isBetaTracingEnabled, 97 startToolBlockedOnUserSpan, 98 startToolExecutionSpan, 99 startToolSpan, 100} from '../../utils/telemetry/sessionTracing.js' 101import { 102 formatError, 103 formatZodValidationError, 104} from '../../utils/toolErrors.js' 105import { 106 processPreMappedToolResultBlock, 107 processToolResultBlock, 108} from '../../utils/toolResultStorage.js' 109import { 110 extractDiscoveredToolNames, 111 isToolSearchEnabledOptimistic, 112 isToolSearchToolAvailable, 113} from '../../utils/toolSearch.js' 114import { 115 McpAuthError, 116 McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 117} from '../mcp/client.js' 118import { mcpInfoFromString } from '../mcp/mcpStringUtils.js' 119import { normalizeNameForMCP } from '../mcp/normalization.js' 120import type { MCPServerConnection } from '../mcp/types.js' 121import { 122 getLoggingSafeMcpBaseUrl, 123 getMcpServerScopeFromToolName, 124 isMcpTool, 125} from '../mcp/utils.js' 126import { 127 resolveHookPermissionDecision, 128 runPostToolUseFailureHooks, 129 runPostToolUseHooks, 130 runPreToolUseHooks, 131} from './toolHooks.js' 132 133/** Minimum total hook duration (ms) to show inline timing summary */ 134export 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. */ 137const 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 */ 150export 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 */ 181function 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 */ 207function 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 252function 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 264export type MessageUpdateLazy<M extends Message = Message> = { 265 message: M 266 contextModifier?: { 267 toolUseID: string 268 modifyContext: (context: ToolUseContext) => ToolUseContext 269 } 270} 271 272export type McpServerType = 273 | 'stdio' 274 | 'sse' 275 | 'http' 276 | 'ws' 277 | 'sdk' 278 | 'sse-ide' 279 | 'ws-ide' 280 | 'claudeai-proxy' 281 | undefined 282 283function 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 */ 308function 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 */ 326function 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 337export 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 492function 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 */ 578export 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 599async 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}