| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263 |
- import cloneDeep from "clone-deep"
- import { serializeError } from "serialize-error"
- import { Anthropic } from "@anthropic-ai/sdk"
- import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
- import { ConsecutiveMistakeError } 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 { ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools"
- import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
- import { AskIgnoredError } from "../task/AskIgnoredError"
- import { Task } from "../task/Task"
- import { fetchInstructionsTool } from "../tools/FetchInstructionsTool"
- import { listFilesTool } from "../tools/ListFilesTool"
- import { readFileTool } from "../tools/ReadFileTool"
- import { TOOL_PROTOCOL } from "@roo-code/types"
- import { writeToFileTool } from "../tools/WriteToFileTool"
- import { applyDiffTool } from "../tools/MultiApplyDiffTool"
- import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
- import { searchReplaceTool } from "../tools/SearchReplaceTool"
- import { editFileTool } from "../tools/EditFileTool"
- import { applyPatchTool } from "../tools/ApplyPatchTool"
- import { searchFilesTool } from "../tools/SearchFilesTool"
- import { browserActionTool } from "../tools/BrowserActionTool"
- 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 { generateImageTool } from "../tools/GenerateImageTool"
- import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
- import { validateToolUse } from "../tools/validateToolUse"
- import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
- import { formatResponse } from "../prompts/responses"
- /**
- * 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) {
- if (cline.abort) {
- throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`)
- }
- if (cline.presentAssistantMessageLocked) {
- cline.presentAssistantMessageHasPendingUpdates = true
- return
- }
- cline.presentAssistantMessageLocked = true
- cline.presentAssistantMessageHasPendingUpdates = false
- 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
- }
- cline.presentAssistantMessageLocked = false
- return
- }
- let block: any
- try {
- block = cloneDeep(cline.assistantMessageContent[cline.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
- } catch (error) {
- console.error(`ERROR cloning block:`, error)
- console.error(
- `Block content:`,
- JSON.stringify(cline.assistantMessageContent[cline.currentStreamingContentIndex], null, 2),
- )
- cline.presentAssistantMessageLocked = false
- 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: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- }
- break
- }
- if (cline.didAlreadyUseTool) {
- const toolCallId = mcpBlock.id
- const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
- if (toolCallId) {
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- }
- break
- }
- // Track if we've already pushed a tool result
- let hasToolResult = false
- const toolCallId = mcpBlock.id
- const toolProtocol = TOOL_PROTOCOL.NATIVE // MCP tools in native mode always use native protocol
- // 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, toolProtocol)
- 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: toolCallId,
- content: resultContent,
- })
- if (imageBlocks.length > 0) {
- cline.userMessageContent.push(...imageBlocks)
- }
- }
- hasToolResult = true
- cline.didAlreadyUseTool = true
- }
- const toolDescription = () => `[mcp_tool: ${mcpBlock.serverName}/${mcpBlock.toolName}]`
- const askApproval = async (
- type: ClineAsk,
- partialMessage?: string,
- progressStatus?: ToolProgressStatus,
- isProtected?: boolean,
- ) => {
- const { response, text, images } = await cline.ask(
- type,
- partialMessage,
- false,
- progressStatus,
- isProtected || false,
- )
- if (response !== "yesButtonClicked") {
- if (text) {
- await cline.say("user_feedback", text, images)
- pushToolResult(
- formatResponse.toolResult(
- formatResponse.toolDeniedWithFeedback(text, toolProtocol),
- images,
- ),
- )
- } else {
- pushToolResult(formatResponse.toolDenied(toolProtocol))
- }
- cline.didRejectTool = true
- 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, toolProtocol))
- }
- if (!mcpBlock.partial) {
- cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics
- TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool", toolProtocol)
- }
- // 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,
- },
- }
- await useMcpToolTool.handle(cline, syntheticToolUse, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag: (tag, text) => text || "",
- toolProtocol,
- })
- 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.
- // Remove end substrings of <thinking or </thinking (below xml
- // parsing is only for opening tags).
- // Tthis is done with the xml parsing below now, but keeping
- // here for reference.
- // content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
- //
- // Remove all instances of <thinking> (with optional line break
- // after) and </thinking> (with optional line break before).
- // - Needs to be separate since we dont want to remove the line
- // break before the first tag.
- // - Needs to happen before the xml parsing below.
- content = content.replace(/<thinking>\s?/g, "")
- content = content.replace(/\s?<\/thinking>/g, "")
- // Remove partial XML tag at the very end of the content (for
- // tool use and thinking tags), Prevents scrollview from
- // jumping when tags are automatically removed.
- const lastOpenBracketIndex = content.lastIndexOf("<")
- if (lastOpenBracketIndex !== -1) {
- const possibleTag = content.slice(lastOpenBracketIndex)
- // Check if there's a '>' after the last '<' (i.e., if the
- // tag is complete) (complete thinking and tool tags will
- // have been removed by now.)
- const hasCloseBracket = possibleTag.includes(">")
- if (!hasCloseBracket) {
- // Extract the potential tag name.
- let tagContent: string
- if (possibleTag.startsWith("</")) {
- tagContent = possibleTag.slice(2).trim()
- } else {
- tagContent = possibleTag.slice(1).trim()
- }
- // Check if tagContent is likely an incomplete tag name
- // (letters and underscores only).
- const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
- // Preemptively remove < or </ to keep from these
- // artifacts showing up in chat (also handles closing
- // thinking tags).
- const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"
- // If the tag is incomplete and at the end, remove it
- // from the content.
- if (isOpeningOrClosing || isLikelyTagName) {
- content = content.slice(0, lastOpenBracketIndex).trim()
- }
- }
- }
- }
- await cline.say("text", content, undefined, block.partial)
- break
- }
- case "tool_use": {
- // Fetch state early so it's available for toolDescription and validation
- const state = await cline.providerRef.deref()?.getState()
- const { mode, customModes, experiments: stateExperiments } = state ?? {}
- 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 "fetch_instructions":
- return `[${block.name} for '${block.params.task}']`
- case "write_to_file":
- return `[${block.name} for '${block.params.path}']`
- case "apply_diff":
- // Handle both legacy format and new multi-file format
- if (block.params.path) {
- return `[${block.name} for '${block.params.path}']`
- } else if (block.params.args) {
- // Try to extract first file path from args for display
- const match = block.params.args.match(/<file>.*?<path>([^<]+)<\/path>/s)
- if (match) {
- const firstPath = match[1]
- // Check if there are multiple files
- const fileCount = (block.params.args.match(/<file>/g) || []).length
- if (fileCount > 1) {
- return `[${block.name} for '${firstPath}' and ${fileCount - 1} more file${fileCount > 2 ? "s" : ""}]`
- } else {
- return `[${block.name} for '${firstPath}']`
- }
- }
- }
- return `[${block.name}]`
- case "search_files":
- return `[${block.name} for '${block.params.regex}'${
- block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
- }]`
- case "search_and_replace":
- return `[${block.name} for '${block.params.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 "browser_action":
- return `[${block.name} for '${block.params.action}']`
- 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": // Add case for the new tool
- return `[${block.name} for '${block.params.query}']`
- 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 "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 protocol, we must send a tool_result for every tool_use to avoid API errors
- const toolCallId = block.id
- 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.`
- if (toolCallId) {
- // Native protocol: MUST send tool_result for every tool_use
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- } else {
- // XML protocol: send as text
- cline.userMessageContent.push({
- type: "text",
- text: errorMessage,
- })
- }
- break
- }
- if (cline.didAlreadyUseTool) {
- // Ignore any content after a tool has already been used.
- // For native protocol, we must send a tool_result for every tool_use to avoid API errors
- const toolCallId = block.id
- const errorMessage = `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`
- if (toolCallId) {
- // Native protocol: MUST send tool_result for every tool_use
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: errorMessage,
- is_error: true,
- })
- } else {
- // XML protocol: send as text
- cline.userMessageContent.push({
- type: "text",
- text: errorMessage,
- })
- }
- break
- }
- // Track if we've already pushed a tool result for this tool call (native protocol only)
- let hasToolResult = false
- // Determine protocol by checking if this tool call has an ID.
- // Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks).
- // XML protocol tool calls NEVER have an ID (parsed from XML text).
- const toolCallId = (block as any).id
- const toolProtocol = toolCallId ? TOOL_PROTOCOL.NATIVE : TOOL_PROTOCOL.XML
- // Multiple native tool calls feature is on hold - always disabled
- // Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
- const isMultipleNativeToolCallsEnabled = false
- // Store approval feedback to merge into tool result (GitHub #10465)
- let approvalFeedback: { text: string; images?: string[] } | undefined
- const pushToolResult = (content: ToolResponse) => {
- if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
- // For native protocol, only allow ONE tool_result per tool call
- if (hasToolResult) {
- console.warn(
- `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`,
- )
- return
- }
- // For native protocol, tool_result content must be a string
- // Images are added as separate blocks in the user message
- let resultContent: string
- let imageBlocks: Anthropic.ImageBlockParam[] = []
- if (typeof content === "string") {
- resultContent = content || "(tool did not return anything)"
- } else {
- // Separate text and image blocks
- const textBlocks = content.filter((item) => item.type === "text")
- imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]
- // Convert text blocks to string for tool_result
- 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,
- toolProtocol,
- )
- resultContent = `${feedbackText}\n\n${resultContent}`
- // Add feedback images to the image blocks
- if (approvalFeedback.images) {
- const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
- imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
- }
- }
- // Add tool_result with text content only
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: resultContent,
- })
- // Add image blocks separately after tool_result
- if (imageBlocks.length > 0) {
- cline.userMessageContent.push(...imageBlocks)
- }
- hasToolResult = true
- } else {
- // For XML protocol, add as text blocks (legacy behavior)
- let resultContent: string
- if (typeof content === "string") {
- resultContent = content || "(tool did not return anything)"
- } else {
- const textBlocks = content.filter((item) => item.type === "text")
- 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,
- toolProtocol,
- )
- resultContent = `${feedbackText}\n\n${resultContent}`
- }
- cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
- if (typeof content === "string") {
- cline.userMessageContent.push({
- type: "text",
- text: resultContent,
- })
- } else {
- // Add text content with merged feedback
- cline.userMessageContent.push({
- type: "text",
- text: resultContent,
- })
- // Add any images from the tool result
- const imageBlocks = content.filter((item) => item.type === "image")
- if (imageBlocks.length > 0) {
- cline.userMessageContent.push(...imageBlocks)
- }
- }
- }
- // For XML protocol: Only one tool per message is allowed
- // For native protocol with experimental flag enabled: Multiple tools can be executed in sequence
- // For native protocol with experimental flag disabled: Single tool per message (default safe behavior)
- if (toolProtocol === TOOL_PROTOCOL.XML) {
- // Once a tool result has been collected, ignore all other tool
- // uses since we should only ever present one tool result per
- // message (XML protocol only).
- cline.didAlreadyUseTool = true
- } else if (toolProtocol === TOOL_PROTOCOL.NATIVE && !isMultipleNativeToolCallsEnabled) {
- // For native protocol with experimental flag disabled, enforce single tool per message
- cline.didAlreadyUseTool = true
- }
- // If toolProtocol is NATIVE and isMultipleNativeToolCallsEnabled is true,
- // allow multiple tool calls in sequence (don't set didAlreadyUseTool)
- }
- const askApproval = async (
- type: ClineAsk,
- partialMessage?: string,
- progressStatus?: ToolProgressStatus,
- isProtected?: boolean,
- ) => {
- const { response, text, images } = await cline.ask(
- type,
- partialMessage,
- false,
- progressStatus,
- isProtected || false,
- )
- if (response !== "yesButtonClicked") {
- // Handle both messageResponse and noButtonClicked with text.
- if (text) {
- await cline.say("user_feedback", text, images)
- pushToolResult(
- formatResponse.toolResult(
- formatResponse.toolDeniedWithFeedback(text, toolProtocol),
- images,
- ),
- )
- } else {
- pushToolResult(formatResponse.toolDenied(toolProtocol))
- }
- cline.didRejectTool = true
- 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 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, toolProtocol))
- }
- // If block is partial, remove partial closing tag so its not
- // presented to user.
- const removeClosingTag = (tag: ToolParamName, text?: string): string => {
- if (!block.partial) {
- return text || ""
- }
- if (!text) {
- return ""
- }
- // This regex dynamically constructs a pattern to match the
- // closing tag:
- // - Optionally matches whitespace before the tag.
- // - Matches '<' or '</' optionally followed by any subset of
- // characters from the tag name.
- const tagRegex = new RegExp(
- `\\s?<\/?${tag
- .split("")
- .map((char) => `(?:${char})?`)
- .join("")}$`,
- "g",
- )
- return text.replace(tagRegex, "")
- }
- // Keep browser open during an active session so other tools can run.
- // Session is active if we've seen any browser_action_result and the last browser_action is not "close".
- try {
- const messages = cline.clineMessages || []
- const hasStarted = messages.some((m: any) => m.say === "browser_action_result")
- let isClosed = false
- for (let i = messages.length - 1; i >= 0; i--) {
- const m = messages[i]
- if (m.say === "browser_action") {
- try {
- const act = JSON.parse(m.text || "{}")
- isClosed = act.action === "close"
- } catch {}
- break
- }
- }
- const sessionActive = hasStarted && !isClosed
- // Only auto-close when no active browser session is present, and this isn't a browser_action
- if (!sessionActive && block.name !== "browser_action") {
- await cline.browserSession.closeBrowser()
- }
- } catch {
- // On any unexpected error, fall back to conservative behavior
- if (block.name !== "browser_action") {
- await cline.browserSession.closeBrowser()
- }
- }
- if (!block.partial) {
- // Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools)
- const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name)
- const recordName = isCustomTool ? "custom_tool" : block.name
- cline.recordToolUsage(recordName)
- TelemetryService.instance.captureToolUsage(cline.taskId, recordName, toolProtocol)
- }
- // 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) {
- const modelInfo = cline.api.getModel()
- // Resolve aliases in includedTools before validation
- // e.g., "edit_file" should resolve to "apply_diff"
- const rawIncludedTools = modelInfo?.info?.includedTools
- const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode")
- const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool))
- try {
- validateToolUse(
- block.name as ToolName,
- mode ?? defaultModeSlug,
- customModes ?? [],
- { apply_diff: cline.diffEnabled },
- 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 protocol)
- // 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, toolProtocol)
- if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
- // For native protocol, push tool_result directly without setting didAlreadyUseTool
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: typeof errorContent === "string" ? errorContent : "(validation error)",
- is_error: true,
- })
- } else {
- // For XML protocol, use the standard pushToolResult
- pushToolResult(errorContent)
- }
- break
- }
- }
- // 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)
- // If execution is not allowed, notify user and break.
- if (!repetitionCheck.allowExecution && repetitionCheck.askUser) {
- // 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 PostHog exception tracking and event.
- TelemetryService.instance.captureConsecutiveMistakeError(cline.taskId)
- TelemetryService.instance.captureException(
- new ConsecutiveMistakeError(
- `Tool repetition limit reached for ${block.name}`,
- cline.taskId,
- cline.consecutiveMistakeCount,
- cline.consecutiveMistakeLimit,
- "tool_repetition",
- cline.apiConfiguration.apiProvider,
- cline.api.getModel().id,
- ),
- )
- // Return tool result message about the repetition
- pushToolResult(
- formatResponse.toolError(
- `Tool call repetition limit reached for ${block.name}. Please try a different approach.`,
- toolProtocol,
- ),
- )
- break
- }
- }
- switch (block.name) {
- case "write_to_file":
- await checkpointSaveAndMark(cline)
- await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "update_todo_list":
- await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "apply_diff": {
- await checkpointSaveAndMark(cline)
- // Check if this tool call came from native protocol by checking for ID
- // Native calls always have IDs, XML calls never do
- if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
- await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- }
- // Get the provider and state to check experiment settings
- const provider = cline.providerRef.deref()
- let isMultiFileApplyDiffEnabled = false
- if (provider) {
- const state = await provider.getState()
- isMultiFileApplyDiffEnabled = experiments.isEnabled(
- state.experiments ?? {},
- EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
- )
- }
- if (isMultiFileApplyDiffEnabled) {
- await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
- } else {
- await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- }
- break
- }
- case "search_and_replace":
- await checkpointSaveAndMark(cline)
- await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "search_replace":
- await checkpointSaveAndMark(cline)
- await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "edit_file":
- await checkpointSaveAndMark(cline)
- await editFileTool.handle(cline, block as ToolUse<"edit_file">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "apply_patch":
- await checkpointSaveAndMark(cline)
- await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- 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,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "fetch_instructions":
- await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "list_files":
- await listFilesTool.handle(cline, block as ToolUse<"list_files">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "codebase_search":
- await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "search_files":
- await searchFilesTool.handle(cline, block as ToolUse<"search_files">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "browser_action":
- await browserActionTool(
- cline,
- block as ToolUse<"browser_action">,
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- )
- break
- case "execute_command":
- await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "use_mcp_tool":
- await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "access_mcp_resource":
- await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "ask_followup_question":
- await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "switch_mode":
- await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "new_task":
- await newTaskTool.handle(cline, block as ToolUse<"new_task">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- toolCallId: block.id,
- })
- break
- case "attempt_completion": {
- const completionCallbacks: AttemptCompletionCallbacks = {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- askFinishSubTaskApproval,
- toolDescription,
- toolProtocol,
- }
- 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,
- removeClosingTag,
- toolProtocol,
- })
- break
- case "generate_image":
- await checkpointSaveAndMark(cline)
- await generateImageTool.handle(cline, block as ToolUse<"generate_image">, {
- askApproval,
- handleError,
- pushToolResult,
- removeClosingTag,
- toolProtocol,
- })
- break
- default: {
- // Handle unknown/invalid tool names OR custom tools
- // This is critical for native protocol 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, toolProtocol))
- break
- }
- }
- const result = await customTool.execute(customToolArgs, {
- mode: mode ?? defaultModeSlug,
- 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(block.name as ToolName, errorMessage)
- await cline.say("error", t("tools:unknownToolError", { toolName: block.name }))
- // Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
- // This prevents the stream from being interrupted with "Response interrupted by tool use result"
- if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
- cline.pushToolResultToUserContent({
- type: "tool_result",
- tool_use_id: toolCallId,
- content: formatResponse.toolError(errorMessage, toolProtocol),
- is_error: true,
- })
- } else {
- pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
- }
- break
- }
- }
- break
- }
- }
- // Seeing out of bounds is fine, it means that the next too call is being
- // built up and ready to add to assistantMessageContent to present.
- // When you see the UI inactive during this, it means that a tool is
- // breaking without presenting any UI. For example the write_to_file tool
- // was breaking when relpath was undefined, and for invalid relpath it never
- // presented UI.
- // This needs to be placed here, if not then calling
- // cline.presentAssistantMessage below would fail (sometimes) since it's
- // locked.
- cline.presentAssistantMessageLocked = false
- // 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.
- presentAssistantMessage(cline)
- return
- } 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
- }
- }
- }
- // Block is partial, but the read stream may have finished.
- if (cline.presentAssistantMessageHasPendingUpdates) {
- presentAssistantMessage(cline)
- }
- }
- /**
- * 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)
- }
- }
|