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