import { markToolRejected } from "../task/toolExecutionContext" import { DispatchState, transitionDispatch } from "../task/dispatchState" import { serializeError } from "serialize-error" import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" import { t } from "../../i18n" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import type { AutoApprovalContext, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools" import { buildAutoDenyReason } from "../auto-approval" import { AskIgnoredError } from "../task/AskIgnoredError" import { Task } from "../task/Task" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" import { writeToFileTool } from "../tools/WriteToFileTool" import { editTool } from "../tools/EditTool" import { searchReplaceTool } from "../tools/SearchReplaceTool" import { editFileTool } from "../tools/EditFileTool" import { applyPatchTool } from "../tools/ApplyPatchTool" import { searchFilesTool } from "../tools/SearchFilesTool" import { executeCommandTool } from "../tools/ExecuteCommandTool" import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool" import { switchModeTool } from "../tools/SwitchModeTool" import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/NewTaskTool" import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { buildToolRequirements, type RequestPolicySnapshot } from "../prompts/tools/effective-tool-policy" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" /** * Maps a raw, potentially model-controlled tool name to a safe analytics key. * Never returns the raw name unless it is a known static tool, so an * arbitrary model-supplied string can never become a `toolsUsed` property key. */ export function toTelemetryToolName( toolName: string, isCustomTool: boolean, experiments?: Record, ): ToolName { if (isCustomTool) { return "custom_tool" } if (toolName.startsWith("mcp_")) { return "use_mcp_tool" } if (isValidToolName(toolName, experiments)) { return toolName } return "invalid_tool_call" } /** * Processes and presents assistant message content to the user interface. * * This function is the core message handling system that: * - Sequentially processes content blocks from the assistant's response. * - Displays text content to the user. * - Executes tool use requests with appropriate user approval. * - Manages the flow of conversation by determining when to proceed to the next content block. * - Coordinates file system checkpointing for modified files. * - Controls the conversation state to determine when to continue to the next request. * * The function uses a locking mechanism to prevent concurrent execution and handles * partial content blocks during streaming. It's designed to work with the streaming * API response pattern, where content arrives incrementally and needs to be processed * as it becomes available. */ export async function presentAssistantMessage(cline: Task, policySnapshot: RequestPolicySnapshot) { if (cline.abort) { return } // Tool policy is frozen at request build; re-reading live settings here // would let a mid-request settings edit contradict this request's prompt. // The message must not end in "aborted" — the fire-and-forget wrapper // suppresses only that suffix, so a missing snapshot logs as a real failure. if (!policySnapshot) { throw new Error( `[presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} missing request policy snapshot`, ) } if (cline.dispatchState !== DispatchState.IDLE) { cline.pendingWork = { mode: "serial" } return } cline.dispatchState = transitionDispatch(cline.dispatchState, DispatchState.SERIAL) try { // Drain updates queued while the lock was held, including one that // arrives after the helper's final check but before this continuation. // The last pending check and the release below run in the same // synchronous step, so no update can be stranded behind the lock. do { await presentAssistantMessageBlock(cline, policySnapshot) } while (!cline.abort && cline.pendingWork) } finally { // Tool handlers and provider-state reads can reject. Never strand the // task behind a dispatch lock after the presenter has unwound. cline.dispatchState = DispatchState.IDLE } } async function presentAssistantMessageBlock(cline: Task, policySnapshot: RequestPolicySnapshot): Promise { if (cline.abort) { return } // Each internal pass consumes the pending update. New updates arriving // during an awaited operation can request another pass. cline.pendingWork = null if (cline.currentStreamingContentIndex >= cline.assistantMessageContent.length) { // This may happen if the last content block was completed before // streaming could finish. If streaming is finished, and we're out of // bounds then this means we already presented/executed the last // content block and are ready to continue to next request. if (cline.didCompleteReadingStream) { cline.userMessageContentReady = true } return } let block: any try { // Performance optimization: Use shallow copy instead of deep clone. // The block is used read-only throughout this function - we never mutate its properties. // We only need to protect against the reference changing during streaming, not nested mutations. // This provides 80-90% reduction in cloning overhead (5-100ms saved per block). block = { ...cline.assistantMessageContent[cline.currentStreamingContentIndex] } } catch (error) { console.error(`ERROR cloning block:`, error) console.error( `Block content:`, JSON.stringify(cline.assistantMessageContent[cline.currentStreamingContentIndex], null, 2), ) return } switch (block.type) { case "mcp_tool_use": { // Handle native MCP tool calls (from mcp_serverName_toolName dynamic tools) // These are converted to the same execution path as use_mcp_tool but preserve // their original name in API history const mcpBlock = block as McpToolUse if (cline.didRejectTool) { // For native protocol, we must send a tool_result for every tool_use to avoid API errors const toolCallId = mcpBlock.id const errorMessage = !mcpBlock.partial ? `Skipping MCP tool ${mcpBlock.name} due to user rejecting a previous tool.` : `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.` if (toolCallId) { cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: errorMessage, is_error: true, }) } break } // Track if we've already pushed a tool result let hasToolResult = false const toolCallId = mcpBlock.id if (toolCallId && !cline.activeToolContexts.has(toolCallId)) { cline.activeToolContexts.set(toolCallId, { toolCallId, didReject: false }) } // Store approval feedback to merge into tool result (GitHub #10465) let approvalFeedback: { text: string; images?: string[] } | undefined const pushToolResult = (content: ToolResponse, _feedbackImages?: string[]) => { if (hasToolResult) { console.warn( `[presentAssistantMessage] Skipping duplicate tool_result for mcp_tool_use: ${toolCallId}`, ) return } let resultContent: string let imageBlocks: Anthropic.ImageBlockParam[] = [] if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { const textBlocks = content.filter((item) => item.type === "text") imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] resultContent = textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || "(tool did not return anything)" } // Merge approval feedback into tool result (GitHub #10465) if (approvalFeedback) { const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text) resultContent = `${feedbackText}\n\n${resultContent}` // Add feedback images to the image blocks if (approvalFeedback.images) { const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images) imageBlocks = [...feedbackImageBlocks, ...imageBlocks] } } if (toolCallId) { cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: resultContent, }) if (imageBlocks.length > 0) { cline.userMessageContent.push(...imageBlocks) } } hasToolResult = true } const askApproval = async ( type: ClineAsk, partialMessage?: string, progressStatus?: ToolProgressStatus, isProtected?: boolean, autoApprovalContext?: AutoApprovalContext, ) => { const { response, text, images, autoDenyDetail } = await cline.ask( type, partialMessage, false, progressStatus, isProtected || false, autoApprovalContext, ) if (response !== "yesButtonClicked") { // Automatic (policy) denial: scoped to this tool call, so no // `didRejectTool` (remaining tool calls in the turn proceed) // and no `user_feedback` say (the reason is system-generated). if (autoDenyDetail) { // `guard_unavailable` marks a guard-state inconsistency, not a // policy denial: the command never ran and a re-issue re-reads // the guard setting, so the payload must stay a retryable error // instead of carrying policy-denial advice. if (autoDenyDetail.kind === "guard_unavailable") { pushToolResult(formatResponse.toolError(buildAutoDenyReason(autoDenyDetail))) } else { pushToolResult( formatResponse.toolAutoDenied({ reason: buildAutoDenyReason(autoDenyDetail), offendingCommand: autoDenyDetail.command, ruleId: autoDenyDetail.dcgRuleId, }), ) } return false } if (text) { await cline.say("user_feedback", text, images) pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)) } else { pushToolResult(formatResponse.toolDenied()) } markToolRejected(cline, toolCallId ?? "") return false } // Store approval feedback to be merged into tool result (GitHub #10465) // Don't push it as a separate tool_result here - that would create duplicates. // The tool will call pushToolResult, which will merge the feedback into the actual result. if (text) { await cline.say("user_feedback", text, images) approvalFeedback = { text, images } } return true } const handleError = async (action: string, error: Error) => { // Silently ignore AskIgnoredError - this is an internal control flow // signal, not an actual error. It occurs when a newer ask supersedes an older one. if (error instanceof AskIgnoredError) { return } const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}` await cline.say( "error", `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) pushToolResult(formatResponse.toolError(errorString)) } // Resolve sanitized server name back to original server name // The serverName from parsing is sanitized (e.g., "my_server" from "my server") // We need the original name to find the actual MCP connection const mcpHub = cline.providerRef.deref()?.getMcpHub() let resolvedServerName = mcpBlock.serverName if (mcpHub) { const originalName = mcpHub.findServerNameBySanitizedName(mcpBlock.serverName) if (originalName) { resolvedServerName = originalName } } // Execute the MCP tool using the same handler as use_mcp_tool // Create a synthetic ToolUse block that the useMcpToolTool can handle const syntheticToolUse: ToolUse<"use_mcp_tool"> = { type: "tool_use", id: mcpBlock.id, name: "use_mcp_tool", params: { server_name: resolvedServerName, tool_name: mcpBlock.toolName, arguments: JSON.stringify(mcpBlock.arguments), }, partial: mcpBlock.partial, nativeArgs: { server_name: resolvedServerName, tool_name: mcpBlock.toolName, arguments: mcpBlock.arguments, }, } // Consult the shared policy layer before executing — ONLY for complete // (non-partial) blocks. Validating partial blocks would surface validation // errors repeatedly during streaming, pushing multiple tool_results for the // same tool_use_id and making the stream appear frozen. if (!mcpBlock.partial) { // Policy inputs come from the request's frozen snapshot, never from a // live provider-state re-read mid-stream. const { customModes, experiments: stateExperiments, disabledTools } = policySnapshot // Read the task-local mode, not the shared provider mode. // A delegated child task may run in a different mode than its parent. const taskMode = await cline.getTaskMode() // Resolve aliases in includedTools before validation // e.g., "write_file" should resolve to "write_to_file" const rawIncludedTools = policySnapshot.modelInfo?.includedTools const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) try { // Validate under the canonical "use_mcp_tool" name: the requirements map // is keyed by canonical/alias names, so the dynamic mcp_* name would never // key-match a disabledTools or excludedTools entry naming use_mcp_tool. // Build requirements through the shared policy module so every suppressed // entry reaches the validator, which checks them before the // always-available class. See `buildToolRequirements` in // effective-tool-policy.ts. const toolRequirements = buildToolRequirements(disabledTools, policySnapshot.modelInfo) validateToolUse( "use_mcp_tool", taskMode, customModes ?? [], toolRequirements, syntheticToolUse.params, stateExperiments, includedTools, ) } catch (error) { cline.consecutiveMistakeCount++ // For validation errors, send a tool_result with the error (required for // native tool calling) but do NOT set didAlreadyUseTool = true — the tool // was never executed, and interrupting the stream here would make the // extension appear to hang. const errorContent = formatResponse.toolError(error.message) if (toolCallId) { cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: errorContent, is_error: true, }) } // The validator's message names the canonical tool, never the // model-controlled dynamic name, so this key is safe. cline.recordToolError("use_mcp_tool", error.message) break } } await useMcpToolTool.handle(cline, syntheticToolUse, { askApproval, handleError, pushToolResult, toolCallId: toolCallId ?? "", onValidated: mcpBlock.partial ? undefined : () => { cline.recordToolUsage("use_mcp_tool") TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool") }, }) break } case "text": { if (cline.didRejectTool || cline.didAlreadyUseTool) { break } let content = block.content if (content) { // Have to do this for partial and complete since sending // content in thinking tags to markdown renderer will // automatically be removed. // Strip any streamed tags from text output. content = content.replace(/\s?/g, "") content = content.replace(/\s?<\/thinking>/g, "") } await cline.say("text", content, undefined, block.partial) break } case "tool_use": { // Native tool calling is the only supported tool calling mechanism. // A tool_use block without an id is invalid and cannot be executed. const toolCallId = (block as any).id as string | undefined if (!toolCallId) { const errorMessage = "Invalid tool call: missing tool_use.id. XML tool calls are no longer supported. Remove any XML tool markup (e.g. ...) and use native tool calling instead." // Record a safe, static analytics key. Never key telemetry on the // model-reported tool name, which is untrusted here. try { cline.recordToolError("invalid_tool_call", errorMessage) } catch { // Best-effort only } cline.consecutiveMistakeCount++ await cline.say("error", errorMessage) cline.userMessageContent.push({ type: "text", text: errorMessage }) cline.didAlreadyUseTool = true break } // Policy inputs come from the request's frozen snapshot, never from a // live provider-state re-read mid-stream. const { customModes, experiments: stateExperiments, disabledTools } = policySnapshot // Read the task-local mode, not the shared provider mode. // A delegated child task may run in a different mode than its parent. const taskMode = await cline.getTaskMode() const toolDescription = (): string => { switch (block.name) { case "execute_command": return `[${block.name} for '${block.params.command}']` case "read_file": // Prefer native typed args when available; fall back to legacy params // Check if nativeArgs exists (native protocol) if (block.nativeArgs) { return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) } return readFileTool.getReadFileToolDescription(block.name, block.params) case "write_to_file": return `[${block.name} for '${block.params.path}']` case "apply_diff": // Native-only: tool args are structured (no XML payloads). return block.params?.path ? `[${block.name} for '${block.params.path}']` : `[${block.name}]` case "search_files": return `[${block.name} for '${block.params.regex}'${ block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" }]` case "edit": case "search_and_replace": return `[${block.name} for '${block.params.file_path}']` case "search_replace": return `[${block.name} for '${block.params.file_path}']` case "edit_file": return `[${block.name} for '${block.params.file_path}']` case "apply_patch": return `[${block.name}]` case "list_files": return `[${block.name} for '${block.params.path}']` case "use_mcp_tool": return `[${block.name} for '${block.params.server_name}']` case "access_mcp_resource": return `[${block.name} for '${block.params.server_name}']` case "ask_followup_question": return `[${block.name} for '${block.params.question}']` case "attempt_completion": return `[${block.name}]` case "switch_mode": return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]` case "codebase_search": return `[${block.name} for '${block.params.query}']` case "read_command_output": return `[${block.name} for '${block.params.artifact_id}']` case "update_todo_list": return `[${block.name}]` case "new_task": { const mode = block.params.mode ?? defaultModeSlug const message = block.params.message ?? "(no message)" const modeName = getModeBySlug(mode, customModes)?.name ?? mode return `[${block.name} in ${modeName} mode: '${message}']` } case "run_slash_command": return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "skill": return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` default: return `[${block.name}]` } } if (cline.didRejectTool) { // Ignore any tool content after user has rejected tool once. // For native tool calling, we must send a tool_result for every tool_use to avoid API errors const errorMessage = !block.partial ? `Skipping tool ${toolDescription()} due to user rejecting a previous tool.` : `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.` cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: errorMessage, is_error: true, }) break } // Track if we've already pushed a tool result for this tool call (native tool calling only) let hasToolResult = false // If this is a native tool call but the parser couldn't construct nativeArgs // (e.g., malformed/unfinished JSON in a streaming tool call), we must NOT attempt to // execute the tool. Instead, emit exactly one structured tool_result so the provider // receives a matching tool_result for the tool_use_id. // // This avoids executing an invalid tool_use block and prevents duplicate/fragmented // error reporting. if (!block.partial) { const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined const isKnownTool = isValidToolName(String(block.name), stateExperiments) if (isKnownTool && !block.nativeArgs && !customTool) { const errorMessage = `Invalid tool call for '${block.name}': missing nativeArgs. ` + `This usually means the model streamed invalid or incomplete arguments and the call could not be finalized.` cline.consecutiveMistakeCount++ try { cline.recordToolError(toTelemetryToolName(block.name, false, stateExperiments), errorMessage) } catch { // Best-effort only } // Push tool_result directly without setting didAlreadyUseTool so streaming can // continue gracefully. cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: formatResponse.toolError(errorMessage), is_error: true, }) break } } if (!cline.activeToolContexts.has(toolCallId)) { cline.activeToolContexts.set(toolCallId, { toolCallId, didReject: false }) } // Store approval feedback to merge into tool result (GitHub #10465) let approvalFeedback: { text: string; images?: string[] } | undefined const pushToolResult = (content: ToolResponse) => { // Native tool calling: only allow ONE tool_result per tool call if (hasToolResult) { console.warn( `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`, ) return } let resultContent: string let imageBlocks: Anthropic.ImageBlockParam[] = [] if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { const textBlocks = content.filter((item) => item.type === "text") imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] resultContent = textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || "(tool did not return anything)" } // Merge approval feedback into tool result (GitHub #10465) if (approvalFeedback) { const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text) resultContent = `${feedbackText}\n\n${resultContent}` if (approvalFeedback.images) { const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images) imageBlocks = [...feedbackImageBlocks, ...imageBlocks] } } cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: resultContent, }) if (imageBlocks.length > 0) { cline.userMessageContent.push(...imageBlocks) } hasToolResult = true } const askApproval = async ( type: ClineAsk, partialMessage?: string, progressStatus?: ToolProgressStatus, isProtected?: boolean, autoApprovalContext?: AutoApprovalContext, ) => { const { response, text, images, queuedMessageId, autoDenyDetail } = await cline.ask( type, partialMessage, false, progressStatus, isProtected || false, autoApprovalContext, ) if (response !== "yesButtonClicked") { // Automatic (policy) denial: scoped to this tool call, so no // `didRejectTool` (remaining tool calls in the turn proceed) // and no `user_feedback` say (the reason is system-generated). // Automatic denials never carry queued feedback — a queued // message forces a real ask. if (autoDenyDetail) { // `guard_unavailable` marks a guard-state inconsistency, not a // policy denial: the command never ran and a re-issue re-reads // the guard setting, so the payload must stay a retryable error // instead of carrying policy-denial advice. if (autoDenyDetail.kind === "guard_unavailable") { pushToolResult(formatResponse.toolError(buildAutoDenyReason(autoDenyDetail))) } else { pushToolResult( formatResponse.toolAutoDenied({ reason: buildAutoDenyReason(autoDenyDetail), offendingCommand: autoDenyDetail.command, ruleId: autoDenyDetail.dcgRuleId, }), ) } return false } // Handle both messageResponse and noButtonClicked with text. if (queuedMessageId) { const persisted = await cline.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) if (!persisted) { throw new Error(`Failed to persist queued approval feedback ${queuedMessageId}`) } } else if (text || images?.length) { await cline.say("user_feedback", text ?? "", images) } if (text || images?.length) { pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)) } else { pushToolResult(formatResponse.toolDenied()) } markToolRejected(cline, toolCallId) return false } // Store approval feedback to be merged into tool result (GitHub #10465) // Don't push it as a separate tool_result here - that would create duplicates. // The tool will call pushToolResult, which will merge the feedback into the actual result. if (queuedMessageId) { const persisted = await cline.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) if (!persisted) { throw new Error(`Failed to persist queued approval feedback ${queuedMessageId}`) } } else if (text || images?.length) { await cline.say("user_feedback", text ?? "", images) } if (text || images?.length) { approvalFeedback = { text: text ?? "", images } } return true } const askFinishSubTaskApproval = async () => { // Ask the user to approve this task has completed, and he has // reviewed it, and we can declare task is finished and return // control to the parent task to continue running the rest of // the sub-tasks. const toolMessage = JSON.stringify({ tool: "finishTask" }) return await askApproval("tool", toolMessage) } const handleError = async (action: string, error: Error) => { // Silently ignore AskIgnoredError - this is an internal control flow // signal, not an actual error. It occurs when a newer ask supersedes an older one. if (error instanceof AskIgnoredError) { return } const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}` await cline.say( "error", `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, ) pushToolResult(formatResponse.toolError(errorString)) } // Validate tool use before execution - ONLY for complete (non-partial) blocks. // Validating partial blocks would cause validation errors to be thrown repeatedly // during streaming, pushing multiple tool_results for the same tool_use_id and // potentially causing the stream to appear frozen. if (!block.partial) { // Resolve aliases in includedTools before validation // e.g., "edit_file" should resolve to "apply_diff" const rawIncludedTools = policySnapshot.modelInfo?.includedTools const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) const isCustomTool = Boolean(stateExperiments?.customTools && customToolRegistry.has(block.name)) try { // Build requirements through the shared policy module so every entry that // carries disabling weight — non-protocol disabled tools, and every excluded // tool, protocol entries included on that leg — reaches the validator, which // checks them before the always-available class. See // `buildToolRequirements` in effective-tool-policy.ts. const toolRequirements = buildToolRequirements(disabledTools, policySnapshot.modelInfo) validateToolUse( block.name as ToolName, taskMode, customModes ?? [], toolRequirements, block.params, stateExperiments, includedTools, ) } catch (error) { cline.consecutiveMistakeCount++ // For validation errors (unknown tool, tool not allowed for mode), we need to: // 1. Send a tool_result with the error (required for native tool calling) // 2. NOT set didAlreadyUseTool = true (the tool was never executed, just failed validation) // This prevents the stream from being interrupted with "Response interrupted by tool use result" // which would cause the extension to appear to hang const errorContent = formatResponse.toolError(error.message) // Push tool_result directly without setting didAlreadyUseTool cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: typeof errorContent === "string" ? errorContent : "(validation error)", is_error: true, }) // Record a safe failure key. Never key telemetry on the raw, // model-controlled tool name. cline.recordToolError( toTelemetryToolName(block.name, isCustomTool, stateExperiments), error.message, ) break } // Validation passed: record exactly one attempt at this single // central point. Individual tool handlers must not also record // usage, or the attempt would be double-counted. const recordName = toTelemetryToolName(block.name, isCustomTool, stateExperiments) cline.recordToolUsage(recordName) TelemetryService.instance.captureToolUsage(cline.taskId, recordName) // Track legacy format usage for read_file tool (for migration monitoring). // Model ids on telemetry events are non-policy labels: a live // `getModel()` read is fine where nothing about this request's tool // policy derives from the result. if (block.name === "read_file" && block.usedLegacyFormat) { TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { taskId: cline.taskId, model: cline.api.getModel()?.id, }) } } // Check for identical consecutive tool calls. if (!block.partial) { // Use the detector to check for repetition, passing the ToolUse // block directly. const repetitionCheck = cline.toolRepetitionDetector.check(block) // Soft block: do NOT involve the user. Return an error to the // model asking it to justify repeating the call (or try a // different approach). The detector keeps counting so continued // repetition will eventually escalate to a hard block. if (repetitionCheck.action === "soft_block") { pushToolResult(formatResponse.toolError(repetitionCheck.message)) break } // Hard block: stop and ask the user for guidance. if (repetitionCheck.action === "hard_block") { // Handle repetition similar to mistake_limit_reached pattern. const { response, text, images } = await cline.ask( repetitionCheck.askUser.messageKey as ClineAsk, repetitionCheck.askUser.messageDetail.replace("{toolName}", block.name), ) if (response === "messageResponse") { // Add user feedback to userContent. cline.userMessageContent.push( { type: "text" as const, text: `Tool repetition limit reached. User feedback: ${text}`, }, ...formatResponse.imageBlocks(images), ) // Add user feedback to chat. await cline.say("user_feedback", text, images) } // Track tool repetition in telemetry via a named event. // The detector's hard limit is consecutiveMistakeLimit and it blocks when the // identical-call count reaches it, so that count equals the limit here. // consecutiveMistakeCount is unrelated and can be zero. TelemetryService.instance.captureConsecutiveMistakeError( cline.taskId, "tool_repetition", cline.consecutiveMistakeLimit, cline.consecutiveMistakeLimit, ) // Return tool result message about the repetition pushToolResult( formatResponse.toolError( `Tool call repetition limit reached for ${block.name}. Please try a different approach.`, ), ) break } } switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "apply_diff": await checkpointSaveAndMark(cline) await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "edit": case "search_and_replace": await checkpointSaveAndMark(cline) await editTool.handle(cline, block as ToolUse<"edit">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "search_replace": await checkpointSaveAndMark(cline) await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "edit_file": await checkpointSaveAndMark(cline) await editFileTool.handle(cline, block as ToolUse<"edit_file">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "apply_patch": await checkpointSaveAndMark(cline) await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "read_file": // Type assertion is safe here because we're in the "read_file" case await readFileTool.handle(cline, block as ToolUse<"read_file">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "list_files": await listFilesTool.handle(cline, block as ToolUse<"list_files">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "codebase_search": await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "search_files": await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "execute_command": await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "read_command_output": await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "use_mcp_tool": await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "access_mcp_resource": await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "ask_followup_question": await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "switch_mode": await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "new_task": await checkpointSaveAndMark(cline) await newTaskTool.handle(cline, block as ToolUse<"new_task">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "attempt_completion": { const completionCallbacks: AttemptCompletionCallbacks = { askApproval, handleError, pushToolResult, askFinishSubTaskApproval, toolDescription, toolCallId, } await attemptCompletionTool.handle( cline, block as ToolUse<"attempt_completion">, completionCallbacks, ) break } case "run_slash_command": await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "skill": await skillTool.handle(cline, block as ToolUse<"skill">, { askApproval, handleError, pushToolResult, toolCallId, }) break case "generate_image": await checkpointSaveAndMark(cline) await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { askApproval, handleError, pushToolResult, toolCallId, }) break default: { // Handle unknown/invalid tool names OR custom tools // This is critical for native tool calling where every tool_use MUST have a tool_result // CRITICAL: Don't process partial blocks for unknown tools - just let them stream in. // If we try to show errors for partial blocks, we'd show the error on every streaming chunk, // creating a loop that appears to freeze the extension. Only handle complete blocks. if (block.partial) { break } const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined if (customTool) { try { let customToolArgs if (customTool.parameters) { try { customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {}) } catch (parseParamsError) { const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` console.error(message) cline.consecutiveMistakeCount++ await cline.say("error", message) pushToolResult(formatResponse.toolError(message)) break } } const result = await customTool.execute(customToolArgs, { mode: taskMode, task: cline, }) console.log( `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, ) pushToolResult(result) cline.consecutiveMistakeCount = 0 } catch (executionError: any) { cline.consecutiveMistakeCount++ // Record custom tool error with static name cline.recordToolError("custom_tool", executionError.message) await handleError(`executing custom tool "${block.name}"`, executionError) } break } // Not a custom tool - handle as unknown tool error const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` cline.consecutiveMistakeCount++ cline.recordToolError("invalid_tool_call", errorMessage) await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) // Push tool_result directly WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), content: formatResponse.toolError(errorMessage), is_error: true, }) break } } break } } // NOTE: When tool is rejected, iterator stream is interrupted and it waits // for `userMessageContentReady` to be true. Future calls to present will // skip execution since `didRejectTool` and iterate until `contentIndex` is // set to message length and it sets userMessageContentReady to true itself // (instead of preemptively doing it in iterator). if (!block.partial || cline.didRejectTool || cline.didAlreadyUseTool) { // Block is finished streaming and executing. if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) { // It's okay that we increment if !didCompleteReadingStream, it'll // just return because out of bounds and as streaming continues it // will call `presentAssitantMessage` if a new block is ready. If // streaming is finished then we set `userMessageContentReady` to // true when out of bounds. This gracefully allows the stream to // continue on and all potential content blocks be presented. // Last block is complete and it is finished executing cline.userMessageContentReady = true // Will allow `pWaitFor` to continue. } // Call next block if it exists (if not then read stream will call it // when it's ready). // Need to increment regardless, so when read stream calls this function // again it will be streaming the next block. cline.currentStreamingContentIndex++ if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { // There are already more content blocks to stream, so we'll call // this function ourselves. return await presentAssistantMessageBlock(cline, policySnapshot) } else { // CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady // This handles the case where assistantMessageContent is empty or becomes empty after processing if (cline.didCompleteReadingStream) { cline.userMessageContentReady = true } } } // Pending updates are drained by presentAssistantMessage, which owns the // lock and checks for them immediately before releasing it. The drain // re-enters the block helper with this request's policy snapshot, so // draining never re-reads live settings either. } /** * save checkpoint and mark done in the current streaming task. * @param task The Task instance to checkpoint save and mark. * @returns */ async function checkpointSaveAndMark(task: Task) { if (task.currentStreamingDidCheckpoint) { return } try { await task.checkpointSave(true) task.currentStreamingDidCheckpoint = true } catch (error) { console.error(`[Task#presentAssistantMessage] Error saving checkpoint: ${error.message}`, error) } }