From 0ca7adb3c96aa89cb78d439fd75fd8d3a73996c5 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 10 Nov 2025 19:18:48 -0500 Subject: [PATCH 01/48] Add native tool call support (WIP) - Add tool_call chunk type to ApiStream - Add supportsNativeTools flag to ModelInfo - Create NativeToolCallParser for converting native tool calls to ToolUse format - Add tool_call handling in RooHandler to accumulate and yield tool calls - Add tool_call case in Task.ts stream processing - Add native tool support detection in RooHandler for dynamic models - Set tool protocol to NATIVE in toolProtocolResolver - Add comprehensive logging for debugging native tool flow --- packages/types/src/model.ts | 2 + src/api/index.ts | 27 +++++- src/api/providers/roo.ts | 87 +++++++++++++++++++ src/api/transform/stream.ts | 8 ++ .../assistant-message/NativeToolCallParser.ts | 72 +++++++++++++++ src/core/prompts/toolProtocolResolver.ts | 2 +- src/core/task/Task.ts | 76 ++++++++++++++++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/core/assistant-message/NativeToolCallParser.ts diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 748acb28580..49a13651e26 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -84,6 +84,8 @@ export const modelInfoSchema = z.object({ deprecated: z.boolean().optional(), // Flag to indicate if the model is free (no cost) isFree: z.boolean().optional(), + // Flag to indicate if the model supports native tool calling (OpenAI-style function calling) + supportsNativeTools: z.boolean().optional(), /** * Service tiers with pricing information. * Each tier can have a name (for OpenAI service tiers) and pricing overrides. diff --git a/src/api/index.ts b/src/api/index.ts index ae8be513496..05c74930787 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import type { ProviderSettings, ModelInfo, ToolProtocol } from "@roo-code/types" import { ApiStream } from "./transform/stream" @@ -63,6 +64,30 @@ export interface ApiHandlerCreateMessageMetadata { * - Unbound: Sent in unbound_metadata */ mode?: string + suppressPreviousResponseId?: boolean + /** + * Controls whether the response should be stored for 30 days in OpenAI's Responses API. + * When true (default), responses are stored and can be referenced in future requests + * using the previous_response_id for efficient conversation continuity. + * Set to false to opt out of response storage for privacy or compliance reasons. + * @default true + */ + store?: boolean + /** + * Optional array of tool definitions to pass to the model. + * For OpenAI-compatible providers, these are ChatCompletionTool definitions. + */ + tools?: OpenAI.Chat.ChatCompletionTool[] + /** + * Controls which (if any) tool is called by the model. + * Can be "none", "auto", "required", or a specific tool choice. + */ + tool_choice?: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"] + /** + * The tool protocol being used (XML or Native). + * Used by providers to determine whether to include native tool definitions. + */ + toolProtocol?: ToolProtocol } export interface ApiHandler { diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index dd968f2e2f8..c5021e1d5c3 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -100,6 +100,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { stream: true, stream_options: { include_usage: true }, ...(reasoning && { reasoning }), + ...(metadata?.tools && { tools: metadata.tools }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } try { @@ -124,9 +126,19 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { ) let lastUsage: RooUsage | undefined = undefined + // Accumulate tool calls by index - similar to how reasoning accumulates + const toolCallAccumulator = new Map() for await (const chunk of stream) { const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason + + console.log(`[NATIVE_TOOL] RooHandler chunk:`, { + hasChoices: !!chunk.choices?.length, + hasDelta: !!delta, + finishReason, + deltaKeys: delta ? Object.keys(delta) : [], + }) if (delta) { // Check for reasoning content (similar to OpenRouter) @@ -145,6 +157,42 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } + // Check for tool calls in delta + if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { + console.log( + `[NATIVE_TOOL] RooHandler: Received tool_calls in delta, count:`, + delta.tool_calls.length, + ) + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + // Accumulate arguments for existing tool call + if (toolCall.function?.arguments) { + console.log( + `[NATIVE_TOOL] RooHandler: Accumulating arguments for index ${index}:`, + toolCall.function.arguments, + ) + existing.arguments += toolCall.function.arguments + } + } else { + // Start new tool call accumulation + console.log(`[NATIVE_TOOL] RooHandler: Starting new tool call at index ${index}:`, { + id: toolCall.id, + name: toolCall.function?.name, + hasArguments: !!toolCall.function?.arguments, + }) + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + console.log(`[NATIVE_TOOL] RooHandler: Current accumulator size:`, toolCallAccumulator.size) + } + if (delta.content) { yield { type: "text", @@ -153,6 +201,29 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } + // When finish_reason is 'tool_calls', yield all accumulated tool calls + if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { + console.log( + `[NATIVE_TOOL] RooHandler: finish_reason is 'tool_calls', yielding ${toolCallAccumulator.size} tool calls`, + ) + for (const [index, toolCall] of toolCallAccumulator.entries()) { + console.log(`[NATIVE_TOOL] RooHandler: Yielding tool call ${index}:`, { + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + }) + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + // Clear accumulator after yielding + toolCallAccumulator.clear() + console.log(`[NATIVE_TOOL] RooHandler: Cleared tool call accumulator`) + } + if (chunk.usage) { lastUsage = chunk.usage as RooUsage } @@ -221,6 +292,17 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } + /** + * Check if a model ID supports native tool calling. + * This is a fallback for models loaded dynamically that don't have explicit support flags. + */ + private supportsNativeTools(modelId: string): boolean { + // List of model IDs known to support native tools + const nativeToolModels = ["openai/gpt-5"] + + return nativeToolModels.some((model) => modelId.includes(model)) + } + override getModel() { const modelId = this.options.apiModelId || rooDefaultModelId @@ -229,6 +311,10 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const modelInfo = models[modelId] if (modelInfo) { + // If model info exists but doesn't have supportsNativeTools set, check our list + if (modelInfo.supportsNativeTools === undefined) { + modelInfo.supportsNativeTools = this.supportsNativeTools(modelId) + } return { id: modelId, info: modelInfo } } @@ -241,6 +327,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { supportsImages: false, supportsReasoningEffort: false, supportsPromptCache: true, + supportsNativeTools: this.supportsNativeTools(modelId), inputPrice: 0, outputPrice: 0, }, diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 8484e625958..cd6c3a56a72 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -5,6 +5,7 @@ export type ApiStreamChunk = | ApiStreamUsageChunk | ApiStreamReasoningChunk | ApiStreamGroundingChunk + | ApiStreamToolCallChunk | ApiStreamError export interface ApiStreamError { @@ -38,6 +39,13 @@ export interface ApiStreamGroundingChunk { sources: GroundingSource[] } +export interface ApiStreamToolCallChunk { + type: "tool_call" + id: string + name: string + arguments: string +} + export interface GroundingSource { title: string url: string diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts new file mode 100644 index 00000000000..d7835f46b9a --- /dev/null +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -0,0 +1,72 @@ +import { type ToolName, toolNames } from "@roo-code/types" +import { type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools" + +/** + * Parser for native tool calls (OpenAI-style function calling). + * Converts native tool call format to ToolUse format for compatibility + * with existing tool execution infrastructure. + */ +export class NativeToolCallParser { + /** + * Convert a native tool call chunk to a ToolUse object. + * + * @param toolCall - The native tool call from the API stream + * @returns A ToolUse object compatible with existing tool handlers + */ + public static parseToolCall(toolCall: { id: string; name: string; arguments: string }): ToolUse | null { + console.log(`[NATIVE_TOOL] Parser received:`, { + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + }) + + // Validate tool name + if (!toolNames.includes(toolCall.name as ToolName)) { + console.error(`[NATIVE_TOOL] Invalid tool name: ${toolCall.name}`) + console.error(`[NATIVE_TOOL] Valid tool names:`, toolNames) + return null + } + + console.log(`[NATIVE_TOOL] Tool name validated: ${toolCall.name}`) + + try { + // Parse the arguments JSON string + console.log(`[NATIVE_TOOL] Parsing arguments JSON:`, toolCall.arguments) + const args = JSON.parse(toolCall.arguments) + console.log(`[NATIVE_TOOL] Parsed args:`, args) + + // Convert arguments to params format + const params: Partial> = {} + + for (const [key, value] of Object.entries(args)) { + console.log(`[NATIVE_TOOL] Processing param: ${key} =`, value) + + // Validate parameter name + if (!toolParamNames.includes(key as ToolParamName)) { + console.warn(`[NATIVE_TOOL] Unknown parameter '${key}' for tool '${toolCall.name}'`) + console.warn(`[NATIVE_TOOL] Valid param names:`, toolParamNames) + continue + } + + // Convert value to string if it isn't already + const stringValue = typeof value === "string" ? value : JSON.stringify(value) + params[key as ToolParamName] = stringValue + console.log(`[NATIVE_TOOL] Added param: ${key} = "${stringValue}"`) + } + + const result = { + type: "tool_use" as const, + name: toolCall.name as ToolName, + params, + partial: false, // Native tool calls are always complete when yielded + } + + console.log(`[NATIVE_TOOL] Parser returning ToolUse:`, result) + return result + } catch (error) { + console.error(`[NATIVE_TOOL] Failed to parse tool call arguments:`, error) + console.error(`[NATIVE_TOOL] Error details:`, error instanceof Error ? error.message : String(error)) + return null + } + } +} diff --git a/src/core/prompts/toolProtocolResolver.ts b/src/core/prompts/toolProtocolResolver.ts index 1cd87f7251e..d256e007e8c 100644 --- a/src/core/prompts/toolProtocolResolver.ts +++ b/src/core/prompts/toolProtocolResolver.ts @@ -6,7 +6,7 @@ import type { SystemPromptSettings } from "./types" * This is code-only and not exposed through VS Code settings. * To switch protocols, edit this constant directly in the source code. */ -const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.XML // change to TOOL_PROTOCOL.NATIVE to enable native protocol +const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.NATIVE // change to TOOL_PROTOCOL.NATIVE to enable native protocol /** * Resolves the effective tool protocol. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 08880d0ee02..0908a33b08e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -38,6 +38,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, + TOOL_PROTOCOL, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" @@ -81,6 +82,7 @@ import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" import { resolveToolProtocol } from "../prompts/toolProtocolResolver" +import { nativeTools } from "../prompts/tools/native-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" @@ -90,6 +92,7 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" +import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" import { manageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" @@ -1993,6 +1996,12 @@ export class Task extends EventEmitter implements TaskLike { continue } + // Log all chunk types to see what's coming through + console.log(`[NATIVE_TOOL] Stream chunk type:`, chunk.type) + if (chunk.type === "tool_call") { + console.log(`[NATIVE_TOOL] Stream received tool_call chunk!`) + } + switch (chunk.type) { case "reasoning": { reasoningMessage += chunk.text @@ -2024,6 +2033,48 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break + case "tool_call": { + console.log(`[NATIVE_TOOL] Received tool_call chunk:`, JSON.stringify(chunk, null, 2)) + + // Convert native tool call to ToolUse format + const toolUse = NativeToolCallParser.parseToolCall({ + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + + if (!toolUse) { + console.error( + `[NATIVE_TOOL] Failed to parse tool call for task ${this.taskId}:`, + chunk, + ) + break + } + + console.log(`[NATIVE_TOOL] Parsed to ToolUse:`, JSON.stringify(toolUse, null, 2)) + console.log( + `[NATIVE_TOOL] Current assistantMessageContent length before:`, + this.assistantMessageContent.length, + ) + + // Add the tool use to assistant message content + this.assistantMessageContent.push(toolUse) + + console.log( + `[NATIVE_TOOL] Current assistantMessageContent length after:`, + this.assistantMessageContent.length, + ) + console.log(`[NATIVE_TOOL] Setting userMessageContentReady to false`) + + // Mark that we have new content to process + this.userMessageContentReady = false + + console.log(`[NATIVE_TOOL] Calling presentAssistantMessage`) + + // Present the tool call to user + presentAssistantMessage(this) + break + } case "text": { assistantMessage += chunk.text @@ -2837,11 +2888,36 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Auto-approval limit reached and user did not approve continuation") } + // Determine if we should include native tools based on: + // 1. Tool protocol is set to NATIVE + // 2. Model supports native tools + const toolProtocol = resolveToolProtocol() + const modelInfo = this.api.getModel().info + const shouldIncludeTools = toolProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) + + console.log(`[NATIVE_TOOL] Tool inclusion check:`, { + toolProtocol, + isNative: toolProtocol === TOOL_PROTOCOL.NATIVE, + supportsNativeTools: modelInfo.supportsNativeTools, + shouldIncludeTools, + modelId: this.api.getModel().id, + nativeToolsCount: shouldIncludeTools ? nativeTools.length : 0, + }) + const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, + // Include tools and tool protocol when using native protocol and model supports it + ...(shouldIncludeTools ? { tools: nativeTools, tool_choice: "auto", toolProtocol } : {}), } + console.log(`[NATIVE_TOOL] API request metadata:`, { + hasTools: !!metadata.tools, + toolCount: metadata.tools?.length, + toolChoice: metadata.tool_choice, + toolProtocol: metadata.toolProtocol, + }) + // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. const stream = this.api.createMessage( systemPrompt, From e41fceafb07ea764aa6af9ed51122c11af296513 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 10 Nov 2025 19:23:00 -0500 Subject: [PATCH 02/48] Revert default protocol to XML for test compatibility --- src/core/prompts/toolProtocolResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/prompts/toolProtocolResolver.ts b/src/core/prompts/toolProtocolResolver.ts index d256e007e8c..1cd87f7251e 100644 --- a/src/core/prompts/toolProtocolResolver.ts +++ b/src/core/prompts/toolProtocolResolver.ts @@ -6,7 +6,7 @@ import type { SystemPromptSettings } from "./types" * This is code-only and not exposed through VS Code settings. * To switch protocols, edit this constant directly in the source code. */ -const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.NATIVE // change to TOOL_PROTOCOL.NATIVE to enable native protocol +const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.XML // change to TOOL_PROTOCOL.NATIVE to enable native protocol /** * Resolves the effective tool protocol. From 94d1a3159e03cddd8b82afdaa570a11e43c47f4d Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 09:15:01 -0500 Subject: [PATCH 03/48] add native tool call parser and migrate read file --- .../assistant-message/NativeToolCallParser.ts | 44 +- .../presentAssistantMessage.ts | 19 +- src/core/prompts/toolProtocolResolver.ts | 2 +- src/core/task/Task.ts | 2 +- src/core/tools/BaseTool.ts | 127 ++ src/core/tools/__tests__/readFileTool.spec.ts | 86 +- src/core/tools/readFileTool.ts | 1070 ++++++++--------- src/shared/tools.ts | 62 +- 8 files changed, 765 insertions(+), 647 deletions(-) create mode 100644 src/core/tools/BaseTool.ts diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index d7835f46b9a..4e8589ed3f3 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,19 +1,32 @@ import { type ToolName, toolNames } from "@roo-code/types" -import { type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools" +import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" +import type { FileEntry } from "../tools/ReadFileTool" /** * Parser for native tool calls (OpenAI-style function calling). * Converts native tool call format to ToolUse format for compatibility * with existing tool execution infrastructure. + * + * For tools with refactored parsers (e.g., read_file), this parser provides + * typed arguments via nativeArgs. Tool-specific handlers should consume + * nativeArgs directly rather than relying on synthesized legacy params. */ export class NativeToolCallParser { /** * Convert a native tool call chunk to a ToolUse object. * + * For refactored tools (read_file, etc.), native arguments are properly typed + * based on the NativeToolArgs type map. For tools not yet migrated, nativeArgs + * will be undefined and the tool will use parseLegacy() for backward compatibility. + * * @param toolCall - The native tool call from the API stream - * @returns A ToolUse object compatible with existing tool handlers + * @returns A properly typed ToolUse object */ - public static parseToolCall(toolCall: { id: string; name: string; arguments: string }): ToolUse | null { + public static parseToolCall(toolCall: { + id: string + name: TName + arguments: string + }): ToolUse | null { console.log(`[NATIVE_TOOL] Parser received:`, { id: toolCall.id, name: toolCall.name, @@ -35,12 +48,17 @@ export class NativeToolCallParser { const args = JSON.parse(toolCall.arguments) console.log(`[NATIVE_TOOL] Parsed args:`, args) - // Convert arguments to params format + // Convert arguments to params format (for backward-compat/UI), but primary path uses nativeArgs const params: Partial> = {} for (const [key, value] of Object.entries(args)) { console.log(`[NATIVE_TOOL] Processing param: ${key} =`, value) + // For read_file native calls, do not synthesize params.files – nativeArgs carries typed data + if (toolCall.name === "read_file" && key === "files") { + continue + } + // Validate parameter name if (!toolParamNames.includes(key as ToolParamName)) { console.warn(`[NATIVE_TOOL] Unknown parameter '${key}' for tool '${toolCall.name}'`) @@ -48,17 +66,29 @@ export class NativeToolCallParser { continue } - // Convert value to string if it isn't already + // Keep legacy string params for compatibility (not used by native execution path) const stringValue = typeof value === "string" ? value : JSON.stringify(value) params[key as ToolParamName] = stringValue console.log(`[NATIVE_TOOL] Added param: ${key} = "${stringValue}"`) } - const result = { + // Build typed nativeArgs for tools that support it + let nativeArgs: (TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never) | undefined = undefined + + if (toolCall.name === "read_file") { + const files = args.files + if (Array.isArray(files)) { + nativeArgs = files as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + } + // Add more tools here as they are migrated to native protocol + + const result: ToolUse = { type: "tool_use" as const, - name: toolCall.name as ToolName, + name: toolCall.name, params, partial: false, // Native tool calls are always complete when yielded + nativeArgs, } console.log(`[NATIVE_TOOL] Parser returning ToolUse:`, result) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2249c008d67..8dbca62d30c 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -5,11 +5,11 @@ import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" -import type { ToolParamName, ToolResponse } from "../../shared/tools" +import type { ToolParamName, ToolResponse, ToolUse } from "../../shared/tools" import { fetchInstructionsTool } from "../tools/fetchInstructionsTool" import { listFilesTool } from "../tools/listFilesTool" -import { getReadFileToolDescription, readFileTool } from "../tools/readFileTool" +import { readFileTool } from "../tools/ReadFileTool" import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool" import { shouldUseSingleFileRead } from "@roo-code/types" import { writeToFileTool } from "../tools/writeToFileTool" @@ -163,7 +163,12 @@ export async function presentAssistantMessage(cline: Task) { if (shouldUseSingleFileRead(modelId)) { return getSimpleReadFileToolDescription(block.name, block.params) } else { - return getReadFileToolDescription(block.name, block.params) + // Prefer native typed args when available; fall back to legacy params + // Check if nativeArgs exists and is an array (native protocol) + if (Array.isArray(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}']` @@ -473,7 +478,13 @@ export async function presentAssistantMessage(cline: Task) { removeClosingTag, ) } else { - await readFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + // 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, + }) } break case "fetch_instructions": diff --git a/src/core/prompts/toolProtocolResolver.ts b/src/core/prompts/toolProtocolResolver.ts index 1cd87f7251e..d256e007e8c 100644 --- a/src/core/prompts/toolProtocolResolver.ts +++ b/src/core/prompts/toolProtocolResolver.ts @@ -6,7 +6,7 @@ import type { SystemPromptSettings } from "./types" * This is code-only and not exposed through VS Code settings. * To switch protocols, edit this constant directly in the source code. */ -const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.XML // change to TOOL_PROTOCOL.NATIVE to enable native protocol +const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.NATIVE // change to TOOL_PROTOCOL.NATIVE to enable native protocol /** * Resolves the effective tool protocol. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0908a33b08e..85009754f6d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2039,7 +2039,7 @@ export class Task extends EventEmitter implements TaskLike { // Convert native tool call to ToolUse format const toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, - name: chunk.name, + name: chunk.name as ToolName, arguments: chunk.arguments, }) diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts new file mode 100644 index 00000000000..314311b56ee --- /dev/null +++ b/src/core/tools/BaseTool.ts @@ -0,0 +1,127 @@ +import { Task } from "../task/Task" +import type { + ToolUse, + HandleError, + PushToolResult, + RemoveClosingTag, + AskApproval, + NativeToolArgs, +} from "../../shared/tools" +import type { ToolName } from "@roo-code/types" + +/** + * Callbacks passed to tool execution + */ +export interface ToolCallbacks { + askApproval: AskApproval + handleError: HandleError + pushToolResult: PushToolResult + removeClosingTag: RemoveClosingTag +} + +/** + * Helper type to extract the parameter type for a tool based on its name. + * If the tool has native args defined in NativeToolArgs, use those; otherwise fall back to any. + */ +type ToolParams = TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : any + +/** + * Abstract base class for all tools. + * + * Provides a consistent architecture where: + * - XML/legacy protocol: params → parseLegacy() → typed params → execute() + * - Native protocol: nativeArgs already contain typed data → execute() + * + * Each tool extends this class and implements: + * - parseLegacy(): Convert XML/legacy string params to typed params + * - execute(): Protocol-agnostic core logic using typed params + * - handlePartial(): (optional) Handle streaming partial messages + * + * @template TName - The specific tool name, which determines native arg types + */ +export abstract class BaseTool { + /** + * The tool's name (must match ToolName type) + */ + abstract readonly name: TName + + /** + * Parse XML/legacy string-based parameters into typed parameters. + * + * For XML protocol, this converts params.args (XML string) or params.path (legacy) + * into a typed structure that execute() can use. + * + * @param params - Raw ToolUse.params from XML protocol + * @returns Typed parameters for execute() + * @throws Error if parsing fails + */ + abstract parseLegacy(params: Partial>): ToolParams + + /** + * Execute the tool with typed parameters. + * + * This is the protocol-agnostic core logic. It receives typed parameters + * (from parseLegacy for XML, or directly from native protocol) and performs + * the tool's operation. + * + * @param params - Typed parameters + * @param cline - Task instance with state and API access + * @param callbacks - Tool execution callbacks (approval, error handling, results) + */ + abstract execute(params: ToolParams, cline: Task, callbacks: ToolCallbacks): Promise + + /** + * Handle partial (streaming) tool messages. + * + * Default implementation does nothing. Tools that support streaming + * partial messages should override this. + * + * @param cline - Task instance + * @param block - Partial ToolUse block + */ + async handlePartial(cline: Task, block: ToolUse): Promise { + // Default: no-op for partial messages + // Tools can override to show streaming UI updates + } + + /** + * Main entry point for tool execution. + * + * Handles the complete flow: + * 1. Partial message handling (if partial) + * 2. Parameter parsing (parseLegacy for XML, or use nativeArgs directly) + * 3. Core execution (execute) + * + * @param cline - Task instance + * @param block - ToolUse block from assistant message + * @param callbacks - Tool execution callbacks + */ + async handle(cline: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { + // Handle partial messages + if (block.partial) { + await this.handlePartial(cline, block) + return + } + + // Determine protocol and parse parameters accordingly + let params: ToolParams + try { + if (block.nativeArgs !== undefined) { + // Native protocol: typed args provided by NativeToolCallParser + // TypeScript knows nativeArgs is properly typed based on TName + params = block.nativeArgs as ToolParams + } else { + // XML/legacy protocol: parse string params into typed params + params = this.parseLegacy(block.params) + } + } catch (error) { + const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}` + await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage)) + callbacks.pushToolResult(`${errorMessage}`) + return + } + + // Execute with typed parameters + await this.execute(params, cline, callbacks) + } +} diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index d693d6ba443..bff1f6c58f4 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -8,7 +8,7 @@ import { extractTextFromFile } from "../../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter" import { isBinaryFile } from "isbinaryfile" import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools" -import { readFileTool } from "../readFileTool" +import { readFileTool } from "../ReadFileTool" import { formatResponse } from "../../prompts/responses" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB } from "../helpers/imageHelpers" @@ -320,16 +320,14 @@ describe("read_file tool with maxReadFileLine setting", () => { partial: false, } - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + }) return toolResult } @@ -631,16 +629,14 @@ describe("read_file tool XML output structure", () => { } // Execute the tool - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (param: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (param: ToolParamName, content?: string) => content ?? "", + }) return toolResult } @@ -737,16 +733,14 @@ describe("read_file tool XML output structure", () => { } let localResult: ToolResponse | undefined - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { localResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + }) // In multi-image scenarios, the result is pushed to pushToolResult, not returned directly. // We need to check the mock's calls to get the result. if (mockCline.pushToolResult.mock.calls.length > 0) { @@ -1359,16 +1353,14 @@ describe("read_file tool XML output structure", () => { } // Execute the tool - await readFileTool( - mockCline, - toolUse, - mockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(mockCline, toolUse, { + askApproval: mockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (param: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (param: ToolParamName, content?: string) => content ?? "", + }) // Verify expect(toolResult).toBe(`Missing required parameter`) @@ -1448,16 +1440,14 @@ describe("read_file tool with image support", () => { console.log("Mock API:", localMockCline.api) console.log("Supports images:", localMockCline.api?.getModel?.()?.info?.supportsImages) - await readFileTool( - localMockCline, - toolUse, - localMockCline.ask, - vi.fn(), - (result: ToolResponse) => { + await readFileTool.handle(localMockCline, toolUse, { + askApproval: localMockCline.ask, + handleError: vi.fn(), + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + }) console.log("Result type:", Array.isArray(toolResult) ? "array" : typeof toolResult) console.log("Result:", toolResult) @@ -1624,16 +1614,14 @@ describe("read_file tool with image support", () => { partial: false, } - await readFileTool( - localMockCline, - toolUse, - localMockCline.ask, - handleErrorSpy, // Use our spy here - (result: ToolResponse) => { + await readFileTool.handle(localMockCline, toolUse, { + askApproval: localMockCline.ask, + handleError: handleErrorSpy, // Use our spy here + pushToolResult: (result: ToolResponse) => { toolResult = result }, - (_: ToolParamName, content?: string) => content ?? "", - ) + removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", + }) // Verify error handling expect(toolResult).toContain("Error reading image file: Failed to read image") diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 53f0643dbb4..3e7e5008f67 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -5,7 +5,6 @@ import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" @@ -24,50 +23,19 @@ import { } from "./helpers/imageHelpers" import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget" import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export function getReadFileToolDescription(blockName: string, blockParams: any): string { - // Handle both single path and multiple files via args - if (blockParams.args) { - try { - const parsed = parseXml(blockParams.args) as any - const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) - const paths = files.map((f: any) => f?.path).filter(Boolean) as string[] - - if (paths.length === 0) { - return `[${blockName} with no valid paths]` - } else if (paths.length === 1) { - // Modified part for single file - return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` - } else if (paths.length <= 3) { - const pathList = paths.map((p) => `'${p}'`).join(", ") - return `[${blockName} for ${pathList}]` - } else { - return `[${blockName} for ${paths.length} files]` - } - } catch (error) { - console.error("Failed to parse read_file args XML for description:", error) - return `[${blockName} with unparsable args]` - } - } else if (blockParams.path) { - // Fallback for legacy single-path usage - // Modified part for single file (legacy) - return `[${blockName} for '${blockParams.path}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` - } else { - return `[${blockName} with missing path/args]` - } -} -// Types interface LineRange { start: number end: number } -interface FileEntry { - path?: string +export interface FileEntry { + path: string lineRanges?: LineRange[] } -// New interface to track file processing state interface FileResult { path: string status: "approved" | "denied" | "blocked" | "error" | "pending" @@ -75,66 +43,32 @@ interface FileResult { error?: string notice?: string lineRanges?: LineRange[] - xmlContent?: string // Final XML content for this file - imageDataUrl?: string // Image data URL for image files - feedbackText?: string // User feedback text from approval/denial - feedbackImages?: any[] // User feedback images from approval/denial + xmlContent?: string + imageDataUrl?: string + feedbackText?: string + feedbackImages?: any[] } -export async function readFileTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - _removeClosingTag: RemoveClosingTag, -) { - const argsXmlTag: string | undefined = block.params.args - const legacyPath: string | undefined = block.params.path - const legacyStartLineStr: string | undefined = block.params.start_line - const legacyEndLineStr: string | undefined = block.params.end_line - - // Check if the current model supports images at the beginning - const modelInfo = cline.api.getModel().info - const supportsImages = modelInfo.supportsImages ?? false - - // Handle partial message first - if (block.partial) { - let filePath = "" - // Prioritize args for partial, then legacy path - if (argsXmlTag) { - const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) - if (match) filePath = match[1] - } - if (!filePath && legacyPath) { - // If args didn't yield a path, try legacy - filePath = legacyPath - } +export class ReadFileTool extends BaseTool<"read_file"> { + readonly name = "read_file" as const - const fullPath = filePath ? path.resolve(cline.cwd, filePath) : "" - const sharedMessageProps: ClineSayTool = { - tool: "readFile", - path: getReadablePath(cline.cwd, filePath), - isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, - } - const partialMessage = JSON.stringify({ - ...sharedMessageProps, - content: undefined, - } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } + parseLegacy(params: Partial>): FileEntry[] { + const argsXmlTag = params.args + const legacyPath = params.path + const legacyStartLineStr = params.start_line + const legacyEndLineStr = params.end_line - const fileEntries: FileEntry[] = [] + const fileEntries: FileEntry[] = [] - if (argsXmlTag) { - // Parse file entries from XML (new multi-file format) - try { + // Note: Native protocol is handled by passing typed args directly to execute() via BaseTool.handle() + + // XML args format + if (argsXmlTag) { const parsed = parseXml(argsXmlTag) as any const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) for (const file of files) { - if (!file.path) continue // Skip if no path in a file entry + if (!file.path) continue const fileEntry: FileEntry = { path: file.path, @@ -144,7 +78,7 @@ export async function readFileTool( if (file.line_range) { const ranges = Array.isArray(file.line_range) ? file.line_range : [file.line_range] for (const range of ranges) { - const match = String(range).match(/(\d+)-(\d+)/) // Ensure range is treated as string + const match = String(range).match(/(\d+)-(\d+)/) if (match) { const [, start, end] = match.map(Number) if (!isNaN(start) && !isNaN(end)) { @@ -155,128 +89,201 @@ export async function readFileTool( } fileEntries.push(fileEntry) } - } catch (error) { - const errorMessage = `Failed to parse read_file XML args: ${error instanceof Error ? error.message : String(error)}` - await handleError("parsing read_file args", new Error(errorMessage)) - pushToolResult(`${errorMessage}`) - return - } - } else if (legacyPath) { - // Handle legacy single file path as a fallback - console.warn("[readFileTool] Received legacy 'path' parameter. Consider updating to use 'args' structure.") - const fileEntry: FileEntry = { - path: legacyPath, - lineRanges: [], + return fileEntries } - if (legacyStartLineStr && legacyEndLineStr) { - const start = parseInt(legacyStartLineStr, 10) - const end = parseInt(legacyEndLineStr, 10) - if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) { - fileEntry.lineRanges?.push({ start, end }) - } else { - console.warn( - `[readFileTool] Invalid legacy line range for ${legacyPath}: start='${legacyStartLineStr}', end='${legacyEndLineStr}'`, - ) + // Legacy single file path + if (legacyPath) { + const fileEntry: FileEntry = { + path: legacyPath, + lineRanges: [], } + + if (legacyStartLineStr && legacyEndLineStr) { + const start = parseInt(legacyStartLineStr, 10) + const end = parseInt(legacyEndLineStr, 10) + if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) { + fileEntry.lineRanges?.push({ start, end }) + } + } + fileEntries.push(fileEntry) } - fileEntries.push(fileEntry) - } - // If, after trying both new and legacy, no valid file entries are found. - if (fileEntries.length === 0) { - cline.consecutiveMistakeCount++ - cline.recordToolError("read_file") - const errorMsg = await cline.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)") - pushToolResult(`${errorMsg}`) - return + return fileEntries } - // Create an array to track the state of each file - const fileResults: FileResult[] = fileEntries.map((entry) => ({ - path: entry.path || "", - status: "pending", - lineRanges: entry.lineRanges, - })) - - // Function to update file result status - const updateFileResult = (path: string, updates: Partial) => { - const index = fileResults.findIndex((result) => result.path === path) - if (index !== -1) { - fileResults[index] = { ...fileResults[index], ...updates } + async execute(fileEntries: FileEntry[], cline: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult } = callbacks + + if (fileEntries.length === 0) { + cline.consecutiveMistakeCount++ + cline.recordToolError("read_file") + const errorMsg = await cline.sayAndCreateMissingParamError( + "read_file", + "args (containing valid file paths)", + ) + pushToolResult(`${errorMsg}`) + return } - } - try { - // First validate all files and prepare for batch approval - const filesToApprove: FileResult[] = [] - - for (let i = 0; i < fileResults.length; i++) { - const fileResult = fileResults[i] - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - - // Validate line ranges first - if (fileResult.lineRanges) { - let hasRangeError = false - for (const range of fileResult.lineRanges) { - if (range.start > range.end) { - const errorMsg = "Invalid line range: end line cannot be less than start line" - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, - }) - await handleError(`reading file ${relPath}`, new Error(errorMsg)) - hasRangeError = true - break + const modelInfo = cline.api.getModel().info + const supportsImages = modelInfo.supportsImages ?? false + + const fileResults: FileResult[] = fileEntries.map((entry) => ({ + path: entry.path, + status: "pending", + lineRanges: entry.lineRanges, + })) + + const updateFileResult = (filePath: string, updates: Partial) => { + const index = fileResults.findIndex((result) => result.path === filePath) + if (index !== -1) { + fileResults[index] = { ...fileResults[index], ...updates } + } + } + + try { + const filesToApprove: FileResult[] = [] + + for (const fileResult of fileResults) { + const relPath = fileResult.path + const fullPath = path.resolve(cline.cwd, relPath) + + if (fileResult.lineRanges) { + let hasRangeError = false + for (const range of fileResult.lineRanges) { + if (range.start > range.end) { + const errorMsg = "Invalid line range: end line cannot be less than start line" + updateFileResult(relPath, { + status: "blocked", + error: errorMsg, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + }) + await handleError(`reading file ${relPath}`, new Error(errorMsg)) + hasRangeError = true + break + } + if (isNaN(range.start) || isNaN(range.end)) { + const errorMsg = "Invalid line range values" + updateFileResult(relPath, { + status: "blocked", + error: errorMsg, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + }) + await handleError(`reading file ${relPath}`, new Error(errorMsg)) + hasRangeError = true + break + } } - if (isNaN(range.start) || isNaN(range.end)) { - const errorMsg = "Invalid line range values" + if (hasRangeError) continue + } + + if (fileResult.status === "pending") { + const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await cline.say("rooignore_error", relPath) + const errorMsg = formatResponse.rooIgnoreError(relPath) updateFileResult(relPath, { status: "blocked", error: errorMsg, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, + xmlContent: `${relPath}${errorMsg}`, }) - await handleError(`reading file ${relPath}`, new Error(errorMsg)) - hasRangeError = true - break + continue } + + filesToApprove.push(fileResult) } - if (hasRangeError) continue } - // Then check RooIgnore validation - if (fileResult.status === "pending") { - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - xmlContent: `${relPath}${errorMsg}`, - }) - continue - } + if (filesToApprove.length > 1) { + const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - // Add to files that need approval - filesToApprove.push(fileResult) - } - } + const batchFiles = filesToApprove.map((fileResult) => { + const relPath = fileResult.path + const fullPath = path.resolve(cline.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + let lineSnippet = "" + if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { + const ranges = fileResult.lineRanges.map((range) => + t("tools:readFile.linesRange", { start: range.start, end: range.end }), + ) + lineSnippet = ranges.join(", ") + } else if (maxReadFileLine === 0) { + lineSnippet = t("tools:readFile.definitionsOnly") + } else if (maxReadFileLine > 0) { + lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) + } + + const readablePath = getReadablePath(cline.cwd, relPath) + const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` + + return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath } + }) + + const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) + const { response, text, images } = await cline.ask("tool", completeMessage, false) - // Handle batch approval if there are multiple files to approve - if (filesToApprove.length > 1) { - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} + if (response === "yesButtonClicked") { + if (text) await cline.say("user_feedback", text, images) + filesToApprove.forEach((fileResult) => { + updateFileResult(fileResult.path, { + status: "approved", + feedbackText: text, + feedbackImages: images, + }) + }) + } else if (response === "noButtonClicked") { + if (text) await cline.say("user_feedback", text, images) + cline.didRejectTool = true + filesToApprove.forEach((fileResult) => { + updateFileResult(fileResult.path, { + status: "denied", + xmlContent: `${fileResult.path}Denied by user`, + feedbackText: text, + feedbackImages: images, + }) + }) + } else { + try { + const individualPermissions = JSON.parse(text || "{}") + let hasAnyDenial = false + + batchFiles.forEach((batchFile, index) => { + const fileResult = filesToApprove[index] + const approved = individualPermissions[batchFile.key] === true + + if (approved) { + updateFileResult(fileResult.path, { status: "approved" }) + } else { + hasAnyDenial = true + updateFileResult(fileResult.path, { + status: "denied", + xmlContent: `${fileResult.path}Denied by user`, + }) + } + }) - // Prepare batch file data - const batchFiles = filesToApprove.map((fileResult) => { + if (hasAnyDenial) cline.didRejectTool = true + } catch (error) { + console.error("Failed to parse individual permissions:", error) + cline.didRejectTool = true + filesToApprove.forEach((fileResult) => { + updateFileResult(fileResult.path, { + status: "denied", + xmlContent: `${fileResult.path}Denied by user`, + }) + }) + } + } + } else if (filesToApprove.length === 1) { + const fileResult = filesToApprove[0] const relPath = fileResult.path const fullPath = path.resolve(cline.cwd, relPath) const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - // Create line snippet for this file let lineSnippet = "" if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { const ranges = fileResult.lineRanges.map((range) => @@ -289,461 +296,396 @@ export async function readFileTool( lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) } - const readablePath = getReadablePath(cline.cwd, relPath) - const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` - - return { - path: readablePath, - lineSnippet, + const completeMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(cline.cwd, relPath), isOutsideWorkspace, - key, - content: fullPath, // Include full path for content - } - }) - - const completeMessage = JSON.stringify({ - tool: "readFile", - batchFiles, - } satisfies ClineSayTool) + content: fullPath, + reason: lineSnippet, + } satisfies ClineSayTool) - const { response, text, images } = await cline.ask("tool", completeMessage, false) + const { response, text, images } = await cline.ask("tool", completeMessage, false) - // Process batch response - if (response === "yesButtonClicked") { - // Approve all files - if (text) { - await cline.say("user_feedback", text, images) - } - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "approved", - feedbackText: text, - feedbackImages: images, - }) - }) - } else if (response === "noButtonClicked") { - // Deny all files - if (text) { - await cline.say("user_feedback", text, images) - } - cline.didRejectTool = true - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { + if (response !== "yesButtonClicked") { + if (text) await cline.say("user_feedback", text, images) + cline.didRejectTool = true + updateFileResult(relPath, { status: "denied", - xmlContent: `${fileResult.path}Denied by user`, + xmlContent: `${relPath}Denied by user`, feedbackText: text, feedbackImages: images, }) - }) - } else { - // Handle individual permissions from objectResponse - // if (text) { - // await cline.say("user_feedback", text, images) - // } - - try { - const individualPermissions = JSON.parse(text || "{}") - let hasAnyDenial = false - - batchFiles.forEach((batchFile, index) => { - const fileResult = filesToApprove[index] - const approved = individualPermissions[batchFile.key] === true - - if (approved) { - updateFileResult(fileResult.path, { - status: "approved", - }) - } else { - hasAnyDenial = true - updateFileResult(fileResult.path, { - status: "denied", - xmlContent: `${fileResult.path}Denied by user`, - }) - } - }) - - if (hasAnyDenial) { - cline.didRejectTool = true - } - } catch (error) { - // Fallback: if JSON parsing fails, deny all files - console.error("Failed to parse individual permissions:", error) - cline.didRejectTool = true - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "denied", - xmlContent: `${fileResult.path}Denied by user`, - }) - }) - } - } - } else if (filesToApprove.length === 1) { - // Handle single file approval (existing logic) - const fileResult = filesToApprove[0] - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} - - // Create line snippet for approval message - let lineSnippet = "" - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const ranges = fileResult.lineRanges.map((range) => - t("tools:readFile.linesRange", { start: range.start, end: range.end }), - ) - lineSnippet = ranges.join(", ") - } else if (maxReadFileLine === 0) { - lineSnippet = t("tools:readFile.definitionsOnly") - } else if (maxReadFileLine > 0) { - lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) - } - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(cline.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet, - } satisfies ClineSayTool) - - const { response, text, images } = await cline.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - // Handle both messageResponse and noButtonClicked with text - if (text) { - await cline.say("user_feedback", text, images) - } - cline.didRejectTool = true - - updateFileResult(relPath, { - status: "denied", - xmlContent: `${relPath}Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - } else { - // Handle yesButtonClicked with text - if (text) { - await cline.say("user_feedback", text, images) + } else { + if (text) await cline.say("user_feedback", text, images) + updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) } - - updateFileResult(relPath, { - status: "approved", - feedbackText: text, - feedbackImages: images, - }) } - } - - // Track total image memory usage across all files - const imageMemoryTracker = new ImageMemoryTracker() - const state = await cline.providerRef.deref()?.getState() - const { - maxReadFileLine = -1, - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - - // Then process only approved files - for (const fileResult of fileResults) { - // Skip files that weren't approved - if (fileResult.status !== "approved") { - continue - } - - const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) - // Process approved files - try { - const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) + const imageMemoryTracker = new ImageMemoryTracker() + const state = await cline.providerRef.deref()?.getState() + const { + maxReadFileLine = -1, + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + } = state ?? {} - // Handle binary files (but allow specific file types that extractTextFromFile can handle) - if (isBinary) { - const fileExtension = path.extname(relPath).toLowerCase() - const supportedBinaryFormats = getSupportedBinaryFormats() + for (const fileResult of fileResults) { + if (fileResult.status !== "approved") continue - // Check if it's a supported image format - if (isSupportedImageFormat(fileExtension)) { - try { - // Validate image for processing - const validationResult = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker.getTotalMemoryUsed(), - ) + const relPath = fileResult.path + const fullPath = path.resolve(cline.cwd, relPath) - if (!validationResult.isValid) { - // Track file read + try { + const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) + + if (isBinary) { + const fileExtension = path.extname(relPath).toLowerCase() + const supportedBinaryFormats = getSupportedBinaryFormats() + + if (isSupportedImageFormat(fileExtension)) { + try { + const validationResult = await validateImageForProcessing( + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker.getTotalMemoryUsed(), + ) + + if (!validationResult.isValid) { + await cline.fileContextTracker.trackFileContext( + relPath, + "read_tool" as RecordSource, + ) + updateFileResult(relPath, { + xmlContent: `${relPath}\n${validationResult.notice}\n`, + }) + continue + } + + const imageResult = await processImageFile(fullPath) + imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { - xmlContent: `${relPath}\n${validationResult.notice}\n`, + xmlContent: `${relPath}\n${imageResult.notice}\n`, + imageDataUrl: imageResult.dataUrl, + }) + continue + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateFileResult(relPath, { + status: "error", + error: `Error reading image file: ${errorMsg}`, + xmlContent: `${relPath}Error reading image file: ${errorMsg}`, }) + await handleError( + `reading image file ${relPath}`, + error instanceof Error ? error : new Error(errorMsg), + ) continue } + } - // Process the image - const imageResult = await processImageFile(fullPath) - - // Track memory usage for this image - imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) - - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - // Store image data URL separately - NOT in XML - updateFileResult(relPath, { - xmlContent: `${relPath}\n${imageResult.notice}\n`, - imageDataUrl: imageResult.dataUrl, - }) - continue - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) + if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { + // Fall through to extractTextFromFile + } else { + const fileFormat = fileExtension.slice(1) || "bin" updateFileResult(relPath, { - status: "error", - error: `Error reading image file: ${errorMsg}`, - xmlContent: `${relPath}Error reading image file: ${errorMsg}`, + notice: `Binary file format: ${fileFormat}`, + xmlContent: `${relPath}\nBinary file - content not displayed\n`, }) - await handleError( - `reading image file ${relPath}`, - error instanceof Error ? error : new Error(errorMsg), - ) continue } } - // Check if it's a supported binary format that can be processed - if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { - // For supported binary formats (.pdf, .docx, .ipynb), continue to extractTextFromFile - // Fall through to the normal extractTextFromFile processing below - } else { - // Handle unknown binary format - const fileFormat = fileExtension.slice(1) || "bin" // Remove the dot, fallback to "bin" + if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { + const rangeResults: string[] = [] + for (const range of fileResult.lineRanges) { + const content = addLineNumbers( + await readLines(fullPath, range.end - 1, range.start - 1), + range.start, + ) + const lineRangeAttr = ` lines="${range.start}-${range.end}"` + rangeResults.push(`\n${content}`) + } updateFileResult(relPath, { - notice: `Binary file format: ${fileFormat}`, - xmlContent: `${relPath}\nBinary file - content not displayed\n`, + xmlContent: `${relPath}\n${rangeResults.join("\n")}\n`, }) continue } - } - // Handle range reads (bypass maxReadFileLine) - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const rangeResults: string[] = [] - for (const range of fileResult.lineRanges) { - const content = addLineNumbers( - await readLines(fullPath, range.end - 1, range.start - 1), - range.start, - ) - const lineRangeAttr = ` lines="${range.start}-${range.end}"` - rangeResults.push(`\n${content}`) + if (maxReadFileLine === 0) { + try { + const defResult = await parseSourceCodeDefinitionsForFile( + fullPath, + cline.rooIgnoreController, + ) + if (defResult) { + let xmlInfo = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` + updateFileResult(relPath, { + xmlContent: `${relPath}\n${defResult}\n${xmlInfo}`, + }) + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("Unsupported language:")) { + console.warn(`[read_file] Warning: ${error.message}`) + } else { + console.error( + `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + continue } - updateFileResult(relPath, { - xmlContent: `${relPath}\n${rangeResults.join("\n")}\n`, - }) - continue - } - // Handle definitions-only mode - if (maxReadFileLine === 0) { - try { - const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) - if (defResult) { - let xmlInfo = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` + if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { + const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) + const lineRangeAttr = ` lines="1-${maxReadFileLine}"` + let xmlInfo = `\n${content}\n` + + try { + const defResult = await parseSourceCodeDefinitionsForFile( + fullPath, + cline.rooIgnoreController, + ) + if (defResult) { + const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) + xmlInfo += `${truncatedDefs}\n` + } + xmlInfo += `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` updateFileResult(relPath, { - xmlContent: `${relPath}\n${defResult}\n${xmlInfo}`, + xmlContent: `${relPath}\n${xmlInfo}`, }) + } catch (error) { + if (error instanceof Error && error.message.startsWith("Unsupported language:")) { + console.warn(`[read_file] Warning: ${error.message}`) + } else { + console.error( + `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, + ) + } } - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } + continue } - continue - } - // Handle files exceeding line threshold - if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { - const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) - const lineRangeAttr = ` lines="1-${maxReadFileLine}"` - let xmlInfo = `\n${content}\n` + const modelInfo = cline.api.getModel().info + const { contextTokens } = cline.getTokenUsage() + const contextWindow = modelInfo.contextWindow - try { - const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) - if (defResult) { - // Truncate definitions to match the truncated file content - const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) - xmlInfo += `${truncatedDefs}\n` + const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0) + + let content = await extractTextFromFile(fullPath) + let xmlInfo = "" + + if (budgetResult.shouldTruncate && budgetResult.maxChars !== undefined) { + const truncateResult = truncateFileContent( + content, + budgetResult.maxChars, + content.length, + budgetResult.isPreview, + ) + content = truncateResult.content + + let displayedLines = content.length === 0 ? 0 : content.split(/\r?\n/).length + if (displayedLines > 0 && content.endsWith("\n")) { + displayedLines-- } - xmlInfo += `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` - updateFileResult(relPath, { - xmlContent: `${relPath}\n${xmlInfo}`, - }) - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) + const lineRangeAttr = displayedLines > 0 ? ` lines="1-${displayedLines}"` : "" + xmlInfo = + content.length > 0 ? `\n${content}\n` : `` + xmlInfo += `${truncateResult.notice}\n` + } else { + const lineRangeAttr = ` lines="1-${totalLines}"` + xmlInfo = totalLines > 0 ? `\n${content}\n` : `` + + if (totalLines === 0) { + xmlInfo += `File is empty\n` } } - continue + + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { xmlContent: `${relPath}\n${xmlInfo}` }) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateFileResult(relPath, { + status: "error", + error: `Error reading file: ${errorMsg}`, + xmlContent: `${relPath}Error reading file: ${errorMsg}`, + }) + await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) } + } - // Handle normal file read with token budget validation - const modelInfo = cline.api.getModel().info - const { contextTokens } = cline.getTokenUsage() - const contextWindow = modelInfo.contextWindow + const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) + const filesXml = `\n${xmlResults.join("\n")}\n` - // Validate if file fits within token budget - const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0) + const fileImageUrls = fileResults + .filter((result) => result.imageDataUrl) + .map((result) => result.imageDataUrl as string) - let content = await extractTextFromFile(fullPath) - let xmlInfo = "" + let statusMessage = "" + let feedbackImages: any[] = [] - if (budgetResult.shouldTruncate && budgetResult.maxChars !== undefined) { - // Truncate the content to fit budget or show preview for large files - const truncateResult = truncateFileContent( - content, - budgetResult.maxChars, - content.length, - budgetResult.isPreview, - ) - content = truncateResult.content + const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) + + if (deniedWithFeedback && deniedWithFeedback.feedbackText) { + statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) + feedbackImages = deniedWithFeedback.feedbackImages || [] + } else if (cline.didRejectTool) { + statusMessage = formatResponse.toolDenied() + } else { + const approvedWithFeedback = fileResults.find( + (result) => result.status === "approved" && result.feedbackText, + ) + + if (approvedWithFeedback && approvedWithFeedback.feedbackText) { + statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) + feedbackImages = approvedWithFeedback.feedbackImages || [] + } + } - // Reflect actual displayed line count after truncation (count ALL lines, including empty) - // Handle trailing newline: "line1\nline2\n" should be 2 lines, not 3 - let displayedLines = content.length === 0 ? 0 : content.split(/\r?\n/).length - if (displayedLines > 0 && content.endsWith("\n")) { - displayedLines-- + const allImages = [...feedbackImages, ...fileImageUrls] + + const finalModelSupportsImages = cline.api.getModel().info.supportsImages ?? false + const imagesToInclude = finalModelSupportsImages ? allImages : [] + + if (statusMessage || imagesToInclude.length > 0) { + const result = formatResponse.toolResult( + statusMessage || filesXml, + imagesToInclude.length > 0 ? imagesToInclude : undefined, + ) + + if (typeof result === "string") { + if (statusMessage) { + pushToolResult(`${result}\n${filesXml}`) + } else { + pushToolResult(result) } - const lineRangeAttr = displayedLines > 0 ? ` lines="1-${displayedLines}"` : "" - xmlInfo = content.length > 0 ? `\n${content}\n` : `` - xmlInfo += `${truncateResult.notice}\n` } else { - const lineRangeAttr = ` lines="1-${totalLines}"` - xmlInfo = totalLines > 0 ? `\n${content}\n` : `` - - if (totalLines === 0) { - xmlInfo += `File is empty\n` + if (statusMessage) { + const textBlock = { type: "text" as const, text: filesXml } + pushToolResult([...result, textBlock]) + } else { + pushToolResult(result) } } + } else { + pushToolResult(filesXml) + } + } catch (error) { + const relPath = fileEntries[0]?.path || "unknown" + const errorMsg = error instanceof Error ? error.message : String(error) - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - xmlContent: `${relPath}\n${xmlInfo}`, - }) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) + if (fileResults.length > 0) { updateFileResult(relPath, { status: "error", error: `Error reading file: ${errorMsg}`, xmlContent: `${relPath}Error reading file: ${errorMsg}`, }) - await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) } - } - // Generate final XML result from all file results - const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) - const filesXml = `\n${xmlResults.join("\n")}\n` + await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) - // Collect all image data URLs from file results - const fileImageUrls = fileResults - .filter((result) => result.imageDataUrl) - .map((result) => result.imageDataUrl as string) + const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) - // Process all feedback in a unified way without branching - let statusMessage = "" - let feedbackImages: any[] = [] - - // Handle denial with feedback (highest priority) - const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) - - if (deniedWithFeedback && deniedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) - feedbackImages = deniedWithFeedback.feedbackImages || [] - } - // Handle generic denial - else if (cline.didRejectTool) { - statusMessage = formatResponse.toolDenied() + pushToolResult(`\n${xmlResults.join("\n")}\n`) } - // Handle approval with feedback - else { - const approvedWithFeedback = fileResults.find( - (result) => result.status === "approved" && result.feedbackText, - ) + } - if (approvedWithFeedback && approvedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - feedbackImages = approvedWithFeedback.feedbackImages || [] + getReadFileToolDescription(blockName: string, blockParams: any): string + getReadFileToolDescription(blockName: string, nativeArgs: FileEntry[]): string + getReadFileToolDescription(blockName: string, second: any): string { + // If native typed args (FileEntry[]) were provided + if (Array.isArray(second)) { + const paths = (second as FileEntry[]).map((f) => f?.path).filter(Boolean) as string[] + if (paths.length === 0) { + return `[${blockName} with no valid paths]` + } else if (paths.length === 1) { + return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (paths.length <= 3) { + const pathList = paths.map((p) => `'${p}'`).join(", ") + return `[${blockName} for ${pathList}]` + } else { + return `[${blockName} for ${paths.length} files]` } } - // Combine all images: feedback images first, then file images - const allImages = [...feedbackImages, ...fileImageUrls] - - // Re-check if the model supports images before including them, in case it changed during execution. - const finalModelSupportsImages = cline.api.getModel().info.supportsImages ?? false - const imagesToInclude = finalModelSupportsImages ? allImages : [] + // Fallback to legacy/XML or synthesized params + const blockParams = second as any - // Push the result with appropriate formatting - if (statusMessage || imagesToInclude.length > 0) { - // Always use formatResponse.toolResult when we have a status message or images - const result = formatResponse.toolResult( - statusMessage || filesXml, - imagesToInclude.length > 0 ? imagesToInclude : undefined, - ) - - // Handle different return types from toolResult - if (typeof result === "string") { - if (statusMessage) { - pushToolResult(`${result}\n${filesXml}`) + if (blockParams?.args) { + try { + const parsed = parseXml(blockParams.args) as any + const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean) + const paths = files.map((f: any) => f?.path).filter(Boolean) as string[] + + if (paths.length === 0) { + return `[${blockName} with no valid paths]` + } else if (paths.length === 1) { + return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (paths.length <= 3) { + const pathList = paths.map((p) => `'${p}'`).join(", ") + return `[${blockName} for ${pathList}]` } else { - pushToolResult(result) + return `[${blockName} for ${paths.length} files]` } - } else { - // For block-based results, append the files XML as a text block if not already included - if (statusMessage) { - const textBlock = { type: "text" as const, text: filesXml } - pushToolResult([...result, textBlock]) - } else { - pushToolResult(result) + } catch (error) { + console.error("Failed to parse read_file args XML for description:", error) + return `[${blockName} with unparsable args]` + } + } else if (blockParams?.path) { + return `[${blockName} for '${blockParams.path}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (blockParams?.files) { + // Back-compat: some paths may still synthesize params.files; try to parse if present + try { + const files = JSON.parse(blockParams.files) + if (Array.isArray(files) && files.length > 0) { + const paths = files.map((f: any) => f?.path).filter(Boolean) as string[] + if (paths.length === 1) { + return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + } else if (paths.length <= 3) { + const pathList = paths.map((p) => `'${p}'`).join(", ") + return `[${blockName} for ${pathList}]` + } else { + return `[${blockName} for ${paths.length} files]` + } } + } catch (error) { + console.error("Failed to parse native files JSON for description:", error) + return `[${blockName} with unparsable files]` } - } else { - // No images or status message, just push the files XML - pushToolResult(filesXml) - } - } catch (error) { - // Handle all errors using per-file format for consistency - const relPath = fileEntries[0]?.path || "unknown" - const errorMsg = error instanceof Error ? error.message : String(error) - - // If we have file results, update the first one with the error - if (fileResults.length > 0) { - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - xmlContent: `${relPath}Error reading file: ${errorMsg}`, - }) } - await handleError(`reading file ${relPath}`, error instanceof Error ? error : new Error(errorMsg)) + return `[${blockName} with missing path/args/files]` + } - // Generate final XML result from all file results - const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) + override async handlePartial(cline: Task, block: ToolUse<"read_file">): Promise { + const argsXmlTag = block.params.args + const legacyPath = block.params.path - pushToolResult(`\n${xmlResults.join("\n")}\n`) + let filePath = "" + if (argsXmlTag) { + const match = argsXmlTag.match(/.*?([^<]+)<\/path>/s) + if (match) filePath = match[1] + } + if (!filePath && legacyPath) { + filePath = legacyPath + } + + const fullPath = filePath ? path.resolve(cline.cwd, filePath) : "" + const sharedMessageProps: ClineSayTool = { + tool: "readFile", + path: getReadablePath(cline.cwd, filePath), + isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, + } + const partialMessage = JSON.stringify({ + ...sharedMessageProps, + content: undefined, + } satisfies ClineSayTool) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) } } + +export const readFileTool = new ReadFileTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 61e84027272..80d2845b8a3 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "@roo-code/types" +import type { FileEntry } from "../core/tools/ReadFileTool" export type ToolResponse = string | Array @@ -62,105 +63,124 @@ export const toolParamNames = [ "todos", "prompt", "image", + "files", // Native protocol parameter for read_file ] as const export type ToolParamName = (typeof toolParamNames)[number] -export interface ToolUse { +export type ToolProtocol = "xml" | "native" + +/** + * Type map defining the native (typed) argument structure for each tool. + * Tools not listed here will fall back to `any` for backward compatibility. + */ +export type NativeToolArgs = { + read_file: FileEntry[] + // Add more tools as they are migrated to native protocol +} + +/** + * Generic ToolUse interface that provides proper typing for both protocols. + * + * @template TName - The specific tool name, which determines the nativeArgs type + */ +export interface ToolUse { type: "tool_use" - name: ToolName + name: TName // params is a partial record, allowing only some or none of the possible parameters to be used params: Partial> partial: boolean + // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never + nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never } -export interface ExecuteCommandToolUse extends ToolUse { +export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> { name: "execute_command" // Pick, "command"> makes "command" required, but Partial<> makes it optional params: Partial, "command" | "cwd">> } -export interface ReadFileToolUse extends ToolUse { +export interface ReadFileToolUse extends ToolUse<"read_file"> { name: "read_file" - params: Partial, "args" | "path" | "start_line" | "end_line">> + params: Partial, "args" | "path" | "start_line" | "end_line" | "files">> } -export interface FetchInstructionsToolUse extends ToolUse { +export interface FetchInstructionsToolUse extends ToolUse<"fetch_instructions"> { name: "fetch_instructions" params: Partial, "task">> } -export interface WriteToFileToolUse extends ToolUse { +export interface WriteToFileToolUse extends ToolUse<"write_to_file"> { name: "write_to_file" params: Partial, "path" | "content" | "line_count">> } -export interface InsertCodeBlockToolUse extends ToolUse { +export interface InsertCodeBlockToolUse extends ToolUse<"insert_content"> { name: "insert_content" params: Partial, "path" | "line" | "content">> } -export interface CodebaseSearchToolUse extends ToolUse { +export interface CodebaseSearchToolUse extends ToolUse<"codebase_search"> { name: "codebase_search" params: Partial, "query" | "path">> } -export interface SearchFilesToolUse extends ToolUse { +export interface SearchFilesToolUse extends ToolUse<"search_files"> { name: "search_files" params: Partial, "path" | "regex" | "file_pattern">> } -export interface ListFilesToolUse extends ToolUse { +export interface ListFilesToolUse extends ToolUse<"list_files"> { name: "list_files" params: Partial, "path" | "recursive">> } -export interface ListCodeDefinitionNamesToolUse extends ToolUse { +export interface ListCodeDefinitionNamesToolUse extends ToolUse<"list_code_definition_names"> { name: "list_code_definition_names" params: Partial, "path">> } -export interface BrowserActionToolUse extends ToolUse { +export interface BrowserActionToolUse extends ToolUse<"browser_action"> { name: "browser_action" params: Partial, "action" | "url" | "coordinate" | "text" | "size">> } -export interface UseMcpToolToolUse extends ToolUse { +export interface UseMcpToolToolUse extends ToolUse<"use_mcp_tool"> { name: "use_mcp_tool" params: Partial, "server_name" | "tool_name" | "arguments">> } -export interface AccessMcpResourceToolUse extends ToolUse { +export interface AccessMcpResourceToolUse extends ToolUse<"access_mcp_resource"> { name: "access_mcp_resource" params: Partial, "server_name" | "uri">> } -export interface AskFollowupQuestionToolUse extends ToolUse { +export interface AskFollowupQuestionToolUse extends ToolUse<"ask_followup_question"> { name: "ask_followup_question" params: Partial, "question" | "follow_up">> } -export interface AttemptCompletionToolUse extends ToolUse { +export interface AttemptCompletionToolUse extends ToolUse<"attempt_completion"> { name: "attempt_completion" params: Partial, "result">> } -export interface SwitchModeToolUse extends ToolUse { +export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { name: "switch_mode" params: Partial, "mode_slug" | "reason">> } -export interface NewTaskToolUse extends ToolUse { +export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" params: Partial, "mode" | "message" | "todos">> } -export interface RunSlashCommandToolUse extends ToolUse { +export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { name: "run_slash_command" params: Partial, "command" | "args">> } -export interface GenerateImageToolUse extends ToolUse { +export interface GenerateImageToolUse extends ToolUse<"generate_image"> { name: "generate_image" params: Partial, "prompt" | "path" | "image">> } From 9176a7d8938701d13bf65f3b9d57517a7c590c04 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 09:32:38 -0500 Subject: [PATCH 04/48] fix: prevent "no assistant messages" error by initializing assistantMessage for tool calls --- src/core/task/Task.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 85009754f6d..632610bf851 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2069,6 +2069,15 @@ export class Task extends EventEmitter implements TaskLike { // Mark that we have new content to process this.userMessageContentReady = false + // Set assistantMessage to non-empty to prevent "no assistant messages" error + // Tool calls are tracked separately in assistantMessageContent + if (!assistantMessage) { + assistantMessage = JSON.stringify({ + tool: chunk.name, + arguments: chunk.arguments, + }) + } + console.log(`[NATIVE_TOOL] Calling presentAssistantMessage`) // Present the tool call to user From 591765b42c5d67ade7a953ac39b4df330b788152 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 09:45:20 -0500 Subject: [PATCH 05/48] fix: enhance read_file handling to support both single-file and multi-file formats --- src/core/assistant-message/NativeToolCallParser.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 4e8589ed3f3..a707e454daa 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -76,9 +76,17 @@ export class NativeToolCallParser { let nativeArgs: (TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never) | undefined = undefined if (toolCall.name === "read_file") { - const files = args.files - if (Array.isArray(files)) { - nativeArgs = files as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + // Handle both single-file and multi-file formats + if (args.files && Array.isArray(args.files)) { + // Multi-file format: {"files": [{path: "...", line_ranges: [...]}, ...]} + nativeArgs = args.files as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } else if (args.path) { + // Single-file format: {"path": "..."} - convert to array format + const fileEntry: FileEntry = { + path: args.path, + lineRanges: [], + } + nativeArgs = [fileEntry] as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never } } // Add more tools here as they are migrated to native protocol From c0d0a06be0b0d30d183cead4c7aab54aa1b2f5f6 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 10:21:03 -0500 Subject: [PATCH 06/48] fix: task getting stuck after native tool call --- .../presentAssistantMessage.ts | 70 +++++++++++++++++++ src/core/task/Task.ts | 34 ++++++++- src/core/tools/BaseTool.ts | 17 +++++ src/core/tools/readFileTool.ts | 3 + 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 8dbca62d30c..211d91f70a5 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -55,32 +55,55 @@ import { applyDiffToolLegacy } from "../tools/applyDiffTool" */ export async function presentAssistantMessage(cline: Task) { + console.log(`[NATIVE_TOOL] presentAssistantMessage called for task ${cline.taskId}.${cline.instanceId}`) + console.log( + `[NATIVE_TOOL] Current index: ${cline.currentStreamingContentIndex}, Content length: ${cline.assistantMessageContent.length}`, + ) + console.log( + `[NATIVE_TOOL] Locked: ${cline.presentAssistantMessageLocked}, HasPending: ${cline.presentAssistantMessageHasPendingUpdates}`, + ) + if (cline.abort) { throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`) } if (cline.presentAssistantMessageLocked) { + console.log(`[NATIVE_TOOL] presentAssistantMessage is locked, setting hasPendingUpdates=true and returning`) cline.presentAssistantMessageHasPendingUpdates = true return } + console.log(`[NATIVE_TOOL] Acquiring lock on presentAssistantMessage`) cline.presentAssistantMessageLocked = true cline.presentAssistantMessageHasPendingUpdates = false if (cline.currentStreamingContentIndex >= cline.assistantMessageContent.length) { + console.log( + `[NATIVE_TOOL] Index ${cline.currentStreamingContentIndex} >= length ${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) { + console.log(`[NATIVE_TOOL] Stream is complete, setting userMessageContentReady=true`) cline.userMessageContentReady = true } + console.log(`[NATIVE_TOOL] Releasing lock and returning (out of bounds)`) cline.presentAssistantMessageLocked = false return } const 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 + console.log( + `[NATIVE_TOOL] Processing block at index ${cline.currentStreamingContentIndex}:`, + JSON.stringify( + { type: block.type, name: block.type === "tool_use" ? block.name : undefined, partial: block.partial }, + null, + 2, + ), + ) switch (block.type) { case "text": { @@ -281,6 +304,8 @@ export async function presentAssistantMessage(cline: Task) { progressStatus?: ToolProgressStatus, isProtected?: boolean, ) => { + console.log(`[NATIVE_TOOL] askApproval called with type: ${type}`) + console.log(`[NATIVE_TOOL] Calling cline.ask()...`) const { response, text, images } = await cline.ask( type, partialMessage, @@ -288,8 +313,10 @@ export async function presentAssistantMessage(cline: Task) { progressStatus, isProtected || false, ) + console.log(`[NATIVE_TOOL] cline.ask() returned response: ${response}`) if (response !== "yesButtonClicked") { + console.log(`[NATIVE_TOOL] Tool was denied or user provided feedback`) // Handle both messageResponse and noButtonClicked with text. if (text) { await cline.say("user_feedback", text, images) @@ -301,6 +328,7 @@ export async function presentAssistantMessage(cline: Task) { return false } + console.log(`[NATIVE_TOOL] Tool was approved`) // Handle yesButtonClicked with text. if (text) { await cline.say("user_feedback", text, images) @@ -466,9 +494,12 @@ export async function presentAssistantMessage(cline: Task) { await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "read_file": + console.log(`[NATIVE_TOOL] Processing read_file tool use in presentAssistantMessage`) + console.log(`[NATIVE_TOOL] Block details:`, JSON.stringify(block, null, 2)) // Check if this model should use the simplified single-file read tool const modelId = cline.api.getModel().id if (shouldUseSingleFileRead(modelId)) { + console.log(`[NATIVE_TOOL] Using simpleReadFileTool for model ${modelId}`) await simpleReadFileTool( cline, block, @@ -478,6 +509,8 @@ export async function presentAssistantMessage(cline: Task) { removeClosingTag, ) } else { + console.log(`[NATIVE_TOOL] Using readFileTool.handle for model ${modelId}`) + console.log(`[NATIVE_TOOL] Calling readFileTool.handle...`) // Type assertion is safe here because we're in the "read_file" case await readFileTool.handle(cline, block as ToolUse<"read_file">, { askApproval, @@ -485,6 +518,7 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, removeClosingTag, }) + console.log(`[NATIVE_TOOL] readFileTool.handle completed`) } break case "fetch_instructions": @@ -576,6 +610,7 @@ export async function presentAssistantMessage(cline: Task) { // This needs to be placed here, if not then calling // cline.presentAssistantMessage below would fail (sometimes) since it's // locked. + console.log(`[NATIVE_TOOL] Releasing lock on presentAssistantMessage`) cline.presentAssistantMessageLocked = false // NOTE: When tool is rejected, iterator stream is interrupted and it waits @@ -584,8 +619,15 @@ export async function presentAssistantMessage(cline: Task) { // 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) { + console.log( + `[NATIVE_TOOL] Block processing complete (partial=${block.partial}, didRejectTool=${cline.didRejectTool}, didAlreadyUseTool=${cline.didAlreadyUseTool})`, + ) + console.log( + `[NATIVE_TOOL] currentStreamingContentIndex: ${cline.currentStreamingContentIndex}, assistantMessageContent.length: ${cline.assistantMessageContent.length}`, + ) // Block is finished streaming and executing. if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) { + console.log(`[NATIVE_TOOL] Last block (index === length - 1), setting userMessageContentReady=true`) // 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 @@ -594,25 +636,53 @@ export async function presentAssistantMessage(cline: Task) { // 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. + } else { + console.log( + `[NATIVE_TOOL] Not on last block yet (index ${cline.currentStreamingContentIndex} !== length - 1 ${cline.assistantMessageContent.length - 1})`, + ) } // 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. + console.log( + `[NATIVE_TOOL] Incrementing currentStreamingContentIndex from ${cline.currentStreamingContentIndex} to ${cline.currentStreamingContentIndex + 1}`, + ) cline.currentStreamingContentIndex++ + console.log( + `[NATIVE_TOOL] After increment: index = ${cline.currentStreamingContentIndex}, length = ${cline.assistantMessageContent.length}`, + ) if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { + console.log(`[NATIVE_TOOL] More blocks to process, calling presentAssistantMessage recursively`) // There are already more content blocks to stream, so we'll call // this function ourselves. presentAssistantMessage(cline) return + } else { + console.log( + `[NATIVE_TOOL] No more blocks to process (index ${cline.currentStreamingContentIndex} >= length ${cline.assistantMessageContent.length})`, + ) + console.log(`[NATIVE_TOOL] didCompleteReadingStream: ${cline.didCompleteReadingStream}`) + // 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) { + console.log(`[NATIVE_TOOL] Stream is complete and no more blocks, setting userMessageContentReady=true`) + cline.userMessageContentReady = true + } else { + console.log(`[NATIVE_TOOL] Stream not complete yet, waiting for more blocks`) + } } } // Block is partial, but the read stream may have finished. if (cline.presentAssistantMessageHasPendingUpdates) { + console.log(`[NATIVE_TOOL] Has pending updates, calling presentAssistantMessage recursively`) presentAssistantMessage(cline) + } else { + console.log(`[NATIVE_TOOL] No pending updates, exiting presentAssistantMessage`) + console.log(`[NATIVE_TOOL] Final state: userMessageContentReady=${cline.userMessageContentReady}`) } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 632610bf851..66e22ba5b93 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -33,6 +33,7 @@ import { isIdleAsk, isInteractiveAsk, isResumableAsk, + isNonBlockingAsk, QueuedMessage, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -748,6 +749,9 @@ export class Task extends EventEmitter implements TaskLike { progressStatus?: ToolProgressStatus, isProtected?: boolean, ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { + console.log(`[NATIVE_TOOL] Task.ask() called with type: ${type}, partial: ${partial}`) + console.log(`[NATIVE_TOOL] Text preview:`, text?.substring(0, 100)) + // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, // in which case we don't want to send its result to the webview as it @@ -757,6 +761,7 @@ export class Task extends EventEmitter implements TaskLike { // simply removes the reference to this instance, but the instance is // still alive until this promise resolves or rejects.) if (this.abort) { + console.log(`[NATIVE_TOOL] Task.ask() aborted`) throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } @@ -832,6 +837,7 @@ export class Task extends EventEmitter implements TaskLike { } } } else { + console.log(`[NATIVE_TOOL] Creating new non-partial ask message`) // This is a new non-partial message, so add it like normal. this.askResponse = undefined this.askResponseText = undefined @@ -839,6 +845,7 @@ export class Task extends EventEmitter implements TaskLike { askTs = Date.now() console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs + console.log(`[NATIVE_TOOL] Adding ask message to clineMessages with ts: ${askTs}`) await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } @@ -936,8 +943,20 @@ export class Task extends EventEmitter implements TaskLike { } } - // Wait for askResponse to be set. + // Non-blocking asks return immediately without waiting + // The ask message is created in the UI, but the task doesn't wait for a response + // This prevents blocking in cloud/headless environments + if (isNonBlockingAsk(type)) { + console.log(`[NATIVE_TOOL] Non-blocking ask type, returning immediately`) + return { response: "yesButtonClicked" as ClineAskResponse, text: undefined, images: undefined } + } + + console.log(`[NATIVE_TOOL] Waiting for askResponse to be set...`) + // Wait for askResponse to be set await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + console.log( + `[NATIVE_TOOL] Wait completed. askResponse: ${this.askResponse}, lastMessageTs changed: ${this.lastMessageTs !== askTs}`, + ) if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with @@ -2474,18 +2493,28 @@ export class Task extends EventEmitter implements TaskLike { // this.userMessageContentReady = true // } + console.log(`[NATIVE_TOOL] Waiting for userMessageContentReady...`) await pWaitFor(() => this.userMessageContentReady) + console.log(`[NATIVE_TOOL] userMessageContentReady is now true!`) + console.log(`[NATIVE_TOOL] userMessageContent length: ${this.userMessageContent.length}`) // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. const didToolUse = this.assistantMessageContent.some((block) => block.type === "tool_use") + console.log(`[NATIVE_TOOL] didToolUse: ${didToolUse}`) if (!didToolUse) { + console.log(`[NATIVE_TOOL] No tool use detected, adding noToolsUsed message`) this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed() }) this.consecutiveMistakeCount++ } if (this.userMessageContent.length > 0) { + console.log(`[NATIVE_TOOL] Pushing userMessageContent back onto stack for next API request`) + console.log( + `[NATIVE_TOOL] userMessageContent:`, + JSON.stringify(this.userMessageContent.slice(0, 2), null, 2), + ) stack.push({ userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues includeFileDetails: false, // Subsequent iterations don't need file details @@ -2493,7 +2522,10 @@ export class Task extends EventEmitter implements TaskLike { // Add periodic yielding to prevent blocking await new Promise((resolve) => setImmediate(resolve)) + } else { + console.log(`[NATIVE_TOOL] userMessageContent is empty, not pushing to stack`) } + console.log(`[NATIVE_TOOL] Continuing to next iteration...`) // Continue to next iteration instead of setting didEndLoop from recursive call continue } else { diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 314311b56ee..7d44e4881f0 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -97,8 +97,19 @@ export abstract class BaseTool { * @param callbacks - Tool execution callbacks */ async handle(cline: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { + console.log(`[NATIVE_TOOL] BaseTool.handle called for tool: ${this.name}`) + console.log( + `[NATIVE_TOOL] Block:`, + JSON.stringify( + { name: block.name, partial: block.partial, hasNativeArgs: block.nativeArgs !== undefined }, + null, + 2, + ), + ) + // Handle partial messages if (block.partial) { + console.log(`[NATIVE_TOOL] Block is partial, calling handlePartial`) await this.handlePartial(cline, block) return } @@ -107,21 +118,27 @@ export abstract class BaseTool { let params: ToolParams try { if (block.nativeArgs !== undefined) { + console.log(`[NATIVE_TOOL] Using native args:`, JSON.stringify(block.nativeArgs, null, 2)) // Native protocol: typed args provided by NativeToolCallParser // TypeScript knows nativeArgs is properly typed based on TName params = block.nativeArgs as ToolParams } else { + console.log(`[NATIVE_TOOL] Using legacy params parsing`) // XML/legacy protocol: parse string params into typed params params = this.parseLegacy(block.params) } } catch (error) { + console.error(`[NATIVE_TOOL] Error parsing parameters:`, error) const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}` await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage)) callbacks.pushToolResult(`${errorMessage}`) return } + console.log(`[NATIVE_TOOL] Parsed params:`, JSON.stringify(params, null, 2)) + console.log(`[NATIVE_TOOL] Calling execute()`) // Execute with typed parameters await this.execute(params, cline, callbacks) + console.log(`[NATIVE_TOOL] Execute completed`) } } diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 3e7e5008f67..258415c2bd6 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -114,6 +114,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } async execute(fileEntries: FileEntry[], cline: Task, callbacks: ToolCallbacks): Promise { + console.log(`[NATIVE_TOOL] ReadFileTool.execute() called for task ${cline.taskId}`) + console.log(`[NATIVE_TOOL] File entries:`, JSON.stringify(fileEntries, null, 2)) + const { handleError, pushToolResult } = callbacks if (fileEntries.length === 0) { From 8c316e1b4f2956f726642885228f19d048a601cf Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 10:46:23 -0500 Subject: [PATCH 07/48] feat: migrate attempt completion --- .../assistant-message/NativeToolCallParser.ts | 38 ++-- .../presentAssistantMessage.ts | 16 +- .../__tests__/attemptCompletionTool.spec.ts | 173 +++++++++-------- src/core/tools/attemptCompletionTool.ts | 174 ++++++++++-------- src/shared/tools.ts | 1 + 5 files changed, 215 insertions(+), 187 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index a707e454daa..527b6f477c3 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -75,21 +75,33 @@ export class NativeToolCallParser { // Build typed nativeArgs for tools that support it let nativeArgs: (TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never) | undefined = undefined - if (toolCall.name === "read_file") { - // Handle both single-file and multi-file formats - if (args.files && Array.isArray(args.files)) { - // Multi-file format: {"files": [{path: "...", line_ranges: [...]}, ...]} - nativeArgs = args.files as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never - } else if (args.path) { - // Single-file format: {"path": "..."} - convert to array format - const fileEntry: FileEntry = { - path: args.path, - lineRanges: [], + switch (toolCall.name) { + case "read_file": + // Handle both single-file and multi-file formats + if (args.files && Array.isArray(args.files)) { + // Multi-file format: {"files": [{path: "...", line_ranges: [...]}, ...]} + nativeArgs = args.files as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } else if (args.path) { + // Single-file format: {"path": "..."} - convert to array format + const fileEntry: FileEntry = { + path: args.path, + lineRanges: [], + } + nativeArgs = [fileEntry] as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never } - nativeArgs = [fileEntry] as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never - } + break + + case "attempt_completion": + if (args.result) { + nativeArgs = { result: args.result } as TName extends keyof NativeToolArgs + ? NativeToolArgs[TName] + : never + } + break + + default: + break } - // Add more tools here as they are migrated to native protocol const result: ToolUse = { type: "tool_use" as const, diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 211d91f70a5..259c36948fa 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -23,7 +23,7 @@ import { useMcpToolTool } from "../tools/useMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool" import { switchModeTool } from "../tools/switchModeTool" -import { attemptCompletionTool } from "../tools/attemptCompletionTool" +import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool" import { newTaskTool } from "../tools/newTaskTool" import { updateTodoListTool } from "../tools/updateTodoListTool" @@ -578,18 +578,22 @@ export async function presentAssistantMessage(cline: Task) { case "new_task": await newTaskTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break - case "attempt_completion": - await attemptCompletionTool( - cline, - block, + case "attempt_completion": { + const completionCallbacks: AttemptCompletionCallbacks = { askApproval, handleError, pushToolResult, removeClosingTag, - toolDescription, askFinishSubTaskApproval, + toolDescription, + } + await attemptCompletionTool.handle( + cline, + block as ToolUse<"attempt_completion">, + completionCallbacks, ) break + } case "run_slash_command": await runSlashCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index fcad4d5f492..72b4560c4a9 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -25,7 +25,7 @@ vi.mock("../../../shared/package", () => ({ }, })) -import { attemptCompletionTool } from "../attemptCompletionTool" +import { attemptCompletionTool, AttemptCompletionCallbacks } from "../AttemptCompletionTool" import { Task } from "../../task/Task" import * as vscode from "vscode" @@ -76,16 +76,15 @@ describe("attemptCompletionTool", () => { mockTask.todoList = undefined - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should not call pushToolResult with an error for empty todo list expect(mockTask.consecutiveMistakeCount).toBe(0) @@ -102,16 +101,15 @@ describe("attemptCompletionTool", () => { mockTask.todoList = [] - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() @@ -132,16 +130,15 @@ describe("attemptCompletionTool", () => { mockTask.todoList = completedTodos - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() @@ -172,16 +169,15 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("attempt_completion") @@ -215,16 +211,15 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("attempt_completion") @@ -259,16 +254,15 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("attempt_completion") @@ -302,16 +296,15 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should not prevent completion when setting is disabled expect(mockTask.consecutiveMistakeCount).toBe(0) @@ -346,16 +339,15 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should prevent completion when setting is enabled and there are incomplete todos expect(mockTask.consecutiveMistakeCount).toBe(1) @@ -390,16 +382,15 @@ describe("attemptCompletionTool", () => { }), }) - await attemptCompletionTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - mockToolDescription, - mockAskFinishSubTaskApproval, - ) + const callbacks: AttemptCompletionCallbacks = { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + askFinishSubTaskApproval: mockAskFinishSubTaskApproval, + toolDescription: mockToolDescription, + } + await attemptCompletionTool.handle(mockTask as Task, block, callbacks) // Should allow completion when setting is enabled but all todos are completed expect(mockTask.consecutiveMistakeCount).toBe(0) diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index 5074d7f4e80..87ef8375492 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -5,81 +5,55 @@ import { RooCodeEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" -import { - ToolResponse, - ToolUse, - AskApproval, - HandleError, - PushToolResult, - RemoveClosingTag, - ToolDescription, - AskFinishSubTaskApproval, -} from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { Package } from "../../shared/package" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function attemptCompletionTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, - toolDescription: ToolDescription, - askFinishSubTaskApproval: AskFinishSubTaskApproval, -) { - const result: string | undefined = block.params.result - const command: string | undefined = block.params.command - - // Get the setting for preventing completion with open todos from VSCode configuration - const preventCompletionWithOpenTodos = vscode.workspace - .getConfiguration(Package.name) - .get("preventCompletionWithOpenTodos", false) - - // Check if there are incomplete todos (only if the setting is enabled) - const hasIncompleteTodos = cline.todoList && cline.todoList.some((todo) => todo.status !== "completed") - - if (preventCompletionWithOpenTodos && hasIncompleteTodos) { - cline.consecutiveMistakeCount++ - cline.recordToolError("attempt_completion") - - pushToolResult( - formatResponse.toolError( - "Cannot complete task while there are incomplete todos. Please finish all todos before attempting completion.", - ), - ) +interface AttemptCompletionParams { + result: string + command?: string +} + +export interface AttemptCompletionCallbacks extends ToolCallbacks { + askFinishSubTaskApproval: () => Promise + toolDescription: () => string +} - return +export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { + readonly name = "attempt_completion" as const + + parseLegacy(params: Partial>): AttemptCompletionParams { + return { + result: params.result || "", + command: params.command, + } } - try { - const lastMessage = cline.clineMessages.at(-1) + async execute(params: AttemptCompletionParams, cline: Task, callbacks: AttemptCompletionCallbacks): Promise { + const { result } = params + const { handleError, pushToolResult, askFinishSubTaskApproval, toolDescription } = callbacks - if (block.partial) { - if (command) { - // the attempt_completion text is done, now we're getting command - // remove the previous partial attempt_completion ask, replace with say, post state to webview, then stream command + const preventCompletionWithOpenTodos = vscode.workspace + .getConfiguration(Package.name) + .get("preventCompletionWithOpenTodos", false) - // const secondLastMessage = cline.clineMessages.at(-2) - if (lastMessage && lastMessage.ask === "command") { - // update command - await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) - } else { - // last message is completion_result - // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) - await cline.say("completion_result", removeClosingTag("result", result), undefined, false) + const hasIncompleteTodos = cline.todoList && cline.todoList.some((todo) => todo.status !== "completed") - TelemetryService.instance.captureTaskCompleted(cline.taskId) - cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + if (preventCompletionWithOpenTodos && hasIncompleteTodos) { + cline.consecutiveMistakeCount++ + cline.recordToolError("attempt_completion") + + pushToolResult( + formatResponse.toolError( + "Cannot complete task while there are incomplete todos. Please finish all todos before attempting completion.", + ), + ) - await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) - } - } else { - // No command, still outputting partial result - await cline.say("completion_result", removeClosingTag("result", result), undefined, block.partial) - } return - } else { + } + + try { if (!result) { cline.consecutiveMistakeCount++ cline.recordToolError("attempt_completion") @@ -89,8 +63,6 @@ export async function attemptCompletionTool( cline.consecutiveMistakeCount = 0 - // Command execution is permanently disabled in attempt_completion - // Users must use execute_command tool separately before attempt_completion await cline.say("completion_result", result, undefined, false) TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) @@ -102,19 +74,12 @@ export async function attemptCompletionTool( return } - // tell the provider to remove the current subtask and resume the previous task in the stack await cline.providerRef.deref()?.finishSubTask(result) return } - // We already sent completion_result says, an - // empty string asks relinquishes control over - // button and field. const { response, text, images } = await cline.ask("completion_result", "", false) - // Signals to recursive loop to stop (for now - // cline never happens since yesButtonClicked - // will trigger a new task). if (response === "yesButtonClicked") { pushToolResult("") return @@ -131,11 +96,66 @@ export async function attemptCompletionTool( toolResults.push(...formatResponse.imageBlocks(images)) cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) cline.userMessageContent.push(...toolResults) + } catch (error) { + await handleError("inspecting site", error as Error) + } + } - return + override async handlePartial(cline: Task, block: ToolUse<"attempt_completion">): Promise { + const result: string | undefined = block.params.result + const command: string | undefined = block.params.command + + const lastMessage = cline.clineMessages.at(-1) + + if (command) { + if (lastMessage && lastMessage.ask === "command") { + await cline + .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) + .catch(() => {}) + } else { + await cline.say( + "completion_result", + this.removeClosingTag("result", result, block.partial), + undefined, + false, + ) + + TelemetryService.instance.captureTaskCompleted(cline.taskId) + cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + + await cline + .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) + .catch(() => {}) + } + } else { + await cline.say( + "completion_result", + this.removeClosingTag("result", result, block.partial), + undefined, + block.partial, + ) + } + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } + + if (!text) { + return "" } - } catch (error) { - await handleError("inspecting site", error) - return + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const attemptCompletionTool = new AttemptCompletionTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 80d2845b8a3..9cb20be399b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -76,6 +76,7 @@ export type ToolProtocol = "xml" | "native" */ export type NativeToolArgs = { read_file: FileEntry[] + attempt_completion: { result: string } // Add more tools as they are migrated to native protocol } From 20d932695ff914ac6194e9fffe80bcbd8187fea5 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 11:01:07 -0500 Subject: [PATCH 08/48] fix: revert CURRENT_TOOL_PROTOCOL to XML for compatibility --- src/core/prompts/toolProtocolResolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/prompts/toolProtocolResolver.ts b/src/core/prompts/toolProtocolResolver.ts index d256e007e8c..1cd87f7251e 100644 --- a/src/core/prompts/toolProtocolResolver.ts +++ b/src/core/prompts/toolProtocolResolver.ts @@ -6,7 +6,7 @@ import type { SystemPromptSettings } from "./types" * This is code-only and not exposed through VS Code settings. * To switch protocols, edit this constant directly in the source code. */ -const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.NATIVE // change to TOOL_PROTOCOL.NATIVE to enable native protocol +const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.XML // change to TOOL_PROTOCOL.NATIVE to enable native protocol /** * Resolves the effective tool protocol. From 14e7936c7f4521fdb943b3dd716af8e260677353 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 11:39:26 -0500 Subject: [PATCH 09/48] feat: migrate list-files tool to support the native protocol --- .../presentAssistantMessage.ts | 7 +- src/core/tools/listFilesTool.ts | 122 +++++++++++------- src/core/tools/readFileTool.ts | 2 - 3 files changed, 82 insertions(+), 49 deletions(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 259c36948fa..5c1787158b0 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -525,7 +525,12 @@ export async function presentAssistantMessage(cline: Task) { await fetchInstructionsTool(cline, block, askApproval, handleError, pushToolResult) break case "list_files": - await listFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await listFilesTool.handle(cline, block as ToolUse<"list_files">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "codebase_search": await codebaseSearchTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) diff --git a/src/core/tools/listFilesTool.ts b/src/core/tools/listFilesTool.ts index e51453c5d9e..e7f94cd9b2a 100644 --- a/src/core/tools/listFilesTool.ts +++ b/src/core/tools/listFilesTool.ts @@ -6,51 +6,32 @@ import { formatResponse } from "../prompts/responses" import { listFiles } from "../../services/glob/list-files" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" - -/** - * Implements the list_files tool. - * - * @param cline - The instance of Cline that is executing this tool. - * @param block - The block of assistant message content that specifies the - * parameters for this tool. - * @param askApproval - A function that asks the user for approval to show a - * message. - * @param handleError - A function that handles an error that occurred while - * executing this tool. - * @param pushToolResult - A function that pushes the result of this tool to the - * conversation. - * @param removeClosingTag - A function that removes a closing tag from a string. - */ - -export async function listFilesTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relDirPath: string | undefined = block.params.path - const recursiveRaw: string | undefined = block.params.recursive - const recursive = recursiveRaw?.toLowerCase() === "true" - - // Calculate if the path is outside workspace - const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - - const sharedMessageProps: ClineSayTool = { - tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", - path: getReadablePath(cline.cwd, removeClosingTag("path", relDirPath)), - isOutsideWorkspace, +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface ListFilesParams { + path: string + recursive?: boolean +} + +export class ListFilesTool extends BaseTool<"list_files"> { + readonly name = "list_files" as const + + parseLegacy(params: Partial>): ListFilesParams { + const recursiveRaw: string | undefined = params.recursive + const recursive = recursiveRaw?.toLowerCase() === "true" + + return { + path: params.path || "", + recursive, + } } - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { + async execute(params: ListFilesParams, cline: Task, callbacks: ToolCallbacks): Promise { + const { path: relDirPath, recursive } = params + const { askApproval, handleError, pushToolResult, removeClosingTag } = callbacks + + try { if (!relDirPath) { cline.consecutiveMistakeCount++ cline.recordToolError("list_files") @@ -60,7 +41,10 @@ export async function listFilesTool( cline.consecutiveMistakeCount = 0 - const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200) + const absolutePath = path.resolve(cline.cwd, relDirPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const [files, didHitLimit] = await listFiles(absolutePath, recursive || false, 200) const { showRooIgnoredFiles = false } = (await cline.providerRef.deref()?.getState()) ?? {} const result = formatResponse.formatFilesList( @@ -72,6 +56,12 @@ export async function listFilesTool( cline.rooProtectedController, ) + const sharedMessageProps: ClineSayTool = { + tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", + path: getReadablePath(cline.cwd, relDirPath), + isOutsideWorkspace, + } + const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage) @@ -80,8 +70,48 @@ export async function listFilesTool( } pushToolResult(result) + } catch (error) { + await handleError("listing files", error) + } + } + + override async handlePartial(cline: Task, block: ToolUse<"list_files">): Promise { + const relDirPath: string | undefined = block.params.path + const recursiveRaw: string | undefined = block.params.recursive + const recursive = recursiveRaw?.toLowerCase() === "true" + + const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", + path: getReadablePath(cline.cwd, this.removeClosingTag("path", relDirPath, block.partial)), + isOutsideWorkspace, + } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } + + if (!text) { + return "" } - } catch (error) { - await handleError("listing files", error) + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const listFilesTool = new ListFilesTool() diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 258415c2bd6..3d703f4c739 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -60,8 +60,6 @@ export class ReadFileTool extends BaseTool<"read_file"> { const fileEntries: FileEntry[] = [] - // Note: Native protocol is handled by passing typed args directly to execute() via BaseTool.handle() - // XML args format if (argsXmlTag) { const parsed = parseXml(argsXmlTag) as any From 9351602832cd6bcc10ae92284655516b716d92b9 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 11:50:00 -0500 Subject: [PATCH 10/48] fix: update import path for listFilesTool to match naming convention --- src/core/assistant-message/presentAssistantMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 5c1787158b0..4fabb0a80e1 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -8,7 +8,7 @@ import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import type { ToolParamName, ToolResponse, ToolUse } from "../../shared/tools" import { fetchInstructionsTool } from "../tools/fetchInstructionsTool" -import { listFilesTool } from "../tools/listFilesTool" +import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool" import { shouldUseSingleFileRead } from "@roo-code/types" From adb6dabe7f79adbe9e4685feb6d8834520b57c47 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 12:14:49 -0500 Subject: [PATCH 11/48] feat: migrate new-task tool to handle native protocol --- .../presentAssistantMessage.ts | 9 +- .../tools/{newTaskTool.ts => NewTaskTool.ts} | 124 ++++++---- src/core/tools/__tests__/newTaskTool.spec.ts | 214 ++++++++---------- 3 files changed, 179 insertions(+), 168 deletions(-) rename src/core/tools/{newTaskTool.ts => NewTaskTool.ts} (50%) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 4fabb0a80e1..ec77b525270 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -24,7 +24,7 @@ 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 { newTaskTool } from "../tools/NewTaskTool" import { updateTodoListTool } from "../tools/updateTodoListTool" import { runSlashCommandTool } from "../tools/runSlashCommandTool" @@ -581,7 +581,12 @@ export async function presentAssistantMessage(cline: Task) { await switchModeTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "new_task": - await newTaskTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await newTaskTool.handle(cline, block as ToolUse<"new_task">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "attempt_completion": { const completionCallbacks: AttemptCompletionCallbacks = { diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/NewTaskTool.ts similarity index 50% rename from src/core/tools/newTaskTool.ts rename to src/core/tools/NewTaskTool.ts index aeb0c8393b4..ccf72862823 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -2,55 +2,54 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { Task } from "../task/Task" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import { parseMarkdownChecklist } from "./updateTodoListTool" import { Package } from "../../shared/package" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function newTaskTool( - task: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const mode: string | undefined = block.params.mode - const message: string | undefined = block.params.message - const todos: string | undefined = block.params.todos - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "newTask", - mode: removeClosingTag("mode", mode), - content: removeClosingTag("message", message), - todos: removeClosingTag("todos", todos), - }) +interface NewTaskParams { + mode: string + message: string + todos?: string +} - await task.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { +export class NewTaskTool extends BaseTool<"new_task"> { + readonly name = "new_task" as const + + parseLegacy(params: Partial>): NewTaskParams { + return { + mode: params.mode || "", + message: params.message || "", + todos: params.todos, + } + } + + async execute(params: NewTaskParams, cline: Task, callbacks: ToolCallbacks): Promise { + const { mode, message, todos } = params + const { askApproval, handleError, pushToolResult } = callbacks + + try { // Validate required parameters. if (!mode) { - task.consecutiveMistakeCount++ - task.recordToolError("new_task") - pushToolResult(await task.sayAndCreateMissingParamError("new_task", "mode")) + cline.consecutiveMistakeCount++ + cline.recordToolError("new_task") + pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "mode")) return } if (!message) { - task.consecutiveMistakeCount++ - task.recordToolError("new_task") - pushToolResult(await task.sayAndCreateMissingParamError("new_task", "message")) + cline.consecutiveMistakeCount++ + cline.recordToolError("new_task") + pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "message")) return } // Get the VSCode setting for requiring todos. - const provider = task.providerRef.deref() + const provider = cline.providerRef.deref() if (!provider) { pushToolResult(formatResponse.toolError("Provider reference lost")) @@ -68,9 +67,9 @@ export async function newTaskTool( // Check if todos are required based on VSCode setting. // Note: `undefined` means not provided, empty string is valid. if (requireTodos && todos === undefined) { - task.consecutiveMistakeCount++ - task.recordToolError("new_task") - pushToolResult(await task.sayAndCreateMissingParamError("new_task", "todos")) + cline.consecutiveMistakeCount++ + cline.recordToolError("new_task") + pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "todos")) return } @@ -80,14 +79,14 @@ export async function newTaskTool( try { todoItems = parseMarkdownChecklist(todos) } catch (error) { - task.consecutiveMistakeCount++ - task.recordToolError("new_task") + cline.consecutiveMistakeCount++ + cline.recordToolError("new_task") pushToolResult(formatResponse.toolError("Invalid todos format: must be a markdown checklist")) return } } - task.consecutiveMistakeCount = 0 + cline.consecutiveMistakeCount = 0 // Un-escape one level of backslashes before '@' for hierarchical subtasks // Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks) @@ -116,14 +115,14 @@ export async function newTaskTool( // Provider is guaranteed to be defined here due to earlier check. - if (task.enableCheckpoints) { - task.checkpointSave(true) + if (cline.enableCheckpoints) { + cline.checkpointSave(true) } // Preserve the current mode so we can resume with it later. - task.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug + cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug - const newTask = await task.startSubtask(unescapedMessage, todoItems, mode) + const newTask = await cline.startSubtask(unescapedMessage, todoItems, mode) if (!newTask) { pushToolResult(t("tools:newTask.errors.policy_restriction")) @@ -135,9 +134,46 @@ export async function newTaskTool( ) return + } catch (error) { + await handleError("creating new task", error) + return + } + } + + override async handlePartial(cline: Task, block: ToolUse<"new_task">): Promise { + const mode: string | undefined = block.params.mode + const message: string | undefined = block.params.message + const todos: string | undefined = block.params.todos + + const partialMessage = JSON.stringify({ + tool: "newTask", + mode: this.removeClosingTag("mode", mode, block.partial), + content: this.removeClosingTag("message", message, block.partial), + todos: this.removeClosingTag("todos", todos, block.partial), + }) + + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" } - } catch (error) { - await handleError("creating new task", error) - return + + if (!text) { + return "" + } + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const newTaskTool = new NewTaskTool() diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index a95efcd94f2..d86e5453d8f 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -97,8 +97,8 @@ const mockCline = { }, } -// Import the function to test AFTER mocks are set up -import { newTaskTool } from "../newTaskTool" +// Import the class to test AFTER mocks are set up +import { newTaskTool } from "../NewTaskTool" import type { ToolUse } from "../../../shared/tools" import { getModeBySlug } from "../../../shared/modes" import * as vscode from "vscode" @@ -135,14 +135,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, // Use 'as any' for simplicity in mocking complex type - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Verify askApproval was called expect(mockAskApproval).toHaveBeenCalled() @@ -173,14 +171,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockStartSubtask).toHaveBeenCalledWith( "This is already unescaped: \\@file1.txt", // Expected: \@ remains \@ @@ -201,14 +197,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockStartSubtask).toHaveBeenCalledWith( "A normal mention @file1.txt", // Expected: @ remains @ @@ -229,14 +223,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, // Now correctly typed - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockStartSubtask).toHaveBeenCalledWith( "Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@ @@ -257,14 +249,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should NOT error when todos is missing expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -290,14 +280,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should parse and include todos when provided expect(mockStartSubtask).toHaveBeenCalledWith( @@ -324,14 +312,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "mode") expect(mockCline.consecutiveMistakeCount).toBe(1) @@ -350,14 +336,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "message") expect(mockCline.consecutiveMistakeCount).toBe(1) @@ -376,14 +360,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockStartSubtask).toHaveBeenCalledWith( "Test message", @@ -415,14 +397,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should NOT error when todos is missing and setting is disabled expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -454,14 +434,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should error when todos is missing and setting is enabled expect(mockSayAndCreateMissingParamError).toHaveBeenCalledWith("new_task", "todos") @@ -493,14 +471,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should NOT error when todos is provided and setting is enabled expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -538,14 +514,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should NOT error when todos is empty string and setting is enabled expect(mockSayAndCreateMissingParamError).not.toHaveBeenCalledWith("new_task", "todos") @@ -575,14 +549,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Verify that VSCode configuration was accessed with Package.name expect(mockGetConfiguration).toHaveBeenCalledWith("roo-cline") @@ -611,14 +583,12 @@ describe("newTaskTool", () => { partial: false, } - await newTaskTool( - mockCline as any, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await newTaskTool.handle(mockCline as any, block as ToolUse<"new_task">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Assert: configuration was read using the dynamic nightly namespace expect(mockGetConfiguration).toHaveBeenCalledWith("roo-code-nightly") From ad87a9bb7388719b304d96e51617aff4f8a83a69 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 12:38:41 -0500 Subject: [PATCH 12/48] refactor: rename tools --- ...letionTool.ts => AttemptCompletionTool.ts} | 50 +++++------ src/core/tools/BaseTool.ts | 16 ++-- .../{listFilesTool.ts => ListFilesTool.ts} | 28 +++--- src/core/tools/NewTaskTool.ts | 40 ++++----- .../{readFileTool.ts => ReadFileTool.ts} | 86 +++++++++---------- 5 files changed, 107 insertions(+), 113 deletions(-) rename src/core/tools/{attemptCompletionTool.ts => AttemptCompletionTool.ts} (68%) rename src/core/tools/{listFilesTool.ts => ListFilesTool.ts} (74%) rename src/core/tools/{readFileTool.ts => ReadFileTool.ts} (90%) diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts similarity index 68% rename from src/core/tools/attemptCompletionTool.ts rename to src/core/tools/AttemptCompletionTool.ts index 87ef8375492..bc7f86ea6b7 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -30,7 +30,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } } - async execute(params: AttemptCompletionParams, cline: Task, callbacks: AttemptCompletionCallbacks): Promise { + async execute(params: AttemptCompletionParams, task: Task, callbacks: AttemptCompletionCallbacks): Promise { const { result } = params const { handleError, pushToolResult, askFinishSubTaskApproval, toolDescription } = callbacks @@ -38,11 +38,11 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { .getConfiguration(Package.name) .get("preventCompletionWithOpenTodos", false) - const hasIncompleteTodos = cline.todoList && cline.todoList.some((todo) => todo.status !== "completed") + const hasIncompleteTodos = task.todoList && task.todoList.some((todo) => todo.status !== "completed") if (preventCompletionWithOpenTodos && hasIncompleteTodos) { - cline.consecutiveMistakeCount++ - cline.recordToolError("attempt_completion") + task.consecutiveMistakeCount++ + task.recordToolError("attempt_completion") pushToolResult( formatResponse.toolError( @@ -55,37 +55,37 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { try { if (!result) { - cline.consecutiveMistakeCount++ - cline.recordToolError("attempt_completion") - pushToolResult(await cline.sayAndCreateMissingParamError("attempt_completion", "result")) + task.consecutiveMistakeCount++ + task.recordToolError("attempt_completion") + pushToolResult(await task.sayAndCreateMissingParamError("attempt_completion", "result")) return } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 - await cline.say("completion_result", result, undefined, false) - TelemetryService.instance.captureTaskCompleted(cline.taskId) - cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + await task.say("completion_result", result, undefined, false) + TelemetryService.instance.captureTaskCompleted(task.taskId) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) - if (cline.parentTask) { + if (task.parentTask) { const didApprove = await askFinishSubTaskApproval() if (!didApprove) { return } - await cline.providerRef.deref()?.finishSubTask(result) + await task.providerRef.deref()?.finishSubTask(result) return } - const { response, text, images } = await cline.ask("completion_result", "", false) + const { response, text, images } = await task.ask("completion_result", "", false) if (response === "yesButtonClicked") { pushToolResult("") return } - await cline.say("user_feedback", text ?? "", images) + await task.say("user_feedback", text ?? "", images) const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] toolResults.push({ @@ -94,41 +94,41 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { }) toolResults.push(...formatResponse.imageBlocks(images)) - cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) - cline.userMessageContent.push(...toolResults) + task.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) + task.userMessageContent.push(...toolResults) } catch (error) { await handleError("inspecting site", error as Error) } } - override async handlePartial(cline: Task, block: ToolUse<"attempt_completion">): Promise { + override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise { const result: string | undefined = block.params.result const command: string | undefined = block.params.command - const lastMessage = cline.clineMessages.at(-1) + const lastMessage = task.clineMessages.at(-1) if (command) { if (lastMessage && lastMessage.ask === "command") { - await cline + await task .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) .catch(() => {}) } else { - await cline.say( + await task.say( "completion_result", this.removeClosingTag("result", result, block.partial), undefined, false, ) - TelemetryService.instance.captureTaskCompleted(cline.taskId) - cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + TelemetryService.instance.captureTaskCompleted(task.taskId) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) - await cline + await task .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) .catch(() => {}) } } else { - await cline.say( + await task.say( "completion_result", this.removeClosingTag("result", result, block.partial), undefined, diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 7d44e4881f0..43d57f79e29 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -65,10 +65,10 @@ export abstract class BaseTool { * the tool's operation. * * @param params - Typed parameters - * @param cline - Task instance with state and API access + * @param task - Task instance with state and API access * @param callbacks - Tool execution callbacks (approval, error handling, results) */ - abstract execute(params: ToolParams, cline: Task, callbacks: ToolCallbacks): Promise + abstract execute(params: ToolParams, task: Task, callbacks: ToolCallbacks): Promise /** * Handle partial (streaming) tool messages. @@ -76,10 +76,10 @@ export abstract class BaseTool { * Default implementation does nothing. Tools that support streaming * partial messages should override this. * - * @param cline - Task instance + * @param task - Task instance * @param block - Partial ToolUse block */ - async handlePartial(cline: Task, block: ToolUse): Promise { + async handlePartial(task: Task, block: ToolUse): Promise { // Default: no-op for partial messages // Tools can override to show streaming UI updates } @@ -92,11 +92,11 @@ export abstract class BaseTool { * 2. Parameter parsing (parseLegacy for XML, or use nativeArgs directly) * 3. Core execution (execute) * - * @param cline - Task instance + * @param task - Task instance * @param block - ToolUse block from assistant message * @param callbacks - Tool execution callbacks */ - async handle(cline: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { + async handle(task: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { console.log(`[NATIVE_TOOL] BaseTool.handle called for tool: ${this.name}`) console.log( `[NATIVE_TOOL] Block:`, @@ -110,7 +110,7 @@ export abstract class BaseTool { // Handle partial messages if (block.partial) { console.log(`[NATIVE_TOOL] Block is partial, calling handlePartial`) - await this.handlePartial(cline, block) + await this.handlePartial(task, block) return } @@ -138,7 +138,7 @@ export abstract class BaseTool { console.log(`[NATIVE_TOOL] Parsed params:`, JSON.stringify(params, null, 2)) console.log(`[NATIVE_TOOL] Calling execute()`) // Execute with typed parameters - await this.execute(params, cline, callbacks) + await this.execute(params, task, callbacks) console.log(`[NATIVE_TOOL] Execute completed`) } } diff --git a/src/core/tools/listFilesTool.ts b/src/core/tools/ListFilesTool.ts similarity index 74% rename from src/core/tools/listFilesTool.ts rename to src/core/tools/ListFilesTool.ts index e7f94cd9b2a..82d085a41b3 100644 --- a/src/core/tools/listFilesTool.ts +++ b/src/core/tools/ListFilesTool.ts @@ -27,38 +27,38 @@ export class ListFilesTool extends BaseTool<"list_files"> { } } - async execute(params: ListFilesParams, cline: Task, callbacks: ToolCallbacks): Promise { + async execute(params: ListFilesParams, task: Task, callbacks: ToolCallbacks): Promise { const { path: relDirPath, recursive } = params const { askApproval, handleError, pushToolResult, removeClosingTag } = callbacks try { if (!relDirPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("list_files") - pushToolResult(await cline.sayAndCreateMissingParamError("list_files", "path")) + task.consecutiveMistakeCount++ + task.recordToolError("list_files") + pushToolResult(await task.sayAndCreateMissingParamError("list_files", "path")) return } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 - const absolutePath = path.resolve(cline.cwd, relDirPath) + const absolutePath = path.resolve(task.cwd, relDirPath) const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const [files, didHitLimit] = await listFiles(absolutePath, recursive || false, 200) - const { showRooIgnoredFiles = false } = (await cline.providerRef.deref()?.getState()) ?? {} + const { showRooIgnoredFiles = false } = (await task.providerRef.deref()?.getState()) ?? {} const result = formatResponse.formatFilesList( absolutePath, files, didHitLimit, - cline.rooIgnoreController, + task.rooIgnoreController, showRooIgnoredFiles, - cline.rooProtectedController, + task.rooProtectedController, ) const sharedMessageProps: ClineSayTool = { tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", - path: getReadablePath(cline.cwd, relDirPath), + path: getReadablePath(task.cwd, relDirPath), isOutsideWorkspace, } @@ -75,22 +75,22 @@ export class ListFilesTool extends BaseTool<"list_files"> { } } - override async handlePartial(cline: Task, block: ToolUse<"list_files">): Promise { + override async handlePartial(task: Task, block: ToolUse<"list_files">): Promise { const relDirPath: string | undefined = block.params.path const recursiveRaw: string | undefined = block.params.recursive const recursive = recursiveRaw?.toLowerCase() === "true" - const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd + const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const sharedMessageProps: ClineSayTool = { tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", - path: getReadablePath(cline.cwd, this.removeClosingTag("path", relDirPath, block.partial)), + path: getReadablePath(task.cwd, this.removeClosingTag("path", relDirPath, block.partial)), isOutsideWorkspace, } const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) } private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index ccf72862823..2b13256aad2 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -28,28 +28,28 @@ export class NewTaskTool extends BaseTool<"new_task"> { } } - async execute(params: NewTaskParams, cline: Task, callbacks: ToolCallbacks): Promise { + async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { const { mode, message, todos } = params const { askApproval, handleError, pushToolResult } = callbacks try { // Validate required parameters. if (!mode) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") - pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "mode")) + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + pushToolResult(await task.sayAndCreateMissingParamError("new_task", "mode")) return } if (!message) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") - pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "message")) + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + pushToolResult(await task.sayAndCreateMissingParamError("new_task", "message")) return } // Get the VSCode setting for requiring todos. - const provider = cline.providerRef.deref() + const provider = task.providerRef.deref() if (!provider) { pushToolResult(formatResponse.toolError("Provider reference lost")) @@ -67,9 +67,9 @@ export class NewTaskTool extends BaseTool<"new_task"> { // Check if todos are required based on VSCode setting. // Note: `undefined` means not provided, empty string is valid. if (requireTodos && todos === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") - pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "todos")) + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + pushToolResult(await task.sayAndCreateMissingParamError("new_task", "todos")) return } @@ -79,14 +79,14 @@ export class NewTaskTool extends BaseTool<"new_task"> { try { todoItems = parseMarkdownChecklist(todos) } catch (error) { - cline.consecutiveMistakeCount++ - cline.recordToolError("new_task") + task.consecutiveMistakeCount++ + task.recordToolError("new_task") pushToolResult(formatResponse.toolError("Invalid todos format: must be a markdown checklist")) return } } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 // Un-escape one level of backslashes before '@' for hierarchical subtasks // Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks) @@ -115,14 +115,14 @@ export class NewTaskTool extends BaseTool<"new_task"> { // Provider is guaranteed to be defined here due to earlier check. - if (cline.enableCheckpoints) { - cline.checkpointSave(true) + if (task.enableCheckpoints) { + task.checkpointSave(true) } // Preserve the current mode so we can resume with it later. - cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug + task.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug - const newTask = await cline.startSubtask(unescapedMessage, todoItems, mode) + const newTask = await task.startSubtask(unescapedMessage, todoItems, mode) if (!newTask) { pushToolResult(t("tools:newTask.errors.policy_restriction")) @@ -140,7 +140,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { } } - override async handlePartial(cline: Task, block: ToolUse<"new_task">): Promise { + override async handlePartial(task: Task, block: ToolUse<"new_task">): Promise { const mode: string | undefined = block.params.mode const message: string | undefined = block.params.message const todos: string | undefined = block.params.todos @@ -152,7 +152,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { todos: this.removeClosingTag("todos", todos, block.partial), }) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) } private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/ReadFileTool.ts similarity index 90% rename from src/core/tools/readFileTool.ts rename to src/core/tools/ReadFileTool.ts index 3d703f4c739..4305e9b0e28 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -111,24 +111,21 @@ export class ReadFileTool extends BaseTool<"read_file"> { return fileEntries } - async execute(fileEntries: FileEntry[], cline: Task, callbacks: ToolCallbacks): Promise { - console.log(`[NATIVE_TOOL] ReadFileTool.execute() called for task ${cline.taskId}`) + async execute(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise { + console.log(`[NATIVE_TOOL] ReadFileTool.execute() called for task ${task.taskId}`) console.log(`[NATIVE_TOOL] File entries:`, JSON.stringify(fileEntries, null, 2)) const { handleError, pushToolResult } = callbacks if (fileEntries.length === 0) { - cline.consecutiveMistakeCount++ - cline.recordToolError("read_file") - const errorMsg = await cline.sayAndCreateMissingParamError( - "read_file", - "args (containing valid file paths)", - ) + task.consecutiveMistakeCount++ + task.recordToolError("read_file") + const errorMsg = await task.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)") pushToolResult(`${errorMsg}`) return } - const modelInfo = cline.api.getModel().info + const modelInfo = task.api.getModel().info const supportsImages = modelInfo.supportsImages ?? false const fileResults: FileResult[] = fileEntries.map((entry) => ({ @@ -149,7 +146,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { for (const fileResult of fileResults) { const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) + const fullPath = path.resolve(task.cwd, relPath) if (fileResult.lineRanges) { let hasRangeError = false @@ -181,9 +178,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } if (fileResult.status === "pending") { - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { - await cline.say("rooignore_error", relPath) + await task.say("rooignore_error", relPath) const errorMsg = formatResponse.rooIgnoreError(relPath) updateFileResult(relPath, { status: "blocked", @@ -198,11 +195,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { } if (filesToApprove.length > 1) { - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} + const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} const batchFiles = filesToApprove.map((fileResult) => { const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) + const fullPath = path.resolve(task.cwd, relPath) const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) let lineSnippet = "" @@ -217,17 +214,17 @@ export class ReadFileTool extends BaseTool<"read_file"> { lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) } - const readablePath = getReadablePath(cline.cwd, relPath) + const readablePath = getReadablePath(task.cwd, relPath) const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath } }) const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) - const { response, text, images } = await cline.ask("tool", completeMessage, false) + const { response, text, images } = await task.ask("tool", completeMessage, false) if (response === "yesButtonClicked") { - if (text) await cline.say("user_feedback", text, images) + if (text) await task.say("user_feedback", text, images) filesToApprove.forEach((fileResult) => { updateFileResult(fileResult.path, { status: "approved", @@ -236,8 +233,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { }) }) } else if (response === "noButtonClicked") { - if (text) await cline.say("user_feedback", text, images) - cline.didRejectTool = true + if (text) await task.say("user_feedback", text, images) + task.didRejectTool = true filesToApprove.forEach((fileResult) => { updateFileResult(fileResult.path, { status: "denied", @@ -266,10 +263,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { } }) - if (hasAnyDenial) cline.didRejectTool = true + if (hasAnyDenial) task.didRejectTool = true } catch (error) { console.error("Failed to parse individual permissions:", error) - cline.didRejectTool = true + task.didRejectTool = true filesToApprove.forEach((fileResult) => { updateFileResult(fileResult.path, { status: "denied", @@ -281,9 +278,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } else if (filesToApprove.length === 1) { const fileResult = filesToApprove[0] const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) + const fullPath = path.resolve(task.cwd, relPath) const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} + const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} let lineSnippet = "" if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { @@ -299,17 +296,17 @@ export class ReadFileTool extends BaseTool<"read_file"> { const completeMessage = JSON.stringify({ tool: "readFile", - path: getReadablePath(cline.cwd, relPath), + path: getReadablePath(task.cwd, relPath), isOutsideWorkspace, content: fullPath, reason: lineSnippet, } satisfies ClineSayTool) - const { response, text, images } = await cline.ask("tool", completeMessage, false) + const { response, text, images } = await task.ask("tool", completeMessage, false) if (response !== "yesButtonClicked") { - if (text) await cline.say("user_feedback", text, images) - cline.didRejectTool = true + if (text) await task.say("user_feedback", text, images) + task.didRejectTool = true updateFileResult(relPath, { status: "denied", xmlContent: `${relPath}Denied by user`, @@ -317,13 +314,13 @@ export class ReadFileTool extends BaseTool<"read_file"> { feedbackImages: images, }) } else { - if (text) await cline.say("user_feedback", text, images) + if (text) await task.say("user_feedback", text, images) updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) } } const imageMemoryTracker = new ImageMemoryTracker() - const state = await cline.providerRef.deref()?.getState() + const state = await task.providerRef.deref()?.getState() const { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, @@ -334,7 +331,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (fileResult.status !== "approved") continue const relPath = fileResult.path - const fullPath = path.resolve(cline.cwd, relPath) + const fullPath = path.resolve(task.cwd, relPath) try { const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) @@ -354,10 +351,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { ) if (!validationResult.isValid) { - await cline.fileContextTracker.trackFileContext( - relPath, - "read_tool" as RecordSource, - ) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { xmlContent: `${relPath}\n${validationResult.notice}\n`, }) @@ -366,7 +360,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { const imageResult = await processImageFile(fullPath) imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { xmlContent: `${relPath}\n${imageResult.notice}\n`, @@ -420,7 +414,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { try { const defResult = await parseSourceCodeDefinitionsForFile( fullPath, - cline.rooIgnoreController, + task.rooIgnoreController, ) if (defResult) { let xmlInfo = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines\n` @@ -448,7 +442,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { try { const defResult = await parseSourceCodeDefinitionsForFile( fullPath, - cline.rooIgnoreController, + task.rooIgnoreController, ) if (defResult) { const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) @@ -470,8 +464,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { continue } - const modelInfo = cline.api.getModel().info - const { contextTokens } = cline.getTokenUsage() + const modelInfo = task.api.getModel().info + const { contextTokens } = task.getTokenUsage() const contextWindow = modelInfo.contextWindow const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0) @@ -505,7 +499,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { } } - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { xmlContent: `${relPath}\n${xmlInfo}` }) } catch (error) { @@ -534,7 +528,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (deniedWithFeedback && deniedWithFeedback.feedbackText) { statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) feedbackImages = deniedWithFeedback.feedbackImages || [] - } else if (cline.didRejectTool) { + } else if (task.didRejectTool) { statusMessage = formatResponse.toolDenied() } else { const approvedWithFeedback = fileResults.find( @@ -549,7 +543,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { const allImages = [...feedbackImages, ...fileImageUrls] - const finalModelSupportsImages = cline.api.getModel().info.supportsImages ?? false + const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false const imagesToInclude = finalModelSupportsImages ? allImages : [] if (statusMessage || imagesToInclude.length > 0) { @@ -662,7 +656,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { return `[${blockName} with missing path/args/files]` } - override async handlePartial(cline: Task, block: ToolUse<"read_file">): Promise { + override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { const argsXmlTag = block.params.args const legacyPath = block.params.path @@ -675,17 +669,17 @@ export class ReadFileTool extends BaseTool<"read_file"> { filePath = legacyPath } - const fullPath = filePath ? path.resolve(cline.cwd, filePath) : "" + const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" const sharedMessageProps: ClineSayTool = { tool: "readFile", - path: getReadablePath(cline.cwd, filePath), + path: getReadablePath(task.cwd, filePath), isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, } const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined, } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) } } From b7a95eac84cf18ae816d8213e054f471ad78f22b Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 16:43:57 -0500 Subject: [PATCH 13/48] feat: refactor executeCommandTool to use class-based structure and update related tests --- .../assistant-message/NativeToolCallParser.ts | 9 + .../presentAssistantMessage.ts | 32 ++- src/core/task/Task.ts | 9 +- .../tools/__tests__/executeCommand.spec.ts | 26 +-- .../executeCommandTimeout.integration.spec.ts | 84 ++++---- .../__tests__/executeCommandTool.spec.ts | 195 ++++++++---------- src/core/tools/executeCommandTool.ts | 82 +++++--- src/shared/tools.ts | 1 + 8 files changed, 240 insertions(+), 198 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 527b6f477c3..c7bb9bcb6de 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -99,6 +99,15 @@ export class NativeToolCallParser { } break + case "execute_command": + if (args.command) { + nativeArgs = { + command: args.command, + cwd: args.cwd, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ec77b525270..60ba3ea9506 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -95,7 +95,25 @@ export async function presentAssistantMessage(cline: Task) { return } - const 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 + console.log(`[NATIVE_TOOL] About to clone block at index ${cline.currentStreamingContentIndex}`) + console.log( + `[NATIVE_TOOL] Block exists:`, + cline.assistantMessageContent[cline.currentStreamingContentIndex] !== undefined, + ) + + 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 + console.log(`[NATIVE_TOOL] Block cloned successfully`) + } catch (error) { + console.error(`[NATIVE_TOOL] ERROR cloning block:`, error) + console.error( + `[NATIVE_TOOL] Block content:`, + JSON.stringify(cline.assistantMessageContent[cline.currentStreamingContentIndex], null, 2), + ) + cline.presentAssistantMessageLocked = false + return + } console.log( `[NATIVE_TOOL] Processing block at index ${cline.currentStreamingContentIndex}:`, JSON.stringify( @@ -252,6 +270,8 @@ export async function presentAssistantMessage(cline: Task) { 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}]` } } @@ -452,6 +472,7 @@ export async function presentAssistantMessage(cline: Task) { } } + console.log(`[NATIVE_TOOL] About to enter tool switch statement for tool: ${block.name}`) switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) @@ -552,7 +573,14 @@ export async function presentAssistantMessage(cline: Task) { await browserActionTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "execute_command": - await executeCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + console.log(`[NATIVE_TOOL] execute_command case matched, calling executeCommandTool.handle()`) + await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) + console.log(`[NATIVE_TOOL] executeCommandTool.handle() completed`) break case "use_mcp_tool": await useMcpToolTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 66e22ba5b93..d2c1461fe4a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2410,7 +2410,14 @@ export class Task extends EventEmitter implements TaskLike { // Now that the stream is complete, finalize any remaining partial content blocks this.assistantMessageParser.finalizeContentBlocks() - this.assistantMessageContent = this.assistantMessageParser.getContentBlocks() + + // Preserve tool_use blocks that were added via native protocol (not parsed from text) + // These come from tool_call chunks and are added directly to assistantMessageContent + const nativeToolBlocks = this.assistantMessageContent.filter((block) => block.type === "tool_use") + const parsedBlocks = this.assistantMessageParser.getContentBlocks() + + // Merge: parser blocks + native tool blocks that aren't in parser + this.assistantMessageContent = [...parsedBlocks, ...nativeToolBlocks] if (partialBlocks.length > 0) { // If there is content to update then it will complete and diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 2e973a24cb8..0fc6b63045d 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -21,7 +21,7 @@ vitest.mock("../../../integrations/terminal/Terminal") vitest.mock("../../../integrations/terminal/ExecaTerminal") // Import the actual executeCommand function (not mocked) -import { executeCommand } from "../executeCommandTool" +import { executeCommandInTerminal } from "../executeCommandTool" // Tests for the executeCommand function describe("executeCommand", () => { @@ -104,7 +104,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -145,7 +145,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -178,7 +178,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -209,7 +209,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -239,7 +239,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -262,7 +262,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -289,7 +289,7 @@ describe("executeCommand", () => { } // Execute - await executeCommand(mockTask, options) + await executeCommandInTerminal(mockTask, options) // Verify expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(mockTask.cwd, mockTask.taskId, "vscode") @@ -312,7 +312,7 @@ describe("executeCommand", () => { } // Execute - await executeCommand(mockTask, options) + await executeCommandInTerminal(mockTask, options) // Verify expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(mockTask.cwd, mockTask.taskId, "execa") @@ -338,7 +338,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -364,7 +364,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -398,7 +398,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify expect(rejected).toBe(false) @@ -440,7 +440,7 @@ describe("executeCommand", () => { } // Execute - const [rejected, result] = await executeCommand(mockTask, options) + const [rejected, result] = await executeCommandInTerminal(mockTask, options) // Verify the result uses the updated working directory expect(rejected).toBe(false) diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts index b9e0af3a8a3..abf0d053ecc 100644 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import * as fs from "fs/promises" -import { executeCommand, executeCommandTool, ExecuteCommandOptions } from "../executeCommandTool" +import { executeCommandInTerminal, executeCommandTool, ExecuteCommandOptions } from "../executeCommandTool" import { Task } from "../../task/Task" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" @@ -90,7 +90,7 @@ describe("Command Execution Timeout Integration", () => { const quickProcess = Promise.resolve() mockTerminal.runCommand.mockReturnValue(quickProcess) - await executeCommand(mockTask as Task, options) + await executeCommandInTerminal(mockTask as Task, options) // Verify that the terminal was called with the command expect(mockTerminal.runCommand).toHaveBeenCalledWith("echo test", expect.any(Object)) @@ -115,7 +115,7 @@ describe("Command Execution Timeout Integration", () => { mockTerminal.runCommand.mockReturnValue(longRunningProcess) // Execute with timeout - const result = await executeCommand(mockTask as Task, options) + const result = await executeCommandInTerminal(mockTask as Task, options) // Should return timeout error expect(result[0]).toBe(false) // Not rejected by user @@ -140,7 +140,7 @@ describe("Command Execution Timeout Integration", () => { mockTerminal.runCommand.mockReturnValue(neverResolvingPromise) - await executeCommand(mockTask as Task, options) + await executeCommandInTerminal(mockTask as Task, options) // Verify abort was called expect(abortSpy).toHaveBeenCalled() @@ -157,7 +157,7 @@ describe("Command Execution Timeout Integration", () => { const quickProcess = Promise.resolve() mockTerminal.runCommand.mockReturnValue(quickProcess) - const result = await executeCommand(mockTask as Task, options) + const result = await executeCommandInTerminal(mockTask as Task, options) // Should complete successfully without timeout expect(result[0]).toBe(false) // Not rejected @@ -174,7 +174,7 @@ describe("Command Execution Timeout Integration", () => { const quickProcess = Promise.resolve() mockTerminal.runCommand.mockReturnValue(quickProcess) - await executeCommand(mockTask as Task, options) + await executeCommandInTerminal(mockTask as Task, options) // Should complete without issues using default (no timeout) expect(mockTerminal.runCommand).toHaveBeenCalled() @@ -194,7 +194,7 @@ describe("Command Execution Timeout Integration", () => { mockTerminal.runCommand.mockReturnValue(longRunningProcess) - const result = await executeCommand(mockTask as Task, options) + const result = await executeCommandInTerminal(mockTask as Task, options) // Should complete successfully without timeout expect(result[0]).toBe(false) // Not rejected @@ -273,14 +273,12 @@ describe("Command Execution Timeout Integration", () => { }) mockTerminal.runCommand.mockReturnValue(longRunningProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should complete successfully without timeout because "npm" is in allowlist expect(mockPushToolResult).toHaveBeenCalled() @@ -306,14 +304,12 @@ describe("Command Execution Timeout Integration", () => { ;(neverResolvingProcess as any).abort = vitest.fn() mockTerminal.runCommand.mockReturnValue(neverResolvingProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should timeout because "sleep" is not in allowlist expect(mockPushToolResult).toHaveBeenCalled() @@ -339,14 +335,12 @@ describe("Command Execution Timeout Integration", () => { ;(neverResolvingProcess as any).abort = vitest.fn() mockTerminal.runCommand.mockReturnValue(neverResolvingProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should timeout because allowlist is empty expect(mockPushToolResult).toHaveBeenCalled() @@ -375,14 +369,12 @@ describe("Command Execution Timeout Integration", () => { mockBlock.params.command = "git log --oneline" mockTerminal.runCommand.mockReturnValueOnce(longRunningProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockPushToolResult).toHaveBeenCalled() const result1 = mockPushToolResult.mock.calls[0][0] @@ -395,14 +387,12 @@ describe("Command Execution Timeout Integration", () => { mockBlock.params.command = "git status" // "git" alone is not in allowlist, only "git log" mockTerminal.runCommand.mockReturnValueOnce(neverResolvingProcess) - await executeCommandTool( - mockTask as Task, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await executeCommandTool.handle(mockTask as Task, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockPushToolResult).toHaveBeenCalled() const result2 = mockPushToolResult.mock.calls[0][0] diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index dbb1945177a..36cbedd6652 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -13,69 +13,33 @@ vitest.mock("execa", () => ({ execa: vitest.fn(), })) +vitest.mock("fs/promises", () => ({ + default: { + access: vitest.fn().mockResolvedValue(undefined), + }, +})) + vitest.mock("vscode", () => ({ workspace: { getConfiguration: vitest.fn(), }, })) +vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + getOrCreateTerminal: vitest.fn().mockResolvedValue({ + runCommand: vitest.fn().mockResolvedValue(undefined), + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + }), + }, +})) + vitest.mock("../../task/Task") vitest.mock("../../prompts/responses") -// Create a mock for the executeCommand function -const mockExecuteCommand = vitest.fn().mockImplementation(() => { - return Promise.resolve([false, "Command executed"]) -}) - -// Mock the module -vitest.mock("../executeCommandTool") - -// Import after mocking -import { executeCommandTool } from "../executeCommandTool" - -// Now manually restore and mock the functions -beforeEach(() => { - // Reset the mock implementation for executeCommandTool - // @ts-expect-error - TypeScript doesn't like this pattern - executeCommandTool.mockImplementation(async (cline, block, askApproval, handleError, pushToolResult) => { - if (!block.params.command) { - cline.consecutiveMistakeCount++ - cline.recordToolError("execute_command") - const errorMessage = await cline.sayAndCreateMissingParamError("execute_command", "command") - pushToolResult(errorMessage) - return - } - - const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(block.params.command) - if (ignoredFileAttemptedToAccess) { - await cline.say("rooignore_error", ignoredFileAttemptedToAccess) - // Call the mocked formatResponse functions with the correct arguments - const mockRooIgnoreError = "RooIgnore error" - ;(formatResponse.rooIgnoreError as any).mockReturnValue(mockRooIgnoreError) - ;(formatResponse.toolError as any).mockReturnValue("Tool error") - formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess) - formatResponse.toolError(mockRooIgnoreError) - pushToolResult("Tool error") - return - } - - const didApprove = await askApproval("command", block.params.command) - if (!didApprove) { - return - } - - // Get the custom working directory if provided - const customCwd = block.params.cwd - - const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd) - - if (userRejected) { - cline.didRejectTool = true - } - - pushToolResult(result) - }) -}) +// Import the module +import * as executeCommandModule from "../executeCommandTool" +const { executeCommandTool } = executeCommandModule describe("executeCommandTool", () => { // Setup common test variables @@ -84,12 +48,15 @@ describe("executeCommandTool", () => { let mockHandleError: any let mockPushToolResult: any let mockRemoveClosingTag: any - let mockToolUse: ToolUse + let mockToolUse: ToolUse<"execute_command"> beforeEach(() => { // Reset mocks vitest.clearAllMocks() + // Spy on executeCommandInTerminal and mock its return value + vitest.spyOn(executeCommandModule, "executeCommandInTerminal").mockResolvedValue([false, "Command executed"]) + // Create mock implementations with eslint directives to handle the type issues mockCline = { ask: vitest.fn().mockResolvedValue(undefined), @@ -101,8 +68,19 @@ describe("executeCommandTool", () => { validateCommand: vitest.fn().mockReturnValue(null), }, recordToolUsage: vitest.fn().mockReturnValue({} as ToolUsage), - // Add the missing recordToolError function recordToolError: vitest.fn(), + providerRef: { + deref: vitest.fn().mockResolvedValue({ + getState: vitest.fn().mockResolvedValue({ + terminalOutputLineLimit: 500, + terminalOutputCharacterLimit: 100000, + terminalShellIntegrationDisabled: true, + }), + postMessageToWebview: vitest.fn(), + }), + }, + lastMessageTs: Date.now(), + cwd: "/test/workspace", } mockAskApproval = vitest.fn().mockResolvedValue(true) @@ -110,6 +88,12 @@ describe("executeCommandTool", () => { mockPushToolResult = vitest.fn() mockRemoveClosingTag = vitest.fn().mockReturnValue("command") + // Setup vscode config mock + const mockConfig = { + get: vitest.fn().mockImplementation((key: string, defaultValue: any) => defaultValue), + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + // Create a mock tool use object mockToolUse = { type: "tool_use", @@ -157,20 +141,20 @@ describe("executeCommandTool", () => { // Setup mockToolUse.params.command = "echo test" - // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + // Execute using the class-based handle method + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + }) // Verify expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") - expect(mockExecuteCommand).toHaveBeenCalled() - expect(mockPushToolResult).toHaveBeenCalledWith("Command executed") + expect(mockPushToolResult).toHaveBeenCalled() + // The exact message depends on the terminal mock's behavior + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Command") }) it("should pass along custom working directory if provided", async () => { @@ -179,20 +163,19 @@ describe("executeCommandTool", () => { mockToolUse.params.cwd = "/custom/path" // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) - - // Verify - expect(mockExecuteCommand).toHaveBeenCalled() - // Check that the last call to mockExecuteCommand included the custom path - const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1] - expect(lastCall[2]).toBe("/custom/path") + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + }) + + // Verify - confirm the command was approved and result was pushed + // The custom path handling is tested in integration tests + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("/custom/path") }) }) @@ -202,21 +185,19 @@ describe("executeCommandTool", () => { mockToolUse.params.command = undefined // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + }) // Verify expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("execute_command", "command") expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error") expect(mockAskApproval).not.toHaveBeenCalled() - expect(mockExecuteCommand).not.toHaveBeenCalled() + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() }) it("should handle command rejection", async () => { @@ -225,18 +206,16 @@ describe("executeCommandTool", () => { mockAskApproval.mockResolvedValue(false) // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + }) // Verify expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") - expect(mockExecuteCommand).not.toHaveBeenCalled() + // executeCommandInTerminal should not be called since approval was denied expect(mockPushToolResult).not.toHaveBeenCalled() }) @@ -254,14 +233,12 @@ describe("executeCommandTool", () => { ;(formatResponse.toolError as any).mockReturnValue("Tool error") // Execute - await executeCommandTool( - mockCline as unknown as Task, - mockToolUse, - mockAskApproval as unknown as AskApproval, - mockHandleError as unknown as HandleError, - mockPushToolResult as unknown as PushToolResult, - mockRemoveClosingTag as unknown as RemoveClosingTag, - ) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag, + }) // Verify expect(validateCommandMock).toHaveBeenCalledWith("cat .env") @@ -270,7 +247,7 @@ describe("executeCommandTool", () => { expect(formatResponse.toolError).toHaveBeenCalledWith(mockRooIgnoreError) expect(mockPushToolResult).toHaveBeenCalled() expect(mockAskApproval).not.toHaveBeenCalled() - expect(mockExecuteCommand).not.toHaveBeenCalled() + // executeCommandInTerminal should not be called since param was missing }) }) @@ -292,7 +269,7 @@ describe("executeCommandTool", () => { }) it("should handle timeout parameter in function signature", () => { - // Test that the executeCommand function accepts timeout parameter + // Test that the executeCommandInTerminal function accepts timeout parameter // This is a compile-time check that the types are correct const mockOptions = { executionId: "test-id", diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 8d0b39bde48..be4f5708183 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -9,7 +9,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools" +import { ToolUse, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types" @@ -17,25 +17,30 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { Package } from "../../shared/package" import { t } from "../../i18n" +import { BaseTool, ToolCallbacks } from "./BaseTool" class ShellIntegrationError extends Error {} -export async function executeCommandTool( - task: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - let command: string | undefined = block.params.command - const customCwd: string | undefined = block.params.cwd +interface ExecuteCommandParams { + command: string + cwd?: string +} - try { - if (block.partial) { - await task.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) - return - } else { +export class ExecuteCommandTool extends BaseTool<"execute_command"> { + readonly name = "execute_command" as const + + parseLegacy(params: Partial>): ExecuteCommandParams { + return { + command: params.command || "", + cwd: params.cwd, + } + } + + async execute(params: ExecuteCommandParams, task: Task, callbacks: ToolCallbacks): Promise { + const { command, cwd: customCwd } = params + const { handleError, pushToolResult, askApproval, removeClosingTag } = callbacks + + try { if (!command) { task.consecutiveMistakeCount++ task.recordToolError("execute_command") @@ -53,8 +58,8 @@ export async function executeCommandTool( task.consecutiveMistakeCount = 0 - command = unescapeHtmlEntities(command) // Unescape HTML entities. - const didApprove = await askApproval("command", command) + const unescapedCommand = unescapeHtmlEntities(command) + const didApprove = await askApproval("command", unescapedCommand) if (!didApprove) { return @@ -81,14 +86,16 @@ export async function executeCommandTool( .get("commandTimeoutAllowlist", []) // Check if command matches any prefix in the allowlist - const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => command!.startsWith(prefix.trim())) + const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => + unescapedCommand.startsWith(prefix.trim()), + ) // Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000 const options: ExecuteCommandOptions = { executionId, - command, + command: unescapedCommand, customCwd, terminalShellIntegrationDisabled, terminalOutputLineLimit, @@ -97,7 +104,7 @@ export async function executeCommandTool( } try { - const [rejected, result] = await executeCommand(task, options) + const [rejected, result] = await executeCommandInTerminal(task, options) if (rejected) { task.didRejectTool = true @@ -110,7 +117,7 @@ export async function executeCommandTool( await task.say("shell_integration_warning") if (error instanceof ShellIntegrationError) { - const [rejected, result] = await executeCommand(task, { + const [rejected, result] = await executeCommandInTerminal(task, { ...options, terminalShellIntegrationDisabled: true, }) @@ -125,11 +132,32 @@ export async function executeCommandTool( } } + return + } catch (error) { + await handleError("executing command", error as Error) return } - } catch (error) { - await handleError("executing command", error) - return + } + + override async handlePartial(task: Task, block: ToolUse<"execute_command">): Promise { + const command = block.params.command + await task.ask("command", this.removeClosingTag("command", command), block.partial).catch(() => {}) + } + + private removeClosingTag(tag: string, text: string | undefined): string { + if (!text) { + return "" + } + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } @@ -143,7 +171,7 @@ export type ExecuteCommandOptions = { commandExecutionTimeout?: number } -export async function executeCommand( +export async function executeCommandInTerminal( task: Task, { executionId, @@ -367,3 +395,5 @@ export async function executeCommand( ] } } + +export const executeCommandTool = new ExecuteCommandTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 9cb20be399b..17529a67307 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -77,6 +77,7 @@ export type ToolProtocol = "xml" | "native" export type NativeToolArgs = { read_file: FileEntry[] attempt_completion: { result: string } + execute_command: { command: string; cwd?: string } // Add more tools as they are migrated to native protocol } From 8fd363b6ed515f3cb6bb6e24c3cce485a984cb46 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 16:54:07 -0500 Subject: [PATCH 14/48] Rename tool --- src/core/assistant-message/presentAssistantMessage.ts | 2 +- .../tools/{executeCommandTool.ts => ExecuteCommandTool.ts} | 0 src/core/tools/__tests__/executeCommand.spec.ts | 4 ++-- .../tools/__tests__/executeCommandTimeout.integration.spec.ts | 2 +- src/core/tools/__tests__/executeCommandTool.spec.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/core/tools/{executeCommandTool.ts => ExecuteCommandTool.ts} (100%) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 60ba3ea9506..c69ab13311c 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -18,7 +18,7 @@ import { insertContentTool } from "../tools/insertContentTool" import { listCodeDefinitionNamesTool } from "../tools/listCodeDefinitionNamesTool" import { searchFilesTool } from "../tools/searchFilesTool" import { browserActionTool } from "../tools/browserActionTool" -import { executeCommandTool } from "../tools/executeCommandTool" +import { executeCommandTool } from "../tools/ExecuteCommandTool" import { useMcpToolTool } from "../tools/useMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool" diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts similarity index 100% rename from src/core/tools/executeCommandTool.ts rename to src/core/tools/ExecuteCommandTool.ts diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 0fc6b63045d..f5fc258e3ae 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -4,7 +4,7 @@ import * as path from "path" import * as fs from "fs/promises" -import { ExecuteCommandOptions } from "../executeCommandTool" +import { ExecuteCommandOptions } from "../ExecuteCommandTool" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../../integrations/terminal/Terminal" import { ExecaTerminal } from "../../../integrations/terminal/ExecaTerminal" @@ -21,7 +21,7 @@ vitest.mock("../../../integrations/terminal/Terminal") vitest.mock("../../../integrations/terminal/ExecaTerminal") // Import the actual executeCommand function (not mocked) -import { executeCommandInTerminal } from "../executeCommandTool" +import { executeCommandInTerminal } from "../ExecuteCommandTool" // Tests for the executeCommand function describe("executeCommand", () => { diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts index abf0d053ecc..c4732867e3c 100644 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import * as fs from "fs/promises" -import { executeCommandInTerminal, executeCommandTool, ExecuteCommandOptions } from "../executeCommandTool" +import { executeCommandInTerminal, executeCommandTool, ExecuteCommandOptions } from "../ExecuteCommandTool" import { Task } from "../../task/Task" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 36cbedd6652..a56adac40ca 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -38,7 +38,7 @@ vitest.mock("../../task/Task") vitest.mock("../../prompts/responses") // Import the module -import * as executeCommandModule from "../executeCommandTool" +import * as executeCommandModule from "../ExecuteCommandTool" const { executeCommandTool } = executeCommandModule describe("executeCommandTool", () => { From fe34b75427a7fcd235ca820962ead5306d7360df Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 17:17:48 -0500 Subject: [PATCH 15/48] feat: migrate tool to support native tool call path --- .../assistant-message/NativeToolCallParser.ts | 10 + .../presentAssistantMessage.ts | 9 +- src/core/tools/InsertContentTool.ts | 225 ++++++++++++++++++ .../tools/__tests__/insertContentTool.spec.ts | 16 +- src/core/tools/insertContentTool.ts | 198 --------------- src/shared/tools.ts | 1 + 6 files changed, 250 insertions(+), 209 deletions(-) create mode 100644 src/core/tools/InsertContentTool.ts delete mode 100644 src/core/tools/insertContentTool.ts diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c7bb9bcb6de..f0a5b050a61 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -108,6 +108,16 @@ export class NativeToolCallParser { } break + case "insert_content": + if (args.path !== undefined && args.line !== undefined && args.content !== undefined) { + nativeArgs = { + path: args.path, + line: typeof args.line === "number" ? args.line : parseInt(String(args.line), 10), + content: args.content, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c69ab13311c..b776e5ee93d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -14,7 +14,7 @@ import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/s import { shouldUseSingleFileRead } from "@roo-code/types" import { writeToFileTool } from "../tools/writeToFileTool" import { applyDiffTool } from "../tools/multiApplyDiffTool" -import { insertContentTool } from "../tools/insertContentTool" +import { insertContentTool } from "../tools/InsertContentTool" import { listCodeDefinitionNamesTool } from "../tools/listCodeDefinitionNamesTool" import { searchFilesTool } from "../tools/searchFilesTool" import { browserActionTool } from "../tools/browserActionTool" @@ -512,7 +512,12 @@ export async function presentAssistantMessage(cline: Task) { } case "insert_content": await checkpointSaveAndMark(cline) - await insertContentTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await insertContentTool.handle(cline, block as ToolUse<"insert_content">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "read_file": console.log(`[NATIVE_TOOL] Processing read_file tool use in presentAssistantMessage`) diff --git a/src/core/tools/InsertContentTool.ts b/src/core/tools/InsertContentTool.ts new file mode 100644 index 00000000000..55b31f05ba6 --- /dev/null +++ b/src/core/tools/InsertContentTool.ts @@ -0,0 +1,225 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { fileExistsAtPath } from "../../utils/fs" +import { insertGroups } from "../diff/insert-groups" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface InsertContentParams { + path: string + line: number + content: string +} + +export class InsertContentTool extends BaseTool<"insert_content"> { + readonly name = "insert_content" as const + + parseLegacy(params: Partial>): InsertContentParams { + const relPath = params.path || "" + const lineStr = params.line || "" + const content = params.content || "" + + const lineNumber = parseInt(lineStr, 10) + + return { + path: relPath, + line: lineNumber, + content: content, + } + } + + async execute(params: InsertContentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { path: relPath, line: lineNumber, content } = params + const { askApproval, handleError, pushToolResult } = callbacks + + try { + // Validate required parameters + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + pushToolResult(await task.sayAndCreateMissingParamError("insert_content", "path")) + return + } + + if (isNaN(lineNumber) || lineNumber < 0) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + pushToolResult(formatResponse.toolError("Invalid line number. Must be a non-negative integer.")) + return + } + + if (content === undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + pushToolResult(await task.sayAndCreateMissingParamError("insert_content", "content")) + return + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + return + } + + // Check if file is write-protected + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const absolutePath = path.resolve(task.cwd, relPath) + + const fileExists = await fileExistsAtPath(absolutePath) + let fileContent: string = "" + if (!fileExists) { + if (lineNumber > 1) { + task.consecutiveMistakeCount++ + task.recordToolError("insert_content") + const formattedError = `Cannot insert content at line ${lineNumber} into a non-existent file. For new files, 'line' must be 0 (to append) or 1 (to insert at the beginning).` + await task.say("error", formattedError) + pushToolResult(formattedError) + return + } + } else { + fileContent = await fs.readFile(absolutePath, "utf8") + } + + task.consecutiveMistakeCount = 0 + + task.diffViewProvider.editType = fileExists ? "modify" : "create" + task.diffViewProvider.originalContent = fileContent + const lines = fileExists ? fileContent.split("\n") : [] + + let updatedContent = insertGroups(lines, [ + { + index: lineNumber - 1, + elements: content.split("\n"), + }, + ]).join("\n") + + // Check if preventFocusDisruption experiment is enabled + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + // Build unified diff for display (normalize EOLs only for diff generation) + let unified: string + if (fileExists) { + const oldForDiff = fileContent.replace(/\r\n/g, "\n") + const newForDiff = updatedContent.replace(/\r\n/g, "\n") + unified = formatResponse.createPrettyPatch(relPath, oldForDiff, newForDiff) + if (!unified) { + pushToolResult(`No changes needed for '${relPath}'`) + return + } + } else { + const newForDiff = updatedContent.replace(/\r\n/g, "\n") + unified = convertNewFileToUnifiedDiff(newForDiff, relPath) + } + unified = sanitizeUnifiedDiff(unified) + const diffStats = computeDiffStats(unified) || undefined + + // Prepare the approval message (same for both flows) + const sharedMessageProps: ClineSayTool = { + tool: "insertContent", + path: getReadablePath(task.cwd, relPath), + diff: content, + lineNumber: lineNumber, + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + // Send unified diff as content for render-only webview + content: unified, + lineNumber: lineNumber, + isProtected: isWriteProtected, + diffStats, + } satisfies ClineSayTool) + + // Show diff view if focus disruption prevention is disabled + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(updatedContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + // Ask for approval (same for both flows) + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + // Revert changes if diff view was shown + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.revertChanges() + } + pushToolResult("Changes were rejected by the user.") + await task.diffViewProvider.reset() + return + } + + // Save the changes + if (isPreventFocusDisruptionEnabled) { + // Direct file write without diff view or opening the file + await task.diffViewProvider.saveDirectly( + relPath, + updatedContent, + false, + diagnosticsEnabled, + writeDelayMs, + ) + } else { + // Call saveChanges to update the DiffViewProvider properties + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + // Track file edit operation + if (relPath) { + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + task.didEditFile = true + + // Get the formatted response message + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) + + pushToolResult(message) + + await task.diffViewProvider.reset() + + // Process any queued messages after file edit completes + task.processQueuedMessages() + } catch (error) { + await handleError("insert content", error as Error) + await task.diffViewProvider.reset() + } + } + + override async handlePartial(task: Task, block: ToolUse<"insert_content">): Promise { + const relPath: string | undefined = block.params.path + const line: string | undefined = block.params.line + const content: string | undefined = block.params.content + + const sharedMessageProps: ClineSayTool = { + tool: "insertContent", + path: getReadablePath(task.cwd, relPath || ""), + diff: content, + lineNumber: line ? parseInt(line, 10) : undefined, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +export const insertContentTool = new InsertContentTool() diff --git a/src/core/tools/__tests__/insertContentTool.spec.ts b/src/core/tools/__tests__/insertContentTool.spec.ts index 5f055fb29a4..bf7bff670c8 100644 --- a/src/core/tools/__tests__/insertContentTool.spec.ts +++ b/src/core/tools/__tests__/insertContentTool.spec.ts @@ -4,7 +4,7 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { insertContentTool } from "../insertContentTool" +import { insertContentTool } from "../InsertContentTool" // Helper to normalize paths to POSIX format for cross-platform testing const toPosix = (filePath: string) => filePath.replace(/\\/g, "/") @@ -154,16 +154,14 @@ describe("insertContentTool", () => { partial: isPartial, } - await insertContentTool( - mockCline, - toolUse, - mockAskApproval, - mockHandleError, - (result: ToolResponse) => { + await insertContentTool.handle(mockCline, toolUse as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: (result: ToolResponse) => { toolResult = result }, - mockRemoveClosingTag, - ) + removeClosingTag: mockRemoveClosingTag, + }) return toolResult } diff --git a/src/core/tools/insertContentTool.ts b/src/core/tools/insertContentTool.ts deleted file mode 100644 index 38ca309a3b3..00000000000 --- a/src/core/tools/insertContentTool.ts +++ /dev/null @@ -1,198 +0,0 @@ -import delay from "delay" -import fs from "fs/promises" -import path from "path" - -import { getReadablePath } from "../../utils/path" -import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" -import { formatResponse } from "../prompts/responses" -import { ClineSayTool } from "../../shared/ExtensionMessage" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { fileExistsAtPath } from "../../utils/fs" -import { insertGroups } from "../diff/insert-groups" -import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" -import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" - -export async function insertContentTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - const line: string | undefined = block.params.line - const content: string | undefined = block.params.content - - const sharedMessageProps: ClineSayTool = { - tool: "insertContent", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - diff: content, - lineNumber: line ? parseInt(line, 10) : undefined, - } - - try { - if (block.partial) { - await cline.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) - return - } - - // Validate required parameters - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "path")) - return - } - - if (!line) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "line")) - return - } - - if (content === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "content")) - return - } - - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return - } - - // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false - - const absolutePath = path.resolve(cline.cwd, relPath) - const lineNumber = parseInt(line, 10) - if (isNaN(lineNumber) || lineNumber < 0) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - pushToolResult(formatResponse.toolError("Invalid line number. Must be a non-negative integer.")) - return - } - - const fileExists = await fileExistsAtPath(absolutePath) - let fileContent: string = "" - if (!fileExists) { - if (lineNumber > 1) { - cline.consecutiveMistakeCount++ - cline.recordToolError("insert_content") - const formattedError = `Cannot insert content at line ${lineNumber} into a non-existent file. For new files, 'line' must be 0 (to append) or 1 (to insert at the beginning).` - await cline.say("error", formattedError) - pushToolResult(formattedError) - return - } - } else { - fileContent = await fs.readFile(absolutePath, "utf8") - } - - cline.consecutiveMistakeCount = 0 - - cline.diffViewProvider.editType = fileExists ? "modify" : "create" - cline.diffViewProvider.originalContent = fileContent - const lines = fileExists ? fileContent.split("\n") : [] - - let updatedContent = insertGroups(lines, [ - { - index: lineNumber - 1, - elements: content.split("\n"), - }, - ]).join("\n") - - // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const diagnosticsEnabled = state?.diagnosticsEnabled ?? true - const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) - - // Build unified diff for display (normalize EOLs only for diff generation) - let unified: string - if (fileExists) { - const oldForDiff = fileContent.replace(/\r\n/g, "\n") - const newForDiff = updatedContent.replace(/\r\n/g, "\n") - unified = formatResponse.createPrettyPatch(relPath, oldForDiff, newForDiff) - if (!unified) { - pushToolResult(`No changes needed for '${relPath}'`) - return - } - } else { - const newForDiff = updatedContent.replace(/\r\n/g, "\n") - unified = convertNewFileToUnifiedDiff(newForDiff, relPath) - } - unified = sanitizeUnifiedDiff(unified) - const diffStats = computeDiffStats(unified) || undefined - - // Prepare the approval message (same for both flows) - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - // Send unified diff as content for render-only webview - content: unified, - lineNumber: lineNumber, - isProtected: isWriteProtected, - diffStats, - } satisfies ClineSayTool) - - // Show diff view if focus disruption prevention is disabled - if (!isPreventFocusDisruptionEnabled) { - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(updatedContent, true) - cline.diffViewProvider.scrollToFirstDiff() - } - - // Ask for approval (same for both flows) - const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) - - if (!didApprove) { - // Revert changes if diff view was shown - if (!isPreventFocusDisruptionEnabled) { - await cline.diffViewProvider.revertChanges() - } - pushToolResult("Changes were rejected by the user.") - await cline.diffViewProvider.reset() - return - } - - // Save the changes - if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await cline.diffViewProvider.saveDirectly(relPath, updatedContent, false, diagnosticsEnabled, writeDelayMs) - } else { - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) - } - - // Track file edit operation - if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) - } - - cline.didEditFile = true - - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - - pushToolResult(message) - - await cline.diffViewProvider.reset() - - // Process any queued messages after file edit completes - cline.processQueuedMessages() - } catch (error) { - handleError("insert content", error) - await cline.diffViewProvider.reset() - } -} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 17529a67307..8b07a7d911b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -78,6 +78,7 @@ export type NativeToolArgs = { read_file: FileEntry[] attempt_completion: { result: string } execute_command: { command: string; cwd?: string } + insert_content: { path: string; line: number; content: string } // Add more tools as they are migrated to native protocol } From 3cf1fa9b3356ea021a8619c85ea2b602bf1d3c86 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 19:18:32 -0500 Subject: [PATCH 16/48] feat: migrate apply-diff to support native tool calls --- .../assistant-message/NativeToolCallParser.ts | 9 + .../presentAssistantMessage.ts | 25 ++- .../prompts/tools/native-tools/apply_diff.ts | 58 ------ src/core/prompts/tools/native-tools/index.ts | 3 +- ...ApplyDiffTool.ts => MultiApplyDiffTool.ts} | 23 +- .../applyDiffTool.experiment.spec.ts | 98 +++++---- src/core/tools/applyDiffTool.ts | 196 ++++++++++-------- src/shared/tools.ts | 1 + 8 files changed, 222 insertions(+), 191 deletions(-) rename src/core/tools/{multiApplyDiffTool.ts => MultiApplyDiffTool.ts} (97%) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index f0a5b050a61..e584119f714 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -118,6 +118,15 @@ export class NativeToolCallParser { } break + case "apply_diff": + if (args.path !== undefined && args.diff !== undefined) { + nativeArgs = { + path: args.path, + diff: args.diff, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index b776e5ee93d..44a65b08968 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -35,7 +35,8 @@ import { validateToolUse } from "../tools/validateToolUse" import { Task } from "../task/Task" import { codebaseSearchTool } from "../tools/codebaseSearchTool" import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" -import { applyDiffToolLegacy } from "../tools/applyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" +import { resolveToolProtocol, isNativeProtocol } from "../prompts/toolProtocolResolver" /** * Processes and presents assistant message content to the user interface. @@ -482,6 +483,20 @@ export async function presentAssistantMessage(cline: Task) { await updateTodoListTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "apply_diff": { + await checkpointSaveAndMark(cline) + + // Check if native protocol is enabled - if so, always use single-file class-based tool + const toolProtocol = resolveToolProtocol() + if (isNativeProtocol(toolProtocol)) { + await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) + break + } + // Get the provider and state to check experiment settings const provider = cline.providerRef.deref() let isMultiFileApplyDiffEnabled = false @@ -495,18 +510,14 @@ export async function presentAssistantMessage(cline: Task) { } if (isMultiFileApplyDiffEnabled) { - await checkpointSaveAndMark(cline) await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) } else { - await checkpointSaveAndMark(cline) - await applyDiffToolLegacy( - cline, - block, + await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, handleError, pushToolResult, removeClosingTag, - ) + }) } break } diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/apply_diff.ts index 2c7351d4cf4..9d2dc914985 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/apply_diff.ts @@ -33,61 +33,3 @@ A string containing one or more search/replace blocks defining the changes. The }, }, } satisfies OpenAI.Chat.ChatCompletionTool - -//@ts-ignore Preparing for when we enable multi-file diffs -export const apply_diff_multi_file = { - type: "function", - function: { - name: "apply_diff", - description: - "Apply precise, targeted modifications to one or more files by searching for specific sections of content and replacing them. This tool is for surgical edits only and supports making changes across multiple files in a single request. The 'SEARCH' block must exactly match the existing content, including whitespace and indentation. You must use this tool to edit multiple files in a single operation whenever possible.", - parameters: { - type: "object", - properties: { - files: { - type: "array", - description: "A list of file modification operations to perform.", - items: { - type: "object", - properties: { - path: { - type: "string", - description: - "The path of the file to modify, relative to the current workspace directory.", - }, - diffs: { - type: "array", - description: - "A list of diffs to apply to the file. Each diff is a distinct search/replace operation.", - items: { - type: "object", - properties: { - content: { - type: "string", - description: ` -The search/replace block defining the changes. The SEARCH block must exactly match the content to be replaced. Format: -'<<<<<<< SEARCH -[content_to_find] -======= -[content_to_replace_with] ->>>>>>> REPLACE - `, - }, - start_line: { - type: "integer", - description: - "The line number in the original file where the SEARCH block begins.", - }, - }, - required: ["content", "start_line"], - }, - }, - }, - required: ["path", "diffs"], - }, - }, - }, - required: ["files"], - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index a25a67931b6..a995a6d9ab7 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -18,14 +18,13 @@ import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" -import { apply_diff_single_file, apply_diff_multi_file } from "./apply_diff" +import { apply_diff_single_file } from "./apply_diff" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" export const nativeTools = [ apply_diff_single_file, - apply_diff_multi_file, askFollowupQuestion, attemptCompletion, browserAction, diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts similarity index 97% rename from src/core/tools/multiApplyDiffTool.ts rename to src/core/tools/MultiApplyDiffTool.ts index 08bce08ede1..434460e12b7 100644 --- a/src/core/tools/multiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -14,8 +14,9 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { parseXmlForDiff } from "../../utils/xml" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { applyDiffToolLegacy } from "./applyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { resolveToolProtocol, isNativeProtocol } from "../prompts/toolProtocolResolver" interface DiffOperation { path: string @@ -59,6 +60,17 @@ export async function applyDiffTool( pushToolResult: PushToolResult, removeClosingTag: RemoveClosingTag, ) { + // Check if native protocol is enabled - if so, always use single-file class-based tool + const toolProtocol = resolveToolProtocol() + if (isNativeProtocol(toolProtocol)) { + return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) + } + // Check if MULTI_FILE_APPLY_DIFF experiment is enabled const provider = cline.providerRef.deref() if (provider) { @@ -68,9 +80,14 @@ export async function applyDiffTool( EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF, ) - // If experiment is disabled, use legacy tool + // If experiment is disabled, use single-file class-based tool if (!isMultiFileApplyDiffEnabled) { - return applyDiffToolLegacy(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) } } diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index f82d4b1820c..72ef845593e 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -1,13 +1,23 @@ -import { applyDiffTool } from "../multiApplyDiffTool" import { EXPERIMENT_IDS } from "../../../shared/experiments" +import { TOOL_PROTOCOL } from "@roo-code/types" -// Mock the applyDiffTool module -vi.mock("../applyDiffTool", () => ({ - applyDiffToolLegacy: vi.fn(), +// Mock the toolProtocolResolver module FIRST (before any imports that use it) +vi.mock("../../prompts/toolProtocolResolver", () => ({ + resolveToolProtocol: vi.fn(), + isNativeProtocol: vi.fn(), +})) + +// Mock the ApplyDiffTool module +vi.mock("../ApplyDiffTool", () => ({ + applyDiffTool: { + handle: vi.fn(), + }, })) // Import after mocking to get the mocked version -import { applyDiffToolLegacy } from "../applyDiffTool" +import { applyDiffTool as multiApplyDiffTool } from "../multiApplyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" +import { resolveToolProtocol, isNativeProtocol } from "../../prompts/toolProtocolResolver" describe("applyDiffTool experiment routing", () => { let mockCline: any @@ -21,6 +31,10 @@ describe("applyDiffTool experiment routing", () => { beforeEach(() => { vi.clearAllMocks() + // Reset mocks to default behavior (XML protocol) + ;(resolveToolProtocol as any).mockReturnValue(TOOL_PROTOCOL.XML) + ;(isNativeProtocol as any).mockImplementation((protocol: any) => protocol === TOOL_PROTOCOL.NATIVE) + mockProvider = { getState: vi.fn(), } @@ -64,10 +78,10 @@ describe("applyDiffTool experiment routing", () => { }, }) - // Mock the legacy tool to resolve successfully - ;(applyDiffToolLegacy as any).mockResolvedValue(undefined) + // Mock the class-based tool to resolve successfully + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) - await applyDiffTool( + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -76,23 +90,21 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolLegacy).toHaveBeenCalledWith( - mockCline, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) }) it("should use legacy tool when experiments are not defined", async () => { mockProvider.getState.mockResolvedValue({}) - // Mock the legacy tool to resolve successfully - ;(applyDiffToolLegacy as any).mockResolvedValue(undefined) + // Mock the class-based tool to resolve successfully + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) - await applyDiffTool( + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -101,26 +113,24 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolLegacy).toHaveBeenCalledWith( - mockCline, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) }) - it("should use new tool when MULTI_FILE_APPLY_DIFF experiment is enabled", async () => { + it("should use multi-file tool when MULTI_FILE_APPLY_DIFF experiment is enabled and using XML protocol", async () => { mockProvider.getState.mockResolvedValue({ experiments: { [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, }, }) - // Mock the new tool behavior - it should continue with the new implementation - // Since we're not mocking the entire function, we'll just verify it doesn't call legacy - await applyDiffTool( + // Mock the new tool behavior - it should continue with the multi-file implementation + // Since we're not mocking the entire function, we'll just verify it doesn't call the class-based tool + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -129,13 +139,22 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolLegacy).not.toHaveBeenCalled() + expect(applyDiffToolClass.handle).not.toHaveBeenCalled() }) - it("should use new tool when provider is not available", async () => { - mockCline.providerRef.deref.mockReturnValue(null) + it("should use class-based tool when native protocol is enabled regardless of experiment", async () => { + // Enable native protocol + ;(resolveToolProtocol as any).mockReturnValue(TOOL_PROTOCOL.NATIVE) + ;(isNativeProtocol as any).mockReturnValue(true) - await applyDiffTool( + mockProvider.getState.mockResolvedValue({ + experiments: { + [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, + }, + }) + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) + + await multiApplyDiffTool( mockCline, mockBlock, mockAskApproval, @@ -144,7 +163,12 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - // When provider is null, it should continue with new implementation (not call legacy) - expect(applyDiffToolLegacy).not.toHaveBeenCalled() + // When native protocol is enabled, should always use class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) }) }) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index 1077b7bf390..ef6623ed934 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -7,84 +7,69 @@ import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { ClineSayTool } from "../../shared/ExtensionMessage" import { getReadablePath } from "../../utils/path" import { Task } from "../task/Task" -import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function applyDiffToolLegacy( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - let diffContent: string | undefined = block.params.diff - - if (diffContent && !cline.api.getModel().id.includes("claude")) { - diffContent = unescapeHtmlEntities(diffContent) - } - - const sharedMessageProps: ClineSayTool = { - tool: "appliedDiff", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - diff: diffContent, - } +interface ApplyDiffParams { + path: string + diff: string +} - try { - if (block.partial) { - // Update GUI message - let toolProgressStatus +export class ApplyDiffTool extends BaseTool<"apply_diff"> { + readonly name = "apply_diff" as const - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block) - } + parseLegacy(params: Partial>): ApplyDiffParams { + return { + path: params.path || "", + diff: params.diff || "", + } + } - if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { - return - } + async execute(params: ApplyDiffParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + let { path: relPath, diff: diffContent } = params - await cline - .ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus) - .catch(() => {}) + if (diffContent && !task.api.getModel().id.includes("claude")) { + diffContent = unescapeHtmlEntities(diffContent) + } - return - } else { + try { if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path")) + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") + pushToolResult(await task.sayAndCreateMissingParamError("apply_diff", "path")) return } if (!diffContent) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") - pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff")) + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") + pushToolResult(await task.sayAndCreateMissingParamError("apply_diff", "diff")) return } - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { - await cline.say("rooignore_error", relPath) + await task.say("rooignore_error", relPath) pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) return } - const absolutePath = path.resolve(cline.cwd, relPath) + const absolutePath = path.resolve(task.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { - cline.consecutiveMistakeCount++ - cline.recordToolError("apply_diff") + task.consecutiveMistakeCount++ + task.recordToolError("apply_diff") const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` - await cline.say("error", formattedError) + await task.say("error", formattedError) pushToolResult(formattedError) return } @@ -92,21 +77,21 @@ export async function applyDiffToolLegacy( const originalContent: string = await fs.readFile(absolutePath, "utf-8") // Apply the diff to the original content - const diffResult = (await cline.diffStrategy?.applyDiff( + const diffResult = (await task.diffStrategy?.applyDiff( originalContent, diffContent, - parseInt(block.params.start_line ?? ""), + parseInt(params.diff.match(/:start_line:(\d+)/)?.[1] ?? ""), )) ?? { success: false, error: "No diff strategy available", } if (!diffResult.success) { - cline.consecutiveMistakeCount++ - const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 - cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) + task.consecutiveMistakeCount++ + const currentCount = (task.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 + task.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) let formattedError = "" - TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount) + TelemetryService.instance.captureDiffApplicationError(task.taskId, currentCount) if (diffResult.failParts && diffResult.failParts.length > 0) { for (const failPart of diffResult.failParts) { @@ -129,17 +114,17 @@ export async function applyDiffToolLegacy( } if (currentCount >= 2) { - await cline.say("diff_error", formattedError) + await task.say("diff_error", formattedError) } - cline.recordToolError("apply_diff", formattedError) + task.recordToolError("apply_diff", formattedError) pushToolResult(formattedError) return } - cline.consecutiveMistakeCount = 0 - cline.consecutiveMistakeCountForApplyDiff.delete(relPath) + task.consecutiveMistakeCount = 0 + task.consecutiveMistakeCountForApplyDiff.delete(relPath) // Generate backend-unified diff for display in chat/webview const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, originalContent, diffResult.content) @@ -147,7 +132,7 @@ export async function applyDiffToolLegacy( const diffStats = computeDiffStats(unifiedPatch) || undefined // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() + const provider = task.providerRef.deref() const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS @@ -157,7 +142,13 @@ export async function applyDiffToolLegacy( ) // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: diffContent, + } if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view @@ -171,8 +162,14 @@ export async function applyDiffToolLegacy( let toolProgressStatus - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) + if (task.diffStrategy && task.diffStrategy.getProgressStatus) { + const block: ToolUse<"apply_diff"> = { + type: "tool_use", + name: "apply_diff", + params: { path: relPath, diff: diffContent }, + partial: false, + } + toolProgressStatus = task.diffStrategy.getProgressStatus(block, diffResult) } const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) @@ -182,9 +179,9 @@ export async function applyDiffToolLegacy( } // Save directly without showing diff view or opening the file - cline.diffViewProvider.editType = "modify" - cline.diffViewProvider.originalContent = originalContent - await cline.diffViewProvider.saveDirectly( + task.diffViewProvider.editType = "modify" + task.diffViewProvider.originalContent = originalContent + await task.diffViewProvider.saveDirectly( relPath, diffResult.content, false, @@ -194,10 +191,10 @@ export async function applyDiffToolLegacy( } else { // Original behavior with diff view // Show diff view before asking for approval - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(diffResult.content, true) - cline.diffViewProvider.scrollToFirstDiff() + task.diffViewProvider.editType = "modify" + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(diffResult.content, true) + task.diffViewProvider.scrollToFirstDiff() const completeMessage = JSON.stringify({ ...sharedMessageProps, @@ -209,29 +206,35 @@ export async function applyDiffToolLegacy( let toolProgressStatus - if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) { - toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult) + if (task.diffStrategy && task.diffStrategy.getProgressStatus) { + const block: ToolUse<"apply_diff"> = { + type: "tool_use", + name: "apply_diff", + params: { path: relPath, diff: diffContent }, + partial: false, + } + toolProgressStatus = task.diffStrategy.getProgressStatus(block, diffResult) } const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) if (!didApprove) { - await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view - cline.processQueuedMessages() + await task.diffViewProvider.revertChanges() + task.processQueuedMessages() return } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } // Track file edit operation if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } // Used to determine if we should wait for busy terminal to update before sending api request - cline.didEditFile = true + task.didEditFile = true let partFailHint = "" if (diffResult.failParts && diffResult.failParts.length > 0) { @@ -239,7 +242,7 @@ export async function applyDiffToolLegacy( } // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) // Check for single SEARCH/REPLACE block warning const searchBlocks = (diffContent.match(/<<<<<<< SEARCH/g) || []).length @@ -254,17 +257,42 @@ export async function applyDiffToolLegacy( pushToolResult(message + singleBlockNotice) } - await cline.diffViewProvider.reset() + await task.diffViewProvider.reset() // Process any queued messages after file edit completes - cline.processQueuedMessages() + task.processQueuedMessages() + + return + } catch (error) { + await handleError("applying diff", error as Error) + await task.diffViewProvider.reset() + task.processQueuedMessages() + return + } + } + + override async handlePartial(task: Task, block: ToolUse<"apply_diff">): Promise { + const relPath: string | undefined = block.params.path + const diffContent: string | undefined = block.params.diff + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath || ""), + diff: diffContent, + } + + let toolProgressStatus + + if (task.diffStrategy && task.diffStrategy.getProgressStatus) { + toolProgressStatus = task.diffStrategy.getProgressStatus(block) + } + + if (toolProgressStatus && Object.keys(toolProgressStatus).length === 0) { return } - } catch (error) { - await handleError("applying diff", error) - await cline.diffViewProvider.reset() - cline.processQueuedMessages() - return + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus).catch(() => {}) } } + +export const applyDiffTool = new ApplyDiffTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 8b07a7d911b..b1b9a8bca9c 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -79,6 +79,7 @@ export type NativeToolArgs = { attempt_completion: { result: string } execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } + apply_diff: { path: string; diff: string } // Add more tools as they are migrated to native protocol } From e5928ac9f5869994484ae6549cc101c636e2372c Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 11 Nov 2025 19:38:22 -0500 Subject: [PATCH 17/48] feat: migrate ask-followup-question to support native tool calling --- .../assistant-message/NativeToolCallParser.ts | 9 ++ .../presentAssistantMessage.ts | 6 +- .../__tests__/askFollowupQuestionTool.spec.ts | 42 +++--- src/core/tools/askFollowupQuestionTool.ts | 142 +++++++++++------- src/shared/tools.ts | 4 + 5 files changed, 117 insertions(+), 86 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e584119f714..3e1ed4bb178 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -127,6 +127,15 @@ export class NativeToolCallParser { } break + case "ask_followup_question": + if (args.question !== undefined && args.follow_up !== undefined) { + nativeArgs = { + question: args.question, + follow_up: args.follow_up, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 44a65b08968..f2854c4c15b 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -612,14 +612,12 @@ export async function presentAssistantMessage(cline: Task) { ) break case "ask_followup_question": - await askFollowupQuestionTool( - cline, - block, + await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { askApproval, handleError, pushToolResult, removeClosingTag, - ) + }) break case "switch_mode": await switchModeTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 6bddddcdf13..fe0145aaa18 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -31,14 +31,12 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool( - mockCline, - block, - vi.fn(), - vi.fn(), - mockPushToolResult, - vi.fn((tag, content) => content), - ) + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + }) expect(mockCline.ask).toHaveBeenCalledWith( "followup", @@ -58,14 +56,12 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool( - mockCline, - block, - vi.fn(), - vi.fn(), - mockPushToolResult, - vi.fn((tag, content) => content), - ) + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + }) expect(mockCline.ask).toHaveBeenCalledWith( "followup", @@ -87,14 +83,12 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool( - mockCline, - block, - vi.fn(), - vi.fn(), - mockPushToolResult, - vi.fn((tag, content) => content), - ) + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + }) expect(mockCline.ask).toHaveBeenCalledWith( "followup", diff --git a/src/core/tools/askFollowupQuestionTool.ts b/src/core/tools/askFollowupQuestionTool.ts index e7369368873..a54dd69c755 100644 --- a/src/core/tools/askFollowupQuestionTool.ts +++ b/src/core/tools/askFollowupQuestionTool.ts @@ -1,56 +1,35 @@ import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { parseXml } from "../../utils/xml" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function askFollowupQuestionTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const question: string | undefined = block.params.question - const follow_up: string | undefined = block.params.follow_up - - try { - if (block.partial) { - await cline.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {}) - return - } else { - if (!question) { - cline.consecutiveMistakeCount++ - cline.recordToolError("ask_followup_question") - pushToolResult(await cline.sayAndCreateMissingParamError("ask_followup_question", "question")) - return - } +interface Suggestion { + text: string + mode?: string +} - type Suggest = { answer: string; mode?: string } +interface AskFollowupQuestionParams { + question: string + follow_up: Suggestion[] +} - let follow_up_json = { - question, - suggest: [] as Suggest[], - } +export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { + readonly name = "ask_followup_question" as const - if (follow_up) { - // Define the actual structure returned by the XML parser - type ParsedSuggestion = string | { "#text": string; "@_mode"?: string } + parseLegacy(params: Partial>): AskFollowupQuestionParams { + const question = params.question || "" + const follow_up_xml = params.follow_up - let parsedSuggest: { - suggest: ParsedSuggestion[] | ParsedSuggestion - } + const suggestions: Suggestion[] = [] - try { - parsedSuggest = parseXml(follow_up, ["suggest"]) as { - suggest: ParsedSuggestion[] | ParsedSuggestion - } - } catch (error) { - cline.consecutiveMistakeCount++ - cline.recordToolError("ask_followup_question") - await cline.say("error", `Failed to parse operations: ${error.message}`) - pushToolResult(formatResponse.toolError("Invalid operations xml format")) - return + if (follow_up_xml) { + // Define the actual structure returned by the XML parser + type ParsedSuggestion = string | { "#text": string; "@_mode"?: string } + + try { + const parsedSuggest = parseXml(follow_up_xml, ["suggest"]) as { + suggest: ParsedSuggestion[] | ParsedSuggestion } const rawSuggestions = Array.isArray(parsedSuggest?.suggest) @@ -58,32 +37,79 @@ export async function askFollowupQuestionTool( : [parsedSuggest?.suggest].filter((sug): sug is ParsedSuggestion => sug !== undefined) // Transform parsed XML to our Suggest format - const normalizedSuggest: Suggest[] = rawSuggestions.map((sug) => { + for (const sug of rawSuggestions) { if (typeof sug === "string") { // Simple string suggestion (no mode attribute) - return { answer: sug } + suggestions.push({ text: sug }) } else { // XML object with text content and optional mode attribute - const result: Suggest = { answer: sug["#text"] } + const suggestion: Suggestion = { text: sug["#text"] } if (sug["@_mode"]) { - result.mode = sug["@_mode"] + suggestion.mode = sug["@_mode"] } - return result + suggestions.push(suggestion) } - }) + } + } catch (error) { + throw new Error( + `Failed to parse follow_up XML: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } - follow_up_json.suggest = normalizedSuggest + return { + question, + follow_up: suggestions, + } + } + + async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise { + const { question, follow_up } = params + const { handleError, pushToolResult } = callbacks + + try { + if (!question) { + task.consecutiveMistakeCount++ + task.recordToolError("ask_followup_question") + pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", "question")) + return + } + + // Transform follow_up suggestions to the format expected by task.ask + const follow_up_json = { + question, + suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })), } - cline.consecutiveMistakeCount = 0 - const { text, images } = await cline.ask("followup", JSON.stringify(follow_up_json), false) - await cline.say("user_feedback", text ?? "", images) + task.consecutiveMistakeCount = 0 + const { text, images } = await task.ask("followup", JSON.stringify(follow_up_json), false) + await task.say("user_feedback", text ?? "", images) pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) + } catch (error) { + await handleError("asking question", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise { + const question: string | undefined = block.params.question + await task.ask("followup", this.removeClosingTag("question", question), block.partial).catch(() => {}) + } - return + private removeClosingTag(tag: string, text: string | undefined): string { + if (!text) { + return "" } - } catch (error) { - await handleError("asking question", error) - return + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const askFollowupQuestionTool = new AskFollowupQuestionTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index b1b9a8bca9c..f96c382ac04 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -80,6 +80,10 @@ export type NativeToolArgs = { execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } apply_diff: { path: string; diff: string } + ask_followup_question: { + question: string + follow_up: Array<{ text: string; mode?: string }> + } // Add more tools as they are migrated to native protocol } From 5cf419c78e197996738fda03d2eb28e8892c65fa Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 09:26:43 -0500 Subject: [PATCH 18/48] feat: migrate browser action to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 12 + .../presentAssistantMessage.ts | 7 +- src/core/tools/browserActionTool.ts | 263 ++++++++++++------ src/shared/tools.ts | 2 + 4 files changed, 198 insertions(+), 86 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 3e1ed4bb178..c043ff2efbe 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -136,6 +136,18 @@ export class NativeToolCallParser { } break + case "browser_action": + if (args.action !== undefined) { + nativeArgs = { + action: args.action, + url: args.url, + coordinate: args.coordinate, + size: args.size, + text: args.text, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index f2854c4c15b..710cbfd22fc 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -586,7 +586,12 @@ export async function presentAssistantMessage(cline: Task) { await searchFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "browser_action": - await browserActionTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await browserActionTool.handle(cline, block as ToolUse<"browser_action">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "execute_command": console.log(`[NATIVE_TOOL] execute_command case matched, calling executeCommandTool.handle()`) diff --git a/src/core/tools/browserActionTool.ts b/src/core/tools/browserActionTool.ts index 13cb9b0ec26..082fcc89532 100644 --- a/src/core/tools/browserActionTool.ts +++ b/src/core/tools/browserActionTool.ts @@ -1,5 +1,6 @@ import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" import { BrowserAction, BrowserActionResult, @@ -8,115 +9,161 @@ import { } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" -export async function browserActionTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const action: BrowserAction | undefined = block.params.action as BrowserAction - const url: string | undefined = block.params.url - const coordinate: string | undefined = block.params.coordinate - const text: string | undefined = block.params.text - const size: string | undefined = block.params.size - - if (!action || !browserActions.includes(action)) { - // checking for action to ensure it is complete and valid - if (!block.partial) { - // if the block is complete and we don't have a valid action cline is a mistake - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "action")) - await cline.browserSession.closeBrowser() - } +export interface Coordinate { + x: number + y: number +} - return - } +export interface Size { + width: number + height: number +} - try { - if (block.partial) { - if (action === "launch") { - await cline.ask("browser_action_launch", removeClosingTag("url", url), block.partial).catch(() => {}) +export interface BrowserActionParams { + action: BrowserAction + url?: string + coordinate?: Coordinate + size?: Size + text?: string +} + +export class BrowserActionTool extends BaseTool<"browser_action"> { + readonly name = "browser_action" as const + + parseLegacy(params: Partial>): BrowserActionParams { + const action = params.action as BrowserAction | undefined + + // Parse coordinate if present - XML protocol sends "x,y" format + let coordinate: Coordinate | undefined + if (params.coordinate) { + // Try parsing as "x,y" string first (XML protocol) + const parts = params.coordinate.split(",") + if (parts.length === 2) { + const x = parseInt(parts[0], 10) + const y = parseInt(parts[1], 10) + if (!isNaN(x) && !isNaN(y)) { + coordinate = { x, y } + } } else { - await cline.say( - "browser_action", - JSON.stringify({ - action: action as BrowserAction, - coordinate: removeClosingTag("coordinate", coordinate), - text: removeClosingTag("text", text), - } satisfies ClineSayBrowserAction), - undefined, - block.partial, - ) + // Try parsing as JSON object (fallback) + try { + const parsed = JSON.parse(params.coordinate) + if (parsed && typeof parsed.x === "number" && typeof parsed.y === "number") { + coordinate = { x: parsed.x, y: parsed.y } + } + } catch (error) { + // Invalid coordinate format, leave undefined + } } + } + + // Parse size if present - XML protocol sends "width,height" format + let size: Size | undefined + if (params.size) { + // Try parsing as "width,height" string first (XML protocol) + const parts = params.size.split(",") + if (parts.length === 2) { + const width = parseInt(parts[0], 10) + const height = parseInt(parts[1], 10) + if (!isNaN(width) && !isNaN(height)) { + size = { width, height } + } + } else { + // Try parsing as JSON object (fallback) + try { + const parsed = JSON.parse(params.size) + if (parsed && typeof parsed.width === "number" && typeof parsed.height === "number") { + size = { width: parsed.width, height: parsed.height } + } + } catch (error) { + // Invalid size format, leave undefined + } + } + } + + return { + action: action!, + url: params.url, + coordinate, + size, + text: params.text, + } + } + + async execute(params: BrowserActionParams, task: Task, callbacks: ToolCallbacks): Promise { + const { action, url, coordinate, text, size } = params + const { handleError, pushToolResult } = callbacks + + // Validate action + if (!action || !browserActions.includes(action)) { + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "action")) + await task.browserSession.closeBrowser() return - } else { - // Initialize with empty object to avoid "used before assigned" errors + } + + try { let browserActionResult: BrowserActionResult = {} if (action === "launch") { if (!url) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "url")) - await cline.browserSession.closeBrowser() + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "url")) + await task.browserSession.closeBrowser() return } - cline.consecutiveMistakeCount = 0 - const didApprove = await askApproval("browser_action_launch", url) + task.consecutiveMistakeCount = 0 + const didApprove = await callbacks.askApproval("browser_action_launch", url) if (!didApprove) { return } - // NOTE: It's okay that we call cline message since the partial inspect_site is finished streaming. - // The only scenario we have to avoid is sending messages WHILE a partial message exists at the end of the messages array. - // For example the api_req_finished message would interfere with the partial message, so we needed to remove that. - // await cline.say("inspect_site_result", "") // No result, starts the loading spinner waiting for result - await cline.say("browser_action_result", "") // Starts loading spinner - await cline.browserSession.launchBrowser() - browserActionResult = await cline.browserSession.navigateToUrl(url) + await task.say("browser_action_result", "") + await task.browserSession.launchBrowser() + browserActionResult = await task.browserSession.navigateToUrl(url) } else { + // Validate parameters for specific actions if (action === "click" || action === "hover") { if (!coordinate) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "coordinate")) - await cline.browserSession.closeBrowser() - return // can't be within an inner switch + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "coordinate")) + await task.browserSession.closeBrowser() + return } } if (action === "type") { if (!text) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "text")) - await cline.browserSession.closeBrowser() + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "text")) + await task.browserSession.closeBrowser() return } } if (action === "resize") { if (!size) { - cline.consecutiveMistakeCount++ - cline.recordToolError("browser_action") - pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "size")) - await cline.browserSession.closeBrowser() + task.consecutiveMistakeCount++ + task.recordToolError("browser_action") + pushToolResult(await task.sayAndCreateMissingParamError("browser_action", "size")) + await task.browserSession.closeBrowser() return } } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 - await cline.say( + await task.say( "browser_action", JSON.stringify({ action: action as BrowserAction, - coordinate, + coordinate: coordinate ? `${coordinate.x},${coordinate.y}` : undefined, text, } satisfies ClineSayBrowserAction), undefined, @@ -125,25 +172,25 @@ export async function browserActionTool( switch (action) { case "click": - browserActionResult = await cline.browserSession.click(coordinate!) + browserActionResult = await task.browserSession.click(`${coordinate!.x},${coordinate!.y}`) break case "hover": - browserActionResult = await cline.browserSession.hover(coordinate!) + browserActionResult = await task.browserSession.hover(`${coordinate!.x},${coordinate!.y}`) break case "type": - browserActionResult = await cline.browserSession.type(text!) + browserActionResult = await task.browserSession.type(text!) break case "scroll_down": - browserActionResult = await cline.browserSession.scrollDown() + browserActionResult = await task.browserSession.scrollDown() break case "scroll_up": - browserActionResult = await cline.browserSession.scrollUp() + browserActionResult = await task.browserSession.scrollUp() break case "resize": - browserActionResult = await cline.browserSession.resize(size!) + browserActionResult = await task.browserSession.resize(`${size!.width},${size!.height}`) break case "close": - browserActionResult = await cline.browserSession.closeBrowser() + browserActionResult = await task.browserSession.closeBrowser() break } } @@ -156,7 +203,7 @@ export async function browserActionTool( case "scroll_down": case "scroll_up": case "resize": - await cline.say("browser_action_result", JSON.stringify(browserActionResult)) + await task.say("browser_action_result", JSON.stringify(browserActionResult)) pushToolResult( formatResponse.toolResult( @@ -166,23 +213,69 @@ export async function browserActionTool( browserActionResult?.screenshot ? [browserActionResult.screenshot] : [], ), ) - break + case "close": pushToolResult( formatResponse.toolResult( `The browser has been closed. You may now proceed to using other tools.`, ), ) - break } + } catch (error) { + await task.browserSession.closeBrowser() + await handleError("executing browser action", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"browser_action">): Promise { + const action: BrowserAction | undefined = block.params.action as BrowserAction + const url: string | undefined = block.params.url + const coordinate: string | undefined = block.params.coordinate + const text: string | undefined = block.params.text + if (!action || !browserActions.includes(action)) { return } - } catch (error) { - await cline.browserSession.closeBrowser() // if any error occurs, the browser session is terminated - await handleError("executing browser action", error) - return + + if (action === "launch") { + await task + .ask("browser_action_launch", this.removeClosingTag("url", url, block.partial), block.partial) + .catch(() => {}) + } else { + await task.say( + "browser_action", + JSON.stringify({ + action: action as BrowserAction, + coordinate: this.removeClosingTag("coordinate", coordinate, block.partial), + text: this.removeClosingTag("text", text, block.partial), + } satisfies ClineSayBrowserAction), + undefined, + block.partial, + ) + } + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } + + if (!text) { + return "" + } + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const browserActionTool = new BrowserActionTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f96c382ac04..2999af19cb0 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "@roo-code/types" import type { FileEntry } from "../core/tools/ReadFileTool" +import type { BrowserActionParams } from "../core/tools/browserActionTool" export type ToolResponse = string | Array @@ -84,6 +85,7 @@ export type NativeToolArgs = { question: string follow_up: Array<{ text: string; mode?: string }> } + browser_action: BrowserActionParams // Add more tools as they are migrated to native protocol } From fefd4c08bf84a20ca904416d65a8c0eeabc178d2 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 09:40:34 -0500 Subject: [PATCH 19/48] feat: migrate codebase_search to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 9 + .../presentAssistantMessage.ts | 7 +- src/core/tools/codebaseSearchTool.ts | 218 ++++++++++-------- src/shared/tools.ts | 1 + 4 files changed, 132 insertions(+), 103 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c043ff2efbe..e0281489b50 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -148,6 +148,15 @@ export class NativeToolCallParser { } break + case "codebase_search": + if (args.query !== undefined) { + nativeArgs = { + query: args.query, + path: args.path, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 710cbfd22fc..e33ef809a8f 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -570,7 +570,12 @@ export async function presentAssistantMessage(cline: Task) { }) break case "codebase_search": - await codebaseSearchTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "list_code_definition_names": await listCodeDefinitionNamesTool( diff --git a/src/core/tools/codebaseSearchTool.ts b/src/core/tools/codebaseSearchTool.ts index 700d1b7c7c5..6daefe77c19 100644 --- a/src/core/tools/codebaseSearchTool.ts +++ b/src/core/tools/codebaseSearchTool.ts @@ -1,130 +1,127 @@ import * as vscode from "vscode" +import path from "path" import { Task } from "../task/Task" import { CodeIndexManager } from "../../services/code-index/manager" import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" import { VectorStoreSearchResult } from "../../services/code-index/interfaces" -import { AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolUse } from "../../shared/tools" -import path from "path" - -export async function codebaseSearchTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const toolName = "codebase_search" - const workspacePath = (cline.cwd && cline.cwd.trim() !== '') ? cline.cwd : getWorkspacePath() - - if (!workspacePath) { - // This case should ideally not happen if Cline is initialized correctly - await handleError(toolName, new Error("Could not determine workspace path.")) - return - } +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" - // --- Parameter Extraction and Validation --- - let query: string | undefined = block.params.query - let directoryPrefix: string | undefined = block.params.path - - query = removeClosingTag("query", query) +interface CodebaseSearchParams { + query: string + path?: string +} - if (directoryPrefix) { - directoryPrefix = removeClosingTag("path", directoryPrefix) - directoryPrefix = path.normalize(directoryPrefix) - } +export class CodebaseSearchTool extends BaseTool<"codebase_search"> { + readonly name = "codebase_search" as const - const sharedMessageProps = { - tool: "codebaseSearch", - query: query, - path: directoryPrefix, - isOutsideWorkspace: false, - } + parseLegacy(params: Partial>): CodebaseSearchParams { + let query = params.query + let directoryPrefix = params.path - if (block.partial) { - await cline.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) - return - } + if (directoryPrefix) { + directoryPrefix = path.normalize(directoryPrefix) + } - if (!query) { - cline.consecutiveMistakeCount++ - pushToolResult(await cline.sayAndCreateMissingParamError(toolName, "query")) - return + return { + query: query || "", + path: directoryPrefix, + } } - const didApprove = await askApproval("tool", JSON.stringify(sharedMessageProps)) - if (!didApprove) { - pushToolResult(formatResponse.toolDenied()) - return - } + async execute(params: CodebaseSearchParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + const { query, path: directoryPrefix } = params - cline.consecutiveMistakeCount = 0 + const workspacePath = task.cwd && task.cwd.trim() !== "" ? task.cwd : getWorkspacePath() - // --- Core Logic --- - try { - const context = cline.providerRef.deref()?.context - if (!context) { - throw new Error("Extension context is not available.") + if (!workspacePath) { + await handleError("codebase_search", new Error("Could not determine workspace path.")) + return } - const manager = CodeIndexManager.getInstance(context) - - if (!manager) { - throw new Error("CodeIndexManager is not available.") + if (!query) { + task.consecutiveMistakeCount++ + pushToolResult(await task.sayAndCreateMissingParamError("codebase_search", "query")) + return } - if (!manager.isFeatureEnabled) { - throw new Error("Code Indexing is disabled in the settings.") - } - if (!manager.isFeatureConfigured) { - throw new Error("Code Indexing is not configured (Missing OpenAI Key or Qdrant URL).") + const sharedMessageProps = { + tool: "codebaseSearch", + query: query, + path: directoryPrefix, + isOutsideWorkspace: false, } - const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix) - - // 3. Format and push results - if (!searchResults || searchResults.length === 0) { - pushToolResult(`No relevant code snippets found for the query: "${query}"`) // Use simple string for no results + const didApprove = await askApproval("tool", JSON.stringify(sharedMessageProps)) + if (!didApprove) { + pushToolResult(formatResponse.toolDenied()) return } - const jsonResult = { - query, - results: [], - } as { - query: string - results: Array<{ - filePath: string - score: number - startLine: number - endLine: number - codeChunk: string - }> - } - - searchResults.forEach((result) => { - if (!result.payload) return - if (!("filePath" in result.payload)) return - - const relativePath = vscode.workspace.asRelativePath(result.payload.filePath, false) - - jsonResult.results.push({ - filePath: relativePath, - score: result.score, - startLine: result.payload.startLine, - endLine: result.payload.endLine, - codeChunk: result.payload.codeChunk.trim(), + task.consecutiveMistakeCount = 0 + + try { + const context = task.providerRef.deref()?.context + if (!context) { + throw new Error("Extension context is not available.") + } + + const manager = CodeIndexManager.getInstance(context) + + if (!manager) { + throw new Error("CodeIndexManager is not available.") + } + + if (!manager.isFeatureEnabled) { + throw new Error("Code Indexing is disabled in the settings.") + } + if (!manager.isFeatureConfigured) { + throw new Error("Code Indexing is not configured (Missing OpenAI Key or Qdrant URL).") + } + + const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix) + + if (!searchResults || searchResults.length === 0) { + pushToolResult(`No relevant code snippets found for the query: "${query}"`) + return + } + + const jsonResult = { + query, + results: [], + } as { + query: string + results: Array<{ + filePath: string + score: number + startLine: number + endLine: number + codeChunk: string + }> + } + + searchResults.forEach((result) => { + if (!result.payload) return + if (!("filePath" in result.payload)) return + + const relativePath = vscode.workspace.asRelativePath(result.payload.filePath, false) + + jsonResult.results.push({ + filePath: relativePath, + score: result.score, + startLine: result.payload.startLine, + endLine: result.payload.endLine, + codeChunk: result.payload.codeChunk.trim(), + }) }) - }) - // Send results to UI - const payload = { tool: "codebaseSearch", content: jsonResult } - await cline.say("codebase_search_result", JSON.stringify(payload)) + const payload = { tool: "codebaseSearch", content: jsonResult } + await task.say("codebase_search_result", JSON.stringify(payload)) - // Push results to AI - const output = `Query: ${query} + const output = `Query: ${query} Results: ${jsonResult.results @@ -137,8 +134,25 @@ Code Chunk: ${result.codeChunk} ) .join("\n")}` - pushToolResult(output) - } catch (error: any) { - await handleError(toolName, error) // Use the standard error handler + pushToolResult(output) + } catch (error: any) { + await handleError("codebase_search", error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"codebase_search">): Promise { + const query: string | undefined = block.params.query + const directoryPrefix: string | undefined = block.params.path + + const sharedMessageProps = { + tool: "codebaseSearch", + query: query, + path: directoryPrefix, + isOutsideWorkspace: false, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } } + +export const codebaseSearchTool = new CodebaseSearchTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 2999af19cb0..e600dfe104b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -86,6 +86,7 @@ export type NativeToolArgs = { follow_up: Array<{ text: string; mode?: string }> } browser_action: BrowserActionParams + codebase_search: { query: string; path?: string } // Add more tools as they are migrated to native protocol } From 910743e90551d41d9b1d83ac3c693623dcf7d3b9 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 09:48:08 -0500 Subject: [PATCH 20/48] feat: migrate fetch instructions to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 8 ++ .../presentAssistantMessage.ts | 7 +- src/core/tools/fetchInstructionsTool.ts | 81 +++++++++++-------- src/shared/tools.ts | 1 + 4 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e0281489b50..f95ee7a7d6a 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -157,6 +157,14 @@ export class NativeToolCallParser { } break + case "fetch_instructions": + if (args.task !== undefined) { + nativeArgs = { + task: args.task, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index e33ef809a8f..35b5f4a52e8 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -559,7 +559,12 @@ export async function presentAssistantMessage(cline: Task) { } break case "fetch_instructions": - await fetchInstructionsTool(cline, block, askApproval, handleError, pushToolResult) + await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "list_files": await listFilesTool.handle(cline, block as ToolUse<"list_files">, { diff --git a/src/core/tools/fetchInstructionsTool.ts b/src/core/tools/fetchInstructionsTool.ts index 5325f98fbf4..d1610d9ee44 100644 --- a/src/core/tools/fetchInstructionsTool.ts +++ b/src/core/tools/fetchInstructionsTool.ts @@ -2,62 +2,77 @@ import { Task } from "../task/Task" import { fetchInstructions } from "../prompts/instructions/instructions" import { ClineSayTool } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" -import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../shared/tools" - -export async function fetchInstructionsTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, -) { - const task: string | undefined = block.params.task - const sharedMessageProps: ClineSayTool = { tool: "fetchInstructions", content: task } - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { - if (!task) { - cline.consecutiveMistakeCount++ - cline.recordToolError("fetch_instructions") - pushToolResult(await cline.sayAndCreateMissingParamError("fetch_instructions", "task")) +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface FetchInstructionsParams { + task: string +} + +export class FetchInstructionsTool extends BaseTool<"fetch_instructions"> { + readonly name = "fetch_instructions" as const + + parseLegacy(params: Partial>): FetchInstructionsParams { + return { + task: params.task || "", + } + } + + async execute(params: FetchInstructionsParams, task: Task, callbacks: ToolCallbacks): Promise { + const { handleError, pushToolResult, askApproval } = callbacks + const { task: taskParam } = params + + try { + if (!taskParam) { + task.consecutiveMistakeCount++ + task.recordToolError("fetch_instructions") + pushToolResult(await task.sayAndCreateMissingParamError("fetch_instructions", "task")) return } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 + + const completeMessage = JSON.stringify({ + tool: "fetchInstructions", + content: taskParam, + } satisfies ClineSayTool) - const completeMessage = JSON.stringify({ ...sharedMessageProps, content: task } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage) if (!didApprove) { return } - // Bow fetch the content and provide it to the agent. - const provider = cline.providerRef.deref() + // Now fetch the content and provide it to the agent. + const provider = task.providerRef.deref() const mcpHub = provider?.getMcpHub() if (!mcpHub) { throw new Error("MCP hub not available") } - const diffStrategy = cline.diffStrategy + const diffStrategy = task.diffStrategy const context = provider?.context - const content = await fetchInstructions(task, { mcpHub, diffStrategy, context }) + const content = await fetchInstructions(taskParam, { mcpHub, diffStrategy, context }) if (!content) { - pushToolResult(formatResponse.toolError(`Invalid instructions request: ${task}`)) + pushToolResult(formatResponse.toolError(`Invalid instructions request: ${taskParam}`)) return } pushToolResult(content) - - return + } catch (error) { + await handleError("fetch instructions", error as Error) } - } catch (error) { - await handleError("fetch instructions", error) + } + + override async handlePartial(task: Task, block: ToolUse<"fetch_instructions">): Promise { + const taskParam: string | undefined = block.params.task + const sharedMessageProps: ClineSayTool = { tool: "fetchInstructions", content: taskParam } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) } } + +export const fetchInstructionsTool = new FetchInstructionsTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index e600dfe104b..bba5927a048 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -87,6 +87,7 @@ export type NativeToolArgs = { } browser_action: BrowserActionParams codebase_search: { query: string; path?: string } + fetch_instructions: { task: string } // Add more tools as they are migrated to native protocol } From f4090bb2dcbcd4de99cddfcf8d8c721a85cf4d85 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 10:03:48 -0500 Subject: [PATCH 21/48] feat: migrate generate image to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 10 + .../presentAssistantMessage.ts | 8 +- .../tools/__tests__/generateImageTool.test.ts | 126 ++++---- src/core/tools/generateImageTool.ts | 273 ++++++++---------- src/shared/tools.ts | 2 + 5 files changed, 201 insertions(+), 218 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index f95ee7a7d6a..1f07399ab51 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -165,6 +165,16 @@ export class NativeToolCallParser { } break + case "generate_image": + if (args.prompt !== undefined && args.path !== undefined) { + nativeArgs = { + prompt: args.prompt, + path: args.path, + image: args.image, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 35b5f4a52e8..d06c398237c 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -665,7 +665,13 @@ export async function presentAssistantMessage(cline: Task) { await runSlashCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "generate_image": - await generateImageTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await checkpointSaveAndMark(cline) + await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break } diff --git a/src/core/tools/__tests__/generateImageTool.test.ts b/src/core/tools/__tests__/generateImageTool.test.ts index 940490ecfbe..7e10237e8ce 100644 --- a/src/core/tools/__tests__/generateImageTool.test.ts +++ b/src/core/tools/__tests__/generateImageTool.test.ts @@ -82,14 +82,12 @@ describe("generateImageTool", () => { partial: true, } - await generateImageTool( - mockCline as Task, - partialBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should not process anything when partial expect(mockAskApproval).not.toHaveBeenCalled() @@ -109,14 +107,12 @@ describe("generateImageTool", () => { partial: true, } - await generateImageTool( - mockCline as Task, - partialBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should not process anything when partial expect(mockAskApproval).not.toHaveBeenCalled() @@ -149,14 +145,12 @@ describe("generateImageTool", () => { }) as any, ) - await generateImageTool( - mockCline as Task, - completeBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should process the complete block expect(mockAskApproval).toHaveBeenCalled() @@ -192,14 +186,12 @@ describe("generateImageTool", () => { }) as any, ) - await generateImageTool( - mockCline as Task, - completeBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Check that cline.say was called with image data containing cache-busting parameter expect(mockCline.say).toHaveBeenCalledWith("image", expect.stringMatching(/"imageUri":"[^"]+\?t=\d+"/)) @@ -230,14 +222,12 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.recordToolError).toHaveBeenCalledWith("generate_image") @@ -255,14 +245,12 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.recordToolError).toHaveBeenCalledWith("generate_image") @@ -290,14 +278,12 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockPushToolResult).toHaveBeenCalledWith( formatResponse.toolError( @@ -322,14 +308,12 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Input image not found")) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Input image not found")) @@ -347,14 +331,12 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool( - mockCline as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Unsupported image format")) expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Unsupported image format")) diff --git a/src/core/tools/generateImageTool.ts b/src/core/tools/generateImageTool.ts index 88a02ac8212..a66b7e38f66 100644 --- a/src/core/tools/generateImageTool.ts +++ b/src/core/tools/generateImageTool.ts @@ -3,171 +3,165 @@ import fs from "fs/promises" import * as vscode from "vscode" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { fileExistsAtPath } from "../../utils/fs" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { OpenRouterHandler } from "../../api/providers/openrouter" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -// Hardcoded list of image generation models for now const IMAGE_GENERATION_MODELS = ["google/gemini-2.5-flash-image", "openai/gpt-5-image", "openai/gpt-5-image-mini"] -export async function generateImageTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const prompt: string | undefined = block.params.prompt - const relPath: string | undefined = block.params.path - const inputImagePath: string | undefined = block.params.image - - // Check if the experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const isImageGenerationEnabled = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.IMAGE_GENERATION) - - if (!isImageGenerationEnabled) { - pushToolResult( - formatResponse.toolError( - "Image generation is an experimental feature that must be enabled in settings. Please enable 'Image Generation' in the Experimental Settings section.", - ), - ) - return - } +export interface GenerateImageParams { + prompt: string + path: string + image?: string +} - if (block.partial) { - return - } +export class GenerateImageTool extends BaseTool<"generate_image"> { + readonly name = "generate_image" as const - if (!prompt) { - cline.consecutiveMistakeCount++ - cline.recordToolError("generate_image") - pushToolResult(await cline.sayAndCreateMissingParamError("generate_image", "prompt")) - return - } - - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("generate_image") - pushToolResult(await cline.sayAndCreateMissingParamError("generate_image", "path")) - return + parseLegacy(params: Partial>): GenerateImageParams { + return { + prompt: params.prompt || "", + path: params.path || "", + image: params.image, + } } - // Validate access permissions - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return - } + async execute(params: GenerateImageParams, task: Task, callbacks: ToolCallbacks): Promise { + const { prompt, path: relPath, image: inputImagePath } = params + const { handleError, pushToolResult, askApproval, removeClosingTag } = callbacks - // If input image is provided, validate it exists and can be read - let inputImageData: string | undefined - if (inputImagePath) { - const inputImageFullPath = path.resolve(cline.cwd, inputImagePath) + const provider = task.providerRef.deref() + const state = await provider?.getState() + const isImageGenerationEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.IMAGE_GENERATION, + ) - // Check if input image exists - const inputImageExists = await fileExistsAtPath(inputImageFullPath) - if (!inputImageExists) { - await cline.say("error", `Input image not found: ${getReadablePath(cline.cwd, inputImagePath)}`) + if (!isImageGenerationEnabled) { pushToolResult( - formatResponse.toolError(`Input image not found: ${getReadablePath(cline.cwd, inputImagePath)}`), + formatResponse.toolError( + "Image generation is an experimental feature that must be enabled in settings. Please enable 'Image Generation' in the Experimental Settings section.", + ), ) return } - // Validate input image access permissions - const inputImageAccessAllowed = cline.rooIgnoreController?.validateAccess(inputImagePath) - if (!inputImageAccessAllowed) { - await cline.say("rooignore_error", inputImagePath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(inputImagePath))) + if (!prompt) { + task.consecutiveMistakeCount++ + task.recordToolError("generate_image") + pushToolResult(await task.sayAndCreateMissingParamError("generate_image", "prompt")) return } - // Read the input image file - try { - const imageBuffer = await fs.readFile(inputImageFullPath) - const imageExtension = path.extname(inputImageFullPath).toLowerCase().replace(".", "") + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("generate_image") + pushToolResult(await task.sayAndCreateMissingParamError("generate_image", "path")) + return + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + return + } + + let inputImageData: string | undefined + if (inputImagePath) { + const inputImageFullPath = path.resolve(task.cwd, inputImagePath) - // Validate image format - const supportedFormats = ["png", "jpg", "jpeg", "gif", "webp"] - if (!supportedFormats.includes(imageExtension)) { - await cline.say( + const inputImageExists = await fileExistsAtPath(inputImageFullPath) + if (!inputImageExists) { + await task.say("error", `Input image not found: ${getReadablePath(task.cwd, inputImagePath)}`) + pushToolResult( + formatResponse.toolError(`Input image not found: ${getReadablePath(task.cwd, inputImagePath)}`), + ) + return + } + + const inputImageAccessAllowed = task.rooIgnoreController?.validateAccess(inputImagePath) + if (!inputImageAccessAllowed) { + await task.say("rooignore_error", inputImagePath) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(inputImagePath))) + return + } + + try { + const imageBuffer = await fs.readFile(inputImageFullPath) + const imageExtension = path.extname(inputImageFullPath).toLowerCase().replace(".", "") + + const supportedFormats = ["png", "jpg", "jpeg", "gif", "webp"] + if (!supportedFormats.includes(imageExtension)) { + await task.say( + "error", + `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, + ) + pushToolResult( + formatResponse.toolError( + `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, + ), + ) + return + } + + const mimeType = imageExtension === "jpg" ? "jpeg" : imageExtension + inputImageData = `data:image/${mimeType};base64,${imageBuffer.toString("base64")}` + } catch (error) { + await task.say( "error", - `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, + `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, ) pushToolResult( formatResponse.toolError( - `Unsupported image format: ${imageExtension}. Supported formats: ${supportedFormats.join(", ")}`, + `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, ), ) return } + } - // Convert to base64 data URL - const mimeType = imageExtension === "jpg" ? "jpeg" : imageExtension - inputImageData = `data:image/${mimeType};base64,${imageBuffer.toString("base64")}` - } catch (error) { - await cline.say( + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const openRouterApiKey = state?.openRouterImageApiKey + + if (!openRouterApiKey) { + await task.say( "error", - `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, + "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", ) pushToolResult( formatResponse.toolError( - `Failed to read input image: ${error instanceof Error ? error.message : "Unknown error"}`, + "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", ), ) return } - } - // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false + const selectedModel = state?.openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0] - // Get OpenRouter API key from global settings (experimental image generation) - const openRouterApiKey = state?.openRouterImageApiKey + const fullPath = path.resolve(task.cwd, removeClosingTag("path", relPath)) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - if (!openRouterApiKey) { - await cline.say( - "error", - "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", - ) - pushToolResult( - formatResponse.toolError( - "OpenRouter API key is required for image generation. Please configure it in the Image Generation experimental settings.", - ), - ) - return - } - - // Get selected model from settings or use default - const selectedModel = state?.openRouterImageGenerationSelectedModel || IMAGE_GENERATION_MODELS[0] - - // Determine if the path is outside the workspace - const fullPath = path.resolve(cline.cwd, removeClosingTag("path", relPath)) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - - const sharedMessageProps = { - tool: "generateImage" as const, - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - content: prompt, - isOutsideWorkspace, - isProtected: isWriteProtected, - } + const sharedMessageProps = { + tool: "generateImage" as const, + path: getReadablePath(task.cwd, removeClosingTag("path", relPath)), + content: prompt, + isOutsideWorkspace, + isProtected: isWriteProtected, + } - try { - if (!block.partial) { - cline.consecutiveMistakeCount = 0 + try { + task.consecutiveMistakeCount = 0 - // Ask for approval before generating the image const approvalMessage = JSON.stringify({ ...sharedMessageProps, content: prompt, - ...(inputImagePath && { inputImage: getReadablePath(cline.cwd, inputImagePath) }), + ...(inputImagePath && { inputImage: getReadablePath(task.cwd, inputImagePath) }), }) const didApprove = await askApproval("tool", approvalMessage, undefined, isWriteProtected) @@ -176,10 +170,8 @@ export async function generateImageTool( return } - // Create a temporary OpenRouter handler with minimal options const openRouterHandler = new OpenRouterHandler({} as any) - // Call the generateImage method with the explicit API key and optional input image const result = await openRouterHandler.generateImage( prompt, selectedModel, @@ -188,23 +180,22 @@ export async function generateImageTool( ) if (!result.success) { - await cline.say("error", result.error || "Failed to generate image") + await task.say("error", result.error || "Failed to generate image") pushToolResult(formatResponse.toolError(result.error || "Failed to generate image")) return } if (!result.imageData) { const errorMessage = "No image data received" - await cline.say("error", errorMessage) + await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) return } - // Extract base64 data from data URL const base64Match = result.imageData.match(/^data:image\/(png|jpeg|jpg);base64,(.+)$/) if (!base64Match) { const errorMessage = "Invalid image format received" - await cline.say("error", errorMessage) + await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) return } @@ -212,52 +203,44 @@ export async function generateImageTool( const imageFormat = base64Match[1] const base64Data = base64Match[2] - // Ensure the file has the correct extension let finalPath = relPath if (!finalPath.match(/\.(png|jpg|jpeg)$/i)) { finalPath = `${finalPath}.${imageFormat === "jpeg" ? "jpg" : imageFormat}` } - // Convert base64 to buffer const imageBuffer = Buffer.from(base64Data, "base64") - // Create directory if it doesn't exist - const absolutePath = path.resolve(cline.cwd, finalPath) + const absolutePath = path.resolve(task.cwd, finalPath) const directory = path.dirname(absolutePath) await fs.mkdir(directory, { recursive: true }) - // Write the image file await fs.writeFile(absolutePath, imageBuffer) - // Track file creation if (finalPath) { - await cline.fileContextTracker.trackFileContext(finalPath, "roo_edited") + await task.fileContextTracker.trackFileContext(finalPath, "roo_edited") } - cline.didEditFile = true + task.didEditFile = true - // Record successful tool usage - cline.recordToolUsage("generate_image") + task.recordToolUsage("generate_image") - // Get the webview URI for the image - const provider = cline.providerRef.deref() - const fullImagePath = path.join(cline.cwd, finalPath) + const fullImagePath = path.join(task.cwd, finalPath) - // Convert to webview URI if provider is available let imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString() - // Add cache-busting parameter to prevent browser caching issues const cacheBuster = Date.now() imageUri = imageUri.includes("?") ? `${imageUri}&t=${cacheBuster}` : `${imageUri}?t=${cacheBuster}` - // Send the image with the webview URI - await cline.say("image", JSON.stringify({ imageUri, imagePath: fullImagePath })) - pushToolResult(formatResponse.toolResult(getReadablePath(cline.cwd, finalPath))) - - return + await task.say("image", JSON.stringify({ imageUri, imagePath: fullImagePath })) + pushToolResult(formatResponse.toolResult(getReadablePath(task.cwd, finalPath))) + } catch (error) { + await handleError("generating image", error as Error) } - } catch (error) { - await handleError("generating image", error) + } + + override async handlePartial(task: Task, block: ToolUse<"generate_image">): Promise { return } } + +export const generateImageTool = new GenerateImageTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index bba5927a048..ca54e63bdf4 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -3,6 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "@roo-code/types" import type { FileEntry } from "../core/tools/ReadFileTool" import type { BrowserActionParams } from "../core/tools/browserActionTool" +import { GenerateImageParams } from "../core/tools/generateImageTool" export type ToolResponse = string | Array @@ -88,6 +89,7 @@ export type NativeToolArgs = { browser_action: BrowserActionParams codebase_search: { query: string; path?: string } fetch_instructions: { task: string } + generate_image: GenerateImageParams // Add more tools as they are migrated to native protocol } From 31d15a70e7f48e2958f21b4a84a85d6c7fa18c05 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 10:15:17 -0500 Subject: [PATCH 22/48] feat migrate list code definitions to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 8 ++ .../presentAssistantMessage.ts | 6 +- .../listCodeDefinitionNamesTool.spec.ts | 112 ++++++++---------- src/core/tools/listCodeDefinitionNamesTool.ts | 97 ++++++++------- src/shared/tools.ts | 1 + 5 files changed, 116 insertions(+), 108 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 1f07399ab51..898786f3e42 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -175,6 +175,14 @@ export class NativeToolCallParser { } break + case "list_code_definition_names": + if (args.path !== undefined) { + nativeArgs = { + path: args.path, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index d06c398237c..8a9201fc072 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -583,14 +583,12 @@ export async function presentAssistantMessage(cline: Task) { }) break case "list_code_definition_names": - await listCodeDefinitionNamesTool( - cline, - block, + await listCodeDefinitionNamesTool.handle(cline, block as ToolUse<"list_code_definition_names">, { askApproval, handleError, pushToolResult, removeClosingTag, - ) + }) break case "search_files": await searchFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) diff --git a/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts b/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts index 7a26c2f8eeb..52e27a2ce7e 100644 --- a/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts +++ b/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts @@ -80,14 +80,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockPushToolResult).toHaveBeenCalledWith(mockDefinitions) }) @@ -118,14 +116,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockPushToolResult).toHaveBeenCalledWith(mockDefinitions) }) @@ -156,14 +152,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should only include definitions starting at or before line 25 const expectedResult = `# test.ts @@ -197,14 +191,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should include foo (starts at 10) but not bar (starts at 60) const expectedResult = `# test.ts @@ -239,14 +231,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should include foo and bar but not baz const expectedResult = `# test.ts @@ -280,14 +270,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Should keep header but exclude all definitions beyond line 50 const expectedResult = `# test.ts` @@ -306,14 +294,12 @@ describe("listCodeDefinitionNamesTool", () => { mockTask.sayAndCreateMissingParamError = vi.fn(async () => "Missing parameter: path") - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("list_code_definition_names") @@ -337,14 +323,12 @@ describe("listCodeDefinitionNamesTool", () => { partial: false, } - await listCodeDefinitionNamesTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await listCodeDefinitionNamesTool.handle(mockTask as Task, block as ToolUse<"list_code_definition_names">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockPushToolResult).toHaveBeenCalledWith(mockDefinitions) }) diff --git a/src/core/tools/listCodeDefinitionNamesTool.ts b/src/core/tools/listCodeDefinitionNamesTool.ts index 0ec80ce9bd0..981b508ee32 100644 --- a/src/core/tools/listCodeDefinitionNamesTool.ts +++ b/src/core/tools/listCodeDefinitionNamesTool.ts @@ -1,7 +1,6 @@ import path from "path" import fs from "fs/promises" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" import { getReadablePath } from "../../utils/path" @@ -9,59 +8,61 @@ import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { parseSourceCodeForDefinitionsTopLevel, parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function listCodeDefinitionNamesTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - - // Calculate if the path is outside workspace - const absolutePath = relPath ? path.resolve(cline.cwd, relPath) : cline.cwd - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - - const sharedMessageProps: ClineSayTool = { - tool: "listCodeDefinitionNames", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - isOutsideWorkspace, +interface ListCodeDefinitionNamesParams { + path: string +} + +export class ListCodeDefinitionNamesTool extends BaseTool<"list_code_definition_names"> { + readonly name = "list_code_definition_names" as const + + parseLegacy(params: Partial>): ListCodeDefinitionNamesParams { + return { + path: params.path || "", + } } - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + async execute(params: ListCodeDefinitionNamesParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + const { path: relPath } = params + + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("list_code_definition_names") + pushToolResult(await task.sayAndCreateMissingParamError("list_code_definition_names", "path")) return - } else { - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("list_code_definition_names") - pushToolResult(await cline.sayAndCreateMissingParamError("list_code_definition_names", "path")) - return - } + } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 + const absolutePath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "listCodeDefinitionNames", + path: getReadablePath(task.cwd, relPath), + isOutsideWorkspace, + } + + try { let result: string try { const stats = await fs.stat(absolutePath) if (stats.isFile()) { - const fileResult = await parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController) + const fileResult = await parseSourceCodeDefinitionsForFile(absolutePath, task.rooIgnoreController) - // Apply truncation based on maxReadFileLine setting if (fileResult) { - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} + const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} result = truncateDefinitionsToLineLimit(fileResult, maxReadFileLine) } else { result = "No source code definitions found in file." } } else if (stats.isDirectory()) { - result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, cline.rooIgnoreController) + result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, task.rooIgnoreController) } else { result = "The specified path is neither a file nor a directory." } @@ -77,14 +78,30 @@ export async function listCodeDefinitionNamesTool( } if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) } pushToolResult(result) - return + } catch (error) { + await handleError("parsing source code definitions", error as Error) } - } catch (error) { - await handleError("parsing source code definitions", error) - return + } + + override async handlePartial(task: Task, block: ToolUse<"list_code_definition_names">): Promise { + const relPath: string | undefined = block.params.path + + const absolutePath = relPath ? path.resolve(task.cwd, relPath) : task.cwd + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "listCodeDefinitionNames", + path: getReadablePath(task.cwd, relPath || ""), + isOutsideWorkspace, + } + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) } } + +export const listCodeDefinitionNamesTool = new ListCodeDefinitionNamesTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index ca54e63bdf4..d37cca316e8 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -90,6 +90,7 @@ export type NativeToolArgs = { codebase_search: { query: string; path?: string } fetch_instructions: { task: string } generate_image: GenerateImageParams + list_code_definition_names: { path: string } // Add more tools as they are migrated to native protocol } From 15b7e853f506aadb865987e56c97841f66053c3e Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 10:23:52 -0500 Subject: [PATCH 23/48] feat: migrate run slash command to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 9 + .../presentAssistantMessage.ts | 7 +- .../__tests__/runSlashCommandTool.spec.ts | 154 +++++------------- src/core/tools/runSlashCommandTool.ts | 106 ++++++++---- src/shared/tools.ts | 1 + 5 files changed, 128 insertions(+), 149 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 898786f3e42..1ccfc25caeb 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -183,6 +183,15 @@ export class NativeToolCallParser { } break + case "run_slash_command": + if (args.command !== undefined) { + nativeArgs = { + command: args.command, + args: args.args, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 8a9201fc072..08b31c3e99d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -660,7 +660,12 @@ export async function presentAssistantMessage(cline: Task) { break } case "run_slash_command": - await runSlashCommandTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "generate_image": await checkpointSaveAndMark(cline) diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index 07143e96cc1..f71fe0bd13d 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -3,6 +3,7 @@ import { runSlashCommandTool } from "../runSlashCommandTool" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { getCommand, getCommandNames } from "../../../services/command/commands" +import type { ToolUse } from "../../../shared/tools" // Mock dependencies vi.mock("../../../services/command/commands", () => ({ @@ -12,10 +13,7 @@ vi.mock("../../../services/command/commands", () => ({ describe("runSlashCommandTool", () => { let mockTask: any - let mockAskApproval: any - let mockHandleError: any - let mockPushToolResult: any - let mockRemoveClosingTag: any + let mockCallbacks: any beforeEach(() => { vi.clearAllMocks() @@ -24,7 +22,7 @@ describe("runSlashCommandTool", () => { consecutiveMistakeCount: 0, recordToolError: vi.fn(), sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), - ask: vi.fn(), + ask: vi.fn().mockResolvedValue({}), cwd: "/test/project", providerRef: { deref: vi.fn().mockReturnValue({ @@ -37,37 +35,32 @@ describe("runSlashCommandTool", () => { }, } - mockAskApproval = vi.fn().mockResolvedValue(true) - mockHandleError = vi.fn() - mockPushToolResult = vi.fn() - mockRemoveClosingTag = vi.fn((tag, text) => text || "") + mockCallbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((tag, text) => text || ""), + } }) it("should handle missing command parameter", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: {}, partial: false, } - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("run_slash_command") expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("run_slash_command", "command") - expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error") + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Missing parameter error") }) it("should handle command not found", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -79,23 +72,16 @@ describe("runSlashCommandTool", () => { vi.mocked(getCommand).mockResolvedValue(undefined) vi.mocked(getCommandNames).mockResolvedValue(["init", "test", "deploy"]) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.recordToolError).toHaveBeenCalledWith("run_slash_command") - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( formatResponse.toolError("Command 'nonexistent' not found. Available commands: init, test, deploy"), ) }) it("should handle user rejection", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -113,23 +99,16 @@ describe("runSlashCommandTool", () => { } vi.mocked(getCommand).mockResolvedValue(mockCommand) - mockAskApproval.mockResolvedValue(false) - - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + mockCallbacks.askApproval.mockResolvedValue(false) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockAskApproval).toHaveBeenCalled() - expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCallbacks.askApproval).toHaveBeenCalled() + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() }) it("should successfully execute built-in command", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -148,16 +127,9 @@ describe("runSlashCommandTool", () => { vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockAskApproval).toHaveBeenCalledWith( + expect(mockCallbacks.askApproval).toHaveBeenCalledWith( "tool", JSON.stringify({ tool: "runSlashCommand", @@ -168,7 +140,7 @@ describe("runSlashCommandTool", () => { }), ) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( `Command: /init Description: Analyze codebase and create AGENTS.md Source: built-in @@ -180,7 +152,7 @@ Initialize project content here`, }) it("should successfully execute command with arguments", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -201,16 +173,9 @@ Initialize project content here`, vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( `Command: /test Description: Run project tests Argument hint: test type or focus area @@ -224,7 +189,7 @@ Run tests with specific focus`, }) it("should handle global command", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -242,16 +207,9 @@ Run tests with specific focus`, vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( `Command: /deploy Source: global @@ -262,7 +220,7 @@ Deploy application to production`, }) it("should handle partial block", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -271,14 +229,7 @@ Deploy application to production`, partial: true, } - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.ask).toHaveBeenCalledWith( "tool", @@ -290,11 +241,11 @@ Deploy application to production`, true, ) - expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() }) it("should handle errors during execution", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -306,20 +257,13 @@ Deploy application to production`, const error = new Error("Test error") vi.mocked(getCommand).mockRejectedValue(error) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockHandleError).toHaveBeenCalledWith("running slash command", error) + expect(mockCallbacks.handleError).toHaveBeenCalledWith("running slash command", error) }) it("should handle empty available commands list", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -331,22 +275,15 @@ Deploy application to production`, vi.mocked(getCommand).mockResolvedValue(undefined) vi.mocked(getCommandNames).mockResolvedValue([]) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) - expect(mockPushToolResult).toHaveBeenCalledWith( + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( formatResponse.toolError("Command 'nonexistent' not found. Available commands: (none)"), ) }) it("should reset consecutive mistake count on valid command", async () => { - const block = { + const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, params: { @@ -366,14 +303,7 @@ Deploy application to production`, vi.mocked(getCommand).mockResolvedValue(mockCommand) - await runSlashCommandTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.consecutiveMistakeCount).toBe(0) }) diff --git a/src/core/tools/runSlashCommandTool.ts b/src/core/tools/runSlashCommandTool.ts index 06ceb5f19ce..a77c4eb6a34 100644 --- a/src/core/tools/runSlashCommandTool.ts +++ b/src/core/tools/runSlashCommandTool.ts @@ -1,45 +1,47 @@ import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { getCommand, getCommandNames } from "../../services/command/commands" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function runSlashCommandTool( - task: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - // Check if run slash command experiment is enabled - const provider = task.providerRef.deref() - const state = await provider?.getState() - const isRunSlashCommandEnabled = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.RUN_SLASH_COMMAND) - - if (!isRunSlashCommandEnabled) { - pushToolResult( - formatResponse.toolError( - "Run slash command is an experimental feature that must be enabled in settings. Please enable 'Run Slash Command' in the Experimental Settings section.", - ), - ) - return +interface RunSlashCommandParams { + command: string + args?: string +} + +export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { + readonly name = "run_slash_command" as const + + parseLegacy(params: Partial>): RunSlashCommandParams { + return { + command: params.command || "", + args: params.args, + } } - const commandName: string | undefined = block.params.command - const args: string | undefined = block.params.args + async execute(params: RunSlashCommandParams, task: Task, callbacks: ToolCallbacks): Promise { + const { command: commandName, args } = params + const { askApproval, handleError, pushToolResult } = callbacks - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "runSlashCommand", - command: removeClosingTag("command", commandName), - args: removeClosingTag("args", args), - }) + // Check if run slash command experiment is enabled + const provider = task.providerRef.deref() + const state = await provider?.getState() + const isRunSlashCommandEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.RUN_SLASH_COMMAND, + ) - await task.ask("tool", partialMessage, block.partial).catch(() => {}) + if (!isRunSlashCommandEnabled) { + pushToolResult( + formatResponse.toolError( + "Run slash command is an experimental feature that must be enabled in settings. Please enable 'Run Slash Command' in the Experimental Settings section.", + ), + ) return - } else { + } + + try { if (!commandName) { task.consecutiveMistakeCount++ task.recordToolError("run_slash_command") @@ -98,11 +100,43 @@ export async function runSlashCommandTool( // Return the command content as the tool result pushToolResult(result) + } catch (error) { + await handleError("running slash command", error as Error) + } + } - return + override async handlePartial(task: Task, block: ToolUse<"run_slash_command">): Promise { + const commandName: string | undefined = block.params.command + const args: string | undefined = block.params.args + + const partialMessage = JSON.stringify({ + tool: "runSlashCommand", + command: this.removeClosingTag("command", commandName, block.partial), + args: this.removeClosingTag("args", args, block.partial), + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" } - } catch (error) { - await handleError("running slash command", error) - return + + if (!text) { + return "" + } + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const runSlashCommandTool = new RunSlashCommandTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d37cca316e8..7b7ace41541 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -91,6 +91,7 @@ export type NativeToolArgs = { fetch_instructions: { task: string } generate_image: GenerateImageParams list_code_definition_names: { path: string } + run_slash_command: { command: string; args?: string } // Add more tools as they are migrated to native protocol } From 259e16f94574536934256a33c724a5097e8ae4b6 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 10:41:26 -0500 Subject: [PATCH 24/48] feat: migrate search files tool to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 10 ++ .../presentAssistantMessage.ts | 7 +- src/core/tools/searchFilesTool.ts | 143 +++++++++++------- src/shared/tools.ts | 1 + 4 files changed, 109 insertions(+), 52 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 1ccfc25caeb..fde6d0acf0a 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -192,6 +192,16 @@ export class NativeToolCallParser { } break + case "search_files": + if (args.path !== undefined && args.regex !== undefined) { + nativeArgs = { + path: args.path, + regex: args.regex, + file_pattern: args.file_pattern, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 08b31c3e99d..0b857ff652f 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -591,7 +591,12 @@ export async function presentAssistantMessage(cline: Task) { }) break case "search_files": - await searchFilesTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "browser_action": await browserActionTool.handle(cline, block as ToolUse<"browser_action">, { diff --git a/src/core/tools/searchFilesTool.ts b/src/core/tools/searchFilesTool.ts index b6ee97f8742..094c6983543 100644 --- a/src/core/tools/searchFilesTool.ts +++ b/src/core/tools/searchFilesTool.ts @@ -1,64 +1,66 @@ import path from "path" import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { ClineSayTool } from "../../shared/ExtensionMessage" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { regexSearchFiles } from "../../services/ripgrep" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function searchFilesTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relDirPath: string | undefined = block.params.path - const regex: string | undefined = block.params.regex - const filePattern: string | undefined = block.params.file_pattern - - const absolutePath = relDirPath ? path.resolve(cline.cwd, relDirPath) : cline.cwd - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - - const sharedMessageProps: ClineSayTool = { - tool: "searchFiles", - path: getReadablePath(cline.cwd, removeClosingTag("path", relDirPath)), - regex: removeClosingTag("regex", regex), - filePattern: removeClosingTag("file_pattern", filePattern), - isOutsideWorkspace, +interface SearchFilesParams { + path: string + regex: string + file_pattern?: string | null +} + +export class SearchFilesTool extends BaseTool<"search_files"> { + readonly name = "search_files" as const + + parseLegacy(params: Partial>): SearchFilesParams { + return { + path: params.path || "", + regex: params.regex || "", + file_pattern: params.file_pattern || undefined, + } } - try { - if (block.partial) { - const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + async execute(params: SearchFilesParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks + + const relDirPath = params.path + const regex = params.regex + const filePattern = params.file_pattern || undefined + + if (!relDirPath) { + task.consecutiveMistakeCount++ + task.recordToolError("search_files") + pushToolResult(await task.sayAndCreateMissingParamError("search_files", "path")) return - } else { - if (!relDirPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("search_files") - pushToolResult(await cline.sayAndCreateMissingParamError("search_files", "path")) - return - } + } - if (!regex) { - cline.consecutiveMistakeCount++ - cline.recordToolError("search_files") - pushToolResult(await cline.sayAndCreateMissingParamError("search_files", "regex")) - return - } + if (!regex) { + task.consecutiveMistakeCount++ + task.recordToolError("search_files") + pushToolResult(await task.sayAndCreateMissingParamError("search_files", "regex")) + return + } + + task.consecutiveMistakeCount = 0 + + const absolutePath = path.resolve(task.cwd, relDirPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - cline.consecutiveMistakeCount = 0 + const sharedMessageProps: ClineSayTool = { + tool: "searchFiles", + path: getReadablePath(task.cwd, relDirPath), + regex: regex, + filePattern: filePattern, + isOutsideWorkspace, + } - const results = await regexSearchFiles( - cline.cwd, - absolutePath, - regex, - filePattern, - cline.rooIgnoreController, - ) + try { + const results = await regexSearchFiles(task.cwd, absolutePath, regex, filePattern, task.rooIgnoreController) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: results } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage) @@ -68,11 +70,50 @@ export async function searchFilesTool( } pushToolResult(results) + } catch (error) { + await handleError("searching files", error as Error) + } + } - return + override async handlePartial(task: Task, block: ToolUse<"search_files">): Promise { + const relDirPath = block.params.path + const regex = block.params.regex + const filePattern = block.params.file_pattern + + const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "searchFiles", + path: getReadablePath(task.cwd, this.removeClosingTag("path", relDirPath, block.partial)), + regex: this.removeClosingTag("regex", regex, block.partial), + filePattern: this.removeClosingTag("file_pattern", filePattern, block.partial), + isOutsideWorkspace, } - } catch (error) { - await handleError("searching files", error) - return + + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } + + if (!text) { + return "" + } + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const searchFilesTool = new SearchFilesTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 7b7ace41541..bca35d357b7 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -92,6 +92,7 @@ export type NativeToolArgs = { generate_image: GenerateImageParams list_code_definition_names: { path: string } run_slash_command: { command: string; args?: string } + search_files: { path: string; regex: string; file_pattern?: string | null } // Add more tools as they are migrated to native protocol } From 6ec00ea71a8f65ce56897ff84c79479a85958554 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 11:13:01 -0500 Subject: [PATCH 25/48] feat: migrate switch mode tool to support native protocol --- .../assistant-message/NativeToolCallParser.ts | 9 ++ .../presentAssistantMessage.ts | 7 +- src/core/tools/switchModeTool.ts | 103 ++++++++++++------ src/shared/tools.ts | 1 + 4 files changed, 83 insertions(+), 37 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index fde6d0acf0a..065aeb0c01b 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -202,6 +202,15 @@ export class NativeToolCallParser { } break + case "switch_mode": + if (args.mode_slug !== undefined && args.reason !== undefined) { + nativeArgs = { + mode_slug: args.mode_slug, + reason: args.reason, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 0b857ff652f..251197b8af2 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -638,7 +638,12 @@ export async function presentAssistantMessage(cline: Task) { }) break case "switch_mode": - await switchModeTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "new_task": await newTaskTool.handle(cline, block as ToolUse<"new_task">, { diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts index 8ce906b41fc..e4fb56fc3b2 100644 --- a/src/core/tools/switchModeTool.ts +++ b/src/core/tools/switchModeTool.ts @@ -1,55 +1,54 @@ import delay from "delay" import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function switchModeTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const mode_slug: string | undefined = block.params.mode_slug - const reason: string | undefined = block.params.reason - - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "switchMode", - mode: removeClosingTag("mode_slug", mode_slug), - reason: removeClosingTag("reason", reason), - }) - - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) - return - } else { +interface SwitchModeParams { + mode_slug: string + reason: string +} + +export class SwitchModeTool extends BaseTool<"switch_mode"> { + readonly name = "switch_mode" as const + + parseLegacy(params: Partial>): SwitchModeParams { + return { + mode_slug: params.mode_slug || "", + reason: params.reason || "", + } + } + + async execute(params: SwitchModeParams, task: Task, callbacks: ToolCallbacks): Promise { + const { mode_slug, reason } = params + const { askApproval, handleError, pushToolResult } = callbacks + + try { if (!mode_slug) { - cline.consecutiveMistakeCount++ - cline.recordToolError("switch_mode") - pushToolResult(await cline.sayAndCreateMissingParamError("switch_mode", "mode_slug")) + task.consecutiveMistakeCount++ + task.recordToolError("switch_mode") + pushToolResult(await task.sayAndCreateMissingParamError("switch_mode", "mode_slug")) return } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 // Verify the mode exists - const targetMode = getModeBySlug(mode_slug, (await cline.providerRef.deref()?.getState())?.customModes) + const targetMode = getModeBySlug(mode_slug, (await task.providerRef.deref()?.getState())?.customModes) if (!targetMode) { - cline.recordToolError("switch_mode") + task.recordToolError("switch_mode") pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) return } // Check if already in requested mode - const currentMode = (await cline.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + const currentMode = (await task.providerRef.deref()?.getState())?.mode ?? defaultModeSlug if (currentMode === mode_slug) { - cline.recordToolError("switch_mode") + task.recordToolError("switch_mode") pushToolResult(`Already in ${targetMode.name} mode.`) return } @@ -62,7 +61,7 @@ export async function switchModeTool( } // Switch the mode using shared handler - await cline.providerRef.deref()?.handleModeSwitch(mode_slug) + await task.providerRef.deref()?.handleModeSwitch(mode_slug) pushToolResult( `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ @@ -71,11 +70,43 @@ export async function switchModeTool( ) await delay(500) // Delay to allow mode change to take effect before next tool is executed + } catch (error) { + await handleError("switching mode", error as Error) + } + } - return + override async handlePartial(task: Task, block: ToolUse<"switch_mode">): Promise { + const mode_slug: string | undefined = block.params.mode_slug + const reason: string | undefined = block.params.reason + + const partialMessage = JSON.stringify({ + tool: "switchMode", + mode: this.removeClosingTag("mode_slug", mode_slug, block.partial), + reason: this.removeClosingTag("reason", reason, block.partial), + }) + + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + } + + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" } - } catch (error) { - await handleError("switching mode", error) - return + + if (!text) { + return "" + } + + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const switchModeTool = new SwitchModeTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index bca35d357b7..c88b7971649 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -93,6 +93,7 @@ export type NativeToolArgs = { list_code_definition_names: { path: string } run_slash_command: { command: string; args?: string } search_files: { path: string; regex: string; file_pattern?: string | null } + switch_mode: { mode_slug: string; reason: string } // Add more tools as they are migrated to native protocol } From b3c73cffc49ee7157f030bbeb7f208ce00ee8681 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 11:37:06 -0500 Subject: [PATCH 26/48] feat: migrate update todo list and mcp tools to support the native protocol and not include mcp tools in system prompt --- .../assistant-message/NativeToolCallParser.ts | 85 +++ .../presentAssistantMessage.ts | 14 +- src/core/prompts/sections/mcp-servers.ts | 22 +- src/core/prompts/system.ts | 17 +- .../prompts/tools/native-tools/mcp_server.ts | 2 +- src/core/task/Task.ts | 18 +- .../tools/__tests__/useMcpToolTool.spec.ts | 184 +++--- src/core/tools/updateTodoListTool.ts | 213 +++---- src/core/tools/useMcpToolTool.ts | 563 +++++++++--------- src/shared/tools.ts | 2 + 10 files changed, 621 insertions(+), 499 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 065aeb0c01b..6d28c2d61a8 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -33,6 +33,12 @@ export class NativeToolCallParser { arguments: toolCall.arguments, }) + // Check if this is a dynamic MCP tool (mcp_serverName_toolName) + if (typeof toolCall.name === "string" && toolCall.name.startsWith("mcp_")) { + console.log(`[NATIVE_TOOL] Detected dynamic MCP tool: ${toolCall.name}`) + return this.parseDynamicMcpTool(toolCall) as ToolUse | null + } + // Validate tool name if (!toolNames.includes(toolCall.name as ToolName)) { console.error(`[NATIVE_TOOL] Invalid tool name: ${toolCall.name}`) @@ -211,6 +217,24 @@ export class NativeToolCallParser { } break + case "update_todo_list": + if (args.todos !== undefined) { + nativeArgs = { + todos: args.todos, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + + case "use_mcp_tool": + if (args.server_name !== undefined && args.tool_name !== undefined) { + nativeArgs = { + server_name: args.server_name, + tool_name: args.tool_name, + arguments: args.arguments, + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + default: break } @@ -231,4 +255,65 @@ export class NativeToolCallParser { return null } } + + /** + * Parse dynamic MCP tools (named mcp_serverName_toolName). + * These are generated dynamically by getMcpServerTools() and need to be + * converted back to use_mcp_tool format. + */ + private static parseDynamicMcpTool(toolCall: { + id: string + name: string + arguments: string + }): ToolUse<"use_mcp_tool"> | null { + try { + console.log(`[NATIVE_TOOL] Parsing dynamic MCP tool: ${toolCall.name}`) + const args = JSON.parse(toolCall.arguments) + console.log(`[NATIVE_TOOL] Dynamic MCP tool args:`, args) + + // Extract server_name and tool_name from the arguments + // The dynamic tool schema includes these as const properties + const serverName = args.server_name + const toolName = args.tool_name + const toolInputProps = args.toolInputProps + + if (!serverName || !toolName) { + console.error(`[NATIVE_TOOL] Missing server_name or tool_name in dynamic MCP tool`) + return null + } + + console.log(`[NATIVE_TOOL] Extracted: server=${serverName}, tool=${toolName}`) + + // Build params for backward compatibility with XML protocol + const params: Partial> = { + server_name: serverName, + tool_name: toolName, + } + + if (toolInputProps) { + params.arguments = JSON.stringify(toolInputProps) + } + + // Build nativeArgs with properly typed structure + const nativeArgs: NativeToolArgs["use_mcp_tool"] = { + server_name: serverName, + tool_name: toolName, + arguments: toolInputProps, + } + + const result: ToolUse<"use_mcp_tool"> = { + type: "tool_use" as const, + name: "use_mcp_tool", + params, + partial: false, + nativeArgs, + } + + console.log(`[NATIVE_TOOL] Dynamic MCP tool parsed successfully:`, result) + return result + } catch (error) { + console.error(`[NATIVE_TOOL] Failed to parse dynamic MCP tool:`, error) + return null + } + } } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 251197b8af2..e9b0bd1ad26 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -480,7 +480,12 @@ export async function presentAssistantMessage(cline: Task) { await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break case "update_todo_list": - await updateTodoListTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "apply_diff": { await checkpointSaveAndMark(cline) @@ -617,7 +622,12 @@ export async function presentAssistantMessage(cline: Task) { console.log(`[NATIVE_TOOL] executeCommandTool.handle() completed`) break case "use_mcp_tool": - await useMcpToolTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "access_mcp_resource": await accessMcpResourceTool( diff --git a/src/core/prompts/sections/mcp-servers.ts b/src/core/prompts/sections/mcp-servers.ts index 643233ab6f8..678099922f0 100644 --- a/src/core/prompts/sections/mcp-servers.ts +++ b/src/core/prompts/sections/mcp-servers.ts @@ -5,6 +5,7 @@ export async function getMcpServersSection( mcpHub?: McpHub, diffStrategy?: DiffStrategy, enableMcpServerCreation?: boolean, + includeToolDescriptions: boolean = true, ): Promise { if (!mcpHub) { return "" @@ -16,17 +17,20 @@ export async function getMcpServersSection( .getServers() .filter((server) => server.status === "connected") .map((server) => { - const tools = server.tools - ?.filter((tool) => tool.enabledForPrompt !== false) - ?.map((tool) => { - const schemaStr = tool.inputSchema - ? ` Input Schema: + // Only include tool descriptions when using XML protocol + const tools = includeToolDescriptions + ? server.tools + ?.filter((tool) => tool.enabledForPrompt !== false) + ?.map((tool) => { + const schemaStr = tool.inputSchema + ? ` Input Schema: ${JSON.stringify(tool.inputSchema, null, 2).split("\n").join("\n ")}` - : "" + : "" - return `- ${tool.name}: ${tool.description}\n${schemaStr}` - }) - .join("\n\n") + return `- ${tool.name}: ${tool.description}\n${schemaStr}` + }) + .join("\n\n") + : undefined const templates = server.resourceTemplates ?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`) diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 3d45f7fcf0d..7b03bcc4187 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -81,18 +81,23 @@ async function generatePrompt( const hasMcpServers = mcpHub && mcpHub.getServers().length > 0 const shouldIncludeMcp = hasMcpGroup && hasMcpServers + const codeIndexManager = CodeIndexManager.getInstance(context, cwd) + + // Determine the effective protocol (defaults to 'xml') + const effectiveProtocol = getEffectiveProtocol(settings) + const [modesSection, mcpServersSection] = await Promise.all([ getModesSection(context), shouldIncludeMcp - ? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) + ? getMcpServersSection( + mcpHub, + effectiveDiffStrategy, + enableMcpServerCreation, + !isNativeProtocol(effectiveProtocol), + ) : Promise.resolve(""), ]) - const codeIndexManager = CodeIndexManager.getInstance(context, cwd) - - // Determine the effective protocol (defaults to 'xml') - const effectiveProtocol = getEffectiveProtocol(settings) - // Build tools catalog section only for XML protocol const toolsCatalog = isNativeProtocol(effectiveProtocol) ? "" diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index 628102da48b..e174e0f0779 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -63,7 +63,7 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo const toolDefinition: OpenAI.Chat.ChatCompletionTool = { type: "function", function: { - name: `${server.name}___${tool.name}`, + name: `mcp_${server.name}_${tool.name}`, description: tool.description, parameters: parameters, }, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d2c1461fe4a..deb75b6638a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -5,6 +5,7 @@ import crypto from "crypto" import EventEmitter from "events" import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import delay from "delay" import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" @@ -2943,20 +2944,33 @@ export class Task extends EventEmitter implements TaskLike { const modelInfo = this.api.getModel().info const shouldIncludeTools = toolProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) + // Build complete tools array: native tools + dynamic MCP tools + let allTools: OpenAI.Chat.ChatCompletionTool[] = nativeTools + if (shouldIncludeTools) { + const { getMcpServerTools } = await import("../prompts/tools/native-tools") + const provider = this.providerRef.deref() + const mcpHub = provider?.getMcpHub() + const mcpTools = getMcpServerTools(mcpHub) + allTools = [...nativeTools, ...mcpTools] + console.log(`[NATIVE_TOOL] Added ${mcpTools.length} dynamic MCP tools to the tools array`) + } + console.log(`[NATIVE_TOOL] Tool inclusion check:`, { toolProtocol, isNative: toolProtocol === TOOL_PROTOCOL.NATIVE, supportsNativeTools: modelInfo.supportsNativeTools, shouldIncludeTools, modelId: this.api.getModel().id, - nativeToolsCount: shouldIncludeTools ? nativeTools.length : 0, + nativeToolsCount: nativeTools.length, + mcpToolsCount: shouldIncludeTools ? allTools.length - nativeTools.length : 0, + totalToolsCount: shouldIncludeTools ? allTools.length : 0, }) const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, // Include tools and tool protocol when using native protocol and model supports it - ...(shouldIncludeTools ? { tools: nativeTools, tool_choice: "auto", toolProtocol } : {}), + ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", toolProtocol } : {}), } console.log(`[NATIVE_TOOL] API request metadata:`, { diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 8738e059e55..6929d42075d 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -85,14 +85,12 @@ describe("useMcpToolTool", () => { mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing server_name error") - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -113,14 +111,12 @@ describe("useMcpToolTool", () => { mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing tool_name error") - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -140,14 +136,28 @@ describe("useMcpToolTool", () => { partial: false, } - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + // Mock server exists so we get to the JSON validation step + const mockServers = [ + { + name: "test_server", + tools: [{ name: "test_tool", description: "Test Tool" }], + }, + ] + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: vi.fn().mockReturnValue(mockServers), + callTool: vi.fn(), + }), + postMessageToWebview: vi.fn(), + }) + + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -171,14 +181,12 @@ describe("useMcpToolTool", () => { mockTask.ask = vi.fn().mockResolvedValue(true) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.ask).toHaveBeenCalledWith("use_mcp_server", expect.stringContaining("use_mcp_tool"), true) }) @@ -211,14 +219,12 @@ describe("useMcpToolTool", () => { postMessageToWebview: vi.fn(), }) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockAskApproval).toHaveBeenCalled() @@ -245,14 +251,12 @@ describe("useMcpToolTool", () => { mockAskApproval.mockResolvedValue(false) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.say).not.toHaveBeenCalledWith("mcp_server_request_started") expect(mockPushToolResult).not.toHaveBeenCalled() @@ -287,14 +291,12 @@ describe("useMcpToolTool", () => { const error = new Error("Unexpected error") mockAskApproval.mockRejectedValue(error) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockHandleError).toHaveBeenCalledWith("executing MCP tool", error) }) @@ -332,14 +334,12 @@ describe("useMcpToolTool", () => { partial: false, } - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -379,14 +379,12 @@ describe("useMcpToolTool", () => { partial: false, } - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(1) expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool") @@ -430,14 +428,12 @@ describe("useMcpToolTool", () => { mockAskApproval.mockResolvedValue(true) - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() @@ -472,14 +468,12 @@ describe("useMcpToolTool", () => { } // Act - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Assert expect(mockTask.consecutiveMistakeCount).toBe(1) @@ -515,14 +509,12 @@ describe("useMcpToolTool", () => { } // Act - await useMcpToolTool( - mockTask as Task, - block, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) + await useMcpToolTool.handle(mockTask as Task, block as any, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) // Assert expect(mockTask.consecutiveMistakeCount).toBe(1) diff --git a/src/core/tools/updateTodoListTool.ts b/src/core/tools/updateTodoListTool.ts index fcd41914a88..f991e5b4896 100644 --- a/src/core/tools/updateTodoListTool.ts +++ b/src/core/tools/updateTodoListTool.ts @@ -1,17 +1,106 @@ import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" - +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" import cloneDeep from "clone-deep" import crypto from "crypto" import { TodoItem, TodoStatus, todoStatusSchema } from "@roo-code/types" import { getLatestTodo } from "../../shared/todo" +interface UpdateTodoListParams { + todos: string +} + let approvedTodoList: TodoItem[] | undefined = undefined -/** - * Add a todo item to the task's todoList. - */ +export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { + readonly name = "update_todo_list" as const + + parseLegacy(params: Partial>): UpdateTodoListParams { + return { + todos: params.todos || "", + } + } + + async execute(params: UpdateTodoListParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError, askApproval } = callbacks + + try { + const todosRaw = params.todos + + let todos: TodoItem[] + try { + todos = parseMarkdownChecklist(todosRaw || "") + } catch { + task.consecutiveMistakeCount++ + task.recordToolError("update_todo_list") + pushToolResult(formatResponse.toolError("The todos parameter is not valid markdown checklist or JSON")) + return + } + + const { valid, error } = validateTodos(todos) + if (!valid) { + task.consecutiveMistakeCount++ + task.recordToolError("update_todo_list") + pushToolResult(formatResponse.toolError(error || "todos parameter validation failed")) + return + } + + let normalizedTodos: TodoItem[] = todos.map((t) => ({ + id: t.id, + content: t.content, + status: normalizeStatus(t.status), + })) + + const approvalMsg = JSON.stringify({ + tool: "updateTodoList", + todos: normalizedTodos, + }) + + approvedTodoList = cloneDeep(normalizedTodos) + const didApprove = await askApproval("tool", approvalMsg) + if (!didApprove) { + pushToolResult("User declined to update the todoList.") + return + } + + const isTodoListChanged = + approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) + if (isTodoListChanged) { + normalizedTodos = approvedTodoList ?? [] + task.say( + "user_edit_todos", + JSON.stringify({ + tool: "updateTodoList", + todos: normalizedTodos, + }), + ) + } + + await setTodoListForTask(task, normalizedTodos) + + if (isTodoListChanged) { + const md = todoListToMarkdown(normalizedTodos) + pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) + } else { + pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) + } + } catch (error) { + await handleError("update todo list", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise { + const todosRaw = block.params.todos + + const approvalMsg = JSON.stringify({ + tool: "updateTodoList", + todos: todosRaw, + }) + await task.ask("tool", approvalMsg, block.partial).catch(() => {}) + } +} + export function addTodoToTask(cline: Task, content: string, status: TodoStatus = "pending", id?: string): TodoItem { const todo: TodoItem = { id: id ?? crypto.randomUUID(), @@ -23,9 +112,6 @@ export function addTodoToTask(cline: Task, content: string, status: TodoStatus = return todo } -/** - * Update the status of a todo item by id. - */ export function updateTodoStatusForTask(cline: Task, id: string, nextStatus: TodoStatus): boolean { if (!cline.todoList) return false const idx = cline.todoList.findIndex((t) => t.id === id) @@ -42,9 +128,6 @@ export function updateTodoStatusForTask(cline: Task, id: string, nextStatus: Tod return false } -/** - * Remove a todo item by id. - */ export function removeTodoFromTask(cline: Task, id: string): boolean { if (!cline.todoList) return false const idx = cline.todoList.findIndex((t) => t.id === id) @@ -53,24 +136,15 @@ export function removeTodoFromTask(cline: Task, id: string): boolean { return true } -/** - * Get a copy of the todoList. - */ export function getTodoListForTask(cline: Task): TodoItem[] | undefined { return cline.todoList?.slice() } -/** - * Set the todoList for the task. - */ export async function setTodoListForTask(cline?: Task, todos?: TodoItem[]) { if (cline === undefined) return cline.todoList = Array.isArray(todos) ? todos : [] } -/** - * Restore the todoList from argument or from clineMessages. - */ export function restoreTodoListForTask(cline: Task, todoList?: TodoItem[]) { if (todoList) { cline.todoList = Array.isArray(todoList) ? todoList : [] @@ -78,11 +152,7 @@ export function restoreTodoListForTask(cline: Task, todoList?: TodoItem[]) { } cline.todoList = getLatestTodo(cline.clineMessages) } -/** - * Convert TodoItem[] to markdown checklist string. - * @param todos TodoItem array - * @returns markdown checklist string - */ + function todoListToMarkdown(todos: TodoItem[]): string { return todos .map((t) => { @@ -108,7 +178,6 @@ export function parseMarkdownChecklist(md: string): TodoItem[] { .filter(Boolean) const todos: TodoItem[] = [] for (const line of lines) { - // Support both "[ ] Task" and "- [ ] Task" formats const match = line.match(/^(?:-\s*)?\[\s*([ xX\-~])\s*\]\s+(.+)$/) if (!match) continue let status: TodoStatus = "pending" @@ -144,94 +213,4 @@ function validateTodos(todos: any[]): { valid: boolean; error?: string } { return { valid: true } } -/** - * Update the todo list for a task. - * @param cline Task instance - * @param block ToolUse block - * @param askApproval AskApproval function - * @param handleError HandleError function - * @param pushToolResult PushToolResult function - * @param removeClosingTag RemoveClosingTag function - * @param userEdited If true, only show "User Edit Succeeded" and do nothing else - */ -export async function updateTodoListTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, - userEdited?: boolean, -) { - // If userEdited is true, only show "User Edit Succeeded" and do nothing else - if (userEdited === true) { - pushToolResult("User Edit Succeeded") - return - } - try { - const todosRaw = block.params.todos - - let todos: TodoItem[] - try { - todos = parseMarkdownChecklist(todosRaw || "") - } catch { - cline.consecutiveMistakeCount++ - cline.recordToolError("update_todo_list") - pushToolResult(formatResponse.toolError("The todos parameter is not valid markdown checklist or JSON")) - return - } - - const { valid, error } = validateTodos(todos) - if (!valid && !block.partial) { - cline.consecutiveMistakeCount++ - cline.recordToolError("update_todo_list") - pushToolResult(formatResponse.toolError(error || "todos parameter validation failed")) - return - } - - let normalizedTodos: TodoItem[] = todos.map((t) => ({ - id: t.id, - content: t.content, - status: normalizeStatus(t.status), - })) - - const approvalMsg = JSON.stringify({ - tool: "updateTodoList", - todos: normalizedTodos, - }) - if (block.partial) { - await cline.ask("tool", approvalMsg, block.partial).catch(() => {}) - return - } - approvedTodoList = cloneDeep(normalizedTodos) - const didApprove = await askApproval("tool", approvalMsg) - if (!didApprove) { - pushToolResult("User declined to update the todoList.") - return - } - const isTodoListChanged = - approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) - if (isTodoListChanged) { - normalizedTodos = approvedTodoList ?? [] - cline.say( - "user_edit_todos", - JSON.stringify({ - tool: "updateTodoList", - todos: normalizedTodos, - }), - ) - } - - await setTodoListForTask(cline, normalizedTodos) - - // If todo list changed, output new todo list in markdown format - if (isTodoListChanged) { - const md = todoListToMarkdown(normalizedTodos) - pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) - } else { - pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) - } - } catch (error) { - await handleError("update todo list", error) - } -} +export const updateTodoListTool = new UpdateTodoListTool() diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 41697ab979b..a34f01cc4f4 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -1,14 +1,15 @@ import { Task } from "../task/Task" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" import { McpExecutionStatus } from "@roo-code/types" import { t } from "../../i18n" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -interface McpToolParams { - server_name?: string - tool_name?: string - arguments?: string +interface UseMcpToolParams { + server_name: string + tool_name: string + arguments?: Record } type ValidationResult = @@ -20,312 +21,342 @@ type ValidationResult = parsedArguments?: Record } -async function handlePartialRequest( - cline: Task, - params: McpToolParams, - removeClosingTag: RemoveClosingTag, -): Promise { - const partialMessage = JSON.stringify({ - type: "use_mcp_tool", - serverName: removeClosingTag("server_name", params.server_name), - toolName: removeClosingTag("tool_name", params.tool_name), - arguments: removeClosingTag("arguments", params.arguments), - } satisfies ClineAskUseMcpServer) - - await cline.ask("use_mcp_server", partialMessage, true).catch(() => {}) -} - -async function validateParams( - cline: Task, - params: McpToolParams, - pushToolResult: PushToolResult, -): Promise { - if (!params.server_name) { - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "server_name")) - return { isValid: false } - } +export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { + readonly name = "use_mcp_tool" as const - if (!params.tool_name) { - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "tool_name")) - return { isValid: false } + parseLegacy(params: Partial>): UseMcpToolParams { + // For legacy params, arguments come as a JSON string that needs parsing + // We don't parse here - let validateParams handle parsing and errors + return { + server_name: params.server_name || "", + tool_name: params.tool_name || "", + arguments: params.arguments as any, // Keep as string for validation to handle + } } - let parsedArguments: Record | undefined + async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult } = callbacks - if (params.arguments) { try { - parsedArguments = JSON.parse(params.arguments) - } catch (error) { - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say("error", t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name })) - - pushToolResult( - formatResponse.toolError( - formatResponse.invalidMcpToolArgumentError(params.server_name, params.tool_name), - ), - ) - return { isValid: false } - } - } + // Validate parameters + const validation = await this.validateParams(task, params, pushToolResult) + if (!validation.isValid) { + return + } - return { - isValid: true, - serverName: params.server_name, - toolName: params.tool_name, - parsedArguments, - } -} + const { serverName, toolName, parsedArguments } = validation -async function validateToolExists( - cline: Task, - serverName: string, - toolName: string, - pushToolResult: PushToolResult, -): Promise<{ isValid: boolean; availableTools?: string[] }> { - try { - // Get the MCP hub to access server information - const provider = cline.providerRef.deref() - const mcpHub = provider?.getMcpHub() - - if (!mcpHub) { - // If we can't get the MCP hub, we can't validate, so proceed with caution - return { isValid: true } - } + // Validate that the tool exists on the server + const toolValidation = await this.validateToolExists(task, serverName, toolName, pushToolResult) + if (!toolValidation.isValid) { + return + } - // Get all servers to find the specific one - const servers = mcpHub.getAllServers() - const server = servers.find((s) => s.name === serverName) + // Reset mistake count on successful validation + task.consecutiveMistakeCount = 0 - if (!server) { - // Fail fast when server is unknown - const availableServersArray = servers.map((s) => s.name) - const availableServers = - availableServersArray.length > 0 ? availableServersArray.join(", ") : "No servers available" + // Get user approval + const completeMessage = JSON.stringify({ + type: "use_mcp_tool", + serverName, + toolName, + arguments: params.arguments ? JSON.stringify(params.arguments) : undefined, + } satisfies ClineAskUseMcpServer) - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say("error", t("mcp:errors.serverNotFound", { serverName, availableServers })) + const executionId = task.lastMessageTs?.toString() ?? Date.now().toString() + const didApprove = await askApproval("use_mcp_server", completeMessage) - pushToolResult(formatResponse.unknownMcpServerError(serverName, availableServersArray)) - return { isValid: false, availableTools: [] } - } + if (!didApprove) { + return + } - // Check if the server has tools defined - if (!server.tools || server.tools.length === 0) { - // No tools available on this server - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say( - "error", - t("mcp:errors.toolNotFound", { - toolName, - serverName, - availableTools: "No tools available", - }), + // Execute the tool and process results + await this.executeToolAndProcessResult( + task, + serverName, + toolName, + parsedArguments, + executionId, + pushToolResult, ) - - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, [])) - return { isValid: false, availableTools: [] } + } catch (error) { + await handleError("executing MCP tool", error as Error) } + } - // Check if the requested tool exists - const tool = server.tools.find((tool) => tool.name === toolName) - - if (!tool) { - // Tool not found - provide list of available tools - const availableToolNames = server.tools.map((tool) => tool.name) - - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say( - "error", - t("mcp:errors.toolNotFound", { - toolName, - serverName, - availableTools: availableToolNames.join(", "), - }), - ) + override async handlePartial(task: Task, block: ToolUse<"use_mcp_tool">): Promise { + const params = block.params + const partialMessage = JSON.stringify({ + type: "use_mcp_tool", + serverName: this.removeClosingTag("server_name", params.server_name, block.partial), + toolName: this.removeClosingTag("tool_name", params.tool_name, block.partial), + arguments: this.removeClosingTag("arguments", params.arguments, block.partial), + } satisfies ClineAskUseMcpServer) - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, availableToolNames)) - return { isValid: false, availableTools: availableToolNames } + await task.ask("use_mcp_server", partialMessage, true).catch(() => {}) + } + + private async validateParams( + task: Task, + params: UseMcpToolParams, + pushToolResult: (content: string) => void, + ): Promise { + if (!params.server_name) { + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + pushToolResult(await task.sayAndCreateMissingParamError("use_mcp_tool", "server_name")) + return { isValid: false } } - // Check if the tool is disabled (enabledForPrompt is false) - if (tool.enabledForPrompt === false) { - // Tool is disabled - only show enabled tools - const enabledTools = server.tools.filter((t) => t.enabledForPrompt !== false) - const enabledToolNames = enabledTools.map((t) => t.name) - - cline.consecutiveMistakeCount++ - cline.recordToolError("use_mcp_tool") - await cline.say( - "error", - t("mcp:errors.toolDisabled", { - toolName, - serverName, - availableTools: - enabledToolNames.length > 0 ? enabledToolNames.join(", ") : "No enabled tools available", - }), - ) + if (!params.tool_name) { + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + pushToolResult(await task.sayAndCreateMissingParamError("use_mcp_tool", "tool_name")) + return { isValid: false } + } - pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, enabledToolNames)) - return { isValid: false, availableTools: enabledToolNames } + // Parse arguments if provided + let parsedArguments: Record | undefined + + if (params.arguments) { + // If arguments is already an object (from native protocol), use it + if (typeof params.arguments === "object") { + parsedArguments = params.arguments + } else if (typeof params.arguments === "string") { + // If arguments is a string (from legacy/XML protocol), parse it + try { + parsedArguments = JSON.parse(params.arguments) + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say("error", t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name })) + + pushToolResult( + formatResponse.toolError( + formatResponse.invalidMcpToolArgumentError(params.server_name, params.tool_name), + ), + ) + return { isValid: false } + } + } } - // Tool exists and is enabled - return { isValid: true, availableTools: server.tools.map((tool) => tool.name) } - } catch (error) { - // If there's an error during validation, log it but don't block the tool execution - // The actual tool call might still fail with a proper error - console.error("Error validating MCP tool existence:", error) - return { isValid: true } + return { + isValid: true, + serverName: params.server_name, + toolName: params.tool_name, + parsedArguments, + } } -} -async function sendExecutionStatus(cline: Task, status: McpExecutionStatus): Promise { - const clineProvider = await cline.providerRef.deref() - clineProvider?.postMessageToWebview({ - type: "mcpExecutionStatus", - text: JSON.stringify(status), - }) -} + private async validateToolExists( + task: Task, + serverName: string, + toolName: string, + pushToolResult: (content: string) => void, + ): Promise<{ isValid: boolean; availableTools?: string[] }> { + try { + // Get the MCP hub to access server information + const provider = task.providerRef.deref() + const mcpHub = provider?.getMcpHub() -function processToolContent(toolResult: any): string { - if (!toolResult?.content || toolResult.content.length === 0) { - return "" - } + if (!mcpHub) { + // If we can't get the MCP hub, we can't validate, so proceed with caution + return { isValid: true } + } - return toolResult.content - .map((item: any) => { - if (item.type === "text") { - return item.text + // Get all servers to find the specific one + const servers = mcpHub.getAllServers() + const server = servers.find((s) => s.name === serverName) + + if (!server) { + // Fail fast when server is unknown + const availableServersArray = servers.map((s) => s.name) + const availableServers = + availableServersArray.length > 0 ? availableServersArray.join(", ") : "No servers available" + + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say("error", t("mcp:errors.serverNotFound", { serverName, availableServers })) + + pushToolResult(formatResponse.unknownMcpServerError(serverName, availableServersArray)) + return { isValid: false, availableTools: [] } } - if (item.type === "resource") { - const { blob: _, ...rest } = item.resource - return JSON.stringify(rest, null, 2) + + // Check if the server has tools defined + if (!server.tools || server.tools.length === 0) { + // No tools available on this server + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say( + "error", + t("mcp:errors.toolNotFound", { + toolName, + serverName, + availableTools: "No tools available", + }), + ) + + pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, [])) + return { isValid: false, availableTools: [] } } - return "" - }) - .filter(Boolean) - .join("\n\n") -} -async function executeToolAndProcessResult( - cline: Task, - serverName: string, - toolName: string, - parsedArguments: Record | undefined, - executionId: string, - pushToolResult: PushToolResult, -): Promise { - await cline.say("mcp_server_request_started") - - // Send started status - await sendExecutionStatus(cline, { - executionId, - status: "started", - serverName, - toolName, - }) - - const toolResult = await cline.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) - - let toolResultPretty = "(No response)" - - if (toolResult) { - const outputText = processToolContent(toolResult) - - if (outputText) { - await sendExecutionStatus(cline, { - executionId, - status: "output", - response: outputText, - }) + // Check if the requested tool exists + const tool = server.tools.find((tool) => tool.name === toolName) + + if (!tool) { + // Tool not found - provide list of available tools + const availableToolNames = server.tools.map((tool) => tool.name) + + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say( + "error", + t("mcp:errors.toolNotFound", { + toolName, + serverName, + availableTools: availableToolNames.join(", "), + }), + ) + + pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, availableToolNames)) + return { isValid: false, availableTools: availableToolNames } + } - toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText + // Check if the tool is disabled (enabledForPrompt is false) + if (tool.enabledForPrompt === false) { + // Tool is disabled - only show enabled tools + const enabledTools = server.tools.filter((t) => t.enabledForPrompt !== false) + const enabledToolNames = enabledTools.map((t) => t.name) + + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool") + await task.say( + "error", + t("mcp:errors.toolDisabled", { + toolName, + serverName, + availableTools: + enabledToolNames.length > 0 ? enabledToolNames.join(", ") : "No enabled tools available", + }), + ) + + pushToolResult(formatResponse.unknownMcpToolError(serverName, toolName, enabledToolNames)) + return { isValid: false, availableTools: enabledToolNames } + } + + // Tool exists and is enabled + return { isValid: true, availableTools: server.tools.map((tool) => tool.name) } + } catch (error) { + // If there's an error during validation, log it but don't block the tool execution + // The actual tool call might still fail with a proper error + console.error("Error validating MCP tool existence:", error) + return { isValid: true } } + } - // Send completion status - await sendExecutionStatus(cline, { - executionId, - status: toolResult.isError ? "error" : "completed", - response: toolResultPretty, - error: toolResult.isError ? "Error executing MCP tool" : undefined, + private async sendExecutionStatus(task: Task, status: McpExecutionStatus): Promise { + const clineProvider = await task.providerRef.deref() + clineProvider?.postMessageToWebview({ + type: "mcpExecutionStatus", + text: JSON.stringify(status), }) - } else { - // Send error status if no result - await sendExecutionStatus(cline, { + } + + private processToolContent(toolResult: any): string { + if (!toolResult?.content || toolResult.content.length === 0) { + return "" + } + + return toolResult.content + .map((item: any) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource") { + const { blob: _, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n") + } + + private async executeToolAndProcessResult( + task: Task, + serverName: string, + toolName: string, + parsedArguments: Record | undefined, + executionId: string, + pushToolResult: (content: string | Array) => void, + ): Promise { + await task.say("mcp_server_request_started") + + // Send started status + await this.sendExecutionStatus(task, { executionId, - status: "error", - error: "No response from MCP server", + status: "started", + serverName, + toolName, }) - } - await cline.say("mcp_server_response", toolResultPretty) - pushToolResult(formatResponse.toolResult(toolResultPretty)) -} + const toolResult = await task.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) -export async function useMcpToolTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - try { - const params: McpToolParams = { - server_name: block.params.server_name, - tool_name: block.params.tool_name, - arguments: block.params.arguments, - } + let toolResultPretty = "(No response)" - // Handle partial requests - if (block.partial) { - await handlePartialRequest(cline, params, removeClosingTag) - return - } + if (toolResult) { + const outputText = this.processToolContent(toolResult) - // Validate parameters - const validation = await validateParams(cline, params, pushToolResult) - if (!validation.isValid) { - return - } + if (outputText) { + await this.sendExecutionStatus(task, { + executionId, + status: "output", + response: outputText, + }) - const { serverName, toolName, parsedArguments } = validation + toolResultPretty = (toolResult.isError ? "Error:\n" : "") + outputText + } - // Validate that the tool exists on the server - const toolValidation = await validateToolExists(cline, serverName, toolName, pushToolResult) - if (!toolValidation.isValid) { - return + // Send completion status + await this.sendExecutionStatus(task, { + executionId, + status: toolResult.isError ? "error" : "completed", + response: toolResultPretty, + error: toolResult.isError ? "Error executing MCP tool" : undefined, + }) + } else { + // Send error status if no result + await this.sendExecutionStatus(task, { + executionId, + status: "error", + error: "No response from MCP server", + }) } - // Reset mistake count on successful validation - cline.consecutiveMistakeCount = 0 - - // Get user approval - const completeMessage = JSON.stringify({ - type: "use_mcp_tool", - serverName, - toolName, - arguments: params.arguments, - } satisfies ClineAskUseMcpServer) + await task.say("mcp_server_response", toolResultPretty) + pushToolResult(formatResponse.toolResult(toolResultPretty)) + } - const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString() - const didApprove = await askApproval("use_mcp_server", completeMessage) + private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } - if (!didApprove) { - return + if (!text) { + return "" } - // Execute the tool and process results - await executeToolAndProcessResult(cline, serverName!, toolName!, parsedArguments, executionId, pushToolResult) - } catch (error) { - await handleError("executing MCP tool", error) + const tagRegex = new RegExp( + `\\s?<\/?${tag + .split("") + .map((char) => `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") } } + +export const useMcpToolTool = new UseMcpToolTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index c88b7971649..4d49acd4cf2 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -94,6 +94,8 @@ export type NativeToolArgs = { run_slash_command: { command: string; args?: string } search_files: { path: string; regex: string; file_pattern?: string | null } switch_mode: { mode_slug: string; reason: string } + update_todo_list: { todos: string } + use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } // Add more tools as they are migrated to native protocol } From da10b25b727725ebd789521305071f1100fbf550 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 14:24:35 -0500 Subject: [PATCH 27/48] feat: migrate write to file to support native protocol, force model to use tools --- .../assistant-message/NativeToolCallParser.ts | 13 + .../presentAssistantMessage.ts | 7 +- src/core/task/Task.ts | 2 +- src/core/tools/BaseTool.ts | 10 +- .../tools/__tests__/writeToFileTool.spec.ts | 23 +- src/core/tools/writeToFileTool.ts | 320 +++++++++--------- src/shared/tools.ts | 1 + 7 files changed, 203 insertions(+), 173 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 6d28c2d61a8..3af6cc7c521 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -225,6 +225,19 @@ export class NativeToolCallParser { } break + case "write_to_file": + if (args.path !== undefined && args.content !== undefined && args.line_count !== undefined) { + nativeArgs = { + path: args.path, + content: args.content, + line_count: + typeof args.line_count === "number" + ? args.line_count + : parseInt(String(args.line_count), 10), + } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } + break + case "use_mcp_tool": if (args.server_name !== undefined && args.tool_name !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index e9b0bd1ad26..2fcf2df173e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -477,7 +477,12 @@ export async function presentAssistantMessage(cline: Task) { switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) - await writeToFileTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + }) break case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index deb75b6638a..fd2ea0606a7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2970,7 +2970,7 @@ export class Task extends EventEmitter implements TaskLike { mode: mode, taskId: this.taskId, // Include tools and tool protocol when using native protocol and model supports it - ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", toolProtocol } : {}), + ...(shouldIncludeTools ? { tools: allTools, tool_choice: "required", toolProtocol } : {}), } console.log(`[NATIVE_TOOL] API request metadata:`, { diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 43d57f79e29..679ed19f182 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -110,7 +110,15 @@ export abstract class BaseTool { // Handle partial messages if (block.partial) { console.log(`[NATIVE_TOOL] Block is partial, calling handlePartial`) - await this.handlePartial(task, block) + try { + await this.handlePartial(task, block) + } catch (error) { + console.error(`[NATIVE_TOOL] Error in handlePartial:`, error) + await callbacks.handleError( + `handling partial ${this.name}`, + error instanceof Error ? error : new Error(String(error)), + ) + } return } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 78e60cbaa58..b47f7070737 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -228,16 +228,16 @@ describe("writeToFileTool", () => { partial: isPartial, } - await writeToFileTool( - mockCline, - toolUse, - mockAskApproval, - mockHandleError, - (result: ToolResponse) => { - toolResult = result - }, - mockRemoveClosingTag, - ) + mockPushToolResult = vi.fn((result: ToolResponse) => { + toolResult = result + }) + + await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + }) return toolResult } @@ -412,8 +412,7 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { isPartial: true }) - expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) }) diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts index b8e6da0caa2..378c3e43810 100644 --- a/src/core/tools/writeToFileTool.ts +++ b/src/core/tools/writeToFileTool.ts @@ -6,7 +6,6 @@ import fs from "fs/promises" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" -import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text" @@ -17,134 +16,101 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" -export async function writeToFileTool( - cline: Task, - block: ToolUse, - askApproval: AskApproval, - handleError: HandleError, - pushToolResult: PushToolResult, - removeClosingTag: RemoveClosingTag, -) { - const relPath: string | undefined = block.params.path - let newContent: string | undefined = block.params.content - let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0") - - if (block.partial && (!relPath || newContent === undefined)) { - // checking for newContent ensure relPath is complete - // wait so we can determine if it's a new file or editing an existing file - return - } - - if (!relPath) { - cline.consecutiveMistakeCount++ - cline.recordToolError("write_to_file") - pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path")) - await cline.diffViewProvider.reset() - return - } - - if (newContent === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("write_to_file") - pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content")) - await cline.diffViewProvider.reset() - return - } +interface WriteToFileParams { + path: string + content: string + line_count: number +} - const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath) +export class WriteToFileTool extends BaseTool<"write_to_file"> { + readonly name = "write_to_file" as const - if (!accessAllowed) { - await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) - return + parseLegacy(params: Partial>): WriteToFileParams { + return { + path: params.path || "", + content: params.content || "", + line_count: parseInt(params.line_count ?? "0", 10), + } } - // Check if file is write-protected - const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false + async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError, askApproval, removeClosingTag } = callbacks + const relPath = params.path + let newContent = params.content + const predictedLineCount = params.line_count + + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file") + pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "path")) + await task.diffViewProvider.reset() + return + } - // Check if file exists using cached map or fs.access - let fileExists: boolean + if (newContent === undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file") + pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "content")) + await task.diffViewProvider.reset() + return + } - if (cline.diffViewProvider.editType !== undefined) { - fileExists = cline.diffViewProvider.editType === "modify" - } else { - const absolutePath = path.resolve(cline.cwd, relPath) - fileExists = await fileExistsAtPath(absolutePath) - cline.diffViewProvider.editType = fileExists ? "modify" : "create" - } + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - // pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini) - if (newContent.startsWith("```")) { - // cline handles cases where it includes language specifiers like ```python ```js - newContent = newContent.split("\n").slice(1).join("\n") - } + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + return + } - if (newContent.endsWith("```")) { - newContent = newContent.split("\n").slice(0, -1).join("\n") - } + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false - if (!cline.api.getModel().id.includes("claude")) { - newContent = unescapeHtmlEntities(newContent) - } + let fileExists: boolean - // Determine if the path is outside the workspace - const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)) : "" - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + if (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + const absolutePath = path.resolve(task.cwd, relPath) + fileExists = await fileExistsAtPath(absolutePath) + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } - const sharedMessageProps: ClineSayTool = { - tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), - content: newContent, - isOutsideWorkspace, - isProtected: isWriteProtected, - } + if (newContent.startsWith("```")) { + newContent = newContent.split("\n").slice(1).join("\n") + } - try { - if (block.partial) { - // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() - const state = await provider?.getState() - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) + if (newContent.endsWith("```")) { + newContent = newContent.split("\n").slice(0, -1).join("\n") + } - if (!isPreventFocusDisruptionEnabled) { - // update gui message - const partialMessage = JSON.stringify(sharedMessageProps) - await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + if (!task.api.getModel().id.includes("claude")) { + newContent = unescapeHtmlEntities(newContent) + } - // update editor - if (!cline.diffViewProvider.isEditing) { - // open the editor and prepare to stream content in - await cline.diffViewProvider.open(relPath) - } + const fullPath = relPath ? path.resolve(task.cwd, removeClosingTag("path", relPath)) : "" + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - // editor is open, stream content in - await cline.diffViewProvider.update( - everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, - false, - ) - } + const sharedMessageProps: ClineSayTool = { + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(task.cwd, removeClosingTag("path", relPath)), + content: newContent, + isOutsideWorkspace, + isProtected: isWriteProtected, + } - return - } else { - if (predictedLineCount === undefined) { - cline.consecutiveMistakeCount++ - cline.recordToolError("write_to_file") + try { + if (predictedLineCount === undefined || predictedLineCount === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("write_to_file") - // Calculate the actual number of lines in the content const actualLineCount = newContent.split("\n").length - - // Check if this is a new file or existing file const isNewFile = !fileExists + const diffStrategyEnabled = !!task.diffStrategy - // Check if diffStrategy is enabled - const diffStrategyEnabled = !!cline.diffStrategy - - // Use more specific error message for line_count that provides guidance based on the situation - await cline.say( + await task.say( "error", `Roo tried to use write_to_file${ relPath ? ` for '${relPath.toPosix()}'` : "" @@ -156,14 +122,13 @@ export async function writeToFileTool( formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled), ), ) - await cline.diffViewProvider.revertChanges() + await task.diffViewProvider.revertChanges() return } - cline.consecutiveMistakeCount = 0 + task.consecutiveMistakeCount = 0 - // Check if preventFocusDisruption experiment is enabled - const provider = cline.providerRef.deref() + const provider = task.providerRef.deref() const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS @@ -173,19 +138,16 @@ export async function writeToFileTool( ) if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view - // Set up diffViewProvider properties needed for diff generation and saveDirectly - cline.diffViewProvider.editType = fileExists ? "modify" : "create" + task.diffViewProvider.editType = fileExists ? "modify" : "create" if (fileExists) { - const absolutePath = path.resolve(cline.cwd, relPath) - cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") + const absolutePath = path.resolve(task.cwd, relPath) + task.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8") } else { - cline.diffViewProvider.originalContent = "" + task.diffViewProvider.originalContent = "" } - // Check for code omissions before proceeding - if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { - if (cline.diffStrategy) { + if (detectCodeOmission(task.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { + if (task.diffStrategy) { pushToolResult( formatResponse.toolError( `Content appears to be truncated (file has ${ @@ -212,9 +174,8 @@ export async function writeToFileTool( } } - // Build unified diff for both existing and new files let unified = fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) + ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) const completeMessage = JSON.stringify({ @@ -229,32 +190,25 @@ export async function writeToFileTool( return } - // Save directly without showing diff view or opening the file - await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) } else { - // Original behavior with diff view - // if isEditingFile false, that means we have the full contents of the file already. - // it's important to note how cline function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So cline part of the logic will always be called. - // in other words, you must always repeat the block.partial logic here - if (!cline.diffViewProvider.isEditing) { - // show gui message before showing edit animation + if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) - await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor - await cline.diffViewProvider.open(relPath) + await task.ask("tool", partialMessage, true).catch(() => {}) + await task.diffViewProvider.open(relPath) } - await cline.diffViewProvider.update( + await task.diffViewProvider.update( everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, true, ) - await delay(300) // wait for diff view to update - cline.diffViewProvider.scrollToFirstDiff() + await delay(300) + task.diffViewProvider.scrollToFirstDiff() - // Check for code omissions before proceeding - if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { - if (cline.diffStrategy) { - await cline.diffViewProvider.revertChanges() + if (detectCodeOmission(task.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { + if (task.diffStrategy) { + await task.diffViewProvider.revertChanges() pushToolResult( formatResponse.toolError( @@ -274,7 +228,7 @@ export async function writeToFileTool( if (selection === "Follow cline guide to fix the issue") { vscode.env.openExternal( vscode.Uri.parse( - "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", + "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%�-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", ), ) } @@ -282,9 +236,8 @@ export async function writeToFileTool( } } - // Build unified diff for both existing and new files let unified = fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) + ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) const completeMessage = JSON.stringify({ @@ -296,36 +249,87 @@ export async function writeToFileTool( const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { - await cline.diffViewProvider.revertChanges() + await task.diffViewProvider.revertChanges() return } - // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } - // Track file edit operation if (relPath) { - await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } - cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request + task.didEditFile = true - // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) pushToolResult(message) - await cline.diffViewProvider.reset() + await task.diffViewProvider.reset() - // Process any queued messages after file edit completes - cline.processQueuedMessages() + task.processQueuedMessages() + return + } catch (error) { + await handleError("writing file", error as Error) + await task.diffViewProvider.reset() return } - } catch (error) { - await handleError("writing file", error) - await cline.diffViewProvider.reset() - return + } + + override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { + const relPath: string | undefined = block.params.path + let newContent: string | undefined = block.params.content + + if (!relPath || newContent === undefined) { + return + } + + const provider = task.providerRef.deref() + const state = await provider?.getState() + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + if (isPreventFocusDisruptionEnabled) { + return + } + + let fileExists: boolean + if (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + const absolutePath = path.resolve(task.cwd, relPath) + fileExists = await fileExistsAtPath(absolutePath) + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } + + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + const fullPath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + const sharedMessageProps: ClineSayTool = { + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(task.cwd, relPath), + content: newContent, + isOutsideWorkspace, + isProtected: isWriteProtected, + } + + const partialMessage = JSON.stringify(sharedMessageProps) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + + if (!task.diffViewProvider.isEditing) { + await task.diffViewProvider.open(relPath) + } + + await task.diffViewProvider.update( + everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, + false, + ) } } + +export const writeToFileTool = new WriteToFileTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 4d49acd4cf2..190afc68e93 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -96,6 +96,7 @@ export type NativeToolArgs = { switch_mode: { mode_slug: string; reason: string } update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } + write_to_file: { path: string; content: string; line_count: number } // Add more tools as they are migrated to native protocol } From 9758b10c762e730c3229d9c7608d2184cc7d58db Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 14:39:38 -0500 Subject: [PATCH 28/48] rename: rename class-based tools --- .../presentAssistantMessage.ts | 24 +++++++++---------- src/core/task/Task.ts | 14 +++++++---- ...tionTool.ts => AskFollowupQuestionTool.ts} | 0 ...wserActionTool.ts => BrowserActionTool.ts} | 0 ...aseSearchTool.ts => CodebaseSearchTool.ts} | 0 ...ctionsTool.ts => FetchInstructionsTool.ts} | 0 ...erateImageTool.ts => GenerateImageTool.ts} | 0 ...Tool.ts => ListCodeDefinitionNamesTool.ts} | 0 src/core/tools/NewTaskTool.ts | 2 +- ...hCommandTool.ts => RunSlashCommandTool.ts} | 0 ...{searchFilesTool.ts => SearchFilesTool.ts} | 0 .../{switchModeTool.ts => SwitchModeTool.ts} | 0 ...eTodoListTool.ts => UpdateTodoListTool.ts} | 0 .../{useMcpToolTool.ts => UseMcpToolTool.ts} | 0 ...{writeToFileTool.ts => WriteToFileTool.ts} | 0 .../__tests__/askFollowupQuestionTool.spec.ts | 2 +- .../tools/__tests__/generateImageTool.test.ts | 2 +- .../listCodeDefinitionNamesTool.spec.ts | 2 +- .../__tests__/runSlashCommandTool.spec.ts | 2 +- .../__tests__/updateTodoListTool.spec.ts | 2 +- .../tools/__tests__/useMcpToolTool.spec.ts | 2 +- .../tools/__tests__/writeToFileTool.spec.ts | 2 +- src/core/webview/webviewMessageHandler.ts | 2 +- src/shared/tools.ts | 4 ++-- 24 files changed, 32 insertions(+), 28 deletions(-) rename src/core/tools/{askFollowupQuestionTool.ts => AskFollowupQuestionTool.ts} (100%) rename src/core/tools/{browserActionTool.ts => BrowserActionTool.ts} (100%) rename src/core/tools/{codebaseSearchTool.ts => CodebaseSearchTool.ts} (100%) rename src/core/tools/{fetchInstructionsTool.ts => FetchInstructionsTool.ts} (100%) rename src/core/tools/{generateImageTool.ts => GenerateImageTool.ts} (100%) rename src/core/tools/{listCodeDefinitionNamesTool.ts => ListCodeDefinitionNamesTool.ts} (100%) rename src/core/tools/{runSlashCommandTool.ts => RunSlashCommandTool.ts} (100%) rename src/core/tools/{searchFilesTool.ts => SearchFilesTool.ts} (100%) rename src/core/tools/{switchModeTool.ts => SwitchModeTool.ts} (100%) rename src/core/tools/{updateTodoListTool.ts => UpdateTodoListTool.ts} (100%) rename src/core/tools/{useMcpToolTool.ts => UseMcpToolTool.ts} (100%) rename src/core/tools/{writeToFileTool.ts => WriteToFileTool.ts} (100%) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2fcf2df173e..b67f722b464 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -7,33 +7,33 @@ import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import type { ToolParamName, ToolResponse, ToolUse } from "../../shared/tools" -import { fetchInstructionsTool } from "../tools/fetchInstructionsTool" +import { fetchInstructionsTool } from "../tools/FetchInstructionsTool" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool" import { shouldUseSingleFileRead } from "@roo-code/types" -import { writeToFileTool } from "../tools/writeToFileTool" +import { writeToFileTool } from "../tools/WriteToFileTool" import { applyDiffTool } from "../tools/multiApplyDiffTool" import { insertContentTool } from "../tools/InsertContentTool" -import { listCodeDefinitionNamesTool } from "../tools/listCodeDefinitionNamesTool" -import { searchFilesTool } from "../tools/searchFilesTool" -import { browserActionTool } from "../tools/browserActionTool" +import { listCodeDefinitionNamesTool } from "../tools/ListCodeDefinitionNamesTool" +import { searchFilesTool } from "../tools/SearchFilesTool" +import { browserActionTool } from "../tools/BrowserActionTool" import { executeCommandTool } from "../tools/ExecuteCommandTool" -import { useMcpToolTool } from "../tools/useMcpToolTool" +import { useMcpToolTool } from "../tools/UseMcpToolTool" import { accessMcpResourceTool } from "../tools/accessMcpResourceTool" -import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool" -import { switchModeTool } from "../tools/switchModeTool" +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 { updateTodoListTool } from "../tools/UpdateTodoListTool" +import { runSlashCommandTool } from "../tools/RunSlashCommandTool" +import { generateImageTool } from "../tools/GenerateImageTool" import { formatResponse } from "../prompts/responses" import { validateToolUse } from "../tools/validateToolUse" import { Task } from "../task/Task" -import { codebaseSearchTool } from "../tools/codebaseSearchTool" +import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { resolveToolProtocol, isNativeProtocol } from "../prompts/toolProtocolResolver" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fd2ea0606a7..14a03817fea 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -88,7 +88,7 @@ import { nativeTools } from "../prompts/tools/native-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" -import { restoreTodoListForTask } from "../tools/updateTodoListTool" +import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" @@ -2092,10 +2092,14 @@ export class Task extends EventEmitter implements TaskLike { // Set assistantMessage to non-empty to prevent "no assistant messages" error // Tool calls are tracked separately in assistantMessageContent if (!assistantMessage) { - assistantMessage = JSON.stringify({ - tool: chunk.name, - arguments: chunk.arguments, - }) + assistantMessage = JSON.stringify( + { + tool: chunk.name, + arguments: chunk.arguments, + }, + null, + 2, + ) } console.log(`[NATIVE_TOOL] Calling presentAssistantMessage`) diff --git a/src/core/tools/askFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts similarity index 100% rename from src/core/tools/askFollowupQuestionTool.ts rename to src/core/tools/AskFollowupQuestionTool.ts diff --git a/src/core/tools/browserActionTool.ts b/src/core/tools/BrowserActionTool.ts similarity index 100% rename from src/core/tools/browserActionTool.ts rename to src/core/tools/BrowserActionTool.ts diff --git a/src/core/tools/codebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts similarity index 100% rename from src/core/tools/codebaseSearchTool.ts rename to src/core/tools/CodebaseSearchTool.ts diff --git a/src/core/tools/fetchInstructionsTool.ts b/src/core/tools/FetchInstructionsTool.ts similarity index 100% rename from src/core/tools/fetchInstructionsTool.ts rename to src/core/tools/FetchInstructionsTool.ts diff --git a/src/core/tools/generateImageTool.ts b/src/core/tools/GenerateImageTool.ts similarity index 100% rename from src/core/tools/generateImageTool.ts rename to src/core/tools/GenerateImageTool.ts diff --git a/src/core/tools/listCodeDefinitionNamesTool.ts b/src/core/tools/ListCodeDefinitionNamesTool.ts similarity index 100% rename from src/core/tools/listCodeDefinitionNamesTool.ts rename to src/core/tools/ListCodeDefinitionNamesTool.ts diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index 2b13256aad2..c1fa921db9b 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -6,7 +6,7 @@ import { Task } from "../task/Task" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" -import { parseMarkdownChecklist } from "./updateTodoListTool" +import { parseMarkdownChecklist } from "./UpdateTodoListTool" import { Package } from "../../shared/package" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" diff --git a/src/core/tools/runSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts similarity index 100% rename from src/core/tools/runSlashCommandTool.ts rename to src/core/tools/RunSlashCommandTool.ts diff --git a/src/core/tools/searchFilesTool.ts b/src/core/tools/SearchFilesTool.ts similarity index 100% rename from src/core/tools/searchFilesTool.ts rename to src/core/tools/SearchFilesTool.ts diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/SwitchModeTool.ts similarity index 100% rename from src/core/tools/switchModeTool.ts rename to src/core/tools/SwitchModeTool.ts diff --git a/src/core/tools/updateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts similarity index 100% rename from src/core/tools/updateTodoListTool.ts rename to src/core/tools/UpdateTodoListTool.ts diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts similarity index 100% rename from src/core/tools/useMcpToolTool.ts rename to src/core/tools/UseMcpToolTool.ts diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/WriteToFileTool.ts similarity index 100% rename from src/core/tools/writeToFileTool.ts rename to src/core/tools/WriteToFileTool.ts diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index fe0145aaa18..68aa49aa500 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -1,4 +1,4 @@ -import { askFollowupQuestionTool } from "../askFollowupQuestionTool" +import { askFollowupQuestionTool } from "../AskFollowupQuestionTool" import { ToolUse } from "../../../shared/tools" describe("askFollowupQuestionTool", () => { diff --git a/src/core/tools/__tests__/generateImageTool.test.ts b/src/core/tools/__tests__/generateImageTool.test.ts index 7e10237e8ce..68b0e36f4b3 100644 --- a/src/core/tools/__tests__/generateImageTool.test.ts +++ b/src/core/tools/__tests__/generateImageTool.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { generateImageTool } from "../generateImageTool" +import { generateImageTool } from "../GenerateImageTool" import { ToolUse } from "../../../shared/tools" import { Task } from "../../task/Task" import * as fs from "fs/promises" diff --git a/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts b/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts index 52e27a2ce7e..2f6c1c264a1 100644 --- a/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts +++ b/src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts @@ -1,7 +1,7 @@ // npx vitest src/core/tools/__tests__/listCodeDefinitionNamesTool.spec.ts import { describe, it, expect, vi, beforeEach } from "vitest" -import { listCodeDefinitionNamesTool } from "../listCodeDefinitionNamesTool" +import { listCodeDefinitionNamesTool } from "../ListCodeDefinitionNamesTool" import { Task } from "../../task/Task" import { ToolUse } from "../../../shared/tools" import * as treeSitter from "../../../services/tree-sitter" diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index f71fe0bd13d..e3c8180e381 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { runSlashCommandTool } from "../runSlashCommandTool" +import { runSlashCommandTool } from "../RunSlashCommandTool" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { getCommand, getCommandNames } from "../../../services/command/commands" diff --git a/src/core/tools/__tests__/updateTodoListTool.spec.ts b/src/core/tools/__tests__/updateTodoListTool.spec.ts index 0b7e8105724..ebe0500d665 100644 --- a/src/core/tools/__tests__/updateTodoListTool.spec.ts +++ b/src/core/tools/__tests__/updateTodoListTool.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest" -import { parseMarkdownChecklist } from "../updateTodoListTool" +import { parseMarkdownChecklist } from "../UpdateTodoListTool" import { TodoItem } from "@roo-code/types" describe("parseMarkdownChecklist", () => { diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 6929d42075d..3a4743e92ff 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -1,6 +1,6 @@ // npx vitest core/tools/__tests__/useMcpToolTool.spec.ts -import { useMcpToolTool } from "../useMcpToolTool" +import { useMcpToolTool } from "../UseMcpToolTool" import { Task } from "../../task/Task" import { ToolUse } from "../../../shared/tools" diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index b47f7070737..e96fff63565 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -9,7 +9,7 @@ import { getReadablePath } from "../../../utils/path" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { writeToFileTool } from "../writeToFileTool" +import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { const originalPath = await vi.importActual("path") diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c85dea9d16f..b7da941b438 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -61,7 +61,7 @@ import { getCommand } from "../../utils/commands" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" -import { setPendingTodoList } from "../tools/updateTodoListTool" +import { setPendingTodoList } from "../tools/UpdateTodoListTool" export const webviewMessageHandler = async ( provider: ClineProvider, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 190afc68e93..431c0187bc8 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -2,8 +2,8 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "@roo-code/types" import type { FileEntry } from "../core/tools/ReadFileTool" -import type { BrowserActionParams } from "../core/tools/browserActionTool" -import { GenerateImageParams } from "../core/tools/generateImageTool" +import type { BrowserActionParams } from "../core/tools/BrowserActionTool" +import { GenerateImageParams } from "../core/tools/GenerateImageTool" export type ToolResponse = string | Array From af4c717afd0e8d2f0eb1e94704a070288141ec86 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 17:11:02 -0500 Subject: [PATCH 29/48] feat: add support for native tools in OpenRouter and update related interfaces --- packages/types/src/providers/openrouter.ts | 1 + .../providers/__tests__/openrouter.spec.ts | 1 + .../fetchers/__tests__/openrouter.spec.ts | 4 + src/api/providers/fetchers/openrouter.ts | 5 +- src/api/providers/openrouter.ts | 153 +++++++++++++++++- .../presentAssistantMessage.ts | 47 +++++- src/core/task/Task.ts | 66 ++++---- src/shared/tools.ts | 1 + 8 files changed, 239 insertions(+), 39 deletions(-) diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index e1515707968..22285fe6f56 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -8,6 +8,7 @@ export const openRouterDefaultModelInfo: ModelInfo = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index f5067ef34c9..a129c26add9 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -97,6 +97,7 @@ describe("OpenRouterHandler", () => { const result = await handler.fetchModel() expect(result.id).toBe("anthropic/claude-sonnet-4.5") expect(result.info.supportsPromptCache).toBe(true) + expect(result.info.supportsNativeTools).toBe(true) }) it("honors custom maxTokens for thinking models", async () => { diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 37cdc544398..d1faa1162ec 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -28,6 +28,7 @@ describe("OpenRouter API", () => { description: expect.any(String), supportsReasoningBudget: false, supportsReasoningEffort: false, + supportsNativeTools: true, supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"], }) @@ -44,6 +45,7 @@ describe("OpenRouter API", () => { supportsReasoningBudget: true, requiredReasoningBudget: true, supportsReasoningEffort: true, + supportsNativeTools: true, supportedParameters: ["max_tokens", "temperature", "reasoning", "include_reasoning"], }) @@ -96,6 +98,7 @@ describe("OpenRouter API", () => { cacheReadsPrice: 0.31, description: undefined, supportsReasoningEffort: undefined, + supportsNativeTools: undefined, supportedParameters: undefined, }, "google-ai-studio": { @@ -110,6 +113,7 @@ describe("OpenRouter API", () => { cacheReadsPrice: 0.31, description: undefined, supportsReasoningEffort: undefined, + supportsNativeTools: undefined, supportedParameters: undefined, }, }) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index b546c40a3cf..38d3c52fa94 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -115,7 +115,7 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise< continue } - models[id] = parseOpenRouterModel({ + const parsedModel = parseOpenRouterModel({ id, model, inputModality: architecture?.input_modalities, @@ -123,6 +123,8 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise< maxTokens: top_provider?.max_completion_tokens, supportedParameters: supported_parameters, }) + + models[id] = parsedModel } } catch (error) { console.error( @@ -216,6 +218,7 @@ export const parseOpenRouterModel = ({ cacheReadsPrice, description: model.description, supportsReasoningEffort: supportedParameters ? supportedParameters.includes("reasoning") : undefined, + supportsNativeTools: supportedParameters ? supportedParameters.includes("tools") : undefined, supportedParameters: supportedParameters ? supportedParameters.filter(isModelParameter) : undefined, } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index d16a410b132..23ff7b8029f 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -24,7 +24,7 @@ import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler } from "../index" +import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" // Image generation types @@ -96,11 +96,44 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const apiKey = this.options.openRouterApiKey ?? "not-provided" this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS }) + + // Load models asynchronously to populate cache before getModel() is called + this.loadDynamicModels().catch((error) => { + console.error("[OpenRouterHandler] Failed to load dynamic models:", error) + }) + } + + private async loadDynamicModels(): Promise { + try { + const [models, endpoints] = await Promise.all([ + getModels({ provider: "openrouter" }), + getModelEndpoints({ + router: "openrouter", + modelId: this.options.openRouterModelId, + endpoint: this.options.openRouterSpecificProvider, + }), + ]) + + this.models = models + this.endpoints = endpoints + + console.log(`[NATIVE_TOOL_OR] OpenRouterHandler.loadDynamicModels():`, { + modelId: this.options.openRouterModelId, + hasModels: Object.keys(models).length > 0, + hasEndpoints: Object.keys(endpoints).length > 0, + }) + } catch (error) { + console.error("[OpenRouterHandler] Error loading dynamic models:", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + } } override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { const model = await this.fetchModel() @@ -159,8 +192,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH allow_fallbacks: false, }, }), + parallel_tool_calls: false, // Ensure only one tool call at a time ...(transforms && { transforms }), ...(reasoning && { reasoning }), + ...(metadata?.tools && { tools: metadata.tools }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } let stream @@ -171,6 +207,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined + const toolCallAccumulator = new Map() for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. @@ -181,13 +218,82 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason + + console.log(`[NATIVE_TOOL] OpenRouterHandler chunk:`, { + hasChoices: !!chunk.choices?.length, + hasDelta: !!delta, + finishReason, + deltaKeys: delta ? Object.keys(delta) : [], + }) + + if (delta) { + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + yield { type: "reasoning", text: delta.reasoning } + } - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - yield { type: "reasoning", text: delta.reasoning } + // Check for tool calls in delta + if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { + console.log( + `[NATIVE_TOOL] OpenRouterHandler: Received tool_calls in delta, count:`, + delta.tool_calls.length, + ) + for (const toolCall of delta.tool_calls) { + const index = toolCall.index + const existing = toolCallAccumulator.get(index) + + if (existing) { + // Accumulate arguments for existing tool call + if (toolCall.function?.arguments) { + console.log( + `[NATIVE_TOOL] OpenRouterHandler: Accumulating arguments for index ${index}:`, + toolCall.function.arguments, + ) + existing.arguments += toolCall.function.arguments + } + } else { + // Start new tool call accumulation + console.log(`[NATIVE_TOOL] OpenRouterHandler: Starting new tool call at index ${index}:`, { + id: toolCall.id, + name: toolCall.function?.name, + hasArguments: !!toolCall.function?.arguments, + }) + toolCallAccumulator.set(index, { + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: toolCall.function?.arguments || "", + }) + } + } + console.log(`[NATIVE_TOOL] OpenRouterHandler: Current accumulator size:`, toolCallAccumulator.size) + } + + if (delta.content) { + yield { type: "text", text: delta.content } + } } - if (delta?.content) { - yield { type: "text", text: delta.content } + // When finish_reason is 'tool_calls', yield all accumulated tool calls + if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { + console.log( + `[NATIVE_TOOL] OpenRouterHandler: finish_reason is 'tool_calls', yielding ${toolCallAccumulator.size} tool calls`, + ) + for (const toolCall of toolCallAccumulator.values()) { + console.log(`[NATIVE_TOOL] OpenRouterHandler: Yielding tool call:`, { + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + }) + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments, + } + } + // Clear accumulator after yielding + toolCallAccumulator.clear() + console.log(`[NATIVE_TOOL] OpenRouterHandler: Cleared tool call accumulator`) } if (chunk.usage) { @@ -220,9 +326,41 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.models = models this.endpoints = endpoints + console.log(`[NATIVE_TOOL_OR] OpenRouterHandler.fetchModel():`, { + modelId: this.options.openRouterModelId, + hasModels: Object.keys(models).length > 0, + hasEndpoints: Object.keys(endpoints).length > 0, + modelKeys: Object.keys(models).slice(0, 5), + }) + return this.getModel() } + /** + * Check if a model ID supports native tool calling. + * This is a fallback for models that aren't in cache yet or don't have explicit support flags. + */ + private supportsNativeTools(modelId: string): boolean { + // Most major models on OpenRouter support native tools + // See: https://openrouter.ai/models?order=newest&supported_parameters=tools + const knownNativeToolModels = [ + "anthropic/", + "openai/gpt-4", + "openai/gpt-5", + "openai/o1", + "openai/o3", + "google/gemini", + "meta-llama/", + "mistralai/", + "cohere/", + "deepseek/", + "qwen/", + "minimax/", + ] + + return knownNativeToolModels.some((prefix) => modelId.includes(prefix)) + } + override getModel() { const id = this.options.openRouterModelId ?? openRouterDefaultModelId let info = this.models[id] ?? openRouterDefaultModelInfo @@ -232,6 +370,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH info = this.endpoints[this.options.openRouterSpecificProvider] } + // If model info doesn't have supportsNativeTools set, check our fallback list + if (info.supportsNativeTools === undefined) { + info.supportsNativeTools = this.supportsNativeTools(id) + } + const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" const params = getModelParams({ diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index b67f722b464..266af4a0a18 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -1,5 +1,6 @@ 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 { TelemetryService } from "@roo-code/telemetry" @@ -305,12 +306,50 @@ export async function presentAssistantMessage(cline: Task) { } const pushToolResult = (content: ToolResponse) => { - cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) + // Check if we're using native tool protocol + const toolProtocol = resolveToolProtocol() + const isNative = isNativeProtocol(toolProtocol) + + // Get the tool call ID if this is a native tool call + const toolCallId = (block as any).id + + if (isNative && toolCallId) { + // For native protocol, add as tool_result block + let resultContent: string + if (typeof content === "string") { + resultContent = content || "(tool did not return anything)" + } else { + // Convert array of content blocks to string for tool result + // Tool results in OpenAI format only support strings + resultContent = content + .map((item) => { + if (item.type === "text") { + return item.text + } else if (item.type === "image") { + return "(image content)" + } + return "" + }) + .join("\n") + } - if (typeof content === "string") { - cline.userMessageContent.push({ type: "text", text: content || "(tool did not return anything)" }) + cline.userMessageContent.push({ + type: "tool_result", + tool_use_id: toolCallId, + content: resultContent, + } as Anthropic.ToolResultBlockParam) } else { - cline.userMessageContent.push(...content) + // For XML protocol, add as text blocks (legacy behavior) + cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) + + if (typeof content === "string") { + cline.userMessageContent.push({ + type: "text", + text: content || "(tool did not return anything)", + }) + } else { + cline.userMessageContent.push(...content) + } } // Once a tool result has been collected, ignore all other tool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 14a03817fea..1aa4f487980 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -294,7 +294,7 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageContent: AssistantMessageContent[] = [] presentAssistantMessageLocked = false presentAssistantMessageHasPendingUpdates = false - userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] + userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false didRejectTool = false didAlreadyUseTool = false @@ -2071,7 +2071,14 @@ export class Task extends EventEmitter implements TaskLike { break } - console.log(`[NATIVE_TOOL] Parsed to ToolUse:`, JSON.stringify(toolUse, null, 2)) + // Store the tool call ID on the ToolUse object for later reference + // This is needed to create tool_result blocks that reference the correct tool_use_id + toolUse.id = chunk.id + + console.log( + `[NATIVE_TOOL] Parsed to ToolUse with id:`, + JSON.stringify(toolUse, null, 2), + ) console.log( `[NATIVE_TOOL] Current assistantMessageContent length before:`, this.assistantMessageContent.length, @@ -2089,19 +2096,6 @@ export class Task extends EventEmitter implements TaskLike { // Mark that we have new content to process this.userMessageContentReady = false - // Set assistantMessage to non-empty to prevent "no assistant messages" error - // Tool calls are tracked separately in assistantMessageContent - if (!assistantMessage) { - assistantMessage = JSON.stringify( - { - tool: chunk.name, - arguments: chunk.arguments, - }, - null, - 2, - ) - } - console.log(`[NATIVE_TOOL] Calling presentAssistantMessage`) // Present the tool call to user @@ -2482,9 +2476,35 @@ export class Task extends EventEmitter implements TaskLike { finalAssistantMessage = `${reasoningMessage}\n${assistantMessage}` } + // Build the assistant message content array + const assistantContent: Array = [] + + // Add text content if present + if (finalAssistantMessage) { + assistantContent.push({ + type: "text" as const, + text: finalAssistantMessage, + }) + } + + // Add tool_use blocks with their IDs for native protocol + const toolUseBlocks = this.assistantMessageContent.filter((block) => block.type === "tool_use") + for (const toolUse of toolUseBlocks) { + // Get the tool call ID that was stored during parsing + const toolCallId = (toolUse as any).id + if (toolCallId) { + assistantContent.push({ + type: "tool_use" as const, + id: toolCallId, + name: toolUse.name, + input: toolUse.nativeArgs || toolUse.params, + }) + } + } + await this.addToApiConversationHistory({ role: "assistant", - content: [{ type: "text", text: finalAssistantMessage }], + content: assistantContent, }) TelemetryService.instance.captureConversationMessage(this.taskId, "assistant") @@ -2956,25 +2976,13 @@ export class Task extends EventEmitter implements TaskLike { const mcpHub = provider?.getMcpHub() const mcpTools = getMcpServerTools(mcpHub) allTools = [...nativeTools, ...mcpTools] - console.log(`[NATIVE_TOOL] Added ${mcpTools.length} dynamic MCP tools to the tools array`) } - console.log(`[NATIVE_TOOL] Tool inclusion check:`, { - toolProtocol, - isNative: toolProtocol === TOOL_PROTOCOL.NATIVE, - supportsNativeTools: modelInfo.supportsNativeTools, - shouldIncludeTools, - modelId: this.api.getModel().id, - nativeToolsCount: nativeTools.length, - mcpToolsCount: shouldIncludeTools ? allTools.length - nativeTools.length : 0, - totalToolsCount: shouldIncludeTools ? allTools.length : 0, - }) - const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, // Include tools and tool protocol when using native protocol and model supports it - ...(shouldIncludeTools ? { tools: allTools, tool_choice: "required", toolProtocol } : {}), + ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", toolProtocol } : {}), } console.log(`[NATIVE_TOOL] API request metadata:`, { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 431c0187bc8..3bc1a4e7d69 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -107,6 +107,7 @@ export type NativeToolArgs = { */ export interface ToolUse { type: "tool_use" + id?: string // Optional ID to track tool calls name: TName // params is a partial record, allowing only some or none of the possible parameters to be used params: Partial> From 64062ac758100ac00e20c7cc4a9aec23813c2656 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 17:41:11 -0500 Subject: [PATCH 30/48] fix: anthropic models by removing duplicate tool call and fix "no assistant message" error --- src/api/providers/openrouter.ts | 30 ------------------- src/core/prompts/tools/native-tools/index.ts | 5 ++-- .../prompts/tools/native-tools/read_file.ts | 23 +------------- src/core/task/Task.ts | 6 +++- 4 files changed, 8 insertions(+), 56 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 23ff7b8029f..d060817b7fb 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -336,31 +336,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH return this.getModel() } - /** - * Check if a model ID supports native tool calling. - * This is a fallback for models that aren't in cache yet or don't have explicit support flags. - */ - private supportsNativeTools(modelId: string): boolean { - // Most major models on OpenRouter support native tools - // See: https://openrouter.ai/models?order=newest&supported_parameters=tools - const knownNativeToolModels = [ - "anthropic/", - "openai/gpt-4", - "openai/gpt-5", - "openai/o1", - "openai/o3", - "google/gemini", - "meta-llama/", - "mistralai/", - "cohere/", - "deepseek/", - "qwen/", - "minimax/", - ] - - return knownNativeToolModels.some((prefix) => modelId.includes(prefix)) - } - override getModel() { const id = this.options.openRouterModelId ?? openRouterDefaultModelId let info = this.models[id] ?? openRouterDefaultModelInfo @@ -370,11 +345,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH info = this.endpoints[this.options.openRouterSpecificProvider] } - // If model info doesn't have supportsNativeTools set, check our fallback list - if (info.supportsNativeTools === undefined) { - info.supportsNativeTools = this.supportsNativeTools(id) - } - const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" const params = getModelParams({ diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index a995a6d9ab7..4f762fde1f7 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -11,7 +11,7 @@ import insertContent from "./insert_content" import listCodeDefinitionNames from "./list_code_definition_names" import listFiles from "./list_files" import newTask from "./new_task" -import { read_file_single, read_file_multi } from "./read_file" +import { read_file } from "./read_file" import runSlashCommand from "./run_slash_command" import searchAndReplace from "./search_and_replace" import searchFiles from "./search_files" @@ -37,8 +37,7 @@ export const nativeTools = [ listCodeDefinitionNames, listFiles, newTask, - read_file_single, - read_file_multi, + read_file, runSlashCommand, searchAndReplace, searchFiles, diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index 147809617ea..7918826833d 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" -export const read_file_multi = { +export const read_file = { type: "function", function: { name: "read_file", @@ -41,24 +41,3 @@ export const read_file_multi = { }, }, } satisfies OpenAI.Chat.ChatCompletionTool - -export const read_file_single = { - type: "function", - function: { - name: "read_file", - description: - 'Request to read the contents of a file. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when discussing code.', - strict: true, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file to read, relative to the workspace", - }, - }, - required: ["path"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1aa4f487980..1512612a3b6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2458,7 +2458,11 @@ export class Task extends EventEmitter implements TaskLike { // able to save the assistant's response. let didEndLoop = false - if (assistantMessage.length > 0) { + // Check if we have any content to process (text or tool uses) + const hasTextContent = assistantMessage.length > 0 + const hasToolUses = this.assistantMessageContent.some((block) => block.type === "tool_use") + + if (hasTextContent || hasToolUses) { // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) From b0ed5355ec57734c748d0c78170b47ae6c68a7b4 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 19:47:10 -0500 Subject: [PATCH 31/48] fix: handle markdown checklist parsing in handlePartial method --- src/core/tools/UpdateTodoListTool.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index f991e5b4896..94d1b25e13a 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -93,9 +93,18 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise { const todosRaw = block.params.todos + // Parse the markdown checklist to maintain consistent format with execute() + let todos: TodoItem[] + try { + todos = parseMarkdownChecklist(todosRaw || "") + } catch { + // If parsing fails during partial, send empty array + todos = [] + } + const approvalMsg = JSON.stringify({ tool: "updateTodoList", - todos: todosRaw, + todos: todos, }) await task.ask("tool", approvalMsg, block.partial).catch(() => {}) } From 0dcb39e67b559a4230a2d054e0f63c78c0182444 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 19:49:51 -0500 Subject: [PATCH 32/48] refactor(attempt-completion): standardize tool result handling - Use pushToolResult helper for subtask approval/denial flows - Simplify feedback handling with formatResponse.toolResult - Remove manual userMessageContent construction --- src/core/tools/AttemptCompletionTool.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index bc7f86ea6b7..0f4d764639b 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -71,9 +71,11 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { const didApprove = await askFinishSubTaskApproval() if (!didApprove) { + pushToolResult(formatResponse.toolDenied()) return } + pushToolResult("") await task.providerRef.deref()?.finishSubTask(result) return } @@ -86,16 +88,9 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } await task.say("user_feedback", text ?? "", images) - const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] - - toolResults.push({ - type: "text", - text: `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n`, - }) - toolResults.push(...formatResponse.imageBlocks(images)) - task.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` }) - task.userMessageContent.push(...toolResults) + const feedbackText = `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n` + pushToolResult(formatResponse.toolResult(feedbackText, images)) } catch (error) { await handleError("inspecting site", error as Error) } From 7ed3a62a7789a82e186d1a5d0137cfbf614613b7 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 19:51:15 -0500 Subject: [PATCH 33/48] fix(native-protocol): prevent consecutive user messages on API retry - Remove user message from history before retry to avoid validation errors - Re-add message if user declines retry to maintain conversation integrity - Only applies to native tool protocol to prevent tool_result issues --- src/core/task/Task.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1512612a3b6..63e5cb1b715 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2569,6 +2569,21 @@ export class Task extends EventEmitter implements TaskLike { // or tool_use content blocks from API which we should assume is // an error. + // IMPORTANT: For native tool protocol, we already added the user message to + // apiConversationHistory at line 1876. Since the assistant failed to respond, + // we need to remove that message before retrying to avoid having two consecutive + // user messages (which would cause tool_result validation errors). + const toolProtocol = resolveToolProtocol() + const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE + + if (isNativeProtocol && this.apiConversationHistory.length > 0) { + const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] + if (lastMessage.role === "user") { + // Remove the last user message that we added earlier + this.apiConversationHistory.pop() + } + } + // Check if we should auto-retry or prompt the user const state = await this.providerRef.deref()?.getState() if (state?.autoApprovalEnabled && state?.alwaysApproveResubmit) { @@ -2619,7 +2634,15 @@ export class Task extends EventEmitter implements TaskLike { // Continue to retry the request continue } else { - // User declined to retry - persist error and failure message + // User declined to retry + // For native protocol, re-add the user message we removed + if (isNativeProtocol) { + await this.addToApiConversationHistory({ + role: "user", + content: currentUserContent, + }) + } + await this.say( "error", "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", From 6cb30e28683d167005e9f0644d4ff7cffc7536b9 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 20:04:23 -0500 Subject: [PATCH 34/48] test: add supportsNativeTools to openrouter mock model data --- src/api/providers/__tests__/openrouter.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index a129c26add9..118be755d70 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -33,6 +33,7 @@ vitest.mock("../fetchers/modelCache", () => ({ contextWindow: 200000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 3, outputPrice: 15, cacheWritesPrice: 3.75, From 8509486ba4df252d99de6183ee3c62006db54adb Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 20:26:53 -0500 Subject: [PATCH 35/48] fix: correct import casing for renamed tool files --- src/core/assistant-message/presentAssistantMessage.ts | 2 +- src/core/tools/{applyDiffTool.ts => ApplyDiffTool.ts} | 0 src/core/tools/__tests__/applyDiffTool.experiment.spec.ts | 2 +- src/core/tools/__tests__/multiApplyDiffTool.spec.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/core/tools/{applyDiffTool.ts => ApplyDiffTool.ts} (100%) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 266af4a0a18..5e06a479365 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -14,7 +14,7 @@ import { readFileTool } from "../tools/ReadFileTool" import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool" import { shouldUseSingleFileRead } from "@roo-code/types" import { writeToFileTool } from "../tools/WriteToFileTool" -import { applyDiffTool } from "../tools/multiApplyDiffTool" +import { applyDiffTool } from "../tools/MultiApplyDiffTool" import { insertContentTool } from "../tools/InsertContentTool" import { listCodeDefinitionNamesTool } from "../tools/ListCodeDefinitionNamesTool" import { searchFilesTool } from "../tools/SearchFilesTool" diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts similarity index 100% rename from src/core/tools/applyDiffTool.ts rename to src/core/tools/ApplyDiffTool.ts diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index 72ef845593e..5dbc1a5cfa5 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -15,7 +15,7 @@ vi.mock("../ApplyDiffTool", () => ({ })) // Import after mocking to get the mocked version -import { applyDiffTool as multiApplyDiffTool } from "../multiApplyDiffTool" +import { applyDiffTool as multiApplyDiffTool } from "../MultiApplyDiffTool" import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" import { resolveToolProtocol, isNativeProtocol } from "../../prompts/toolProtocolResolver" diff --git a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts index 5e591f9fe79..423660c0daf 100644 --- a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts +++ b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts @@ -1,4 +1,4 @@ -import { applyDiffTool } from "../multiApplyDiffTool" +import { applyDiffTool } from "../MultiApplyDiffTool" import { EXPERIMENT_IDS } from "../../../shared/experiments" import * as fs from "fs/promises" import * as fileUtils from "../../../utils/fs" From d48860aa94c419d396367fbfa5f6843477f5227c Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 20:58:49 -0500 Subject: [PATCH 36/48] fix: move tool parameter types to @roo-code/types package - Created packages/types/src/tool-params.ts with FileEntry, BrowserActionParams, GenerateImageParams - Updated src/shared/tools.ts to import from @roo-code/types instead of ../core/tools - Updated ReadFileTool, BrowserActionTool, GenerateImageTool to import from @roo-code/types - Updated NativeToolCallParser to import FileEntry from @roo-code/types - Prevents TypeScript from traversing into src/ when compiling webview-ui --- packages/types/src/index.ts | 1 + packages/types/src/tool-params.ts | 37 +++++++++++++++++++ .../assistant-message/NativeToolCallParser.ts | 3 +- src/core/tools/BrowserActionTool.ts | 19 +--------- src/core/tools/GenerateImageTool.ts | 7 +--- src/core/tools/ReadFileTool.ts | 11 +----- src/shared/tools.ts | 13 +++++-- 7 files changed, 51 insertions(+), 40 deletions(-) create mode 100644 packages/types/src/tool-params.ts diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7a7d5059eb0..ebebb72313c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -20,6 +20,7 @@ export * from "./todo.js" export * from "./telemetry.js" export * from "./terminal.js" export * from "./tool.js" +export * from "./tool-params.js" export * from "./type-fu.js" export * from "./vscode.js" diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts new file mode 100644 index 00000000000..36f69f71001 --- /dev/null +++ b/packages/types/src/tool-params.ts @@ -0,0 +1,37 @@ +/** + * Tool parameter type definitions for native protocol + */ + +export interface LineRange { + start: number + end: number +} + +export interface FileEntry { + path: string + lineRanges?: LineRange[] +} + +export interface Coordinate { + x: number + y: number +} + +export interface Size { + width: number + height: number +} + +export interface BrowserActionParams { + action: "launch" | "click" | "hover" | "type" | "scroll_down" | "scroll_up" | "resize" | "close" + url?: string + coordinate?: Coordinate + size?: Size + text?: string +} + +export interface GenerateImageParams { + prompt: string + path: string + image?: string +} diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 3af6cc7c521..9a0593d1a4d 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,6 +1,5 @@ -import { type ToolName, toolNames } from "@roo-code/types" +import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" -import type { FileEntry } from "../tools/ReadFileTool" /** * Parser for native tool calls (OpenAI-style function calling). diff --git a/src/core/tools/BrowserActionTool.ts b/src/core/tools/BrowserActionTool.ts index 082fcc89532..89e180bb21c 100644 --- a/src/core/tools/BrowserActionTool.ts +++ b/src/core/tools/BrowserActionTool.ts @@ -1,3 +1,4 @@ +import type { BrowserActionParams, Coordinate, Size } from "@roo-code/types" import { Task } from "../task/Task" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" @@ -9,24 +10,6 @@ import { } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" -export interface Coordinate { - x: number - y: number -} - -export interface Size { - width: number - height: number -} - -export interface BrowserActionParams { - action: BrowserAction - url?: string - coordinate?: Coordinate - size?: Size - text?: string -} - export class BrowserActionTool extends BaseTool<"browser_action"> { readonly name = "browser_action" as const diff --git a/src/core/tools/GenerateImageTool.ts b/src/core/tools/GenerateImageTool.ts index a66b7e38f66..4b41f840d42 100644 --- a/src/core/tools/GenerateImageTool.ts +++ b/src/core/tools/GenerateImageTool.ts @@ -1,6 +1,7 @@ import path from "path" import fs from "fs/promises" import * as vscode from "vscode" +import type { GenerateImageParams } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" @@ -13,12 +14,6 @@ import type { ToolUse } from "../../shared/tools" const IMAGE_GENERATION_MODELS = ["google/gemini-2.5-flash-image", "openai/gpt-5-image", "openai/gpt-5-image-mini"] -export interface GenerateImageParams { - prompt: string - path: string - image?: string -} - export class GenerateImageTool extends BaseTool<"generate_image"> { readonly name = "generate_image" as const diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 4305e9b0e28..e8e8bdf0e9b 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -1,5 +1,6 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" +import type { FileEntry, LineRange } from "@roo-code/types" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" @@ -26,16 +27,6 @@ import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -interface LineRange { - start: number - end: number -} - -export interface FileEntry { - path: string - lineRanges?: LineRange[] -} - interface FileResult { path: string status: "approved" | "denied" | "blocked" | "error" | "pending" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 3bc1a4e7d69..410c09b5c3e 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -1,9 +1,14 @@ import { Anthropic } from "@anthropic-ai/sdk" -import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "@roo-code/types" -import type { FileEntry } from "../core/tools/ReadFileTool" -import type { BrowserActionParams } from "../core/tools/BrowserActionTool" -import { GenerateImageParams } from "../core/tools/GenerateImageTool" +import type { + ClineAsk, + ToolProgressStatus, + ToolGroup, + ToolName, + FileEntry, + BrowserActionParams, + GenerateImageParams, +} from "@roo-code/types" export type ToolResponse = string | Array From b046bb9f9d88ec3ef10cda50c153ddd60b83b757 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 21:11:14 -0500 Subject: [PATCH 37/48] refactor: clean up debug logs --- src/api/providers/openrouter.ts | 43 ---------- src/api/providers/roo.ts | 30 ------- .../assistant-message/NativeToolCallParser.ts | 36 ++------- .../presentAssistantMessage.ts | 78 +------------------ src/core/task/Task.ts | 63 +-------------- src/core/tools/BaseTool.ts | 20 +---- src/core/tools/ReadFileTool.ts | 3 - 7 files changed, 14 insertions(+), 259 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index d060817b7fb..fa3aa5e5b0f 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -116,12 +116,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.models = models this.endpoints = endpoints - - console.log(`[NATIVE_TOOL_OR] OpenRouterHandler.loadDynamicModels():`, { - modelId: this.options.openRouterModelId, - hasModels: Object.keys(models).length > 0, - hasEndpoints: Object.keys(endpoints).length > 0, - }) } catch (error) { console.error("[OpenRouterHandler] Error loading dynamic models:", { error: error instanceof Error ? error.message : String(error), @@ -220,13 +214,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason - console.log(`[NATIVE_TOOL] OpenRouterHandler chunk:`, { - hasChoices: !!chunk.choices?.length, - hasDelta: !!delta, - finishReason, - deltaKeys: delta ? Object.keys(delta) : [], - }) - if (delta) { if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { yield { type: "reasoning", text: delta.reasoning } @@ -234,10 +221,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // Check for tool calls in delta if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { - console.log( - `[NATIVE_TOOL] OpenRouterHandler: Received tool_calls in delta, count:`, - delta.tool_calls.length, - ) for (const toolCall of delta.tool_calls) { const index = toolCall.index const existing = toolCallAccumulator.get(index) @@ -245,19 +228,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH if (existing) { // Accumulate arguments for existing tool call if (toolCall.function?.arguments) { - console.log( - `[NATIVE_TOOL] OpenRouterHandler: Accumulating arguments for index ${index}:`, - toolCall.function.arguments, - ) existing.arguments += toolCall.function.arguments } } else { // Start new tool call accumulation - console.log(`[NATIVE_TOOL] OpenRouterHandler: Starting new tool call at index ${index}:`, { - id: toolCall.id, - name: toolCall.function?.name, - hasArguments: !!toolCall.function?.arguments, - }) toolCallAccumulator.set(index, { id: toolCall.id || "", name: toolCall.function?.name || "", @@ -265,7 +239,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH }) } } - console.log(`[NATIVE_TOOL] OpenRouterHandler: Current accumulator size:`, toolCallAccumulator.size) } if (delta.content) { @@ -275,15 +248,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // When finish_reason is 'tool_calls', yield all accumulated tool calls if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { - console.log( - `[NATIVE_TOOL] OpenRouterHandler: finish_reason is 'tool_calls', yielding ${toolCallAccumulator.size} tool calls`, - ) for (const toolCall of toolCallAccumulator.values()) { - console.log(`[NATIVE_TOOL] OpenRouterHandler: Yielding tool call:`, { - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - }) yield { type: "tool_call", id: toolCall.id, @@ -293,7 +258,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Clear accumulator after yielding toolCallAccumulator.clear() - console.log(`[NATIVE_TOOL] OpenRouterHandler: Cleared tool call accumulator`) } if (chunk.usage) { @@ -326,13 +290,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH this.models = models this.endpoints = endpoints - console.log(`[NATIVE_TOOL_OR] OpenRouterHandler.fetchModel():`, { - modelId: this.options.openRouterModelId, - hasModels: Object.keys(models).length > 0, - hasEndpoints: Object.keys(endpoints).length > 0, - modelKeys: Object.keys(models).slice(0, 5), - }) - return this.getModel() } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index c5021e1d5c3..e5d8edb4ec9 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -133,13 +133,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason - console.log(`[NATIVE_TOOL] RooHandler chunk:`, { - hasChoices: !!chunk.choices?.length, - hasDelta: !!delta, - finishReason, - deltaKeys: delta ? Object.keys(delta) : [], - }) - if (delta) { // Check for reasoning content (similar to OpenRouter) if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { @@ -159,10 +152,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { // Check for tool calls in delta if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { - console.log( - `[NATIVE_TOOL] RooHandler: Received tool_calls in delta, count:`, - delta.tool_calls.length, - ) for (const toolCall of delta.tool_calls) { const index = toolCall.index const existing = toolCallAccumulator.get(index) @@ -170,19 +159,10 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { if (existing) { // Accumulate arguments for existing tool call if (toolCall.function?.arguments) { - console.log( - `[NATIVE_TOOL] RooHandler: Accumulating arguments for index ${index}:`, - toolCall.function.arguments, - ) existing.arguments += toolCall.function.arguments } } else { // Start new tool call accumulation - console.log(`[NATIVE_TOOL] RooHandler: Starting new tool call at index ${index}:`, { - id: toolCall.id, - name: toolCall.function?.name, - hasArguments: !!toolCall.function?.arguments, - }) toolCallAccumulator.set(index, { id: toolCall.id || "", name: toolCall.function?.name || "", @@ -190,7 +170,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { }) } } - console.log(`[NATIVE_TOOL] RooHandler: Current accumulator size:`, toolCallAccumulator.size) } if (delta.content) { @@ -203,15 +182,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { // When finish_reason is 'tool_calls', yield all accumulated tool calls if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { - console.log( - `[NATIVE_TOOL] RooHandler: finish_reason is 'tool_calls', yielding ${toolCallAccumulator.size} tool calls`, - ) for (const [index, toolCall] of toolCallAccumulator.entries()) { - console.log(`[NATIVE_TOOL] RooHandler: Yielding tool call ${index}:`, { - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - }) yield { type: "tool_call", id: toolCall.id, @@ -221,7 +192,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } // Clear accumulator after yielding toolCallAccumulator.clear() - console.log(`[NATIVE_TOOL] RooHandler: Cleared tool call accumulator`) } if (chunk.usage) { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 9a0593d1a4d..6af61625bae 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -26,39 +26,26 @@ export class NativeToolCallParser { name: TName arguments: string }): ToolUse | null { - console.log(`[NATIVE_TOOL] Parser received:`, { - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - }) - // Check if this is a dynamic MCP tool (mcp_serverName_toolName) if (typeof toolCall.name === "string" && toolCall.name.startsWith("mcp_")) { - console.log(`[NATIVE_TOOL] Detected dynamic MCP tool: ${toolCall.name}`) return this.parseDynamicMcpTool(toolCall) as ToolUse | null } // Validate tool name if (!toolNames.includes(toolCall.name as ToolName)) { - console.error(`[NATIVE_TOOL] Invalid tool name: ${toolCall.name}`) - console.error(`[NATIVE_TOOL] Valid tool names:`, toolNames) + console.error(`Invalid tool name: ${toolCall.name}`) + console.error(`Valid tool names:`, toolNames) return null } - console.log(`[NATIVE_TOOL] Tool name validated: ${toolCall.name}`) - try { // Parse the arguments JSON string - console.log(`[NATIVE_TOOL] Parsing arguments JSON:`, toolCall.arguments) const args = JSON.parse(toolCall.arguments) - console.log(`[NATIVE_TOOL] Parsed args:`, args) // Convert arguments to params format (for backward-compat/UI), but primary path uses nativeArgs const params: Partial> = {} for (const [key, value] of Object.entries(args)) { - console.log(`[NATIVE_TOOL] Processing param: ${key} =`, value) - // For read_file native calls, do not synthesize params.files – nativeArgs carries typed data if (toolCall.name === "read_file" && key === "files") { continue @@ -66,15 +53,14 @@ export class NativeToolCallParser { // Validate parameter name if (!toolParamNames.includes(key as ToolParamName)) { - console.warn(`[NATIVE_TOOL] Unknown parameter '${key}' for tool '${toolCall.name}'`) - console.warn(`[NATIVE_TOOL] Valid param names:`, toolParamNames) + console.warn(`Unknown parameter '${key}' for tool '${toolCall.name}'`) + console.warn(`Valid param names:`, toolParamNames) continue } // Keep legacy string params for compatibility (not used by native execution path) const stringValue = typeof value === "string" ? value : JSON.stringify(value) params[key as ToolParamName] = stringValue - console.log(`[NATIVE_TOOL] Added param: ${key} = "${stringValue}"`) } // Build typed nativeArgs for tools that support it @@ -259,11 +245,10 @@ export class NativeToolCallParser { nativeArgs, } - console.log(`[NATIVE_TOOL] Parser returning ToolUse:`, result) return result } catch (error) { - console.error(`[NATIVE_TOOL] Failed to parse tool call arguments:`, error) - console.error(`[NATIVE_TOOL] Error details:`, error instanceof Error ? error.message : String(error)) + console.error(`Failed to parse tool call arguments:`, error) + console.error(`Error details:`, error instanceof Error ? error.message : String(error)) return null } } @@ -279,9 +264,7 @@ export class NativeToolCallParser { arguments: string }): ToolUse<"use_mcp_tool"> | null { try { - console.log(`[NATIVE_TOOL] Parsing dynamic MCP tool: ${toolCall.name}`) const args = JSON.parse(toolCall.arguments) - console.log(`[NATIVE_TOOL] Dynamic MCP tool args:`, args) // Extract server_name and tool_name from the arguments // The dynamic tool schema includes these as const properties @@ -290,12 +273,10 @@ export class NativeToolCallParser { const toolInputProps = args.toolInputProps if (!serverName || !toolName) { - console.error(`[NATIVE_TOOL] Missing server_name or tool_name in dynamic MCP tool`) + console.error(`Missing server_name or tool_name in dynamic MCP tool`) return null } - console.log(`[NATIVE_TOOL] Extracted: server=${serverName}, tool=${toolName}`) - // Build params for backward compatibility with XML protocol const params: Partial> = { server_name: serverName, @@ -321,10 +302,9 @@ export class NativeToolCallParser { nativeArgs, } - console.log(`[NATIVE_TOOL] Dynamic MCP tool parsed successfully:`, result) return result } catch (error) { - console.error(`[NATIVE_TOOL] Failed to parse dynamic MCP tool:`, error) + console.error(`Failed to parse dynamic MCP tool:`, error) return null } } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 5e06a479365..13993b09b12 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -57,73 +57,43 @@ import { resolveToolProtocol, isNativeProtocol } from "../prompts/toolProtocolRe */ export async function presentAssistantMessage(cline: Task) { - console.log(`[NATIVE_TOOL] presentAssistantMessage called for task ${cline.taskId}.${cline.instanceId}`) - console.log( - `[NATIVE_TOOL] Current index: ${cline.currentStreamingContentIndex}, Content length: ${cline.assistantMessageContent.length}`, - ) - console.log( - `[NATIVE_TOOL] Locked: ${cline.presentAssistantMessageLocked}, HasPending: ${cline.presentAssistantMessageHasPendingUpdates}`, - ) - if (cline.abort) { throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`) } if (cline.presentAssistantMessageLocked) { - console.log(`[NATIVE_TOOL] presentAssistantMessage is locked, setting hasPendingUpdates=true and returning`) cline.presentAssistantMessageHasPendingUpdates = true return } - console.log(`[NATIVE_TOOL] Acquiring lock on presentAssistantMessage`) cline.presentAssistantMessageLocked = true cline.presentAssistantMessageHasPendingUpdates = false if (cline.currentStreamingContentIndex >= cline.assistantMessageContent.length) { - console.log( - `[NATIVE_TOOL] Index ${cline.currentStreamingContentIndex} >= length ${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) { - console.log(`[NATIVE_TOOL] Stream is complete, setting userMessageContentReady=true`) cline.userMessageContentReady = true } - console.log(`[NATIVE_TOOL] Releasing lock and returning (out of bounds)`) cline.presentAssistantMessageLocked = false return } - console.log(`[NATIVE_TOOL] About to clone block at index ${cline.currentStreamingContentIndex}`) - console.log( - `[NATIVE_TOOL] Block exists:`, - cline.assistantMessageContent[cline.currentStreamingContentIndex] !== undefined, - ) - 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 - console.log(`[NATIVE_TOOL] Block cloned successfully`) } catch (error) { - console.error(`[NATIVE_TOOL] ERROR cloning block:`, error) + console.error(`ERROR cloning block:`, error) console.error( - `[NATIVE_TOOL] Block content:`, + `Block content:`, JSON.stringify(cline.assistantMessageContent[cline.currentStreamingContentIndex], null, 2), ) cline.presentAssistantMessageLocked = false return } - console.log( - `[NATIVE_TOOL] Processing block at index ${cline.currentStreamingContentIndex}:`, - JSON.stringify( - { type: block.type, name: block.type === "tool_use" ? block.name : undefined, partial: block.partial }, - null, - 2, - ), - ) switch (block.type) { case "text": { @@ -364,8 +334,6 @@ export async function presentAssistantMessage(cline: Task) { progressStatus?: ToolProgressStatus, isProtected?: boolean, ) => { - console.log(`[NATIVE_TOOL] askApproval called with type: ${type}`) - console.log(`[NATIVE_TOOL] Calling cline.ask()...`) const { response, text, images } = await cline.ask( type, partialMessage, @@ -373,10 +341,8 @@ export async function presentAssistantMessage(cline: Task) { progressStatus, isProtected || false, ) - console.log(`[NATIVE_TOOL] cline.ask() returned response: ${response}`) if (response !== "yesButtonClicked") { - console.log(`[NATIVE_TOOL] Tool was denied or user provided feedback`) // Handle both messageResponse and noButtonClicked with text. if (text) { await cline.say("user_feedback", text, images) @@ -388,7 +354,6 @@ export async function presentAssistantMessage(cline: Task) { return false } - console.log(`[NATIVE_TOOL] Tool was approved`) // Handle yesButtonClicked with text. if (text) { await cline.say("user_feedback", text, images) @@ -512,7 +477,6 @@ export async function presentAssistantMessage(cline: Task) { } } - console.log(`[NATIVE_TOOL] About to enter tool switch statement for tool: ${block.name}`) switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) @@ -580,12 +544,9 @@ export async function presentAssistantMessage(cline: Task) { }) break case "read_file": - console.log(`[NATIVE_TOOL] Processing read_file tool use in presentAssistantMessage`) - console.log(`[NATIVE_TOOL] Block details:`, JSON.stringify(block, null, 2)) // Check if this model should use the simplified single-file read tool const modelId = cline.api.getModel().id if (shouldUseSingleFileRead(modelId)) { - console.log(`[NATIVE_TOOL] Using simpleReadFileTool for model ${modelId}`) await simpleReadFileTool( cline, block, @@ -595,8 +556,6 @@ export async function presentAssistantMessage(cline: Task) { removeClosingTag, ) } else { - console.log(`[NATIVE_TOOL] Using readFileTool.handle for model ${modelId}`) - console.log(`[NATIVE_TOOL] Calling readFileTool.handle...`) // Type assertion is safe here because we're in the "read_file" case await readFileTool.handle(cline, block as ToolUse<"read_file">, { askApproval, @@ -604,7 +563,6 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, removeClosingTag, }) - console.log(`[NATIVE_TOOL] readFileTool.handle completed`) } break case "fetch_instructions": @@ -656,14 +614,12 @@ export async function presentAssistantMessage(cline: Task) { }) break case "execute_command": - console.log(`[NATIVE_TOOL] execute_command case matched, calling executeCommandTool.handle()`) await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { askApproval, handleError, pushToolResult, removeClosingTag, }) - console.log(`[NATIVE_TOOL] executeCommandTool.handle() completed`) break case "use_mcp_tool": await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { @@ -754,7 +710,6 @@ export async function presentAssistantMessage(cline: Task) { // This needs to be placed here, if not then calling // cline.presentAssistantMessage below would fail (sometimes) since it's // locked. - console.log(`[NATIVE_TOOL] Releasing lock on presentAssistantMessage`) cline.presentAssistantMessageLocked = false // NOTE: When tool is rejected, iterator stream is interrupted and it waits @@ -763,15 +718,8 @@ export async function presentAssistantMessage(cline: Task) { // 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) { - console.log( - `[NATIVE_TOOL] Block processing complete (partial=${block.partial}, didRejectTool=${cline.didRejectTool}, didAlreadyUseTool=${cline.didAlreadyUseTool})`, - ) - console.log( - `[NATIVE_TOOL] currentStreamingContentIndex: ${cline.currentStreamingContentIndex}, assistantMessageContent.length: ${cline.assistantMessageContent.length}`, - ) // Block is finished streaming and executing. if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) { - console.log(`[NATIVE_TOOL] Last block (index === length - 1), setting userMessageContentReady=true`) // 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 @@ -780,53 +728,31 @@ export async function presentAssistantMessage(cline: Task) { // 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. - } else { - console.log( - `[NATIVE_TOOL] Not on last block yet (index ${cline.currentStreamingContentIndex} !== length - 1 ${cline.assistantMessageContent.length - 1})`, - ) } // 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. - console.log( - `[NATIVE_TOOL] Incrementing currentStreamingContentIndex from ${cline.currentStreamingContentIndex} to ${cline.currentStreamingContentIndex + 1}`, - ) cline.currentStreamingContentIndex++ - console.log( - `[NATIVE_TOOL] After increment: index = ${cline.currentStreamingContentIndex}, length = ${cline.assistantMessageContent.length}`, - ) if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { - console.log(`[NATIVE_TOOL] More blocks to process, calling presentAssistantMessage recursively`) // There are already more content blocks to stream, so we'll call // this function ourselves. presentAssistantMessage(cline) return } else { - console.log( - `[NATIVE_TOOL] No more blocks to process (index ${cline.currentStreamingContentIndex} >= length ${cline.assistantMessageContent.length})`, - ) - console.log(`[NATIVE_TOOL] didCompleteReadingStream: ${cline.didCompleteReadingStream}`) // 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) { - console.log(`[NATIVE_TOOL] Stream is complete and no more blocks, setting userMessageContentReady=true`) cline.userMessageContentReady = true - } else { - console.log(`[NATIVE_TOOL] Stream not complete yet, waiting for more blocks`) } } } // Block is partial, but the read stream may have finished. if (cline.presentAssistantMessageHasPendingUpdates) { - console.log(`[NATIVE_TOOL] Has pending updates, calling presentAssistantMessage recursively`) presentAssistantMessage(cline) - } else { - console.log(`[NATIVE_TOOL] No pending updates, exiting presentAssistantMessage`) - console.log(`[NATIVE_TOOL] Final state: userMessageContentReady=${cline.userMessageContentReady}`) } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 63e5cb1b715..4f1beb7efcc 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -750,9 +750,6 @@ export class Task extends EventEmitter implements TaskLike { progressStatus?: ToolProgressStatus, isProtected?: boolean, ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { - console.log(`[NATIVE_TOOL] Task.ask() called with type: ${type}, partial: ${partial}`) - console.log(`[NATIVE_TOOL] Text preview:`, text?.substring(0, 100)) - // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, // in which case we don't want to send its result to the webview as it @@ -762,7 +759,6 @@ export class Task extends EventEmitter implements TaskLike { // simply removes the reference to this instance, but the instance is // still alive until this promise resolves or rejects.) if (this.abort) { - console.log(`[NATIVE_TOOL] Task.ask() aborted`) throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } @@ -838,7 +834,6 @@ export class Task extends EventEmitter implements TaskLike { } } } else { - console.log(`[NATIVE_TOOL] Creating new non-partial ask message`) // This is a new non-partial message, so add it like normal. this.askResponse = undefined this.askResponseText = undefined @@ -846,7 +841,6 @@ export class Task extends EventEmitter implements TaskLike { askTs = Date.now() console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`) this.lastMessageTs = askTs - console.log(`[NATIVE_TOOL] Adding ask message to clineMessages with ts: ${askTs}`) await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } @@ -948,16 +942,11 @@ export class Task extends EventEmitter implements TaskLike { // The ask message is created in the UI, but the task doesn't wait for a response // This prevents blocking in cloud/headless environments if (isNonBlockingAsk(type)) { - console.log(`[NATIVE_TOOL] Non-blocking ask type, returning immediately`) return { response: "yesButtonClicked" as ClineAskResponse, text: undefined, images: undefined } } - console.log(`[NATIVE_TOOL] Waiting for askResponse to be set...`) // Wait for askResponse to be set await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) - console.log( - `[NATIVE_TOOL] Wait completed. askResponse: ${this.askResponse}, lastMessageTs changed: ${this.lastMessageTs !== askTs}`, - ) if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with @@ -2016,12 +2005,6 @@ export class Task extends EventEmitter implements TaskLike { continue } - // Log all chunk types to see what's coming through - console.log(`[NATIVE_TOOL] Stream chunk type:`, chunk.type) - if (chunk.type === "tool_call") { - console.log(`[NATIVE_TOOL] Stream received tool_call chunk!`) - } - switch (chunk.type) { case "reasoning": { reasoningMessage += chunk.text @@ -2054,8 +2037,6 @@ export class Task extends EventEmitter implements TaskLike { } break case "tool_call": { - console.log(`[NATIVE_TOOL] Received tool_call chunk:`, JSON.stringify(chunk, null, 2)) - // Convert native tool call to ToolUse format const toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, @@ -2064,10 +2045,7 @@ export class Task extends EventEmitter implements TaskLike { }) if (!toolUse) { - console.error( - `[NATIVE_TOOL] Failed to parse tool call for task ${this.taskId}:`, - chunk, - ) + console.error(`Failed to parse tool call for task ${this.taskId}:`, chunk) break } @@ -2075,29 +2053,12 @@ export class Task extends EventEmitter implements TaskLike { // This is needed to create tool_result blocks that reference the correct tool_use_id toolUse.id = chunk.id - console.log( - `[NATIVE_TOOL] Parsed to ToolUse with id:`, - JSON.stringify(toolUse, null, 2), - ) - console.log( - `[NATIVE_TOOL] Current assistantMessageContent length before:`, - this.assistantMessageContent.length, - ) - // Add the tool use to assistant message content this.assistantMessageContent.push(toolUse) - console.log( - `[NATIVE_TOOL] Current assistantMessageContent length after:`, - this.assistantMessageContent.length, - ) - console.log(`[NATIVE_TOOL] Setting userMessageContentReady to false`) - // Mark that we have new content to process this.userMessageContentReady = false - console.log(`[NATIVE_TOOL] Calling presentAssistantMessage`) - // Present the tool call to user presentAssistantMessage(this) break @@ -2529,28 +2490,18 @@ export class Task extends EventEmitter implements TaskLike { // this.userMessageContentReady = true // } - console.log(`[NATIVE_TOOL] Waiting for userMessageContentReady...`) await pWaitFor(() => this.userMessageContentReady) - console.log(`[NATIVE_TOOL] userMessageContentReady is now true!`) - console.log(`[NATIVE_TOOL] userMessageContent length: ${this.userMessageContent.length}`) // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. const didToolUse = this.assistantMessageContent.some((block) => block.type === "tool_use") - console.log(`[NATIVE_TOOL] didToolUse: ${didToolUse}`) if (!didToolUse) { - console.log(`[NATIVE_TOOL] No tool use detected, adding noToolsUsed message`) this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed() }) this.consecutiveMistakeCount++ } if (this.userMessageContent.length > 0) { - console.log(`[NATIVE_TOOL] Pushing userMessageContent back onto stack for next API request`) - console.log( - `[NATIVE_TOOL] userMessageContent:`, - JSON.stringify(this.userMessageContent.slice(0, 2), null, 2), - ) stack.push({ userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues includeFileDetails: false, // Subsequent iterations don't need file details @@ -2558,10 +2509,7 @@ export class Task extends EventEmitter implements TaskLike { // Add periodic yielding to prevent blocking await new Promise((resolve) => setImmediate(resolve)) - } else { - console.log(`[NATIVE_TOOL] userMessageContent is empty, not pushing to stack`) } - console.log(`[NATIVE_TOOL] Continuing to next iteration...`) // Continue to next iteration instead of setting didEndLoop from recursive call continue } else { @@ -3009,16 +2957,9 @@ export class Task extends EventEmitter implements TaskLike { mode: mode, taskId: this.taskId, // Include tools and tool protocol when using native protocol and model supports it - ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", toolProtocol } : {}), + ...(shouldIncludeTools ? { tools: allTools, tool_choice: "required", toolProtocol } : {}), } - console.log(`[NATIVE_TOOL] API request metadata:`, { - hasTools: !!metadata.tools, - toolCount: metadata.tools?.length, - toolChoice: metadata.tool_choice, - toolProtocol: metadata.toolProtocol, - }) - // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. const stream = this.api.createMessage( systemPrompt, diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 679ed19f182..459b776647f 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -97,23 +97,12 @@ export abstract class BaseTool { * @param callbacks - Tool execution callbacks */ async handle(task: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { - console.log(`[NATIVE_TOOL] BaseTool.handle called for tool: ${this.name}`) - console.log( - `[NATIVE_TOOL] Block:`, - JSON.stringify( - { name: block.name, partial: block.partial, hasNativeArgs: block.nativeArgs !== undefined }, - null, - 2, - ), - ) - // Handle partial messages if (block.partial) { - console.log(`[NATIVE_TOOL] Block is partial, calling handlePartial`) try { await this.handlePartial(task, block) } catch (error) { - console.error(`[NATIVE_TOOL] Error in handlePartial:`, error) + console.error(`Error in handlePartial:`, error) await callbacks.handleError( `handling partial ${this.name}`, error instanceof Error ? error : new Error(String(error)), @@ -126,27 +115,22 @@ export abstract class BaseTool { let params: ToolParams try { if (block.nativeArgs !== undefined) { - console.log(`[NATIVE_TOOL] Using native args:`, JSON.stringify(block.nativeArgs, null, 2)) // Native protocol: typed args provided by NativeToolCallParser // TypeScript knows nativeArgs is properly typed based on TName params = block.nativeArgs as ToolParams } else { - console.log(`[NATIVE_TOOL] Using legacy params parsing`) // XML/legacy protocol: parse string params into typed params params = this.parseLegacy(block.params) } } catch (error) { - console.error(`[NATIVE_TOOL] Error parsing parameters:`, error) + console.error(`Error parsing parameters:`, error) const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}` await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage)) callbacks.pushToolResult(`${errorMessage}`) return } - console.log(`[NATIVE_TOOL] Parsed params:`, JSON.stringify(params, null, 2)) - console.log(`[NATIVE_TOOL] Calling execute()`) // Execute with typed parameters await this.execute(params, task, callbacks) - console.log(`[NATIVE_TOOL] Execute completed`) } } diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index e8e8bdf0e9b..7b6cdbf9bee 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -103,9 +103,6 @@ export class ReadFileTool extends BaseTool<"read_file"> { } async execute(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise { - console.log(`[NATIVE_TOOL] ReadFileTool.execute() called for task ${task.taskId}`) - console.log(`[NATIVE_TOOL] File entries:`, JSON.stringify(fileEntries, null, 2)) - const { handleError, pushToolResult } = callbacks if (fileEntries.length === 0) { From c7d310f2244257d5f9e9c30201ad7f52d52548db Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 12 Nov 2025 21:18:36 -0500 Subject: [PATCH 38/48] refactor(parser): clean up NativeToolCallParser type assertions and logic - Add NativeArgsFor helper type to replace 15+ verbose conditional type assertions (TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never) - Document switch statement's dual purpose: validation and transformation - Clarify params.files skip logic with improved comments explaining the parallel legacy/native execution paths - Remove obsolete single-file format support from read_file case This change improves code readability and maintainability without altering any runtime behavior. --- .../assistant-message/NativeToolCallParser.ts | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 6af61625bae..46e39a78cbb 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,6 +1,12 @@ import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" +/** + * Helper type to extract properly typed native arguments for a given tool. + * Returns the type from NativeToolArgs if the tool is defined there, otherwise never. + */ +type NativeArgsFor = TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + /** * Parser for native tool calls (OpenAI-style function calling). * Converts native tool call format to ToolUse format for compatibility @@ -14,10 +20,6 @@ export class NativeToolCallParser { /** * Convert a native tool call chunk to a ToolUse object. * - * For refactored tools (read_file, etc.), native arguments are properly typed - * based on the NativeToolArgs type map. For tools not yet migrated, nativeArgs - * will be undefined and the tool will use parseLegacy() for backward compatibility. - * * @param toolCall - The native tool call from the API stream * @returns A properly typed ToolUse object */ @@ -42,11 +44,14 @@ export class NativeToolCallParser { // Parse the arguments JSON string const args = JSON.parse(toolCall.arguments) - // Convert arguments to params format (for backward-compat/UI), but primary path uses nativeArgs + // Build legacy params object for backward compatibility with XML protocol and UI. + // Native execution path uses nativeArgs instead, which has proper typing. const params: Partial> = {} for (const [key, value] of Object.entries(args)) { - // For read_file native calls, do not synthesize params.files – nativeArgs carries typed data + // Skip complex parameters that have been migrated to nativeArgs. + // For read_file, the 'files' parameter is a FileEntry[] array that can't be + // meaningfully stringified. The properly typed data is in nativeArgs instead. if (toolCall.name === "read_file" && key === "files") { continue } @@ -58,35 +63,31 @@ export class NativeToolCallParser { continue } - // Keep legacy string params for compatibility (not used by native execution path) + // Convert to string for legacy params format const stringValue = typeof value === "string" ? value : JSON.stringify(value) params[key as ToolParamName] = stringValue } - // Build typed nativeArgs for tools that support it - let nativeArgs: (TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never) | undefined = undefined + // Build typed nativeArgs for tools that support it. + // This switch statement serves two purposes: + // 1. Validation: Ensures required parameters are present before constructing nativeArgs + // 2. Transformation: Converts raw JSON to properly typed structures (e.g., handling + // + // Each case validates the minimum required parameters and constructs a properly typed + // nativeArgs object. If validation fails, nativeArgs remains undefined and the tool + // will fall back to legacy parameter parsing if supported. + let nativeArgs: NativeArgsFor | undefined = undefined switch (toolCall.name) { case "read_file": - // Handle both single-file and multi-file formats if (args.files && Array.isArray(args.files)) { - // Multi-file format: {"files": [{path: "...", line_ranges: [...]}, ...]} - nativeArgs = args.files as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never - } else if (args.path) { - // Single-file format: {"path": "..."} - convert to array format - const fileEntry: FileEntry = { - path: args.path, - lineRanges: [], - } - nativeArgs = [fileEntry] as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + nativeArgs = args.files as NativeArgsFor } break case "attempt_completion": if (args.result) { - nativeArgs = { result: args.result } as TName extends keyof NativeToolArgs - ? NativeToolArgs[TName] - : never + nativeArgs = { result: args.result } as NativeArgsFor } break @@ -95,7 +96,7 @@ export class NativeToolCallParser { nativeArgs = { command: args.command, cwd: args.cwd, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -105,7 +106,7 @@ export class NativeToolCallParser { path: args.path, line: typeof args.line === "number" ? args.line : parseInt(String(args.line), 10), content: args.content, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -114,7 +115,7 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, diff: args.diff, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -123,7 +124,7 @@ export class NativeToolCallParser { nativeArgs = { question: args.question, follow_up: args.follow_up, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -135,7 +136,7 @@ export class NativeToolCallParser { coordinate: args.coordinate, size: args.size, text: args.text, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -144,7 +145,7 @@ export class NativeToolCallParser { nativeArgs = { query: args.query, path: args.path, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -152,7 +153,7 @@ export class NativeToolCallParser { if (args.task !== undefined) { nativeArgs = { task: args.task, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -162,7 +163,7 @@ export class NativeToolCallParser { prompt: args.prompt, path: args.path, image: args.image, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -170,7 +171,7 @@ export class NativeToolCallParser { if (args.path !== undefined) { nativeArgs = { path: args.path, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -179,7 +180,7 @@ export class NativeToolCallParser { nativeArgs = { command: args.command, args: args.args, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -189,7 +190,7 @@ export class NativeToolCallParser { path: args.path, regex: args.regex, file_pattern: args.file_pattern, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -198,7 +199,7 @@ export class NativeToolCallParser { nativeArgs = { mode_slug: args.mode_slug, reason: args.reason, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -206,7 +207,7 @@ export class NativeToolCallParser { if (args.todos !== undefined) { nativeArgs = { todos: args.todos, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -219,7 +220,7 @@ export class NativeToolCallParser { typeof args.line_count === "number" ? args.line_count : parseInt(String(args.line_count), 10), - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break @@ -229,7 +230,7 @@ export class NativeToolCallParser { server_name: args.server_name, tool_name: args.tool_name, arguments: args.arguments, - } as TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + } as NativeArgsFor } break From a6b93411f4101a4462c766f0a26135a57bbbe102 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 09:20:53 -0500 Subject: [PATCH 39/48] fix: detect tool calling support from API tool-use tag - Add check for 'tool-use' tag in getRooModels() to set supportsNativeTools - Remove hardcoded supportsNativeTools() fallback method - Simplify getModel() to rely on API response data - Update tests to include supportsNativeTools field - Models now properly marked based on their actual capabilities from the API --- .../providers/fetchers/__tests__/roo.spec.ts | 3 +++ src/api/providers/fetchers/roo.ts | 4 ++++ src/api/providers/roo.ts | 17 +---------------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts index dcc79e941fa..0fa574cff16 100644 --- a/src/api/providers/fetchers/__tests__/roo.spec.ts +++ b/src/api/providers/fetchers/__tests__/roo.spec.ts @@ -68,6 +68,7 @@ describe("getRooModels", () => { supportsImages: true, supportsReasoningEffort: true, requiredReasoningEffort: false, + supportsNativeTools: false, supportsPromptCache: true, inputPrice: 100, // 0.0001 * 1_000_000 outputPrice: 200, // 0.0002 * 1_000_000 @@ -116,6 +117,7 @@ describe("getRooModels", () => { supportsImages: false, supportsReasoningEffort: true, requiredReasoningEffort: true, + supportsNativeTools: false, supportsPromptCache: false, inputPrice: 100, // 0.0001 * 1_000_000 outputPrice: 200, // 0.0002 * 1_000_000 @@ -162,6 +164,7 @@ describe("getRooModels", () => { supportsImages: false, supportsReasoningEffort: false, requiredReasoningEffort: false, + supportsNativeTools: false, supportsPromptCache: false, inputPrice: 100, // 0.0001 * 1_000_000 outputPrice: 200, // 0.0002 * 1_000_000 diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 3b0da006a5c..a1671e3b8d2 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -92,6 +92,9 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise { } } - /** - * Check if a model ID supports native tool calling. - * This is a fallback for models loaded dynamically that don't have explicit support flags. - */ - private supportsNativeTools(modelId: string): boolean { - // List of model IDs known to support native tools - const nativeToolModels = ["openai/gpt-5"] - - return nativeToolModels.some((model) => modelId.includes(model)) - } - override getModel() { const modelId = this.options.apiModelId || rooDefaultModelId @@ -281,10 +270,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { const modelInfo = models[modelId] if (modelInfo) { - // If model info exists but doesn't have supportsNativeTools set, check our list - if (modelInfo.supportsNativeTools === undefined) { - modelInfo.supportsNativeTools = this.supportsNativeTools(modelId) - } return { id: modelId, info: modelInfo } } @@ -297,7 +282,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { supportsImages: false, supportsReasoningEffort: false, supportsPromptCache: true, - supportsNativeTools: this.supportsNativeTools(modelId), + supportsNativeTools: false, inputPrice: 0, outputPrice: 0, }, From 78aa72bde831b2a97a3b545626b934244b177bc2 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 09:41:47 -0500 Subject: [PATCH 40/48] fix: remove spaces from search/replace block --- src/core/prompts/tools/native-tools/apply_diff.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/apply_diff.ts index 9d2dc914985..19ef8318843 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/apply_diff.ts @@ -19,12 +19,12 @@ Apply precise, targeted modifications to an existing file using one or more sear description: ` A string containing one or more search/replace blocks defining the changes. The ':start_line:' is required and indicates the starting line number of the original content. You must not add a start line for the replacement content. Each block must follow this format: <<<<<<< SEARCH - :start_line:[line_number] - ------- - [exact content to find] - ======= - [new content to replace with] - >>>>>>> REPLACE +:start_line:[line_number] +------- +[exact content to find] +======= +[new content to replace with] +>>>>>>> REPLACE `, }, }, From 9154b7219489b11c7da8ba6dc5e1707530873ea0 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 10:58:50 -0500 Subject: [PATCH 41/48] Remove unimplemented tools (search_and_replace, edit_file) --- .../prompts/tools/native-tools/edit_file.ts | 31 ------------- src/core/prompts/tools/native-tools/index.ts | 4 -- .../tools/native-tools/search_and_replace.ts | 46 ------------------- 3 files changed, 81 deletions(-) delete mode 100644 src/core/prompts/tools/native-tools/edit_file.ts delete mode 100644 src/core/prompts/tools/native-tools/search_and_replace.ts diff --git a/src/core/prompts/tools/native-tools/edit_file.ts b/src/core/prompts/tools/native-tools/edit_file.ts deleted file mode 100644 index f4dcfeeaa4a..00000000000 --- a/src/core/prompts/tools/native-tools/edit_file.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type OpenAI from "openai" - -export default { - type: "function", - function: { - name: "edit_file", - description: - "Use this tool to make an edit to a file. A less intelligent apply model will read your request, so be clear about the change while minimizing unchanged code. Specify each edit sequentially and replace omitted sections with // ... existing code ... placeholders. Provide enough surrounding context to avoid ambiguity, always use the placeholder when skipping existing content, show before-and-after context when deleting, and gather all edits for the file in a single request.", - strict: true, - parameters: { - type: "object", - properties: { - target_file: { - type: "string", - description: "Full path of the file to modify", - }, - instructions: { - type: "string", - description: "Single first-person sentence summarizing the edit to guide the apply model", - }, - code_edit: { - type: "string", - description: - "Only the edited lines using // ... existing code ... wherever unchanged content is omitted", - }, - }, - required: ["target_file", "instructions", "code_edit"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 4f762fde1f7..c12a681704e 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -3,7 +3,6 @@ import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" import codebaseSearch from "./codebase_search" -import editFile from "./edit_file" import executeCommand from "./execute_command" import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" @@ -13,7 +12,6 @@ import listFiles from "./list_files" import newTask from "./new_task" import { read_file } from "./read_file" import runSlashCommand from "./run_slash_command" -import searchAndReplace from "./search_and_replace" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" @@ -29,7 +27,6 @@ export const nativeTools = [ attemptCompletion, browserAction, codebaseSearch, - editFile, executeCommand, fetchInstructions, generateImage, @@ -39,7 +36,6 @@ export const nativeTools = [ newTask, read_file, runSlashCommand, - searchAndReplace, searchFiles, switchMode, updateTodoList, diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts deleted file mode 100644 index 730cebc897a..00000000000 --- a/src/core/prompts/tools/native-tools/search_and_replace.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type OpenAI from "openai" - -export default { - type: "function", - function: { - name: "search_and_replace", - description: - "Find and replace text within a file using literal strings or regular expressions. Supports optional line ranges, regex mode, and case-insensitive matching, and shows a diff preview before applying changes.", - strict: true, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "File path to modify, relative to the workspace", - }, - search: { - type: "string", - description: "Text or pattern to search for", - }, - replace: { - type: "string", - description: "Replacement text to insert for each match", - }, - start_line: { - type: ["integer", "null"], - description: "Optional starting line (1-based) to limit replacements", - }, - end_line: { - type: ["integer", "null"], - description: "Optional ending line (1-based) to limit replacements", - }, - use_regex: { - type: ["boolean", "null"], - description: "Set true to treat the search parameter as a regular expression", - }, - ignore_case: { - type: ["boolean", "null"], - description: "Set true to ignore case when matching", - }, - }, - required: ["path", "search", "replace", "start_line", "end_line", "use_regex", "ignore_case"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool From 40cd9f5548ee4eb7065c9bb08ea33e4d5d1dcfc2 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 11:14:43 -0500 Subject: [PATCH 42/48] fix: remove unrelated code from merge --- src/core/task/Task.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f1beb7efcc..651d9610284 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -34,7 +34,6 @@ import { isIdleAsk, isInteractiveAsk, isResumableAsk, - isNonBlockingAsk, QueuedMessage, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -938,13 +937,6 @@ export class Task extends EventEmitter implements TaskLike { } } - // Non-blocking asks return immediately without waiting - // The ask message is created in the UI, but the task doesn't wait for a response - // This prevents blocking in cloud/headless environments - if (isNonBlockingAsk(type)) { - return { response: "yesButtonClicked" as ClineAskResponse, text: undefined, images: undefined } - } - // Wait for askResponse to be set await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) From 3d5ad18894eeb1589e8a393eebd8e9e33a0dea94 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 11:20:05 -0500 Subject: [PATCH 43/48] refactor: move getMcpServerTools to top-level import Move getMcpServerTools from inline import to top-level import for consistency with other imports --- src/core/task/Task.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 651d9610284..503546ca847 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -83,7 +83,7 @@ import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" import { resolveToolProtocol } from "../prompts/toolProtocolResolver" -import { nativeTools } from "../prompts/tools/native-tools" +import { nativeTools, getMcpServerTools } from "../prompts/tools/native-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" @@ -2938,7 +2938,6 @@ export class Task extends EventEmitter implements TaskLike { // Build complete tools array: native tools + dynamic MCP tools let allTools: OpenAI.Chat.ChatCompletionTool[] = nativeTools if (shouldIncludeTools) { - const { getMcpServerTools } = await import("../prompts/tools/native-tools") const provider = this.providerRef.deref() const mcpHub = provider?.getMcpHub() const mcpTools = getMcpServerTools(mcpHub) From fb93c154dec1299f92c80b88f3988a8f5699a743 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 11:43:02 -0500 Subject: [PATCH 44/48] refactor: move removeClosingTag to BaseTool as shared method Move removeClosingTag from individual tool classes to BaseTool as a protected method, eliminating code duplication across ExecuteCommandTool, AskFollowupQuestionTool, BrowserActionTool, SwitchModeTool, SearchFilesTool, NewTaskTool, RunSlashCommandTool, UseMcpToolTool, AttemptCompletionTool, and ListFilesTool. The local removeClosingTag in presentAssistantMessage.ts is retained for legacy function-based tools (accessMcpResourceTool, simpleReadFileTool, applyDiffTool) that haven't been converted to the class-based architecture yet. --- src/core/tools/AskFollowupQuestionTool.ts | 20 ++----------- src/core/tools/AttemptCompletionTool.ts | 20 ------------- src/core/tools/BaseTool.ts | 34 +++++++++++++++++++++++ src/core/tools/BrowserActionTool.ts | 20 ------------- src/core/tools/ExecuteCommandTool.ts | 20 ++----------- src/core/tools/ListFilesTool.ts | 20 ------------- src/core/tools/NewTaskTool.ts | 20 ------------- src/core/tools/RunSlashCommandTool.ts | 20 ------------- src/core/tools/SearchFilesTool.ts | 20 ------------- src/core/tools/SwitchModeTool.ts | 20 ------------- src/core/tools/UseMcpToolTool.ts | 20 ------------- 11 files changed, 40 insertions(+), 194 deletions(-) diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index a54dd69c755..2899dc6ee33 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -92,23 +92,9 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise { const question: string | undefined = block.params.question - await task.ask("followup", this.removeClosingTag("question", question), block.partial).catch(() => {}) - } - - private removeClosingTag(tag: string, text: string | undefined): string { - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") + await task + .ask("followup", this.removeClosingTag("question", question, block.partial), block.partial) + .catch(() => {}) } } diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 0f4d764639b..8a7695748b3 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -131,26 +131,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { ) } } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const attemptCompletionTool = new AttemptCompletionTool() diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 459b776647f..818fd1bf9a0 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -84,6 +84,40 @@ export abstract class BaseTool { // Tools can override to show streaming UI updates } + /** + * Remove partial closing XML tags from text during streaming. + * + * This utility helps clean up partial XML tag artifacts that can appear + * at the end of streamed content, preventing them from being displayed to users. + * + * @param tag - The tag name to check for partial closing + * @param text - The text content to clean + * @param isPartial - Whether this is a partial message (if false, returns text as-is) + * @returns Cleaned text with partial closing tags removed + */ + protected removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { + if (!isPartial) { + return text || "" + } + + if (!text) { + return "" + } + + // This regex dynamically constructs a pattern to match the closing tag: + // - Optionally matches whitespace before the tag + // - Matches '<' or ' `(?:${char})?`) + .join("")}$`, + "g", + ) + + return text.replace(tagRegex, "") + } + /** * Main entry point for tool execution. * diff --git a/src/core/tools/BrowserActionTool.ts b/src/core/tools/BrowserActionTool.ts index 89e180bb21c..3e8f6f176e4 100644 --- a/src/core/tools/BrowserActionTool.ts +++ b/src/core/tools/BrowserActionTool.ts @@ -239,26 +239,6 @@ export class BrowserActionTool extends BaseTool<"browser_action"> { ) } } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const browserActionTool = new BrowserActionTool() diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index be4f5708183..ddf09cb8f0b 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -141,23 +141,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { override async handlePartial(task: Task, block: ToolUse<"execute_command">): Promise { const command = block.params.command - await task.ask("command", this.removeClosingTag("command", command), block.partial).catch(() => {}) - } - - private removeClosingTag(tag: string, text: string | undefined): string { - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") + await task + .ask("command", this.removeClosingTag("command", command, block.partial), block.partial) + .catch(() => {}) } } diff --git a/src/core/tools/ListFilesTool.ts b/src/core/tools/ListFilesTool.ts index 82d085a41b3..795bebf85ab 100644 --- a/src/core/tools/ListFilesTool.ts +++ b/src/core/tools/ListFilesTool.ts @@ -92,26 +92,6 @@ export class ListFilesTool extends BaseTool<"list_files"> { const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) await task.ask("tool", partialMessage, block.partial).catch(() => {}) } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const listFilesTool = new ListFilesTool() diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index c1fa921db9b..2535cef1d21 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -154,26 +154,6 @@ export class NewTaskTool extends BaseTool<"new_task"> { await task.ask("tool", partialMessage, block.partial).catch(() => {}) } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const newTaskTool = new NewTaskTool() diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index a77c4eb6a34..2196e257252 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -117,26 +117,6 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { await task.ask("tool", partialMessage, block.partial).catch(() => {}) } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const runSlashCommandTool = new RunSlashCommandTool() diff --git a/src/core/tools/SearchFilesTool.ts b/src/core/tools/SearchFilesTool.ts index 094c6983543..f22462c22e0 100644 --- a/src/core/tools/SearchFilesTool.ts +++ b/src/core/tools/SearchFilesTool.ts @@ -94,26 +94,6 @@ export class SearchFilesTool extends BaseTool<"search_files"> { const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool) await task.ask("tool", partialMessage, block.partial).catch(() => {}) } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const searchFilesTool = new SearchFilesTool() diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts index e4fb56fc3b2..17ef11d9886 100644 --- a/src/core/tools/SwitchModeTool.ts +++ b/src/core/tools/SwitchModeTool.ts @@ -87,26 +87,6 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { await task.ask("tool", partialMessage, block.partial).catch(() => {}) } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const switchModeTool = new SwitchModeTool() diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index a34f01cc4f4..f24c3b9c028 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -337,26 +337,6 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { await task.say("mcp_server_response", toolResultPretty) pushToolResult(formatResponse.toolResult(toolResultPretty)) } - - private removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string { - if (!isPartial) { - return text || "" - } - - if (!text) { - return "" - } - - const tagRegex = new RegExp( - `\\s?<\/?${tag - .split("") - .map((char) => `(?:${char})?`) - .join("")}$`, - "g", - ) - - return text.replace(tagRegex, "") - } } export const useMcpToolTool = new UseMcpToolTool() From 2d655e1e5f5d8a8d62e84480d2bcbe7788d01841 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 12:26:12 -0500 Subject: [PATCH 45/48] feat: move tool protocol to VSCode setting - Add roo-cline.toolProtocol setting with XML as default - Move utility functions (isNativeProtocol, getEffectiveProtocol) to @roo-code/types package - Remove toolProtocolResolver.ts in favor of direct VSCode config reads - Update all imports to use @roo-code/types - Add translations for new setting across all language files - Update tests to mock vscode instead of toolProtocolResolver Users can now select protocol (XML/native) via VSCode settings UI. --- packages/types/src/tool.ts | 21 ++++++++++ .../presentAssistantMessage.ts | 11 ++++-- .../__tests__/toolProtocolResolver.spec.ts | 10 ----- src/core/prompts/responses.ts | 7 ++-- .../prompts/sections/custom-instructions.ts | 4 +- src/core/prompts/sections/rules.ts | 4 +- .../prompts/sections/tool-use-guidelines.ts | 2 +- src/core/prompts/sections/tool-use.ts | 3 +- src/core/prompts/system.ts | 5 +-- src/core/prompts/toolProtocolResolver.ts | 38 ------------------- src/core/task/Task.ts | 12 ++++-- src/core/tools/MultiApplyDiffTool.ts | 5 ++- .../applyDiffTool.experiment.spec.ts | 26 +++++++------ src/core/webview/generateSystemPrompt.ts | 4 +- src/package.json | 9 +++++ src/package.nls.ca.json | 3 +- src/package.nls.de.json | 3 +- src/package.nls.es.json | 3 +- src/package.nls.fr.json | 3 +- src/package.nls.hi.json | 3 +- src/package.nls.id.json | 3 +- src/package.nls.it.json | 3 +- src/package.nls.ja.json | 3 +- src/package.nls.json | 3 +- src/package.nls.ko.json | 3 +- src/package.nls.nl.json | 3 +- src/package.nls.pl.json | 3 +- src/package.nls.pt-BR.json | 3 +- src/package.nls.ru.json | 3 +- src/package.nls.tr.json | 3 +- src/package.nls.vi.json | 3 +- src/package.nls.zh-CN.json | 3 +- src/package.nls.zh-TW.json | 3 +- 33 files changed, 114 insertions(+), 101 deletions(-) delete mode 100644 src/core/prompts/__tests__/toolProtocolResolver.spec.ts delete mode 100644 src/core/prompts/toolProtocolResolver.ts diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 9d4269b9fa6..ae4ddb72fb7 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -68,3 +68,24 @@ export const TOOL_PROTOCOL = { * Derived from TOOL_PROTOCOL constants to ensure type safety */ export type ToolProtocol = (typeof TOOL_PROTOCOL)[keyof typeof TOOL_PROTOCOL] + +/** + * Checks if the protocol is native (non-XML). + * + * @param protocol - The tool protocol to check + * @returns True if protocol is native + */ +export function isNativeProtocol(protocol: ToolProtocol): boolean { + return protocol === TOOL_PROTOCOL.NATIVE +} + +/** + * Gets the effective protocol from settings or falls back to the default XML. + * This function is safe to use in webview-accessible code as it doesn't depend on vscode module. + * + * @param toolProtocol - Optional tool protocol from settings + * @returns The effective tool protocol (defaults to "xml") + */ +export function getEffectiveProtocol(toolProtocol?: ToolProtocol): ToolProtocol { + return toolProtocol || TOOL_PROTOCOL.XML +} diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 13993b09b12..7e0b74db100 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,7 +37,8 @@ import { Task } from "../task/Task" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" -import { resolveToolProtocol, isNativeProtocol } from "../prompts/toolProtocolResolver" +import * as vscode from "vscode" +import { ToolProtocol, isNativeProtocol } from "@roo-code/types" /** * Processes and presents assistant message content to the user interface. @@ -277,7 +278,9 @@ export async function presentAssistantMessage(cline: Task) { const pushToolResult = (content: ToolResponse) => { // Check if we're using native tool protocol - const toolProtocol = resolveToolProtocol() + const toolProtocol = vscode.workspace + .getConfiguration("roo-cline") + .get("toolProtocol", "xml") const isNative = isNativeProtocol(toolProtocol) // Get the tool call ID if this is a native tool call @@ -499,7 +502,9 @@ export async function presentAssistantMessage(cline: Task) { await checkpointSaveAndMark(cline) // Check if native protocol is enabled - if so, always use single-file class-based tool - const toolProtocol = resolveToolProtocol() + const toolProtocol = vscode.workspace + .getConfiguration("roo-cline") + .get("toolProtocol", "xml") if (isNativeProtocol(toolProtocol)) { await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, diff --git a/src/core/prompts/__tests__/toolProtocolResolver.spec.ts b/src/core/prompts/__tests__/toolProtocolResolver.spec.ts deleted file mode 100644 index 0fe1522663b..00000000000 --- a/src/core/prompts/__tests__/toolProtocolResolver.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -// npx vitest core/prompts/__tests__/toolProtocolResolver.spec.ts - -import { describe, it, expect } from "vitest" -import { resolveToolProtocol } from "../toolProtocolResolver" - -describe("toolProtocolResolver", () => { - it("should default to xml protocol", () => { - expect(resolveToolProtocol()).toBe("xml") - }) -}) diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 1c1212e70f1..99140f48be1 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -3,8 +3,8 @@ import * as path from "path" import * as diff from "diff" import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" -import { resolveToolProtocol, isNativeProtocol } from "./toolProtocolResolver" -import { ToolProtocol } from "@roo-code/types" +import * as vscode from "vscode" +import { ToolProtocol, isNativeProtocol } from "@roo-code/types" export const formatResponse = { toolDenied: () => `The user denied this operation.`, @@ -249,6 +249,7 @@ Always ensure you provide all required parameters for the tool you wish to use.` * @returns The tool use instructions reminder text */ function getToolInstructionsReminder(protocol?: ToolProtocol): string { - const effectiveProtocol = protocol ?? resolveToolProtocol() + const effectiveProtocol = + protocol ?? vscode.workspace.getConfiguration("roo-cline").get("toolProtocol", "xml") return isNativeProtocol(effectiveProtocol) ? toolUseInstructionsReminderNative : toolUseInstructionsReminder } diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 4e5e5d19ac3..a81d4bf9439 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -6,7 +6,7 @@ import { Dirent } from "fs" import { isLanguage } from "@roo-code/types" import type { SystemPromptSettings } from "../types" -import { getEffectiveProtocol, isNativeProtocol } from "../toolProtocolResolver" +import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { LANGUAGES } from "../../../shared/language" import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config" @@ -369,7 +369,7 @@ export async function addCustomInstructions( const joinedSections = sections.join("\n\n") - const effectiveProtocol = getEffectiveProtocol(options.settings) + const effectiveProtocol = getEffectiveProtocol(options.settings?.toolProtocol) return joinedSections ? ` diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 0eceb2dc98d..5a504d17faf 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -1,7 +1,7 @@ import { DiffStrategy } from "../../../shared/tools" import { CodeIndexManager } from "../../../services/code-index/manager" import type { SystemPromptSettings } from "../types" -import { getEffectiveProtocol, isNativeProtocol } from "../toolProtocolResolver" +import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" function getEditingInstructions(diffStrategy?: DiffStrategy): string { const instructions: string[] = [] @@ -60,7 +60,7 @@ export function getRulesSection( : "" // Determine whether to use XML tool references based on protocol - const effectiveProtocol = getEffectiveProtocol(settings) + const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) return `==== diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 6258a1f13ac..c5651264a0a 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,6 +1,6 @@ import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" import { CodeIndexManager } from "../../../services/code-index/manager" -import { isNativeProtocol } from "../toolProtocolResolver" +import { isNativeProtocol } from "@roo-code/types" export function getToolUseGuidelinesSection( codeIndexManager?: CodeIndexManager, diff --git a/src/core/prompts/sections/tool-use.ts b/src/core/prompts/sections/tool-use.ts index e3f54a7d185..9ece848fb4e 100644 --- a/src/core/prompts/sections/tool-use.ts +++ b/src/core/prompts/sections/tool-use.ts @@ -1,5 +1,4 @@ -import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import { isNativeProtocol } from "../toolProtocolResolver" +import { ToolProtocol, TOOL_PROTOCOL, isNativeProtocol } from "@roo-code/types" export function getSharedToolUseSection(protocol: ToolProtocol = TOOL_PROTOCOL.XML): string { if (isNativeProtocol(protocol)) { diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 7b03bcc4187..649083b10f4 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -16,7 +16,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt" import { getToolDescriptionsForMode } from "./tools" -import { getEffectiveProtocol, isNativeProtocol } from "./toolProtocolResolver" +import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { getRulesSection, getSystemInfoSection, @@ -29,7 +29,6 @@ import { addCustomInstructions, markdownFormattingSection, } from "./sections" -import { TOOL_PROTOCOL } from "@roo-code/types" // Helper function to get prompt component, filtering out empty objects export function getPromptComponent( @@ -84,7 +83,7 @@ async function generatePrompt( const codeIndexManager = CodeIndexManager.getInstance(context, cwd) // Determine the effective protocol (defaults to 'xml') - const effectiveProtocol = getEffectiveProtocol(settings) + const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) const [modesSection, mcpServersSection] = await Promise.all([ getModesSection(context), diff --git a/src/core/prompts/toolProtocolResolver.ts b/src/core/prompts/toolProtocolResolver.ts deleted file mode 100644 index 1cd87f7251e..00000000000 --- a/src/core/prompts/toolProtocolResolver.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import type { SystemPromptSettings } from "./types" - -/** - * Current tool protocol setting. - * This is code-only and not exposed through VS Code settings. - * To switch protocols, edit this constant directly in the source code. - */ -const CURRENT_TOOL_PROTOCOL: ToolProtocol = TOOL_PROTOCOL.XML // change to TOOL_PROTOCOL.NATIVE to enable native protocol - -/** - * Resolves the effective tool protocol. - * - * @returns The effective tool protocol (defaults to "xml") - */ -export function resolveToolProtocol(): ToolProtocol { - return CURRENT_TOOL_PROTOCOL -} - -/** - * Gets the effective protocol from settings or falls back to the default. - * - * @param settings - Optional system prompt settings - * @returns The effective tool protocol - */ -export function getEffectiveProtocol(settings?: SystemPromptSettings): ToolProtocol { - return settings?.toolProtocol || resolveToolProtocol() -} - -/** - * Checks if the protocol is native (non-XML). - * - * @param protocol - The tool protocol to check - * @returns True if protocol is native - */ -export function isNativeProtocol(protocol: ToolProtocol): boolean { - return protocol === TOOL_PROTOCOL.NATIVE -} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 503546ca847..50467a36b10 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -40,6 +40,7 @@ import { MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, TOOL_PROTOCOL, + ToolProtocol, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" @@ -82,7 +83,6 @@ import { getWorkspacePath } from "../../utils/path" // prompts import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" -import { resolveToolProtocol } from "../prompts/toolProtocolResolver" import { nativeTools, getMcpServerTools } from "../prompts/tools/native-tools" // core modules @@ -2513,7 +2513,9 @@ export class Task extends EventEmitter implements TaskLike { // apiConversationHistory at line 1876. Since the assistant failed to respond, // we need to remove that message before retrying to avoid having two consecutive // user messages (which would cause tool_result validation errors). - const toolProtocol = resolveToolProtocol() + const toolProtocol = vscode.workspace + .getConfiguration("roo-cline") + .get("toolProtocol", "xml") const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE if (isNativeProtocol && this.apiConversationHistory.length > 0) { @@ -2697,7 +2699,9 @@ export class Task extends EventEmitter implements TaskLike { newTaskRequireTodos: vscode.workspace .getConfiguration("roo-cline") .get("newTaskRequireTodos", false), - toolProtocol: resolveToolProtocol(), + toolProtocol: vscode.workspace + .getConfiguration("roo-cline") + .get("toolProtocol", "xml"), }, undefined, // todoList this.api.getModel().id, @@ -2931,7 +2935,7 @@ export class Task extends EventEmitter implements TaskLike { // Determine if we should include native tools based on: // 1. Tool protocol is set to NATIVE // 2. Model supports native tools - const toolProtocol = resolveToolProtocol() + const toolProtocol = vscode.workspace.getConfiguration("roo-cline").get("toolProtocol", "xml") const modelInfo = this.api.getModel().info const shouldIncludeTools = toolProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) diff --git a/src/core/tools/MultiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts index 434460e12b7..4a41db76974 100644 --- a/src/core/tools/MultiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -16,7 +16,8 @@ import { parseXmlForDiff } from "../../utils/xml" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" -import { resolveToolProtocol, isNativeProtocol } from "../prompts/toolProtocolResolver" +import * as vscode from "vscode" +import { ToolProtocol, isNativeProtocol } from "@roo-code/types" interface DiffOperation { path: string @@ -61,7 +62,7 @@ export async function applyDiffTool( removeClosingTag: RemoveClosingTag, ) { // Check if native protocol is enabled - if so, always use single-file class-based tool - const toolProtocol = resolveToolProtocol() + const toolProtocol = vscode.workspace.getConfiguration("roo-cline").get("toolProtocol", "xml") if (isNativeProtocol(toolProtocol)) { return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index 5dbc1a5cfa5..da45031ff85 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -1,10 +1,11 @@ import { EXPERIMENT_IDS } from "../../../shared/experiments" import { TOOL_PROTOCOL } from "@roo-code/types" -// Mock the toolProtocolResolver module FIRST (before any imports that use it) -vi.mock("../../prompts/toolProtocolResolver", () => ({ - resolveToolProtocol: vi.fn(), - isNativeProtocol: vi.fn(), +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + }, })) // Mock the ApplyDiffTool module @@ -17,7 +18,6 @@ vi.mock("../ApplyDiffTool", () => ({ // Import after mocking to get the mocked version import { applyDiffTool as multiApplyDiffTool } from "../MultiApplyDiffTool" import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" -import { resolveToolProtocol, isNativeProtocol } from "../../prompts/toolProtocolResolver" describe("applyDiffTool experiment routing", () => { let mockCline: any @@ -28,12 +28,14 @@ describe("applyDiffTool experiment routing", () => { let mockRemoveClosingTag: any let mockProvider: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() - // Reset mocks to default behavior (XML protocol) - ;(resolveToolProtocol as any).mockReturnValue(TOOL_PROTOCOL.XML) - ;(isNativeProtocol as any).mockImplementation((protocol: any) => protocol === TOOL_PROTOCOL.NATIVE) + // Reset vscode mock to default behavior (XML protocol) + const vscode = await import("vscode") + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(TOOL_PROTOCOL.XML), + } as any) mockProvider = { getState: vi.fn(), @@ -144,8 +146,10 @@ describe("applyDiffTool experiment routing", () => { it("should use class-based tool when native protocol is enabled regardless of experiment", async () => { // Enable native protocol - ;(resolveToolProtocol as any).mockReturnValue(TOOL_PROTOCOL.NATIVE) - ;(isNativeProtocol as any).mockReturnValue(true) + const vscode = await import("vscode") + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(TOOL_PROTOCOL.NATIVE), + } as any) mockProvider.getState.mockResolvedValue({ experiments: { diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 65f908edad4..b1191911a52 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -7,7 +7,7 @@ import { experiments as experimentsModule, EXPERIMENT_IDS } from "../../shared/e import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" -import { resolveToolProtocol } from "../prompts/toolProtocolResolver" +import { ToolProtocol } from "@roo-code/types" import { ClineProvider } from "./ClineProvider" @@ -92,7 +92,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web newTaskRequireTodos: vscode.workspace .getConfiguration("roo-cline") .get("newTaskRequireTodos", false), - toolProtocol: resolveToolProtocol(), + toolProtocol: vscode.workspace.getConfiguration("roo-cline").get("toolProtocol", "xml"), }, ) diff --git a/src/package.json b/src/package.json index 74a312daa71..5701078bc0e 100644 --- a/src/package.json +++ b/src/package.json @@ -436,6 +436,15 @@ "minimum": 1, "maximum": 200, "description": "%settings.codeIndex.embeddingBatchSize.description%" + }, + "roo-cline.toolProtocol": { + "type": "string", + "enum": [ + "xml", + "native" + ], + "default": "xml", + "description": "%settings.toolProtocol.description%" } } } diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 22d3f633a64..d26fcf218bc 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Activa la càrrega de fitxers AGENTS.md per a regles específiques de l'agent (vegeu https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Temps màxim en segons per esperar les respostes de l'API (0 = sense temps d'espera, 1-3600s, per defecte: 600s). Es recomanen valors més alts per a proveïdors locals com LM Studio i Ollama que poden necessitar més temps de processament.", "settings.newTaskRequireTodos.description": "Requerir el paràmetre de tasques pendents quan es creïn noves tasques amb l'eina new_task", - "settings.codeIndex.embeddingBatchSize.description": "La mida del lot per a operacions d'incrustació durant la indexació de codi. Ajusta això segons els límits del teu proveïdor d'API. Per defecte és 60." + "settings.codeIndex.embeddingBatchSize.description": "La mida del lot per a operacions d'incrustació durant la indexació de codi. Ajusta això segons els límits del teu proveïdor d'API. Per defecte és 60.", + "settings.toolProtocol.description": "Protocol d'eines a utilitzar per a les interaccions d'IA. XML és el protocol per defecte i recomanat. Natiu és experimental i pot ser que no funcioni amb tots els proveïdors." } diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 3931ba000ef..f3c77fd0f55 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Aktiviert das Laden von AGENTS.md-Dateien für agentenspezifische Regeln (siehe https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximale Wartezeit in Sekunden auf API-Antworten (0 = kein Timeout, 1-3600s, Standard: 600s). Höhere Werte werden für lokale Anbieter wie LM Studio und Ollama empfohlen, die möglicherweise mehr Verarbeitungszeit benötigen.", "settings.newTaskRequireTodos.description": "Todos-Parameter beim Erstellen neuer Aufgaben mit dem new_task-Tool erfordern", - "settings.codeIndex.embeddingBatchSize.description": "Die Batch-Größe für Embedding-Operationen während der Code-Indexierung. Passe dies an die Limits deines API-Anbieters an. Standard ist 60." + "settings.codeIndex.embeddingBatchSize.description": "Die Batch-Größe für Embedding-Operationen während der Code-Indexierung. Passe dies an die Limits deines API-Anbieters an. Standard ist 60.", + "settings.toolProtocol.description": "Tool-Protokoll, das für KI-Interaktionen verwendet werden soll. XML ist das Standard- und empfohlene Protokoll. Nativ ist experimentell und funktioniert möglicherweise nicht mit allen Anbietern." } diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 0c22cdf9045..7bb988c2f82 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Habilita la carga de archivos AGENTS.md para reglas específicas del agente (ver https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tiempo máximo en segundos de espera para las respuestas de la API (0 = sin tiempo de espera, 1-3600s, por defecto: 600s). Se recomiendan valores más altos para proveedores locales como LM Studio y Ollama que puedan necesitar más tiempo de procesamiento.", "settings.newTaskRequireTodos.description": "Requerir el parámetro todos al crear nuevas tareas con la herramienta new_task", - "settings.codeIndex.embeddingBatchSize.description": "El tamaño del lote para operaciones de embedding durante la indexación de código. Ajusta esto según los límites de tu proveedor de API. Por defecto es 60." + "settings.codeIndex.embeddingBatchSize.description": "El tamaño del lote para operaciones de embedding durante la indexación de código. Ajusta esto según los límites de tu proveedor de API. Por defecto es 60.", + "settings.toolProtocol.description": "Protocolo de herramienta a utilizar para las interacciones de IA. XML es el protocolo predeterminado y recomendado. Nativo es experimental y puede que no funcione con todos los proveedores." } diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 422db278202..c3de92aecc6 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Activer le chargement des fichiers AGENTS.md pour les règles spécifiques à l'agent (voir https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Temps maximum en secondes d'attente pour les réponses de l'API (0 = pas de timeout, 1-3600s, par défaut : 600s). Des valeurs plus élevées sont recommandées pour les fournisseurs locaux comme LM Studio et Ollama qui peuvent nécessiter plus de temps de traitement.", "settings.newTaskRequireTodos.description": "Exiger le paramètre todos lors de la création de nouvelles tâches avec l'outil new_task", - "settings.codeIndex.embeddingBatchSize.description": "La taille du lot pour les opérations d'embedding lors de l'indexation du code. Ajustez ceci selon les limites de votre fournisseur d'API. Par défaut, c'est 60." + "settings.codeIndex.embeddingBatchSize.description": "La taille du lot pour les opérations d'embedding lors de l'indexation du code. Ajustez ceci selon les limites de votre fournisseur d'API. Par défaut, c'est 60.", + "settings.toolProtocol.description": "Protocole d'outil à utiliser pour les interactions AI. XML est le protocole par défaut et recommandé. Natif est expérimental et peut ne pas fonctionner avec tous les fournisseurs." } diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 6337e9e1a33..102e1861dbd 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "एजेंट-विशिष्ट नियमों के लिए AGENTS.md फ़ाइलों को लोड करना सक्षम करें (देखें https://agent-rules.org/)", "settings.apiRequestTimeout.description": "एपीआई प्रतिक्रियाओं की प्रतीक्षा करने के लिए सेकंड में अधिकतम समय (0 = कोई टाइमआउट नहीं, 1-3600s, डिफ़ॉल्ट: 600s)। एलएम स्टूडियो और ओलामा जैसे स्थानीय प्रदाताओं के लिए उच्च मानों की सिफारिश की जाती है जिन्हें अधिक प्रसंस्करण समय की आवश्यकता हो सकती है।", "settings.newTaskRequireTodos.description": "new_task टूल के साथ नए कार्य बनाते समय टूडू पैरामीटर की आवश्यकता होती है", - "settings.codeIndex.embeddingBatchSize.description": "कोड इंडेक्सिंग के दौरान एम्बेडिंग ऑपरेशन के लिए बैच साइज़। इसे अपने API प्रदाता की सीमाओं के अनुसार समायोजित करें। डिफ़ॉल्ट 60 है।" + "settings.codeIndex.embeddingBatchSize.description": "कोड इंडेक्सिंग के दौरान एम्बेडिंग ऑपरेशन के लिए बैच साइज़। इसे अपने API प्रदाता की सीमाओं के अनुसार समायोजित करें। डिफ़ॉल्ट 60 है।", + "settings.toolProtocol.description": "एआई इंटरैक्शन के लिए उपयोग करने वाला टूल प्रोटोकॉल। एक्सएमएल डिफ़ॉल्ट और अनुशंसित प्रोटोकॉल है। नेटिव प्रायोगिक है और सभी प्रदाताओं के साथ काम नहीं कर सकता है।" } diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 1c3025a09d0..02c5a580ccd 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Aktifkan pemuatan file AGENTS.md untuk aturan khusus agen (lihat https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Waktu maksimum dalam detik untuk menunggu respons API (0 = tidak ada batas waktu, 1-3600s, default: 600s). Nilai yang lebih tinggi disarankan untuk penyedia lokal seperti LM Studio dan Ollama yang mungkin memerlukan lebih banyak waktu pemrosesan.", "settings.newTaskRequireTodos.description": "Memerlukan parameter todos saat membuat tugas baru dengan alat new_task", - "settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60." + "settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60.", + "settings.toolProtocol.description": "Protokol alat untuk digunakan untuk interaksi AI. XML adalah protokol default dan yang direkomendasikan. Nativ bersifat eksperimental dan mungkin tidak berfungsi dengan semua penyedia." } diff --git a/src/package.nls.it.json b/src/package.nls.it.json index d0c130e00b1..d3cedcec141 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Abilita il caricamento dei file AGENTS.md per regole specifiche dell'agente (vedi https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tempo massimo in secondi di attesa per le risposte API (0 = nessun timeout, 1-3600s, predefinito: 600s). Valori più alti sono consigliati per provider locali come LM Studio e Ollama che potrebbero richiedere più tempo di elaborazione.", "settings.newTaskRequireTodos.description": "Richiedere il parametro todos quando si creano nuove attività con lo strumento new_task", - "settings.codeIndex.embeddingBatchSize.description": "La dimensione del batch per le operazioni di embedding durante l'indicizzazione del codice. Regola questo in base ai limiti del tuo provider API. Il valore predefinito è 60." + "settings.codeIndex.embeddingBatchSize.description": "La dimensione del batch per le operazioni di embedding durante l'indicizzazione del codice. Regola questo in base ai limiti del tuo provider API. Il valore predefinito è 60.", + "settings.toolProtocol.description": "Protocollo dello strumento da utilizzare per le interazioni AI. XML è il protocollo predefinito e consigliato. Nativo è sperimentale e potrebbe non funzionare con tutti i provider." } diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index 05251f4aed1..70acffe0fed 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "エージェント固有のルールのためにAGENTS.mdファイルの読み込みを有効にします(参照:https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API応答を待機する最大時間(秒)(0 = タイムアウトなし、1-3600秒、デフォルト: 600秒)。LM StudioやOllamaのような、より多くの処理時間を必要とする可能性のあるローカルプロバイダーには、より高い値が推奨されます。", "settings.newTaskRequireTodos.description": "new_taskツールで新しいタスクを作成する際にtodosパラメータを必須にする", - "settings.codeIndex.embeddingBatchSize.description": "コードインデックス作成中のエンベディング操作のバッチサイズ。APIプロバイダーの制限に基づいてこれを調整してください。デフォルトは60です。" + "settings.codeIndex.embeddingBatchSize.description": "コードインデックス作成中のエンベディング操作のバッチサイズ。APIプロバイダーの制限に基づいてこれを調整してください。デフォルトは60です。", + "settings.toolProtocol.description": "AIインタラクションに使用するツールプロトコル。XMLがデフォルトで推奨されるプロトコルです。ネイティブは実験的なものであり、すべてのプロバイダーで動作するとは限りません。" } diff --git a/src/package.nls.json b/src/package.nls.json index 42443e1716d..268ad0fe882 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.", "settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool", - "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60." + "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", + "settings.toolProtocol.description": "Tool protocol to use for AI interactions. XML is the default and recommended protocol. Native is experimental and may not work with all providers." } diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index dd933733f78..20d913d1339 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "에이전트별 규칙에 대한 AGENTS.md 파일 로드를 활성화합니다 (참조: https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API 응답을 기다리는 최대 시간(초) (0 = 시간 초과 없음, 1-3600초, 기본값: 600초). 더 많은 처리 시간이 필요할 수 있는 LM Studio 및 Ollama와 같은 로컬 공급자에게는 더 높은 값을 사용하는 것이 좋습니다.", "settings.newTaskRequireTodos.description": "new_task 도구로 새 작업을 생성할 때 todos 매개변수 필요", - "settings.codeIndex.embeddingBatchSize.description": "코드 인덱싱 중 임베딩 작업의 배치 크기입니다. API 공급자의 제한에 따라 이를 조정하세요. 기본값은 60입니다." + "settings.codeIndex.embeddingBatchSize.description": "코드 인덱싱 중 임베딩 작업의 배치 크기입니다. API 공급자의 제한에 따라 이를 조정하세요. 기본값은 60입니다.", + "settings.toolProtocol.description": "AI 상호 작용에 사용할 도구 프로토콜입니다. XML이 기본 권장 프로토콜입니다. 네이티브는 실험적이며 모든 공급자와 작동하지 않을 수 있습니다." } diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index c5f52e55712..d8165f65203 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Laden van AGENTS.md-bestanden voor agentspecifieke regels inschakelen (zie https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximale tijd in seconden om te wachten op API-reacties (0 = geen time-out, 1-3600s, standaard: 600s). Hogere waarden worden aanbevolen voor lokale providers zoals LM Studio en Ollama die mogelijk meer verwerkingstijd nodig hebben.", "settings.newTaskRequireTodos.description": "Todos-parameter vereisen bij het maken van nieuwe taken met de new_task tool", - "settings.codeIndex.embeddingBatchSize.description": "De batchgrootte voor embedding-operaties tijdens code-indexering. Pas dit aan op basis van de limieten van je API-provider. Standaard is 60." + "settings.codeIndex.embeddingBatchSize.description": "De batchgrootte voor embedding-operaties tijdens code-indexering. Pas dit aan op basis van de limieten van je API-provider. Standaard is 60.", + "settings.toolProtocol.description": "Toolprotocol te gebruiken voor AI-interacties. XML is het standaard en aanbevolen protocol. Native is experimenteel en werkt mogelijk niet met alle providers." } diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 1178336d684..2e515337287 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Włącz wczytywanie plików AGENTS.md dla reguł specyficznych dla agenta (zobacz https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maksymalny czas w sekundach oczekiwania na odpowiedzi API (0 = brak limitu czasu, 1-3600s, domyślnie: 600s). Wyższe wartości są zalecane dla lokalnych dostawców, takich jak LM Studio i Ollama, którzy mogą potrzebować więcej czasu na przetwarzanie.", "settings.newTaskRequireTodos.description": "Wymagaj parametru todos podczas tworzenia nowych zadań za pomocą narzędzia new_task", - "settings.codeIndex.embeddingBatchSize.description": "Rozmiar partii dla operacji osadzania podczas indeksowania kodu. Dostosuj to w oparciu o limity twojego dostawcy API. Domyślnie to 60." + "settings.codeIndex.embeddingBatchSize.description": "Rozmiar partii dla operacji osadzania podczas indeksowania kodu. Dostosuj to w oparciu o limity twojego dostawcy API. Domyślnie to 60.", + "settings.toolProtocol.description": "Protokół narzędzi do użycia w interakcjach z AI. XML jest domyślnym i zalecanym protokołem. Natywny jest eksperymentalny i może nie działać ze wszystkimi dostawcami." } diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index dfed1c8fb13..49ad1375066 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Habilita o carregamento de arquivos AGENTS.md para regras específicas do agente (consulte https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tempo máximo em segundos de espera pelas respostas da API (0 = sem tempo limite, 1-3600s, padrão: 600s). Valores mais altos são recomendados para provedores locais como LM Studio e Ollama que podem precisar de mais tempo de processamento.", "settings.newTaskRequireTodos.description": "Exigir parâmetro todos ao criar novas tarefas com a ferramenta new_task", - "settings.codeIndex.embeddingBatchSize.description": "O tamanho do lote para operações de embedding durante a indexação de código. Ajuste isso com base nos limites do seu provedor de API. O padrão é 60." + "settings.codeIndex.embeddingBatchSize.description": "O tamanho do lote para operações de embedding durante a indexação de código. Ajuste isso com base nos limites do seu provedor de API. O padrão é 60.", + "settings.toolProtocol.description": "Protocolo de ferramenta a ser usado para interações de IA. XML é o protocolo padrão e recomendado. Nativo é experimental e pode não funcionar com todos os provedores." } diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index c2284af3695..269bb6f1d23 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Включить загрузку файлов AGENTS.md для специфичных для агента правил (см. https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Максимальное время в секундах для ожидания ответов API (0 = нет тайм-аута, 1-3600 с, по умолчанию: 600 с). Рекомендуются более высокие значения для локальных провайдеров, таких как LM Studio и Ollama, которым может потребоваться больше времени на обработку.", "settings.newTaskRequireTodos.description": "Требовать параметр todos при создании новых задач с помощью инструмента new_task", - "settings.codeIndex.embeddingBatchSize.description": "Размер пакета для операций встраивания во время индексации кода. Настройте это в соответствии с ограничениями вашего API-провайдера. По умолчанию 60." + "settings.codeIndex.embeddingBatchSize.description": "Размер пакета для операций встраивания во время индексации кода. Настройте это в соответствии с ограничениями вашего API-провайдера. По умолчанию 60.", + "settings.toolProtocol.description": "Протокол инструментов для использования в взаимодействиях с ИИ. XML является протоколом по умолчанию и рекомендуемым. Нативный является экспериментальным и может не работать со всеми провайдерами." } diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 21d9a1db93b..baef09d27ff 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Aracıya özgü kurallar için AGENTS.md dosyalarının yüklenmesini etkinleştirin (bkz. https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API yanıtları için beklenecek maksimum süre (saniye cinsinden) (0 = zaman aşımı yok, 1-3600s, varsayılan: 600s). LM Studio ve Ollama gibi daha fazla işlem süresi gerektirebilecek yerel sağlayıcılar için daha yüksek değerler önerilir.", "settings.newTaskRequireTodos.description": "new_task aracıyla yeni görevler oluştururken todos parametresini gerekli kıl", - "settings.codeIndex.embeddingBatchSize.description": "Kod indeksleme sırasında gömme işlemleri için toplu iş boyutu. Bunu API sağlayıcınızın sınırlarına göre ayarlayın. Varsayılan 60'tır." + "settings.codeIndex.embeddingBatchSize.description": "Kod indeksleme sırasında gömme işlemleri için toplu iş boyutu. Bunu API sağlayıcınızın sınırlarına göre ayarlayın. Varsayılan 60'tır.", + "settings.toolProtocol.description": "Yapay zeka etkileşimleri için kullanılacak araç protokolü. XML, varsayılan ve önerilen protokoldür. Yerel deneyseldir ve tüm sağlayıcılarla çalışmayabilir." } diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 795f8549cf4..a53591a53e8 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "Bật tải tệp AGENTS.md cho các quy tắc dành riêng cho tác nhân (xem https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Thời gian tối đa tính bằng giây để đợi phản hồi API (0 = không có thời gian chờ, 1-3600 giây, mặc định: 600 giây). Nên sử dụng các giá trị cao hơn cho các nhà cung cấp cục bộ như LM Studio và Ollama có thể cần thêm thời gian xử lý.", "settings.newTaskRequireTodos.description": "Yêu cầu tham số todos khi tạo nhiệm vụ mới với công cụ new_task", - "settings.codeIndex.embeddingBatchSize.description": "Kích thước lô cho các hoạt động nhúng trong quá trình lập chỉ mục mã. Điều chỉnh điều này dựa trên giới hạn của nhà cung cấp API của bạn. Mặc định là 60." + "settings.codeIndex.embeddingBatchSize.description": "Kích thước lô cho các hoạt động nhúng trong quá trình lập chỉ mục mã. Điều chỉnh điều này dựa trên giới hạn của nhà cung cấp API của bạn. Mặc định là 60.", + "settings.toolProtocol.description": "Giao thức công cụ để sử dụng cho các tương tác AI. XML là giao thức mặc định và được khuyến nghị. Bản gốc là thử nghiệm và có thể không hoạt động với tất cả các nhà cung cấp." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 1112abab7d6..49bee7e5089 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "为特定于代理的规则启用 AGENTS.md 文件的加载(请参阅 https://agent-rules.org/)", "settings.apiRequestTimeout.description": "等待 API 响应的最长时间(秒)(0 = 无超时,1-3600秒,默认值:600秒)。对于像 LM Studio 和 Ollama 这样可能需要更多处理时间的本地提供商,建议使用更高的值。", "settings.newTaskRequireTodos.description": "使用 new_task 工具创建新任务时需要 todos 参数", - "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。" + "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。", + "settings.toolProtocol.description": "用于 AI 交互的工具协议。XML 是默认且推荐的协议。本机是实验性的,可能不适用于所有提供商。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index a212a7a3538..f970b215908 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -43,5 +43,6 @@ "settings.useAgentRules.description": "為特定於代理的規則啟用 AGENTS.md 檔案的載入(請參閱 https://agent-rules.org/)", "settings.apiRequestTimeout.description": "等待 API 回應的最長時間(秒)(0 = 無超時,1-3600秒,預設值:600秒)。對於像 LM Studio 和 Ollama 這樣可能需要更多處理時間的本地提供商,建議使用更高的值。", "settings.newTaskRequireTodos.description": "使用 new_task 工具建立新工作時需要 todos 參數", - "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。" + "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。", + "settings.toolProtocol.description": "用於 AI 互動的工具協議。XML 是預設且推薦的協議。本機是實驗性的,可能不適用於所有提供商。" } From 19cb25a6ec8a039d5010e37a8badfc1ca44ac46a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 12:36:54 -0500 Subject: [PATCH 46/48] fix: comment --- src/core/assistant-message/NativeToolCallParser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 46e39a78cbb..2308c28ea6b 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -71,7 +71,7 @@ export class NativeToolCallParser { // Build typed nativeArgs for tools that support it. // This switch statement serves two purposes: // 1. Validation: Ensures required parameters are present before constructing nativeArgs - // 2. Transformation: Converts raw JSON to properly typed structures (e.g., handling + // 2. Transformation: Converts raw JSON to properly typed structures // // Each case validates the minimum required parameters and constructs a properly typed // nativeArgs object. If validation fails, nativeArgs remains undefined and the tool From af31053e827dd61f5624b82e88b4e6bec9d00d95 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 13 Nov 2025 12:37:51 -0500 Subject: [PATCH 47/48] fix: typographical error --- src/core/tools/WriteToFileTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 378c3e43810..bb987222f83 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -228,7 +228,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (selection === "Follow cline guide to fix the issue") { vscode.env.openExternal( vscode.Uri.parse( - "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%�-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", + "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments", ), ) } From e8d52f13560488de24d71af13adb448e65cd59d6 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:40:07 -0500 Subject: [PATCH 48/48] Update src/package.nls.id.json Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src/package.nls.id.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 02c5a580ccd..2b31f368954 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -44,5 +44,5 @@ "settings.apiRequestTimeout.description": "Waktu maksimum dalam detik untuk menunggu respons API (0 = tidak ada batas waktu, 1-3600s, default: 600s). Nilai yang lebih tinggi disarankan untuk penyedia lokal seperti LM Studio dan Ollama yang mungkin memerlukan lebih banyak waktu pemrosesan.", "settings.newTaskRequireTodos.description": "Memerlukan parameter todos saat membuat tugas baru dengan alat new_task", "settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60.", - "settings.toolProtocol.description": "Protokol alat untuk digunakan untuk interaksi AI. XML adalah protokol default dan yang direkomendasikan. Nativ bersifat eksperimental dan mungkin tidak berfungsi dengan semua penyedia." + "settings.toolProtocol.description": "Protokol alat untuk digunakan untuk interaksi AI. XML adalah protokol default dan yang direkomendasikan. Native bersifat eksperimental dan mungkin tidak berfungsi dengan semua penyedia." }