diff --git a/apps/web-roo-code/src/app/legal/cookies/page.tsx b/apps/web-roo-code/src/app/legal/cookies/page.tsx index c8058a34e7..fa8ba0799f 100644 --- a/apps/web-roo-code/src/app/legal/cookies/page.tsx +++ b/apps/web-roo-code/src/app/legal/cookies/page.tsx @@ -187,8 +187,8 @@ export default function CookiePolicy() {

Contact us

If you have questions about our use of cookies, please contact us at{" "} - - privacy@roocode.com + + zgsm@sangfor.com.cn .

diff --git a/packages/telemetry/src/PostHogTelemetryClient.ts b/packages/telemetry/src/PostHogTelemetryClient.ts index 96f1b1373b..4a1c3d9660 100644 --- a/packages/telemetry/src/PostHogTelemetryClient.ts +++ b/packages/telemetry/src/PostHogTelemetryClient.ts @@ -11,6 +11,8 @@ import { shouldReportApiErrorToTelemetry, isApiProviderError, extractApiProviderErrorProperties, + isConsecutiveMistakeError, + extractConsecutiveMistakeErrorProperties, } from "@roo-code/types" import { BaseTelemetryClient } from "./BaseTelemetryClient" @@ -64,10 +66,12 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { console.info(`[PostHogTelemetryClient#capture] ${event.event}`) } + const properties = await this.getEventProperties(event) + this.client.capture({ distinctId: this.distinctId, event: event.event, - properties: await this.getEventProperties(event), + properties, }) } @@ -101,13 +105,16 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { console.info(`[PostHogTelemetryClient#captureException] ${error.message}`) } - // Auto-extract properties from ApiProviderError and merge with additionalProperties. + // Auto-extract properties from known error types and merge with additionalProperties. // Explicit additionalProperties take precedence over auto-extracted properties. let mergedProperties = additionalProperties if (isApiProviderError(error)) { const extractedProperties = extractApiProviderErrorProperties(error) mergedProperties = { ...extractedProperties, ...additionalProperties } + } else if (isConsecutiveMistakeError(error)) { + const extractedProperties = extractConsecutiveMistakeErrorProperties(error) + mergedProperties = { ...extractedProperties, ...additionalProperties } } // Override the error message with the extracted error message. @@ -124,10 +131,12 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { } } - this.client.captureException(error, this.distinctId, { + const exceptionProperties = { ...mergedProperties, $app_version: telemetryProperties?.appVersion, - }) + } + + this.client.captureException(error, this.distinctId, exceptionProperties) } /** diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index f34c607951..ec12935f6c 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.92.0", + "version": "1.93.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/__tests__/telemetry.test.ts b/packages/types/src/__tests__/telemetry.test.ts index 74d0f1ddfe..29e6207794 100644 --- a/packages/types/src/__tests__/telemetry.test.ts +++ b/packages/types/src/__tests__/telemetry.test.ts @@ -9,6 +9,9 @@ import { ApiProviderError, isApiProviderError, extractApiProviderErrorProperties, + ConsecutiveMistakeError, + isConsecutiveMistakeError, + extractConsecutiveMistakeErrorProperties, } from "../telemetry.js" describe("telemetry error utilities", () => { @@ -389,4 +392,185 @@ describe("telemetry error utilities", () => { expect(properties).toHaveProperty("errorCode", 0) }) }) + + describe("ConsecutiveMistakeError", () => { + it("should create an error with correct properties", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 5, 3, "no_tools_used") + + expect(error.message).toBe("Test error") + expect(error.name).toBe("ConsecutiveMistakeError") + expect(error.taskId).toBe("task-123") + expect(error.consecutiveMistakeCount).toBe(5) + expect(error.consecutiveMistakeLimit).toBe(3) + expect(error.reason).toBe("no_tools_used") + }) + + it("should create an error with provider and modelId", () => { + const error = new ConsecutiveMistakeError( + "Test error", + "task-123", + 5, + 3, + "no_tools_used", + "anthropic", + "claude-3-sonnet-20240229", + ) + + expect(error.message).toBe("Test error") + expect(error.name).toBe("ConsecutiveMistakeError") + expect(error.taskId).toBe("task-123") + expect(error.consecutiveMistakeCount).toBe(5) + expect(error.consecutiveMistakeLimit).toBe(3) + expect(error.reason).toBe("no_tools_used") + expect(error.provider).toBe("anthropic") + expect(error.modelId).toBe("claude-3-sonnet-20240229") + }) + + it("should be an instance of Error", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 3, 3) + expect(error).toBeInstanceOf(Error) + }) + + it("should handle zero values", () => { + const error = new ConsecutiveMistakeError("Zero test", "task-000", 0, 0) + + expect(error.taskId).toBe("task-000") + expect(error.consecutiveMistakeCount).toBe(0) + expect(error.consecutiveMistakeLimit).toBe(0) + }) + + it("should default reason to unknown when not provided", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 3, 3) + expect(error.reason).toBe("unknown") + }) + + it("should accept tool_repetition reason", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 3, 3, "tool_repetition") + expect(error.reason).toBe("tool_repetition") + }) + + it("should accept no_tools_used reason", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 3, 3, "no_tools_used") + expect(error.reason).toBe("no_tools_used") + }) + + it("should have undefined provider and modelId when not provided", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 3, 3, "no_tools_used") + expect(error.provider).toBeUndefined() + expect(error.modelId).toBeUndefined() + }) + }) + + describe("isConsecutiveMistakeError", () => { + it("should return true for ConsecutiveMistakeError instances", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 3, 3) + expect(isConsecutiveMistakeError(error)).toBe(true) + }) + + it("should return false for regular Error instances", () => { + const error = new Error("Test error") + expect(isConsecutiveMistakeError(error)).toBe(false) + }) + + it("should return false for ApiProviderError instances", () => { + const error = new ApiProviderError("Test error", "OpenRouter", "gpt-4", "createMessage") + expect(isConsecutiveMistakeError(error)).toBe(false) + }) + + it("should return false for null and undefined", () => { + expect(isConsecutiveMistakeError(null)).toBe(false) + expect(isConsecutiveMistakeError(undefined)).toBe(false) + }) + + it("should return false for non-error objects", () => { + expect(isConsecutiveMistakeError({})).toBe(false) + expect( + isConsecutiveMistakeError({ + taskId: "task-123", + consecutiveMistakeCount: 3, + consecutiveMistakeLimit: 3, + }), + ).toBe(false) + }) + + it("should return false for Error with wrong name", () => { + const error = new Error("Test error") + error.name = "CustomError" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(error as any).taskId = "task-123" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(error as any).consecutiveMistakeCount = 3 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(error as any).consecutiveMistakeLimit = 3 + expect(isConsecutiveMistakeError(error)).toBe(false) + }) + }) + + describe("extractConsecutiveMistakeErrorProperties", () => { + it("should extract all properties from ConsecutiveMistakeError", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 5, 3, "no_tools_used") + const properties = extractConsecutiveMistakeErrorProperties(error) + + expect(properties).toEqual({ + taskId: "task-123", + consecutiveMistakeCount: 5, + consecutiveMistakeLimit: 3, + reason: "no_tools_used", + }) + }) + + it("should extract all properties including provider and modelId", () => { + const error = new ConsecutiveMistakeError( + "Test error", + "task-123", + 5, + 3, + "no_tools_used", + "anthropic", + "claude-3-sonnet-20240229", + ) + const properties = extractConsecutiveMistakeErrorProperties(error) + + expect(properties).toEqual({ + taskId: "task-123", + consecutiveMistakeCount: 5, + consecutiveMistakeLimit: 3, + reason: "no_tools_used", + provider: "anthropic", + modelId: "claude-3-sonnet-20240229", + }) + }) + + it("should not include provider and modelId when undefined", () => { + const error = new ConsecutiveMistakeError("Test error", "task-123", 5, 3, "no_tools_used") + const properties = extractConsecutiveMistakeErrorProperties(error) + + expect(properties).not.toHaveProperty("provider") + expect(properties).not.toHaveProperty("modelId") + }) + + it("should handle zero values correctly", () => { + const error = new ConsecutiveMistakeError("Zero test", "task-000", 0, 0) + const properties = extractConsecutiveMistakeErrorProperties(error) + + expect(properties).toEqual({ + taskId: "task-000", + consecutiveMistakeCount: 0, + consecutiveMistakeLimit: 0, + reason: "unknown", + }) + }) + + it("should handle large numbers", () => { + const error = new ConsecutiveMistakeError("Large test", "task-large", 1000, 500, "tool_repetition") + const properties = extractConsecutiveMistakeErrorProperties(error) + + expect(properties).toEqual({ + taskId: "task-large", + consecutiveMistakeCount: 1000, + consecutiveMistakeLimit: 500, + reason: "tool_repetition", + }) + }) + }) }) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index bac0ad0dd9..9d365b6525 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -124,7 +124,7 @@ export type OrganizationDefaultSettings = z.infer diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index e5b6f5418f..d97884d216 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -19,6 +19,16 @@ export const historyItemSchema = z.object({ size: z.number().optional(), workspace: z.string().optional(), mode: z.string().optional(), + /** + * The tool protocol used by this task. Once a task uses tools with a specific + * protocol (XML or Native), it is permanently locked to that protocol. + * + * - "xml": Tool calls are parsed from XML text (no tool IDs) + * - "native": Tool calls come as tool_call chunks with IDs + * + * This ensures task resumption works correctly even when NTC settings change. + */ + toolProtocol: z.enum(["xml", "native"]).optional(), status: z.enum(["active", "completed", "delegated"]).optional(), delegatedToId: z.string().optional(), // Last child this parent delegated to childIds: z.array(z.string()).optional(), // All children spawned by this task diff --git a/packages/types/src/providers/chutes.ts b/packages/types/src/providers/chutes.ts index aef2901655..96087da4b1 100644 --- a/packages/types/src/providers/chutes.ts +++ b/packages/types/src/providers/chutes.ts @@ -51,6 +51,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek R1 0528 model.", @@ -60,6 +62,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek R1 model.", @@ -69,6 +73,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek V3 model.", @@ -78,6 +84,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek V3.1 model.", @@ -87,6 +95,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.23, outputPrice: 0.9, description: @@ -97,6 +107,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 1.0, outputPrice: 3.0, description: @@ -107,6 +119,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.25, outputPrice: 0.35, description: @@ -117,6 +131,8 @@ export const chutesModels = { contextWindow: 131072, // From Groq supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Unsloth Llama 3.3 70B Instruct model.", @@ -126,6 +142,8 @@ export const chutesModels = { contextWindow: 512000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "ChutesAI Llama 4 Scout 17B Instruct model, 512K context.", @@ -135,6 +153,8 @@ export const chutesModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Unsloth Mistral Nemo Instruct model.", @@ -144,6 +164,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Unsloth Gemma 3 12B IT model.", @@ -153,6 +175,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Nous DeepHermes 3 Llama 3 8B Preview model.", @@ -162,6 +186,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Unsloth Gemma 3 4B IT model.", @@ -171,6 +197,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Nvidia Llama 3.3 Nemotron Super 49B model.", @@ -180,6 +208,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Nvidia Llama 3.1 Nemotron Ultra 253B model.", @@ -189,6 +219,8 @@ export const chutesModels = { contextWindow: 256000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "ChutesAI Llama 4 Maverick 17B Instruct FP8 model.", @@ -198,6 +230,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek V3 Base model.", @@ -207,6 +241,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek R1 Zero model.", @@ -216,6 +252,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "DeepSeek V3 (0324) model.", @@ -225,6 +263,8 @@ export const chutesModels = { contextWindow: 262144, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 235B A22B Instruct 2507 model with 262K context window.", @@ -234,6 +274,8 @@ export const chutesModels = { contextWindow: 40960, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 235B A22B model.", @@ -243,6 +285,8 @@ export const chutesModels = { contextWindow: 40960, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 32B model.", @@ -252,6 +296,8 @@ export const chutesModels = { contextWindow: 40960, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 30B A3B model.", @@ -261,6 +307,8 @@ export const chutesModels = { contextWindow: 40960, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 14B model.", @@ -270,6 +318,8 @@ export const chutesModels = { contextWindow: 40960, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 8B model.", @@ -279,6 +329,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Microsoft MAI-DS-R1 FP8 model.", @@ -288,6 +340,8 @@ export const chutesModels = { contextWindow: 163840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "TNGTech DeepSeek R1T Chimera model.", @@ -297,6 +351,8 @@ export const chutesModels = { contextWindow: 151329, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: @@ -307,6 +363,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: @@ -317,6 +375,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 1, outputPrice: 3, description: "GLM-4.5-turbo model with 128K token context window, optimized for fast inference.", @@ -326,6 +386,8 @@ export const chutesModels = { contextWindow: 202752, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: @@ -336,6 +398,8 @@ export const chutesModels = { contextWindow: 202752, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 1.15, outputPrice: 3.25, description: "GLM-4.6-turbo model with 200K-token context window, optimized for fast inference.", @@ -345,6 +409,8 @@ export const chutesModels = { contextWindow: 128000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: @@ -355,6 +421,8 @@ export const chutesModels = { contextWindow: 262144, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: "Qwen3 Coder 480B A35B Instruct FP8 model, optimized for coding tasks.", @@ -364,6 +432,8 @@ export const chutesModels = { contextWindow: 75000, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.1481, outputPrice: 0.5926, description: "Moonshot AI Kimi K2 Instruct model with 75k context window.", @@ -373,6 +443,8 @@ export const chutesModels = { contextWindow: 262144, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.1999, outputPrice: 0.8001, description: "Moonshot AI Kimi K2 Instruct 0905 model with 256k context window.", @@ -382,6 +454,8 @@ export const chutesModels = { contextWindow: 262144, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.077968332, outputPrice: 0.31202496, description: "Qwen3 235B A22B Thinking 2507 model with 262K context window.", @@ -391,6 +465,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: @@ -401,6 +477,8 @@ export const chutesModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0, outputPrice: 0, description: @@ -411,6 +489,8 @@ export const chutesModels = { contextWindow: 262144, supportsImages: true, supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.16, outputPrice: 0.65, description: diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts index 3f69dfdb59..17aa16db27 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -16,6 +16,7 @@ export const geminiModels = { supportsReasoningEffort: ["low", "high"], reasoningEffort: "low", includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 4.0, @@ -43,6 +44,7 @@ export const geminiModels = { supportsReasoningEffort: ["minimal", "low", "medium", "high"], reasoningEffort: "medium", includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 0.3, @@ -59,6 +61,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -89,6 +92,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -118,6 +122,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -145,6 +150,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -176,6 +182,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -191,6 +198,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -206,6 +214,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -223,6 +232,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, @@ -238,6 +248,7 @@ export const geminiModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, diff --git a/packages/types/src/providers/lite-llm.ts b/packages/types/src/providers/lite-llm.ts index 2121cce4f8..9ee0351458 100644 --- a/packages/types/src/providers/lite-llm.ts +++ b/packages/types/src/providers/lite-llm.ts @@ -9,6 +9,7 @@ export const litellmDefaultModelInfo: ModelInfo = { supportsImages: true, supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, diff --git a/packages/types/src/providers/minimax.ts b/packages/types/src/providers/minimax.ts index e8c0caf780..aac397bf2c 100644 --- a/packages/types/src/providers/minimax.ts +++ b/packages/types/src/providers/minimax.ts @@ -14,6 +14,7 @@ export const minimaxModels = { supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, @@ -28,6 +29,7 @@ export const minimaxModels = { supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, diff --git a/packages/types/src/providers/moonshot.ts b/packages/types/src/providers/moonshot.ts index a3f34db666..7279c71809 100644 --- a/packages/types/src/providers/moonshot.ts +++ b/packages/types/src/providers/moonshot.ts @@ -12,6 +12,7 @@ export const moonshotModels = { supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.6, // $0.60 per million tokens (cache miss) outputPrice: 2.5, // $2.50 per million tokens cacheWritesPrice: 0, // $0 per million tokens (cache miss) @@ -24,6 +25,7 @@ export const moonshotModels = { supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.6, outputPrice: 2.5, cacheReadsPrice: 0.15, @@ -36,6 +38,7 @@ export const moonshotModels = { supportsImages: false, supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 2.4, // $2.40 per million tokens (cache miss) outputPrice: 10, // $10.00 per million tokens cacheWritesPrice: 0, // $0 per million tokens (cache miss) @@ -48,6 +51,7 @@ export const moonshotModels = { supportsImages: false, // Text-only (no image/vision support) supportsPromptCache: true, supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.6, // $0.60 per million tokens (cache miss) outputPrice: 2.5, // $2.50 per million tokens cacheWritesPrice: 0, // $0 per million tokens (cache miss) diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index db010b6c68..916d72afe0 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -16,6 +16,7 @@ export const vertexModels = { supportsReasoningEffort: ["low", "high"], reasoningEffort: "low", includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 4.0, @@ -43,6 +44,7 @@ export const vertexModels = { supportsReasoningEffort: ["minimal", "low", "medium", "high"], reasoningEffort: "medium", includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 0.3, @@ -58,6 +60,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.15, outputPrice: 3.5, maxThinkingTokens: 24_576, @@ -72,6 +75,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.15, outputPrice: 0.6, }, @@ -83,6 +87,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -98,6 +103,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.15, outputPrice: 3.5, maxThinkingTokens: 24_576, @@ -112,6 +118,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.15, outputPrice: 0.6, }, @@ -123,6 +130,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, outputPrice: 15, }, @@ -134,6 +142,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, outputPrice: 15, }, @@ -145,6 +154,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, outputPrice: 15, maxThinkingTokens: 32_768, @@ -158,6 +168,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 2.5, outputPrice: 15, maxThinkingTokens: 32_768, @@ -186,6 +197,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0, outputPrice: 0, }, @@ -197,6 +209,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0, outputPrice: 0, }, @@ -208,6 +221,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.15, outputPrice: 0.6, }, @@ -219,6 +233,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.075, outputPrice: 0.3, }, @@ -230,6 +245,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0, outputPrice: 0, }, @@ -241,6 +257,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.075, outputPrice: 0.3, }, @@ -252,6 +269,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: false, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 1.25, outputPrice: 5, }, @@ -260,6 +278,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, @@ -271,6 +291,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, @@ -282,6 +304,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 1.0, outputPrice: 5.0, cacheWritesPrice: 1.25, @@ -293,6 +317,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 5.0, outputPrice: 25.0, cacheWritesPrice: 6.25, @@ -304,6 +330,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 15.0, outputPrice: 75.0, cacheWritesPrice: 18.75, @@ -315,6 +343,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 15.0, outputPrice: 75.0, cacheWritesPrice: 18.75, @@ -325,6 +355,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, @@ -337,6 +369,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, @@ -347,6 +381,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, @@ -357,6 +393,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 3.0, outputPrice: 15.0, cacheWritesPrice: 3.75, @@ -367,6 +405,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: false, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 1.0, outputPrice: 5.0, cacheWritesPrice: 1.25, @@ -377,6 +417,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 15.0, outputPrice: 75.0, cacheWritesPrice: 18.75, @@ -387,6 +429,8 @@ export const vertexModels = { contextWindow: 200_000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", inputPrice: 0.25, outputPrice: 1.25, cacheWritesPrice: 0.3, @@ -400,6 +444,7 @@ export const vertexModels = { defaultToolProtocol: "native", supportsPromptCache: true, includedTools: ["write_file", "edit_file"], + excludedTools: ["apply_diff"], inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index da7f546029..4e6b6d7cdc 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -482,3 +482,57 @@ export function extractApiProviderErrorProperties(error: ApiProviderError): Reco ...(error.errorCode !== undefined && { errorCode: error.errorCode }), } } + +/** + * Reason why the consecutive mistake limit was reached. + */ +export type ConsecutiveMistakeReason = "no_tools_used" | "tool_repetition" | "unknown" + +/** + * Error class for "Roo is having trouble" consecutive mistake scenarios. + * Triggered when the task reaches the configured consecutive mistake limit. + * Used for structured exception tracking via PostHog. + */ +export class ConsecutiveMistakeError extends Error { + constructor( + message: string, + public readonly taskId: string, + public readonly consecutiveMistakeCount: number, + public readonly consecutiveMistakeLimit: number, + public readonly reason: ConsecutiveMistakeReason = "unknown", + public readonly provider?: string, + public readonly modelId?: string, + ) { + super(message) + this.name = "ConsecutiveMistakeError" + } +} + +/** + * Type guard to check if an error is a ConsecutiveMistakeError. + * Used by telemetry to automatically extract structured properties. + */ +export function isConsecutiveMistakeError(error: unknown): error is ConsecutiveMistakeError { + return ( + error instanceof Error && + error.name === "ConsecutiveMistakeError" && + "taskId" in error && + "consecutiveMistakeCount" in error && + "consecutiveMistakeLimit" in error + ) +} + +/** + * Extracts properties from a ConsecutiveMistakeError for telemetry. + * Returns the structured properties that can be merged with additionalProperties. + */ +export function extractConsecutiveMistakeErrorProperties(error: ConsecutiveMistakeError): Record { + return { + taskId: error.taskId, + consecutiveMistakeCount: error.consecutiveMistakeCount, + consecutiveMistakeLimit: error.consecutiveMistakeLimit, + reason: error.reason, + ...(error.provider !== undefined && { provider: error.provider }), + ...(error.modelId !== undefined && { modelId: error.modelId }), + } +} diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 6bb4a30a99..8cbc49be9f 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -21,6 +21,7 @@ export const toolNames = [ "apply_diff", "search_and_replace", "search_replace", + "edit_file", "apply_patch", "search_files", "list_files", diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 098fd8d8b4..5d5e7da975 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -957,4 +957,298 @@ describe("VertexHandler", () => { ) }) }) + + describe("native tool calling", () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "What's the weather in London?" }], + }, + ] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ] + + it("should include tools in request when native protocol is used", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: mockTools, + }) + + // Consume the stream to trigger the API call + for await (const _chunk of stream) { + // Just consume + } + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "get_weather", + description: "Get the current weather", + input_schema: expect.objectContaining({ + type: "object", + properties: expect.objectContaining({ + location: { type: "string" }, + }), + }), + }), + ]), + tool_choice: { type: "auto", disable_parallel_tool_use: true }, + }), + expect.any(Object), + ) + }) + + it("should not include tools when toolProtocol is xml", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + toolProtocol: "xml", + }) + + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: mockTools, + }) + + // Consume the stream to trigger the API call + for await (const _chunk of stream) { + // Just consume + } + + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("tools") + expect(callArgs).not.toHaveProperty("tool_choice") + }) + + it("should handle tool_use blocks in stream and emit tool_call_partial", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "toolu_123", + name: "get_weather", + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: mockTools, + }) + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Find the tool_call_partial chunk + const toolCallChunk = chunks.find((chunk) => chunk.type === "tool_call_partial") + expect(toolCallChunk).toBeDefined() + expect(toolCallChunk).toEqual({ + type: "tool_call_partial", + index: 0, + id: "toolu_123", + name: "get_weather", + arguments: undefined, + }) + }) + + it("should handle input_json_delta in stream and emit tool_call_partial arguments", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "toolu_123", + name: "get_weather", + }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '{"location":', + }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '"London"}', + }, + }, + { + type: "content_block_stop", + index: 0, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: mockTools, + }) + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Find the tool_call_partial chunks + const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallChunks).toHaveLength(3) + + // First chunk has id and name + expect(toolCallChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, + id: "toolu_123", + name: "get_weather", + arguments: undefined, + }) + + // Subsequent chunks have arguments + expect(toolCallChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"location":', + }) + + expect(toolCallChunks[2]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"London"}', + }) + }) + }) }) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index afb349e5e0..0087db6b0b 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -180,7 +180,7 @@ describe("VsCodeLmHandler", () => { }) }) - it("should handle tool calls", async () => { + it("should handle tool calls as text when not using native tool protocol", async () => { const systemPrompt = "You are a helpful assistant" const messages: Anthropic.Messages.MessageParam[] = [ { @@ -223,6 +223,139 @@ describe("VsCodeLmHandler", () => { }) }) + it("should handle native tool calls when using native tool protocol", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user" as const, + content: "Calculate 2+2", + }, + ] + + const toolCallData = { + name: "calculator", + arguments: { operation: "add", numbers: [2, 2] }, + callId: "call-1", + } + + const tools = [ + { + type: "function" as const, + function: { + name: "calculator", + description: "A simple calculator", + parameters: { + type: "object", + properties: { + operation: { type: "string" }, + numbers: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelToolCallPart( + toolCallData.callId, + toolCallData.name, + toolCallData.arguments, + ) + return + })(), + text: (async function* () { + yield JSON.stringify({ type: "tool_call", ...toolCallData }) + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + toolProtocol: "native", + tools, + }) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toHaveLength(2) // Tool call chunk + usage chunk + expect(chunks[0]).toEqual({ + type: "tool_call", + id: toolCallData.callId, + name: toolCallData.name, + arguments: JSON.stringify(toolCallData.arguments), + }) + }) + + it("should pass tools to request options when using native tool protocol", async () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user" as const, + content: "Calculate 2+2", + }, + ] + + const tools = [ + { + type: "function" as const, + function: { + name: "calculator", + description: "A simple calculator", + parameters: { + type: "object", + properties: { + operation: { type: "string" }, + }, + }, + }, + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("Result: 4") + return + })(), + text: (async function* () { + yield "Result: 4" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + toolProtocol: "native", + tools, + }) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify sendRequest was called with tools in options + expect(mockLanguageModelChat.sendRequest).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + tools: [ + { + name: "calculator", + description: "A simple calculator", + inputSchema: { + type: "object", + properties: { + operation: { type: "string" }, + }, + }, + }, + ], + }), + expect.anything(), + ) + }) + it("should handle errors", async () => { const systemPrompt = "You are a helpful assistant" const messages: Anthropic.Messages.MessageParam[] = [ @@ -259,6 +392,26 @@ describe("VsCodeLmHandler", () => { expect(model.id).toBe("test-vendor/test-family") expect(model.info).toBeDefined() }) + + it("should return supportsNativeTools and defaultToolProtocol in model info", async () => { + const mockModel = { ...mockLanguageModelChat } + ;(vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([mockModel]) + + // Initialize client + await handler["getClient"]() + + const model = handler.getModel() + expect(model.info.supportsNativeTools).toBe(true) + expect(model.info.defaultToolProtocol).toBe("native") + }) + + it("should return supportsNativeTools and defaultToolProtocol in fallback model info", () => { + // Clear the client first + handler["client"] = null + const model = handler.getModel() + expect(model.info.supportsNativeTools).toBe(true) + expect(model.info.defaultToolProtocol).toBe("native") + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 354932aada..892bac7c4d 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -8,6 +8,7 @@ import { vertexDefaultModelId, vertexModels, ANTHROPIC_DEFAULT_MAX_TOKENS, + TOOL_PROTOCOL, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -17,6 +18,11 @@ import { ApiStream } from "../transform/stream" import { addCacheBreakpoints } from "../transform/caching/vertex" import { getModelParams } from "../transform/model-params" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { + convertOpenAIToolsToAnthropic, + convertOpenAIToolChoiceToAnthropic, +} from "../../core/prompts/tools/native-tools/converters" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -63,17 +69,30 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - let { - id, - info: { supportsPromptCache }, - temperature, - maxTokens, - reasoning: thinking, - } = this.getModel() + let { id, info, temperature, maxTokens, reasoning: thinking } = this.getModel() + + const { supportsPromptCache } = info // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API const sanitizedMessages = filterNonAnthropicBlocks(messages) + // Enable native tools using resolveToolProtocol (which checks model's defaultToolProtocol) + // This matches the approach used in AnthropicHandler + // Also exclude tools when tool_choice is "none" since that means "don't use tools" + const toolProtocol = resolveToolProtocol(this.options, info, metadata?.toolProtocol) + const shouldIncludeNativeTools = + metadata?.tools && + metadata.tools.length > 0 && + toolProtocol === TOOL_PROTOCOL.NATIVE && + metadata?.tool_choice !== "none" + + const nativeToolParams = shouldIncludeNativeTools + ? { + tools: convertOpenAIToolsToAnthropic(metadata.tools!), + tool_choice: convertOpenAIToolChoiceToAnthropic(metadata.tool_choice, metadata.parallelToolCalls), + } + : {} + /** * Vertex API has specific limitations for prompt caching: * 1. Maximum of 4 blocks can have cache_control @@ -98,6 +117,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple : systemPrompt, messages: supportsPromptCache ? addCacheBreakpoints(sanitizedMessages) : sanitizedMessages, stream: true, + ...nativeToolParams, } const stream = await this.client.messages.create(params, { signal: metadata?.signal }) @@ -144,6 +164,17 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple yield { type: "reasoning", text: (chunk.content_block as any).thinking } break } + case "tool_use": { + // Emit initial tool call partial with id and name + yield { + type: "tool_call_partial", + index: chunk.index, + id: chunk.content_block!.id, + name: chunk.content_block!.name, + arguments: undefined, + } + break + } } break @@ -158,12 +189,24 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple yield { type: "reasoning", text: (chunk.delta as any).thinking } break } + case "input_json_delta": { + // Emit tool call partial chunks as arguments stream in + yield { + type: "tool_call_partial", + index: chunk.index, + id: undefined, + name: undefined, + arguments: (chunk.delta as any).partial_json, + } + break + } } break } case "content_block_stop": { // Block complete - no action needed for now. + // NativeToolCallParser handles tool call completion // Note: Signature for multi-turn thinking would require using stream.finalMessage() // after iteration completes, which requires restructuring the streaming approach. break diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 5c83d9d09c..89069d0266 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -24,7 +24,10 @@ import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { calculateApiCostAnthropic } from "../../shared/cost" -import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters" +import { + convertOpenAIToolsToAnthropic, + convertOpenAIToolChoiceToAnthropic, +} from "../../core/prompts/tools/native-tools/converters" export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions @@ -73,8 +76,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // Enable native tools by default using resolveToolProtocol (which checks model's defaultToolProtocol) // This matches OpenRouter's approach of always including tools when provided // Also exclude tools when tool_choice is "none" since that means "don't use tools" + // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency const model = this.getModel() - const toolProtocol = resolveToolProtocol(this.options, model.info) + const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol) const shouldIncludeNativeTools = metadata?.tools && metadata.tools.length > 0 && @@ -84,7 +88,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const nativeToolParams = shouldIncludeNativeTools ? { tools: convertOpenAIToolsToAnthropic(metadata.tools!), - tool_choice: this.convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls), + tool_choice: convertOpenAIToolChoiceToAnthropic(metadata.tool_choice, metadata.parallelToolCalls), } : {} @@ -376,49 +380,6 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } } - /** - * Converts OpenAI tool_choice to Anthropic ToolChoice format - * @param toolChoice - OpenAI tool_choice parameter - * @param parallelToolCalls - When true, allows parallel tool calls. When false (default), disables parallel tool calls. - */ - private convertOpenAIToolChoice( - toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], - parallelToolCalls?: boolean, - ): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined { - // Anthropic allows parallel tool calls by default. When parallelToolCalls is false or undefined, - // we disable parallel tool use to ensure one tool call at a time. - const disableParallelToolUse = !parallelToolCalls - - if (!toolChoice) { - // Default to auto with parallel tool use control - return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } - } - - if (typeof toolChoice === "string") { - switch (toolChoice) { - case "none": - return undefined // Anthropic doesn't have "none", just omit tools - case "auto": - return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } - case "required": - return { type: "any", disable_parallel_tool_use: disableParallelToolUse } - default: - return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } - } - } - - // Handle object form { type: "function", function: { name: string } } - if (typeof toolChoice === "object" && "function" in toolChoice) { - return { - type: "tool", - name: toolChoice.function.name, - disable_parallel_tool_use: disableParallelToolUse, - } - } - - return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } - } - async completePrompt(prompt: string, systemPrompt?: string, metadata?: any) { let { id: model, temperature } = this.getModel() diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 9a261ab9d0..8dd51be86e 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -296,21 +296,6 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } - // If we had reasoning but no content, emit a placeholder text to prevent "Empty assistant response" errors. - // This typically happens when the model hits max output tokens while reasoning. - if (hasReasoning && !hasContent) { - let message = t("common:errors.gemini.thinking_complete_no_output") - if (finishReason === "MAX_TOKENS") { - message = t("common:errors.gemini.thinking_complete_truncated") - } else if (finishReason === "SAFETY") { - message = t("common:errors.gemini.thinking_complete_safety") - } else if (finishReason === "RECITATION") { - message = t("common:errors.gemini.thinking_complete_recitation") - } - - yield { type: "text", text: message } - } - if (finalResponse?.responseId) { // Capture responseId so Task.addToApiConversationHistory can store it // alongside the assistant message in api_history.json. diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 6e4b75ea80..cee484b5bf 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -244,7 +244,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Process reasoning_details when switching models to Gemini for native tool call compatibility - const toolProtocol = resolveToolProtocol(this.options, model.info) + // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency + const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol) const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE const isGemini = modelId.startsWith("google/gemini") diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index b13f608f05..3be4bcd496 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -139,7 +139,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan : undefined // Check if native tool protocol is enabled - const toolProtocol = resolveToolProtocol(this.options, info) + // IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency + const toolProtocol = resolveToolProtocol(this.options, info, metadata?.toolProtocol) const useNativeTools = toolProtocol === TOOL_PROTOCOL.NATIVE const completionParams: RequestyChatCompletionParamsStreaming = { diff --git a/src/api/providers/utils/router-tool-preferences.ts b/src/api/providers/utils/router-tool-preferences.ts index 40f8518e3c..bb5ece3b96 100644 --- a/src/api/providers/utils/router-tool-preferences.ts +++ b/src/api/providers/utils/router-tool-preferences.ts @@ -32,6 +32,7 @@ export function applyRouterToolPreferences(modelId: string, info: ModelInfo): Mo if (modelId.includes("gemini")) { result = { ...result, + excludedTools: [...new Set([...(result.excludedTools || []), "apply_diff"])], includedTools: [...new Set([...(result.includedTools || []), "write_file", "edit_file"])], } } diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 87b991b06f..7c4eff978e 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1,5 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" +import OpenAI from "openai" import { type ModelInfo, openAiModelInfoSaneDefaults } from "@roo-code/types" @@ -12,6 +13,21 @@ import { convertToVsCodeLmMessages, extractTextCountFromMessage } from "../trans import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +/** + * Converts OpenAI-format tools to VSCode Language Model tools. + * @param tools Array of OpenAI ChatCompletionTool definitions + * @returns Array of VSCode LanguageModelChatTool definitions + */ +function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode.LanguageModelChatTool[] { + return tools + .filter((tool) => tool.type === "function") + .map((tool) => ({ + name: tool.function.name, + description: tool.function.description || "", + inputSchema: tool.function.parameters as Record | undefined, + })) +} + /** * Handles interaction with VS Code's Language Model API for chat-based operations. * This handler extends BaseProvider to provide VS Code LM specific functionality. @@ -360,14 +376,19 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = "" + // Determine if we're using native tool protocol + const useNativeTools = metadata?.toolProtocol === "native" && metadata?.tools && metadata.tools.length > 0 + try { - // Create the response stream with minimal required options + // Create the response stream with required options const requestOptions: vscode.LanguageModelChatRequestOptions = { justification: `CoStrict would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, } - // Note: Tool support is currently provided by the VSCode Language Model API directly - // Extensions can register tools using vscode.lm.registerTool() + // Add tools to request options when using native tool protocol + if (useNativeTools && metadata?.tools) { + requestOptions.tools = convertToVsCodeLmTools(metadata.tools) + } const response: vscode.LanguageModelChatResponse = await client.sendRequest( vsCodeLmMessages, @@ -408,17 +429,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan continue } - // Convert tool calls to text format with proper error handling - const toolCall = { - type: "tool_call", - name: chunk.name, - arguments: chunk.input, - callId: chunk.callId, - } - - const toolCallText = JSON.stringify(toolCall) - accumulatedText += toolCallText - // Log tool call for debugging console.debug("CoStrict : Processing tool call:", { name: chunk.name, @@ -426,9 +436,32 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan inputSize: JSON.stringify(chunk.input).length, }) - yield { - type: "text", - text: toolCallText, + // Yield native tool_call chunk when using native tool protocol + if (useNativeTools) { + const argumentsString = JSON.stringify(chunk.input) + accumulatedText += argumentsString + yield { + type: "tool_call", + id: chunk.callId, + name: chunk.name, + arguments: argumentsString, + } + } else { + // Fallback: Convert tool calls to text format for XML tool protocol + const toolCall = { + type: "tool_call", + name: chunk.name, + arguments: chunk.input, + callId: chunk.callId, + } + + const toolCallText = JSON.stringify(toolCall) + accumulatedText += toolCallText + + yield { + type: "text", + text: toolCallText, + } } } catch (error) { console.error("CoStrict : Failed to process tool call:", error) @@ -512,6 +545,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan : openAiModelInfoSaneDefaults.contextWindow, supportsImages: false, // VSCode Language Model API currently doesn't support image inputs supportsPromptCache: true, + supportsNativeTools: true, // VSCode Language Model API supports native tool calling + defaultToolProtocol: "native", // Use native tool protocol by default inputPrice: 0, outputPrice: 0, description: `VSCode Language Model: ${modelId}`, @@ -531,6 +566,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan id: fallbackId, info: { ...openAiModelInfoSaneDefaults, + supportsNativeTools: true, // VSCode Language Model API supports native tool calling + defaultToolProtocol: "native", // Use native tool protocol by default description: `VSCode Language Model (Fallback): ${fallbackId}`, }, } diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 745dc7c495..d41a7e9ad0 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -526,6 +526,21 @@ export class NativeToolCallParser { } break + case "edit_file": + if ( + partialArgs.file_path !== undefined || + partialArgs.old_string !== undefined || + partialArgs.new_string !== undefined + ) { + nativeArgs = { + file_path: partialArgs.file_path, + old_string: partialArgs.old_string, + new_string: partialArgs.new_string, + expected_replacements: partialArgs.expected_replacements, + } + } + break + default: break } @@ -563,7 +578,7 @@ export class NativeToolCallParser { return this.parseDynamicMcpTool(toolCall) } - // Resolve tool alias to canonical name (e.g., "edit_file" -> "apply_diff", "temp_edit_file" -> "search_and_replace") + // Resolve tool alias to canonical name const resolvedName = resolveToolAlias(toolCall.name as string) as TName // Validate tool name (after alias resolution) @@ -786,6 +801,21 @@ export class NativeToolCallParser { } break + case "edit_file": + if ( + args.file_path !== undefined && + args.old_string !== undefined && + args.new_string !== undefined + ) { + nativeArgs = { + file_path: args.file_path, + old_string: args.old_string, + new_string: args.new_string, + expected_replacements: args.expected_replacements, + } as NativeArgsFor + } + break + default: break } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2366b031df..95f6abcd04 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -3,6 +3,7 @@ import { serializeError } from "serialize-error" import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" +import { ConsecutiveMistakeError } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { t } from "../../i18n" @@ -24,6 +25,7 @@ import { writeToFileTool } from "../tools/WriteToFileTool" import { applyDiffTool } from "../tools/MultiApplyDiffTool" import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool" import { searchReplaceTool } from "../tools/SearchReplaceTool" +import { editFileTool } from "../tools/EditFileTool" import { applyPatchTool } from "../tools/ApplyPatchTool" import { searchFilesTool } from "../tools/SearchFilesTool" import { browserActionTool } from "../tools/BrowserActionTool" @@ -409,6 +411,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.path}']` case "search_replace": return `[${block.name} for '${block.params.file_path}']` + case "edit_file": + return `[${block.name} for '${block.params.file_path}']` case "apply_patch": return `[${block.name}]` case "list_files": @@ -513,10 +517,12 @@ export async function presentAssistantMessage(cline: Task) { const pushToolResult = (content: ToolResponse) => { const editTools = [ - /* "insert_content", */ "apply_diff", + "write_to_file", + "apply_diff", "search_and_replace", + "search_replace", + "edit_file", "apply_patch", - "write_to_file", ] if (toolProtocol === TOOL_PROTOCOL.NATIVE) { // For native protocol, only allow ONE tool_result per tool call @@ -795,11 +801,22 @@ export async function presentAssistantMessage(cline: Task) { // Add user feedback to chat. await cline.say("user_feedback", text, images) - - // Track tool repetition in telemetry. - TelemetryService.instance.captureConsecutiveMistakeError(cline.taskId) } + // Track tool repetition in telemetry via PostHog exception tracking and event. + TelemetryService.instance.captureConsecutiveMistakeError(cline.taskId) + TelemetryService.instance.captureException( + new ConsecutiveMistakeError( + `Tool repetition limit reached for ${block.name}`, + cline.taskId, + cline.consecutiveMistakeCount, + cline.consecutiveMistakeLimit, + "tool_repetition", + cline.apiConfiguration.apiProvider, + cline.api.getModel().id, + ), + ) + // Return tool result message about the repetition pushToolResult( formatResponse.toolError( @@ -892,6 +909,16 @@ export async function presentAssistantMessage(cline: Task) { toolProtocol, }) break + case "edit_file": + await checkpointSaveAndMark(cline) + await editFileTool.handle(cline, block as ToolUse<"edit_file">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break case "apply_patch": await checkpointSaveAndMark(cline) await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index e7a5958a57..4e8f9e10bd 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -290,8 +290,12 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo } } // Resolve and add tool protocol information + // Use the task's locked tool protocol for consistent environment details. + // This ensures the model sees the same tool format it was started with, + // even if user settings have changed. Fall back to resolving fresh if + // the task hasn't been fully initialized yet (shouldn't happen in practice). const modelInfo = cline.api.getModel().info - const toolProtocol = resolveToolProtocol(state?.apiConfiguration ?? {}, modelInfo) + const toolProtocol = resolveToolProtocol(state?.apiConfiguration ?? {}, modelInfo, cline.taskToolProtocol) details += `\n\n# Current Mode\n` details += `${currentMode}\n` diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index d189b99915..50db6984f2 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -722,6 +722,14 @@ describe("filterMcpToolsForMode", () => { parameters: {}, }, }, + { + type: "function", + function: { + name: "edit_file", + description: "Edit file", + parameters: {}, + }, + }, ] it("should exclude tools when model specifies excludedTools", () => { @@ -823,7 +831,7 @@ describe("filterMcpToolsForMode", () => { expect(toolNames).not.toContain("apply_diff") // Excluded }) - it("should rename tools to alias names when model includes aliases", () => { + it("should honor included aliases while respecting exclusions", () => { const codeMode: ModeConfig = { slug: "code", name: "Code", @@ -834,6 +842,7 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, + excludedTools: ["apply_diff"], includedTools: ["edit_file", "write_file"], } diff --git a/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts b/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts index 6665497a6f..4c1f606754 100644 --- a/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from "vitest" import type OpenAI from "openai" import type Anthropic from "@anthropic-ai/sdk" -import { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "../converters" +import { + convertOpenAIToolToAnthropic, + convertOpenAIToolsToAnthropic, + convertOpenAIToolChoiceToAnthropic, +} from "../converters" describe("converters", () => { describe("convertOpenAIToolToAnthropic", () => { @@ -141,4 +145,68 @@ describe("converters", () => { expect(results).toEqual([]) }) }) + + describe("convertOpenAIToolChoiceToAnthropic", () => { + it("should return auto with disabled parallel tool use when toolChoice is undefined", () => { + const result = convertOpenAIToolChoiceToAnthropic(undefined) + expect(result).toEqual({ type: "auto", disable_parallel_tool_use: true }) + }) + + it("should return auto with enabled parallel tool use when parallelToolCalls is true", () => { + const result = convertOpenAIToolChoiceToAnthropic(undefined, true) + expect(result).toEqual({ type: "auto", disable_parallel_tool_use: false }) + }) + + it("should return undefined for 'none' tool choice", () => { + const result = convertOpenAIToolChoiceToAnthropic("none") + expect(result).toBeUndefined() + }) + + it("should return auto for 'auto' tool choice", () => { + const result = convertOpenAIToolChoiceToAnthropic("auto") + expect(result).toEqual({ type: "auto", disable_parallel_tool_use: true }) + }) + + it("should return any for 'required' tool choice", () => { + const result = convertOpenAIToolChoiceToAnthropic("required") + expect(result).toEqual({ type: "any", disable_parallel_tool_use: true }) + }) + + it("should return auto for unknown string tool choice", () => { + const result = convertOpenAIToolChoiceToAnthropic("unknown" as any) + expect(result).toEqual({ type: "auto", disable_parallel_tool_use: true }) + }) + + it("should convert function object form to tool type", () => { + const result = convertOpenAIToolChoiceToAnthropic({ + type: "function", + function: { name: "get_weather" }, + }) + expect(result).toEqual({ + type: "tool", + name: "get_weather", + disable_parallel_tool_use: true, + }) + }) + + it("should handle function object form with parallel tool calls enabled", () => { + const result = convertOpenAIToolChoiceToAnthropic( + { + type: "function", + function: { name: "read_file" }, + }, + true, + ) + expect(result).toEqual({ + type: "tool", + name: "read_file", + disable_parallel_tool_use: false, + }) + }) + + it("should return auto for object without function property", () => { + const result = convertOpenAIToolChoiceToAnthropic({ type: "something" } as any) + expect(result).toEqual({ type: "auto", disable_parallel_tool_use: true }) + }) + }) }) diff --git a/src/core/prompts/tools/native-tools/converters.ts b/src/core/prompts/tools/native-tools/converters.ts index b2040a0afc..e124a642ff 100644 --- a/src/core/prompts/tools/native-tools/converters.ts +++ b/src/core/prompts/tools/native-tools/converters.ts @@ -47,3 +47,63 @@ export function convertOpenAIToolToAnthropic(tool: OpenAI.Chat.ChatCompletionToo export function convertOpenAIToolsToAnthropic(tools: OpenAI.Chat.ChatCompletionTool[]): Anthropic.Tool[] { return tools.map(convertOpenAIToolToAnthropic) } + +/** + * Converts OpenAI tool_choice to Anthropic ToolChoice format. + * + * Maps OpenAI's tool_choice parameter to Anthropic's equivalent format: + * - "none" → undefined (Anthropic doesn't have "none", just omit tools) + * - "auto" → { type: "auto" } + * - "required" → { type: "any" } + * - { type: "function", function: { name } } → { type: "tool", name } + * + * @param toolChoice - OpenAI tool_choice parameter + * @param parallelToolCalls - When true, allows parallel tool calls. When false (default), disables parallel tool calls. + * @returns Anthropic ToolChoice or undefined if tools should be omitted + * + * @example + * ```typescript + * convertOpenAIToolChoiceToAnthropic("auto", false) + * // Returns: { type: "auto", disable_parallel_tool_use: true } + * + * convertOpenAIToolChoiceToAnthropic({ type: "function", function: { name: "get_weather" } }) + * // Returns: { type: "tool", name: "get_weather", disable_parallel_tool_use: true } + * ``` + */ +export function convertOpenAIToolChoiceToAnthropic( + toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], + parallelToolCalls?: boolean, +): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined { + // Anthropic allows parallel tool calls by default. When parallelToolCalls is false or undefined, + // we disable parallel tool use to ensure one tool call at a time. + const disableParallelToolUse = !parallelToolCalls + + if (!toolChoice) { + // Default to auto with parallel tool use control + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } + } + + if (typeof toolChoice === "string") { + switch (toolChoice) { + case "none": + return undefined // Anthropic doesn't have "none", just omit tools + case "auto": + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } + case "required": + return { type: "any", disable_parallel_tool_use: disableParallelToolUse } + default: + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } + } + } + + // Handle object form { type: "function", function: { name: string } } + if (typeof toolChoice === "object" && "function" in toolChoice) { + return { + type: "tool", + name: toolChoice.function.name, + disable_parallel_tool_use: disableParallelToolUse, + } + } + + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } +} diff --git a/src/core/prompts/tools/native-tools/edit_file.ts b/src/core/prompts/tools/native-tools/edit_file.ts new file mode 100644 index 0000000000..ed6a59f3e1 --- /dev/null +++ b/src/core/prompts/tools/native-tools/edit_file.ts @@ -0,0 +1,70 @@ +import type OpenAI from "openai" + +const EDIT_FILE_DESCRIPTION = `Use this tool to replace text in an existing file, or create a new file. + +This tool performs literal string replacement with support for multiple occurrences. + +USAGE PATTERNS: + +1. MODIFY EXISTING FILE (default): + - Provide file_path, old_string (text to find), and new_string (replacement) + - By default, expects exactly 1 occurrence of old_string + - Use expected_replacements to replace multiple occurrences + +2. CREATE NEW FILE: + - Set old_string to empty string "" + - new_string becomes the entire file content + - File must not already exist + +CRITICAL REQUIREMENTS: + +1. EXACT MATCHING: The old_string must match the file contents EXACTLY, including: + - All whitespace (spaces, tabs, newlines) + - All indentation + - All punctuation and special characters + +2. CONTEXT FOR UNIQUENESS: For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text to ensure uniqueness. + +3. MULTIPLE REPLACEMENTS: If you need to replace multiple identical occurrences: + - Set expected_replacements to the exact count you expect to replace + - ALL occurrences will be replaced + +4. NO ESCAPING: Provide the literal text - do not escape special characters.` + +const edit_file = { + type: "function", + function: { + name: "edit_file", + description: EDIT_FILE_DESCRIPTION, + parameters: { + type: "object", + properties: { + file_path: { + type: "string", + description: + "The path to the file to modify or create. You can use either a relative path in the workspace or an absolute path. If an absolute path is provided, it will be preserved as is.", + }, + old_string: { + type: "string", + description: + "The exact literal text to replace (must match the file contents exactly, including all whitespace and indentation). For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text. Use empty string to create a new file.", + }, + new_string: { + type: "string", + description: + "The exact literal text to replace old_string with. When creating a new file (old_string is empty), this becomes the file content.", + }, + expected_replacements: { + type: "number", + description: + "Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences of the same text.", + minimum: 1, + }, + }, + required: ["file_path", "old_string", "new_string"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool + +export default edit_file diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 760d987b47..79302a39f3 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -15,6 +15,7 @@ import { createReadFileTool } from "./read_file" import runSlashCommand from "./run_slash_command" import searchAndReplace from "./search_and_replace" import searchReplace from "./search_replace" +import edit_file from "./edit_file" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" @@ -47,6 +48,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat runSlashCommand, searchAndReplace, searchReplace, + edit_file, searchFiles, switchMode, updateTodoList, diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 25a548b6e2..eb872a6f7e 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -1,7 +1,7 @@ import NodeCache from "node-cache" import getFolderSize from "get-folder-size" -import type { ClineMessage, HistoryItem } from "@roo-code/types" +import type { ClineMessage, HistoryItem, ToolProtocol } from "@roo-code/types" import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" @@ -23,6 +23,11 @@ export type TaskMetadataOptions = { mode?: string /** Initial status for the task (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" + /** + * The tool protocol locked to this task. Once set, the task will + * continue using this protocol even if user settings change. + */ + toolProtocol?: ToolProtocol } export async function taskMetadata({ @@ -35,6 +40,7 @@ export async function taskMetadata({ workspace, mode, initialStatus, + toolProtocol, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -90,6 +96,8 @@ export async function taskMetadata({ // initialStatus is included when provided (e.g., "active" for child tasks) // to ensure the status is set from the very first save, avoiding race conditions // where attempt_completion might run before a separate status update. + // toolProtocol is persisted to ensure tasks resume with the correct protocol + // even if user settings have changed. const historyItem: HistoryItem = { id, rootTaskId, @@ -107,6 +115,7 @@ export async function taskMetadata({ size: taskDirSize, workspace, mode, + ...(toolProtocol && { toolProtocol }), ...(initialStatus && { status: initialStatus }), } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6727c25b8c..603505b4b0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -32,6 +32,7 @@ import { type HistoryItem, type CreateTaskOptions, type ModelInfo, + type ToolProtocol, RooCodeEventName, // TelemetryEventName, TaskStatus, @@ -49,10 +50,11 @@ import { MIN_CHECKPOINT_TIMEOUT_SECONDS, TOOL_PROTOCOL, CommandExecutionStatus, + ConsecutiveMistakeError, } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" // import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" -import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { TelemetryService } from "@roo-code/telemetry" +import { resolveToolProtocol, detectToolProtocolFromHistory } from "../../utils/resolveToolProtocol" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" @@ -211,6 +213,30 @@ export class Task extends EventEmitter implements TaskLike { */ private _taskMode: string | undefined + /** + * The tool protocol locked to this task. Once set, the task will continue + * using this protocol even if user settings change. + * + * ## Why This Matters + * When NTC (Native Tool Calling) is enabled, XML parsing does NOT occur. + * If a task previously used XML tools, resuming it with NTC enabled would + * break because the tool calls in the history would not be parseable. + * + * ## Lifecycle + * + * ### For new tasks: + * 1. Set immediately in constructor via `resolveToolProtocol()` + * 2. Locked for the lifetime of the task + * + * ### For history items: + * 1. If `historyItem.toolProtocol` exists, use it + * 2. Otherwise, detect from API history via `detectToolProtocolFromHistory()` + * 3. If no tools in history, use `resolveToolProtocol()` from current settings + * + * @private + */ + private _taskToolProtocol: ToolProtocol | undefined + /** * Promise that resolves when the task mode has been initialized. * This ensures async mode initialization completes before the task is used. @@ -300,6 +326,7 @@ export class Task extends EventEmitter implements TaskLike { consecutiveMistakeCount: number = 0 consecutiveMistakeLimit: number consecutiveMistakeCountForApplyDiff: Map = new Map() + consecutiveNoToolUseCount: number = 0 toolUsage: ToolUsage = {} // Checkpoints @@ -477,19 +504,29 @@ export class Task extends EventEmitter implements TaskLike { this._taskMode = historyItem.mode || defaultModeSlug this.taskModeReady = Promise.resolve() TelemetryService.instance.captureTaskRestarted(this.taskId) + + // For history items, use the persisted tool protocol if available. + // If not available (old tasks), it will be detected in resumeTaskFromHistory. + this._taskToolProtocol = historyItem.toolProtocol } else { // For new tasks, don't set the mode yet - wait for async initialization. this._taskMode = undefined this.taskModeReady = this.initializeTaskMode(provider) TelemetryService.instance.captureTaskCreated(this.taskId) + + // For new tasks, resolve and lock the tool protocol immediately. + // This ensures the task will continue using this protocol even if + // user settings change. + const modelInfo = this.api.getModel().info + this._taskToolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) } - // Initialize the assistant message parser only for XML protocol. + // Initialize the assistant message parser based on the locked tool protocol. // For native protocol, tool calls come as tool_call chunks, not XML. - // experiments is always provided via TaskOptions (defaults to experimentDefault in provider) - const modelInfo = this.api.getModel().info - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - this.assistantMessageParser = toolProtocol !== "native" ? new AssistantMessageParser() : undefined + // For history items without a persisted protocol, we default to XML parser + // and will update it in resumeTaskFromHistory after detection. + const effectiveProtocol = this._taskToolProtocol || "xml" + this.assistantMessageParser = effectiveProtocol !== "native" ? new AssistantMessageParser() : undefined this.messageQueueService = new MessageQueueService() @@ -997,6 +1034,7 @@ export class Task extends EventEmitter implements TaskLike { workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. initialStatus: this.initialStatus, + toolProtocol: this._taskToolProtocol, // Persist the locked tool protocol. }) // Emit token/tool usage updates using debounced function @@ -1361,9 +1399,9 @@ export class Task extends EventEmitter implements TaskLike { } /** - * Updates the API configuration and reinitializes the parser based on the new tool protocol. - * This should be called when switching between models/profiles with different tool protocols - * to prevent the parser from being left in an inconsistent state. + * Updates the API configuration but preserves the locked tool protocol. + * The task's tool protocol is locked at creation time and should NOT change + * even when switching between models/profiles with different settings. * * @param newApiConfiguration - The new API configuration to use */ @@ -1372,26 +1410,10 @@ export class Task extends EventEmitter implements TaskLike { this.apiConfiguration = newApiConfiguration this.api = buildApiHandler(newApiConfiguration) - // Determine what the tool protocol should be - const modelInfo = this.api.getModel().info - const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - const shouldUseXmlParser = protocol === "xml" - - // Ensure parser state matches protocol requirement - const parserStateCorrect = - (shouldUseXmlParser && this.assistantMessageParser) || (!shouldUseXmlParser && !this.assistantMessageParser) - - if (parserStateCorrect) { - return - } - - // Fix parser state - if (shouldUseXmlParser && !this.assistantMessageParser) { - this.assistantMessageParser = new AssistantMessageParser() - } else if (!shouldUseXmlParser && this.assistantMessageParser) { - this.assistantMessageParser.reset() - this.assistantMessageParser = undefined - } + // IMPORTANT: Do NOT change the parser based on the new configuration! + // The task's tool protocol is locked at creation time and must remain + // consistent throughout the task's lifetime to ensure history can be + // properly resumed. } public async submitUserMessage( @@ -1475,9 +1497,8 @@ export class Task extends EventEmitter implements TaskLike { const { contextTokens: prevContextTokens } = this.getTokenUsage() // Determine if we're using native tool protocol for proper message handling - const modelInfo = this.api.getModel().info - const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - const useNativeTools = isNativeProtocol(protocol) + // Use the task's locked protocol, NOT the current settings (fallback to xml if not set) + const useNativeTools = isNativeProtocol(this._taskToolProtocol ?? "xml") const { messages, @@ -1662,15 +1683,16 @@ export class Task extends EventEmitter implements TaskLike { } async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) { - const errorMsg = `CoStrict tried to use ${toolName}${ - relPath ? ` for '${relPath.toPosix()}'` : "" - } without value for required parameter '${paramName}'. Retrying...` - await this.say("error", errorMsg) - const modelInfo = this.api.getModel().info - // const state = await this.providerRef.deref()?.getState() - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - this.providerRef.deref()?.log(errorMsg, "error") - return formatResponse.toolError(formatResponse.missingToolParameterError(paramName, toolProtocol)) + await this.say( + "error", + `Roo tried to use ${toolName}${ + relPath ? ` for '${relPath.toPosix()}'` : "" + } without value for required parameter '${paramName}'. Retrying...`, + ) + // Use the task's locked protocol, NOT the current settings (fallback to xml if not set) + return formatResponse.toolError( + formatResponse.missingToolParameterError(paramName, this._taskToolProtocol ?? "xml"), + ) } // Lifecycle @@ -1786,6 +1808,31 @@ export class Task extends EventEmitter implements TaskLike { // the task first. this.apiConversationHistory = await this.getSavedApiConversationHistory() + // If we don't have a persisted tool protocol (old tasks before this feature), + // detect it from the API history. This ensures tasks that previously used + // XML tools will continue using XML even if NTC is now enabled. + if (!this._taskToolProtocol) { + const detectedProtocol = detectToolProtocolFromHistory(this.apiConversationHistory) + if (detectedProtocol) { + // Found tool calls in history - lock to that protocol + this._taskToolProtocol = detectedProtocol + } else { + // No tool calls in history yet - use current settings + const modelInfo = this.api.getModel().info + this._taskToolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) + } + + // Update parser state to match the detected/resolved protocol + const shouldUseXmlParser = this._taskToolProtocol === "xml" + if (shouldUseXmlParser && !this.assistantMessageParser) { + this.assistantMessageParser = new AssistantMessageParser() + } else if (!shouldUseXmlParser && this.assistantMessageParser) { + this.assistantMessageParser.reset() + this.assistantMessageParser = undefined + } + } else { + } + const lastClineMessage = this.clineMessages .slice() .reverse() @@ -1819,9 +1866,8 @@ export class Task extends EventEmitter implements TaskLike { // we need to replace all tool use blocks with a text block since the API disallows // conversations with tool uses and no tool schema. // For native protocol, we preserve tool_use and tool_result blocks as they're expected by the API. - // const state = await this.providerRef.deref()?.getState() - const protocol = resolveToolProtocol(this.apiConfiguration, this.api.getModel().info) - const useNative = isNativeProtocol(protocol) + // IMPORTANT: Use the task's locked protocol, NOT the current settings! + const useNative = isNativeProtocol(this._taskToolProtocol) // Only convert tool blocks to text for XML protocol // For native protocol, the API expects proper tool_use/tool_result structure @@ -1830,9 +1876,9 @@ export class Task extends EventEmitter implements TaskLike { if (Array.isArray(message.content)) { const newContent = message.content.map((block) => { if (block.type === "tool_use") { - // Format tool invocation based on protocol + // Format tool invocation based on the task's locked protocol const params = block.input as Record - const formattedText = formatToolInvocation(block.name, params, protocol) + const formattedText = formatToolInvocation(block.name, params, this._taskToolProtocol) return { type: "text", @@ -2003,6 +2049,9 @@ export class Task extends EventEmitter implements TaskLike { this.abort = true + // Reset consecutive error counters on abort (manual intervention) + this.consecutiveNoToolUseCount = 0 + // Force final token usage update before abort event this.emitFinalTokenUsageUpdate() @@ -2262,10 +2311,8 @@ export class Task extends EventEmitter implements TaskLike { // the user hits max requests and denies resetting the count. break } else { - const modelInfo = this.api.getModel().info - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed(toolProtocol) }] - this.consecutiveMistakeCount++ + // Use the task's locked protocol, NOT the current settings (fallback to xml if not set) + nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed(this._taskToolProtocol ?? "xml") }] } } } @@ -2293,6 +2340,22 @@ export class Task extends EventEmitter implements TaskLike { } if (this.consecutiveMistakeLimit > 0 && this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { + // Track consecutive mistake errors in telemetry via event and PostHog exception tracking. + // The reason is "no_tools_used" because this limit is reached via initiateTaskLoop + // which increments consecutiveMistakeCount when the model doesn't use any tools. + TelemetryService.instance.captureConsecutiveMistakeError(this.taskId) + TelemetryService.instance.captureException( + new ConsecutiveMistakeError( + `Task reached consecutive mistake limit (${this.consecutiveMistakeLimit})`, + this.taskId, + this.consecutiveMistakeCount, + this.consecutiveMistakeLimit, + "no_tools_used", + this.apiConfiguration.apiProvider, + getModelId(this.apiConfiguration), + ), + ) + const { response, text, images } = await this.ask( "mistake_limit_reached", t("common:errors.mistake_limit_guidance"), @@ -2519,7 +2582,14 @@ export class Task extends EventEmitter implements TaskLike { this.cachedStreamingModel = this.api.getModel() const streamModelInfo = this.cachedStreamingModel.info const cachedModelId = this.cachedStreamingModel.id - const streamProtocol = resolveToolProtocol(this.apiConfiguration, streamModelInfo) + // Use the task's locked protocol instead of resolving fresh. + // This ensures task resumption works correctly even if NTC settings changed. + // Fallback to resolving if somehow _taskToolProtocol is not set (should not happen). + const streamProtocol = resolveToolProtocol( + this.apiConfiguration, + streamModelInfo, + this._taskToolProtocol, + ) const shouldUseXmlParser = streamProtocol === "xml" // Yields only if the first chunk is successful, otherwise will @@ -3324,11 +3394,24 @@ export class Task extends EventEmitter implements TaskLike { ) if (!didToolUse) { - const modelInfo = this.api.getModel().info - // const state = await this.providerRef.deref()?.getState() - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed(toolProtocol) }) - this.consecutiveMistakeCount++ + // Increment consecutive no-tool-use counter + this.consecutiveNoToolUseCount++ + + // Only show error and count toward mistake limit after 2 consecutive failures + if (this.consecutiveNoToolUseCount >= 2) { + await this.say("error", "MODEL_NO_TOOLS_USED") + // Only count toward mistake limit after second consecutive failure + this.consecutiveMistakeCount++ + } + + // Use the task's locked protocol for consistent behavior + this.userMessageContent.push({ + type: "text", + text: formatResponse.noToolsUsed(this._taskToolProtocol ?? "xml"), + }) + } else { + // Reset counter when tools are used successfully + this.consecutiveNoToolUseCount = 0 } // Push to stack if there's content OR if we're paused waiting for a subtask. @@ -3358,10 +3441,8 @@ export class Task extends EventEmitter implements TaskLike { // we need to remove that message before retrying to avoid having two consecutive // user messages (which would cause tool_result validation errors). let state = await this.providerRef.deref()?.getState() - if ( - isNativeProtocol(resolveToolProtocol(this.apiConfiguration, this.api.getModel().info)) && - this.apiConversationHistory.length > 0 - ) { + // Use the task's locked protocol, NOT current settings + if (isNativeProtocol(this._taskToolProtocol ?? "xml") && 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 @@ -3431,10 +3512,8 @@ export class Task extends EventEmitter implements TaskLike { } else { // User declined to retry // For native protocol, re-add the user message we removed - // Reuse the state variable from above - if ( - isNativeProtocol(resolveToolProtocol(this.apiConfiguration, this.api.getModel().info)) - ) { + // Use the task's locked protocol, NOT current settings + if (isNativeProtocol(this._taskToolProtocol ?? "xml")) { await this.addToApiConversationHistory({ role: "user", content: currentUserContent, @@ -3533,8 +3612,14 @@ export class Task extends EventEmitter implements TaskLike { const canUseBrowserTool = modelSupportsBrowser && modeSupportsBrowser && (browserToolEnabled ?? true) - // Resolve the tool protocol based on profile, model, and provider settings - const toolProtocol = resolveToolProtocol(apiConfiguration ?? this.apiConfiguration, modelInfo) + // Use the task's locked protocol for system prompt consistency. + // This ensures the system prompt matches the protocol the task was started with, + // even if user settings have changed since then. + const toolProtocol = resolveToolProtocol( + apiConfiguration ?? this.apiConfiguration, + modelInfo, + this._taskToolProtocol, + ) return SYSTEM_PROMPT( provider.context, @@ -3605,8 +3690,8 @@ export class Task extends EventEmitter implements TaskLike { ) // Determine if we're using native tool protocol for proper message handling - const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - const useNativeTools = isNativeProtocol(protocol) + // Use the task's locked protocol, NOT the current settings + const useNativeTools = isNativeProtocol(this._taskToolProtocol ?? "xml") // Send condenseTaskContextStarted to show in-progress indicator await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) @@ -3750,9 +3835,8 @@ export class Task extends EventEmitter implements TaskLike { const currentProfileId = this.getCurrentProfileId(state) // Determine if we're using native tool protocol for proper message handling - const modelInfoForProtocol = this.api.getModel().info - const protocol = resolveToolProtocol(this.apiConfiguration, modelInfoForProtocol) - const useNativeTools = isNativeProtocol(protocol) + // Use the task's locked protocol, NOT the current settings + const useNativeTools = isNativeProtocol(this._taskToolProtocol ?? "xml") // Check if context management will likely run (threshold check) // This allows us to show an in-progress indicator to the user @@ -3877,11 +3961,13 @@ export class Task extends EventEmitter implements TaskLike { } // Determine if we should include native tools based on: - // 1. Tool protocol is set to NATIVE + // 1. Task's locked tool protocol is set to NATIVE // 2. Model supports native tools + // CRITICAL: Use the task's locked protocol to ensure tasks that started with XML + // tools continue using XML even if NTC settings have since changed. const modelInfo = this.api.getModel().info - const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo) - const shouldIncludeTools = toolProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) + const taskProtocol = this._taskToolProtocol ?? "xml" + const shouldIncludeTools = taskProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false) // Build complete tools array: native tools + dynamic MCP tools, filtered by mode restrictions let allTools: OpenAI.Chat.ChatCompletionTool[] = [] @@ -3929,7 +4015,12 @@ export class Task extends EventEmitter implements TaskLike { userId: id, // Include tools and tool protocol when using native protocol and model supports it ...(shouldIncludeTools - ? { tools: allTools, tool_choice: "auto", toolProtocol, parallelToolCalls: parallelToolCallsEnabled } + ? { + tools: allTools, + tool_choice: "auto", + toolProtocol: taskProtocol, + parallelToolCalls: parallelToolCallsEnabled, + } : {}), } @@ -4368,6 +4459,16 @@ export class Task extends EventEmitter implements TaskLike { return this.workspacePath } + /** + * Get the tool protocol locked to this task. + * Returns undefined only if the task hasn't been fully initialized yet. + * + * @see {@link _taskToolProtocol} for lifecycle details + */ + public get taskToolProtocol() { + return this._taskToolProtocol + } + /** * Provides convenient access to high-level message operations. * Uses lazy initialization - the MessageManager is only created when first accessed. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 4b80744601..e905d896b4 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1005,12 +1005,12 @@ describe("Cline", () => { getState: vi.fn().mockResolvedValue({ apiConfiguration: mockApiConfig, }), + getMcpHub: vi.fn().mockReturnValue(undefined), say: vi.fn(), log: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), - getMcpHub: vi.fn().mockReturnValue(undefined), } // Initialize ZgsmAuthService with mock provider diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts new file mode 100644 index 0000000000..8d04fe2301 --- /dev/null +++ b/src/core/tools/EditFileTool.ts @@ -0,0 +1,373 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +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 { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface EditFileParams { + file_path: string + old_string: string + new_string: string + expected_replacements?: number +} + +/** + * Count occurrences of a substring in a string. + * @param str The string to search in + * @param substr The substring to count + * @returns Number of non-overlapping occurrences + */ +function countOccurrences(str: string, substr: string): number { + if (substr === "") return 0 + let count = 0 + let pos = str.indexOf(substr) + while (pos !== -1) { + count++ + pos = str.indexOf(substr, pos + substr.length) + } + return count +} + +/** + * Safely replace all occurrences of a literal string, handling $ escape sequences. + * Standard String.replaceAll treats $ specially in the replacement string. + * This function ensures literal replacement. + * + * @param str The original string + * @param oldString The string to replace + * @param newString The replacement string + * @returns The string with all occurrences replaced + */ +function safeLiteralReplace(str: string, oldString: string, newString: string): string { + if (oldString === "" || !str.includes(oldString)) { + return str + } + + // If newString doesn't contain $, we can use replaceAll directly + if (!newString.includes("$")) { + return str.replaceAll(oldString, newString) + } + + // Escape $ to prevent ECMAScript GetSubstitution issues + // $$ becomes a single $ in the output, so we double-escape + const escapedNewString = newString.replaceAll("$", "$$$$") + return str.replaceAll(oldString, escapedNewString) +} + +/** + * Apply a replacement operation. + * + * @param currentContent The current file content (null if file doesn't exist) + * @param oldString The string to replace + * @param newString The replacement string + * @param isNewFile Whether this is creating a new file + * @returns The resulting content + */ +function applyReplacement( + currentContent: string | null, + oldString: string, + newString: string, + isNewFile: boolean, +): string { + if (isNewFile) { + return newString + } + // If oldString is empty and it's not a new file, do not modify the content + if (oldString === "" || currentContent === null) { + return currentContent ?? "" + } + + return safeLiteralReplace(currentContent, oldString, newString) +} + +export class EditFileTool extends BaseTool<"edit_file"> { + readonly name = "edit_file" as const + + parseLegacy(params: Partial>): EditFileParams { + return { + file_path: params.file_path || "", + old_string: params.old_string || "", + new_string: params.new_string || "", + expected_replacements: params.expected_replacements + ? parseInt(params.expected_replacements, 10) + : undefined, + } + } + + async execute(params: EditFileParams, task: Task, callbacks: ToolCallbacks): Promise { + const { file_path, old_string, new_string, expected_replacements = 1 } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + // Validate required parameters + if (!file_path) { + task.consecutiveMistakeCount++ + task.recordToolError("edit_file") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file", "file_path")) + return + } + + // Determine relative path - file_path can be absolute or relative + let relPath: string + if (path.isAbsolute(file_path)) { + relPath = path.relative(task.cwd, file_path) + } else { + relPath = file_path + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + 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 currentContent: string | null = null + let isNewFile = false + + // Read file or determine if creating new + if (fileExists) { + try { + currentContent = await fs.readFile(absolutePath, "utf8") + // Normalize line endings to LF + currentContent = currentContent.replace(/\r\n/g, "\n") + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("edit_file") + const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) + return + } + + // Check if trying to create a file that already exists + if (old_string === "") { + task.consecutiveMistakeCount++ + task.recordToolError("edit_file") + const errorMessage = `File '${relPath}' already exists. Cannot create a new file with empty old_string when file exists.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) + return + } + } else { + // File doesn't exist + if (old_string === "") { + // Creating a new file + isNewFile = true + } else { + // Trying to replace in non-existent file + task.consecutiveMistakeCount++ + task.recordToolError("edit_file") + const errorMessage = `File not found: ${relPath}. Cannot perform replacement on a non-existent file. Use an empty old_string to create a new file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) + return + } + } + + // Validate replacement operation + if (!isNewFile && currentContent !== null) { + // Check occurrence count + const occurrences = countOccurrences(currentContent, old_string) + + if (occurrences === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("edit_file", "no_match") + pushToolResult( + formatResponse.toolError( + `No match found for the specified 'old_string'. Please ensure it matches the file contents exactly, including all whitespace and indentation.`, + toolProtocol, + ), + ) + return + } + + if (occurrences !== expected_replacements) { + task.consecutiveMistakeCount++ + task.recordToolError("edit_file", "occurrence_mismatch") + pushToolResult( + formatResponse.toolError( + `Expected ${expected_replacements} occurrence(s) but found ${occurrences}. Please adjust your old_string to match exactly ${expected_replacements} occurrence(s), or set expected_replacements to ${occurrences}.`, + toolProtocol, + ), + ) + return + } + + // Validate that old_string and new_string are different + if (old_string === new_string) { + task.consecutiveMistakeCount++ + task.recordToolError("edit_file") + pushToolResult( + formatResponse.toolError( + "No changes to apply. The old_string and new_string are identical.", + toolProtocol, + ), + ) + return + } + } + + // Apply the replacement + const newContent = applyReplacement(currentContent, old_string, new_string, isNewFile) + + // Check if any changes were made + if (!isNewFile && newContent === currentContent) { + pushToolResult(`No changes needed for '${relPath}'`) + return + } + + task.consecutiveMistakeCount = 0 + + // Initialize diff view + task.diffViewProvider.editType = isNewFile ? "create" : "modify" + task.diffViewProvider.originalContent = currentContent || "" + + // Generate and validate diff + const diff = formatResponse.createPrettyPatch(relPath, currentContent || "", newContent) + if (!diff && !isNewFile) { + pushToolResult(`No changes needed for '${relPath}'`) + await task.diffViewProvider.reset() + return + } + + // 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, + ) + + const sanitizedDiff = sanitizeUnifiedDiff(diff || "") + const diffStats = computeDiffStats(sanitizedDiff) || undefined + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: isNewFile ? "newFileCreated" : "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: sanitizedDiff, + isOutsideWorkspace, + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: sanitizedDiff, + 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(newContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + 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, + newContent, + isNewFile, + 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 replacementInfo = + !isNewFile && expected_replacements > 1 ? ` (${expected_replacements} replacements)` : "" + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, isNewFile) + + pushToolResult(message + replacementInfo) + + // Record successful tool usage and cleanup + task.recordToolUsage("edit_file") + await task.diffViewProvider.reset() + + // Process any queued messages after file edit completes + task.processQueuedMessages() + } catch (error) { + await handleError("edit_file", error as Error) + await task.diffViewProvider.reset() + } + } + + override async handlePartial(task: Task, block: ToolUse<"edit_file">): Promise { + const filePath: string | undefined = block.params.file_path + const oldString: string | undefined = block.params.old_string + + let operationPreview: string | undefined + if (oldString !== undefined) { + if (oldString === "") { + operationPreview = "creating new file" + } else { + const preview = oldString.length > 50 ? oldString.substring(0, 50) + "..." : oldString + operationPreview = `replacing: "${preview}"` + } + } + + // Determine relative path for display + let relPath = filePath || "" + if (filePath && path.isAbsolute(filePath)) { + relPath = path.relative(task.cwd, filePath) + } + + const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" + const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: operationPreview, + isOutsideWorkspace, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +export const editFileTool = new EditFileTool() diff --git a/src/core/tools/MultiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts index 504163e024..d9e9a039b3 100644 --- a/src/core/tools/MultiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -64,7 +64,8 @@ export async function applyDiffTool( removeClosingTag: RemoveClosingTag, ) { // Check if native protocol is enabled - if so, always use single-file class-based tool - const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info) + // Use the task's locked protocol for consistency throughout the task lifetime + const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info, cline.taskToolProtocol) if (isNativeProtocol(toolProtocol)) { return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, @@ -802,11 +803,15 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} } } - // Check protocol for notice formatting - const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info) + // Check protocol for notice formatting - reuse the task's locked protocol + const noticeProtocol = resolveToolProtocol( + cline.apiConfiguration, + cline.api.getModel().info, + cline.taskToolProtocol, + ) const singleBlockNotice = totalSearchBlocks === 1 - ? isNativeProtocol(toolProtocol) + ? isNativeProtocol(noticeProtocol) ? "\n" + JSON.stringify({ notice: "Making multiple related changes in a single apply_diff is more efficient. If other changes are needed in this file, please include them as additional SEARCH/REPLACE blocks.", diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 09f690f7d1..054c701448 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -111,7 +111,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { const { handleError, pushToolResult, toolProtocol } = callbacks const fileEntries = params.files const modelInfo = task.api.getModel().info - const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo) + // Use the task's locked protocol for consistent output formatting throughout the task + const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo, task.taskToolProtocol) const useNative = isNativeProtocol(protocol) if (!fileEntries || fileEntries.length === 0) { diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts new file mode 100644 index 0000000000..ab632252df --- /dev/null +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -0,0 +1,455 @@ +import * as path from "path" +import fs from "fs/promises" + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import { getReadablePath } from "../../../utils/path" +import { ToolUse, ToolResponse } from "../../../shared/tools" +import { editFileTool } from "../EditFileTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue(""), + }, +})) + +vi.mock("path", async () => { + const originalPath = await vi.importActual("path") + return { + ...originalPath, + resolve: vi.fn().mockImplementation((...args) => { + const separator = process.platform === "win32" ? "\\" : "/" + return args.join(separator) + }), + isAbsolute: vi.fn().mockReturnValue(false), + relative: vi.fn().mockImplementation((from, to) => to), + } +}) + +vi.mock("delay", () => ({ + default: vi.fn(), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg) => `Error: ${msg}`), + rooIgnoreError: vi.fn((path) => `Access denied: ${path}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../../utils/pathUtils", () => ({ + isPathOutsideWorkspace: vi.fn().mockReturnValue(false), +})) + +vi.mock("../../../utils/path", () => ({ + getReadablePath: vi.fn().mockReturnValue("test/path.txt"), +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +vi.mock("vscode", () => ({ + window: { + showWarningMessage: vi.fn().mockResolvedValue(undefined), + }, + env: { + openExternal: vi.fn(), + }, + Uri: { + parse: vi.fn(), + }, +})) + +describe("editFileTool", () => { + // Test data + const testFilePath = "test/file.txt" + const absoluteFilePath = process.platform === "win32" ? "C:\\test\\file.txt" : "/test/file.txt" + const testFileContent = "Line 1\nLine 2\nLine 3\nLine 4" + const testOldString = "Line 2" + const testNewString = "Modified Line 2" + + // Mocked functions + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + const mockedFsReadFile = fs.readFile as unknown as MockedFunction< + (path: string, encoding: string) => Promise + > + const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction + const mockedGetReadablePath = getReadablePath as MockedFunction + const mockedPathResolve = path.resolve as MockedFunction + const mockedPathIsAbsolute = path.isAbsolute as MockedFunction + + const mockTask: any = {} + let mockAskApproval: ReturnType + let mockHandleError: ReturnType + let mockPushToolResult: ReturnType + let mockRemoveClosingTag: ReturnType + let toolResult: ToolResponse | undefined + + beforeEach(() => { + vi.clearAllMocks() + + mockedPathResolve.mockReturnValue(absoluteFilePath) + mockedPathIsAbsolute.mockReturnValue(false) + mockedFileExistsAtPath.mockResolvedValue(true) + mockedFsReadFile.mockResolvedValue(testFileContent) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedGetReadablePath.mockReturnValue("test/path.txt") + + mockTask.cwd = "/" + mockTask.consecutiveMistakeCount = 0 + mockTask.didEditFile = false + mockTask.providerRef = { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } + mockTask.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockTask.rooProtectedController = { + isWriteProtected: vi.fn().mockReturnValue(false), + } + mockTask.diffViewProvider = { + editType: undefined, + isEditing: false, + originalContent: "", + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + saveChanges: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: null, + finalContent: "final content", + }), + saveDirectly: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Tool result message"), + } + mockTask.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockTask.say = vi.fn().mockResolvedValue(undefined) + mockTask.ask = vi.fn().mockResolvedValue(undefined) + mockTask.recordToolError = vi.fn() + mockTask.recordToolUsage = vi.fn() + mockTask.processQueuedMessages = vi.fn() + mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockRemoveClosingTag = vi.fn((tag, content) => content) + + toolResult = undefined + }) + + /** + * Helper function to execute the edit_file tool with different parameters + */ + async function executeEditFileTool( + params: Partial = {}, + options: { + fileExists?: boolean + fileContent?: string + isPartial?: boolean + accessAllowed?: boolean + } = {}, + ): Promise { + const fileExists = options.fileExists ?? true + const fileContent = options.fileContent ?? testFileContent + const isPartial = options.isPartial ?? false + const accessAllowed = options.accessAllowed ?? true + + mockedFileExistsAtPath.mockResolvedValue(fileExists) + mockedFsReadFile.mockResolvedValue(fileContent) + mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + + const toolUse: ToolUse = { + type: "tool_use", + name: "edit_file", + params: { + file_path: testFilePath, + old_string: testOldString, + new_string: testNewString, + ...params, + }, + partial: isPartial, + } + + mockPushToolResult = vi.fn((result: ToolResponse) => { + toolResult = result + }) + + await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }) + + return toolResult + } + + describe("parameter validation", () => { + it("returns error when file_path is missing", async () => { + const result = await executeEditFileTool({ file_path: undefined }) + + expect(result).toBe("Missing param error") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file") + }) + + it("treats undefined new_string as empty string (deletion)", async () => { + await executeEditFileTool( + { old_string: "Line 2", new_string: undefined }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("allows empty new_string for deletion", async () => { + await executeEditFileTool( + { old_string: "Line 2", new_string: "" }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("returns error when old_string equals new_string", async () => { + const result = await executeEditFileTool({ + old_string: "same", + new_string: "same", + }) + + expect(result).toContain("Error:") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + }) + + describe("file access", () => { + it("returns error when file does not exist and old_string is not empty", async () => { + const result = await executeEditFileTool({}, { fileExists: false }) + + expect(result).toContain("Error:") + expect(result).toContain("File not found") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("returns error when access is denied", async () => { + const result = await executeEditFileTool({}, { accessAllowed: false }) + + expect(result).toContain("Access denied") + }) + }) + + describe("edit_file logic", () => { + it("returns error when no match is found", async () => { + const result = await executeEditFileTool( + { old_string: "NonExistent" }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(result).toContain("Error:") + expect(result).toContain("No match found") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file", "no_match") + }) + + it("returns error when occurrence count does not match expected_replacements", async () => { + const result = await executeEditFileTool( + { old_string: "Line", expected_replacements: "1" }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(result).toContain("Error:") + expect(result).toContain("Expected 1 occurrence(s) but found 3") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file", "occurrence_mismatch") + }) + + it("succeeds when occurrence count matches expected_replacements", async () => { + await executeEditFileTool( + { old_string: "Line", new_string: "Row", expected_replacements: "4" }, + { fileContent: "Line 1\nLine 2\nLine 3\nLine 4" }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockTask.diffViewProvider.editType).toBe("modify") + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("successfully replaces single unique match", async () => { + await executeEditFileTool( + { + old_string: "Line 2", + new_string: "Modified Line 2", + }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockTask.diffViewProvider.editType).toBe("modify") + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("defaults expected_replacements to 1", async () => { + const result = await executeEditFileTool( + { old_string: "Line" }, + { fileContent: "Line 1\nLine 2\nLine 3\nLine 4" }, + ) + + expect(result).toContain("Error:") + expect(result).toContain("Expected 1 occurrence(s) but found 4") + }) + }) + + describe("file creation", () => { + it("creates new file when old_string is empty and file does not exist", async () => { + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockTask.diffViewProvider.editType).toBe("create") + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("returns error when trying to create file that already exists", async () => { + const result = await executeEditFileTool( + { old_string: "", new_string: "Content" }, + { fileExists: true, fileContent: "Existing content" }, + ) + + expect(result).toContain("Error:") + expect(result).toContain("already exists") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + }) + + describe("approval workflow", () => { + it("saves changes when user approves", async () => { + mockAskApproval.mockResolvedValue(true) + + await executeEditFileTool() + + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit_file") + }) + + it("reverts changes when user rejects", async () => { + mockAskApproval.mockResolvedValue(false) + + const result = await executeEditFileTool() + + expect(mockTask.diffViewProvider.revertChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(result).toContain("rejected") + }) + }) + + describe("partial block handling", () => { + it("handles partial block without errors", async () => { + await executeEditFileTool({}, { isPartial: true }) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it("shows creating new file preview when old_string is empty", async () => { + await executeEditFileTool({ old_string: "" }, { isPartial: true }) + + expect(mockTask.ask).toHaveBeenCalled() + }) + }) + + describe("error handling", () => { + it("handles file read errors gracefully", async () => { + mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed")) + + const toolUse: ToolUse = { + type: "tool_use", + name: "edit_file", + params: { + file_path: testFilePath, + old_string: testOldString, + new_string: testNewString, + }, + partial: false, + } + + let capturedResult: ToolResponse | undefined + const localPushToolResult = vi.fn((result: ToolResponse) => { + capturedResult = result + }) + + await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: localPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }) + + expect(capturedResult).toContain("Error:") + expect(capturedResult).toContain("Failed to read file") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("handles general errors and resets diff view", async () => { + mockTask.diffViewProvider.open.mockRejectedValueOnce(new Error("General error")) + + await executeEditFileTool() + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + }) + }) + + describe("file tracking", () => { + it("tracks file context after successful edit", async () => { + await executeEditFileTool() + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") + }) + }) + + describe("CRLF normalization", () => { + it("normalizes CRLF to LF when reading file", async () => { + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + + await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: contentWithCRLF }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + }) + + describe("dollar sign handling", () => { + it("handles $ in new_string correctly", async () => { + await executeEditFileTool( + { old_string: "Line 2", new_string: "Cost: $100" }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 33982ce83d..7468a8d470 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -480,6 +480,13 @@ export const webviewMessageHandler = async ( break } + case "costrictTelemetry": { + const { eventName, properties } = message?.values ?? {} + if (eventName) { + TelemetryService.instance.captureEvent(eventName, properties) + } + break + } case "zgsmAbort": { await provider.cancelTask() break diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5eced4c622..950ed7ddeb 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -339,8 +339,8 @@ export class DiffViewProvider { await task.say("user_feedback_diff", JSON.stringify(say)) } - // Check which protocol we're using - const toolProtocol = resolveToolProtocol(task.apiConfiguration, task.api.getModel().info) + // Check which protocol we're using - use the task's locked protocol for consistency + const toolProtocol = resolveToolProtocol(task.apiConfiguration, task.api.getModel().info, task.taskToolProtocol) const useNative = isNativeProtocol(toolProtocol) // Build notices array diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index fe65ac74fb..646474fae9 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -162,6 +162,7 @@ export interface WebviewMessage { | "showZgsmCodebaseDisableConfirmDialog" | "fetchZgsmQuotaInfo" | "zgsmProviderTip" + | "costrictTelemetry" | "fetchZgsmInviteCode" | "fixCodebase" | "showTaskWithIdInNewTab" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1146633b15..c6e51e10ee 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -74,9 +74,10 @@ export const toolParamNames = [ "patch", // apply_patch parameter "title", // ask_multiple_choice parameter "questions", // ask_multiple_choice parameter - "file_path", // search_replace parameter - "old_string", // search_replace parameter - "new_string", // search_replace parameter + "file_path", // search_replace and edit_file parameter + "old_string", // search_replace and edit_file parameter + "new_string", // search_replace and edit_file parameter + "expected_replacements", // edit_file parameter for multiple occurrences ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -95,6 +96,7 @@ export type NativeToolArgs = { apply_diff: { path: string; diff: string } search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } search_replace: { file_path: string; old_string: string; new_string: string } + edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } ask_followup_question: { question: string @@ -256,6 +258,7 @@ export const TOOL_DISPLAY_NAMES: Record = { apply_diff: "apply changes", search_and_replace: "apply changes using search and replace", search_replace: "apply single search and replace", + edit_file: "edit files using search and replace", apply_patch: "apply patches using codex format", search_files: "search files", list_files: "list files", @@ -287,7 +290,7 @@ export const TOOL_GROUPS: Record = { }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"], - customTools: ["search_and_replace", "search_replace", "apply_patch"], + customTools: ["search_and_replace", "search_replace", "edit_file", "apply_patch"], }, browser: { tools: ["browser_action"], @@ -326,9 +329,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ * To add a new alias, simply add an entry here. No other files need to be modified. */ export const TOOL_ALIASES: Record = { - edit_file: "apply_diff", write_file: "write_to_file", - temp_edit_file: "search_and_replace", } as const export type DiffResult = diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index 9e7eeb2e17..7b5c2e57b6 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -265,4 +265,183 @@ describe("normalizeToolSchema", () => { expect(props.line_ranges.items).toBeDefined() expect(props.line_ranges.description).toBe("Optional line ranges") }) + + describe("format field handling", () => { + it("should preserve supported format values (date-time)", () => { + const input = { + type: "string", + format: "date-time", + description: "Timestamp", + } + + const result = normalizeToolSchema(input) + + expect(result).toEqual({ + type: "string", + format: "date-time", + description: "Timestamp", + additionalProperties: false, + }) + }) + + it("should preserve supported format values (email)", () => { + const input = { + type: "string", + format: "email", + } + + const result = normalizeToolSchema(input) + + expect(result.format).toBe("email") + }) + + it("should preserve supported format values (uuid)", () => { + const input = { + type: "string", + format: "uuid", + } + + const result = normalizeToolSchema(input) + + expect(result.format).toBe("uuid") + }) + + it("should preserve all supported format values", () => { + const supportedFormats = [ + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "ipv4", + "ipv6", + "uuid", + ] + + for (const format of supportedFormats) { + const input = { type: "string", format } + const result = normalizeToolSchema(input) + expect(result.format).toBe(format) + } + }) + + it("should strip unsupported format value (uri)", () => { + const input = { + type: "string", + format: "uri", + description: "URL field", + } + + const result = normalizeToolSchema(input) + + expect(result).toEqual({ + type: "string", + description: "URL field", + additionalProperties: false, + }) + expect(result.format).toBeUndefined() + }) + + it("should strip unsupported format value (uri-reference)", () => { + const input = { + type: "string", + format: "uri-reference", + } + + const result = normalizeToolSchema(input) + + expect(result.format).toBeUndefined() + }) + + it("should strip unsupported format values (various)", () => { + const unsupportedFormats = ["uri", "uri-reference", "iri", "iri-reference", "regex", "json-pointer"] + + for (const format of unsupportedFormats) { + const input = { type: "string", format } + const result = normalizeToolSchema(input) + expect(result.format).toBeUndefined() + } + }) + + it("should strip unsupported format in nested properties", () => { + const input = { + type: "object", + properties: { + url: { + type: "string", + format: "uri", + description: "A URL", + }, + email: { + type: "string", + format: "email", + description: "An email", + }, + }, + } + + const result = normalizeToolSchema(input) + + const props = result.properties as Record> + expect(props.url.format).toBeUndefined() + expect(props.url.description).toBe("A URL") + expect(props.email.format).toBe("email") + expect(props.email.description).toBe("An email") + }) + + it("should strip unsupported format in deeply nested structures", () => { + const input = { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + link: { + type: "string", + format: "uri", + }, + timestamp: { + type: "string", + format: "date-time", + }, + }, + }, + }, + }, + } + + const result = normalizeToolSchema(input) + + const props = result.properties as Record> + const itemsItems = props.items.items as Record + const nestedProps = itemsItems.properties as Record> + expect(nestedProps.link.format).toBeUndefined() + expect(nestedProps.timestamp.format).toBe("date-time") + }) + + it("should handle MCP fetch server schema with uri format", () => { + // This is similar to the actual fetch MCP server schema that caused the error + const input = { + type: "object", + properties: { + url: { + type: "string", + format: "uri", + description: "URL to fetch", + }, + }, + required: ["url"], + } + + const result = normalizeToolSchema(input) + + const props = result.properties as Record> + expect(props.url.format).toBeUndefined() + expect(props.url.type).toBe("string") + expect(props.url.description).toBe("URL to fetch") + }) + }) }) diff --git a/src/utils/__tests__/resolveToolProtocol.spec.ts b/src/utils/__tests__/resolveToolProtocol.spec.ts index 68d3f3a2de..5fbc534438 100644 --- a/src/utils/__tests__/resolveToolProtocol.spec.ts +++ b/src/utils/__tests__/resolveToolProtocol.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest" -import { resolveToolProtocol } from "../resolveToolProtocol" +import { resolveToolProtocol, detectToolProtocolFromHistory } from "../resolveToolProtocol" import { TOOL_PROTOCOL, openAiModelInfoSaneDefaults } from "@roo-code/types" import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import type { Anthropic } from "@anthropic-ai/sdk" describe("resolveToolProtocol", () => { describe("Precedence Level 1: User Profile Setting", () => { @@ -139,18 +140,24 @@ describe("resolveToolProtocol", () => { }) }) - describe("Precedence Level 3: XML Fallback", () => { - it("should use XML fallback when no model default is specified", () => { + describe("Precedence Level 3: Native Fallback", () => { + it("should use Native fallback when no model default is specified and model supports native", () => { const settings: ProviderSettings = { apiProvider: "anthropic", } - const result = resolveToolProtocol(settings, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback + const modelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: true, + } + const result = resolveToolProtocol(settings, modelInfo) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native fallback }) }) describe("Complete Precedence Chain", () => { - it("should respect full precedence: Profile > Model Default > XML Fallback", () => { + it("should respect full precedence: Profile > Model Default > Native Fallback", () => { // Set up a scenario with all levels defined const settings: ProviderSettings = { toolProtocol: "native", // Level 1: User profile setting @@ -186,7 +193,7 @@ describe("resolveToolProtocol", () => { expect(result).toBe(TOOL_PROTOCOL.XML) // Model default wins }) - it("should skip to XML fallback when profile and model default are undefined", () => { + it("should skip to Native fallback when profile and model default are undefined", () => { const settings: ProviderSettings = { apiProvider: "openai-native", } @@ -199,7 +206,7 @@ describe("resolveToolProtocol", () => { } const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native fallback }) it("should skip to XML fallback when model info is unavailable", () => { @@ -208,7 +215,59 @@ describe("resolveToolProtocol", () => { } const result = resolveToolProtocol(settings, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback + expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback (no model info means no native support) + }) + }) + + describe("Locked Protocol (Precedence Level 0)", () => { + it("should return lockedProtocol when provided, ignoring all other settings", () => { + const settings: ProviderSettings = { + toolProtocol: "xml", // User wants XML + apiProvider: "openai-native", + } + const modelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: true, + defaultToolProtocol: "xml", + } + // lockedProtocol overrides everything + const result = resolveToolProtocol(settings, modelInfo, "native") + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + + it("should return XML lockedProtocol even when model supports native", () => { + const settings: ProviderSettings = { + toolProtocol: "native", // User wants native + apiProvider: "anthropic", + } + const modelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: true, // Model supports native + defaultToolProtocol: "native", + } + // lockedProtocol forces XML + const result = resolveToolProtocol(settings, modelInfo, "xml") + expect(result).toBe(TOOL_PROTOCOL.XML) + }) + + it("should fall through to normal resolution when lockedProtocol is undefined", () => { + const settings: ProviderSettings = { + toolProtocol: "xml", + apiProvider: "anthropic", + } + const modelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: true, + } + // undefined lockedProtocol should use normal precedence + const result = resolveToolProtocol(settings, modelInfo, undefined) + expect(result).toBe(TOOL_PROTOCOL.XML) // User setting wins }) }) @@ -216,7 +275,7 @@ describe("resolveToolProtocol", () => { it("should handle missing provider name gracefully", () => { const settings: ProviderSettings = {} const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to global + expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to XML (no model info) }) it("should handle undefined model info gracefully", () => { @@ -224,7 +283,7 @@ describe("resolveToolProtocol", () => { apiProvider: "openai-native", } const result = resolveToolProtocol(settings, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback + expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback (no model info) }) it("should fall back to XML when model doesn't support native", () => { @@ -243,7 +302,7 @@ describe("resolveToolProtocol", () => { }) describe("Real-world Scenarios", () => { - it("should use XML fallback for models without defaultToolProtocol", () => { + it("should use Native fallback for models without defaultToolProtocol", () => { const settings: ProviderSettings = { apiProvider: "openai-native", } @@ -254,7 +313,7 @@ describe("resolveToolProtocol", () => { supportsNativeTools: true, } const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native fallback }) it("should use XML for Claude models with Anthropic provider", () => { @@ -327,3 +386,223 @@ describe("resolveToolProtocol", () => { }) }) }) + +describe("detectToolProtocolFromHistory", () => { + // Helper type for API messages in tests + type ApiMessageForTest = Anthropic.MessageParam & { ts?: number } + + describe("Native Protocol Detection", () => { + it("should detect native protocol when tool_use block has an id", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01abc123", // Native protocol always has an ID + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + + it("should detect native protocol from the first tool_use block found", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Let me help you" }, + { role: "user", content: "Second message" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_first", + name: "read_file", + input: { path: "first.ts" }, + }, + ], + }, + { role: "user", content: "Third message" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_second", + name: "write_to_file", + input: { path: "second.ts", content: "test" }, + }, + ], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + }) + + describe("XML Protocol Detection", () => { + it("should detect XML protocol when tool_use block has no id", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + // No id field - XML protocol tool calls never have an ID + name: "read_file", + input: { path: "test.ts" }, + } as Anthropic.ToolUseBlock, // Cast to bypass type check for missing id + ], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.XML) + }) + + it("should detect XML protocol when id is empty string", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "", // Empty string should be treated as no id + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.XML) + }) + }) + + describe("No Tool Calls", () => { + it("should return undefined when no messages", () => { + const messages: ApiMessageForTest[] = [] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBeUndefined() + }) + + it("should return undefined when only user messages", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { role: "user", content: "How are you?" }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBeUndefined() + }) + + it("should return undefined when assistant messages have no tool_use", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi! How can I help?" }, + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: [{ type: "text", text: "I don't have access to weather data." }], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBeUndefined() + }) + + it("should return undefined when content is string", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBeUndefined() + }) + }) + + describe("Mixed Content", () => { + it("should detect protocol from tool_use even with mixed content", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Read this file" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll read that file for you." }, + { + type: "tool_use", + id: "toolu_mixed", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + + it("should skip user messages and only check assistant messages", () => { + const messages: ApiMessageForTest[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_user", + content: "result", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_assistant", + name: "write_to_file", + input: { path: "out.ts", content: "test" }, + }, + ], + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + }) + + describe("Edge Cases", () => { + it("should handle messages with empty content array", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [] }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBeUndefined() + }) + + it("should handle messages with ts field (ApiMessage format)", () => { + const messages: ApiMessageForTest[] = [ + { role: "user", content: "Hello", ts: Date.now() }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_with_ts", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + ts: Date.now(), + }, + ] + const result = detectToolProtocolFromHistory(messages) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + }) +}) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index de34a8669b..caba962d74 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -6,6 +6,23 @@ import { z } from "zod" */ export type JsonSchema = z4.core.JSONSchema.JSONSchema +/** + * Set of format values supported by OpenAI's Structured Outputs (strict mode). + * Unsupported format values will be stripped during schema normalization. + * @see https://platform.openai.com/docs/guides/structured-outputs#supported-schemas + */ +const OPENAI_SUPPORTED_FORMATS = new Set([ + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "ipv4", + "ipv6", + "uuid", +]) + /** * Zod schema for JSON Schema primitive types */ @@ -76,10 +93,11 @@ const TypeFieldSchema = z.union([JsonSchemaTypeSchema, z.array(JsonSchemaTypeSch /** * Internal Zod schema that normalizes tool input JSON Schema to be compliant with JSON Schema draft 2020-12. * - * This schema performs two key transformations: + * This schema performs three key transformations: * 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode) * 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format * (required by Claude on Bedrock which enforces JSON Schema draft 2020-12) + * 3. Strips unsupported `format` values (e.g., "uri") for OpenAI Structured Outputs compatibility * * Uses recursive parsing so transformations apply to all nested schemas automatically. */ @@ -109,10 +127,12 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType minItems: z.number().optional(), maxItems: z.number().optional(), uniqueItems: z.boolean().optional(), + // Format field - unsupported values will be stripped in transform + format: z.string().optional(), }) .passthrough() .transform((schema) => { - const { type, required, properties, ...rest } = schema + const { type, required, properties, format, ...rest } = schema const result: Record = { ...rest } // If type is an array, convert to anyOf format (JSON Schema 2020-12) @@ -122,6 +142,12 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType result.type = type } + // Strip unsupported format values for OpenAI compatibility + // Only include format if it's a supported value + if (format && OPENAI_SUPPORTED_FORMATS.has(format)) { + result.format = format + } + // Handle properties and required for strict mode if (properties) { result.properties = properties @@ -145,10 +171,11 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType /** * Normalizes a tool input JSON Schema to be compliant with JSON Schema draft 2020-12. * - * This function performs two key transformations: + * This function performs three key transformations: * 1. Sets `additionalProperties: false` by default (required by OpenAI strict mode) * 2. Converts deprecated `type: ["T", "null"]` array syntax to `anyOf` format * (required by Claude on Bedrock which enforces JSON Schema draft 2020-12) + * 3. Strips unsupported `format` values (e.g., "uri") for OpenAI Structured Outputs compatibility * * Uses recursive parsing so transformations apply to all nested schemas automatically. * diff --git a/src/utils/resolveToolProtocol.ts b/src/utils/resolveToolProtocol.ts index 4150442a79..2f8ddea0c3 100644 --- a/src/utils/resolveToolProtocol.ts +++ b/src/utils/resolveToolProtocol.ts @@ -1,20 +1,42 @@ import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import type { Anthropic } from "@anthropic-ai/sdk" +import { findLast, findLastIndex } from "../shared/array" + +/** + * Represents an API message in the conversation history. + * This is a minimal type definition for the detection function. + */ +type ApiMessageForDetection = Anthropic.MessageParam & { + ts?: number +} /** * Resolve the effective tool protocol based on the precedence hierarchy: * + * 0. Locked Protocol (task-level lock, if provided - highest priority) * 1. User Preference - Per-Profile (explicit profile setting) * 2. Model Default (defaultToolProtocol in ModelInfo) - * 3. XML Fallback (final fallback) + * 3. Native Fallback (final fallback) * * Then check support: if protocol is "native" but model doesn't support it, use XML. * * @param providerSettings - The provider settings for the current profile * @param modelInfo - Optional model information containing capabilities + * @param lockedProtocol - Optional task-locked protocol that takes absolute precedence * @returns The resolved tool protocol (either "xml" or "native") */ -export function resolveToolProtocol(providerSettings: ProviderSettings, modelInfo?: ModelInfo): ToolProtocol { +export function resolveToolProtocol( + providerSettings: ProviderSettings, + modelInfo?: ModelInfo, + lockedProtocol?: ToolProtocol, +): ToolProtocol { + // 0. Locked Protocol - task-level lock takes absolute precedence + // This ensures tasks continue using their original protocol even if settings change + if (lockedProtocol) { + return lockedProtocol + } + // If model doesn't support native tools, return XML immediately // Treat undefined as unsupported (only allow native when explicitly true) if (modelInfo?.supportsNativeTools !== true) { @@ -31,6 +53,60 @@ export function resolveToolProtocol(providerSettings: ProviderSettings, modelInf return modelInfo.defaultToolProtocol } - // 3. XML Fallback - return TOOL_PROTOCOL.XML + // 3. Native Fallback + return TOOL_PROTOCOL.NATIVE +} + +/** + * Detect the tool protocol used in an existing conversation history. + * + * This function scans the API conversation history for tool_use blocks + * and determines which protocol was used based on their structure: + * + * - Native protocol: tool_use blocks ALWAYS have an `id` field + * - XML protocol: tool_use blocks NEVER have an `id` field + * + * This is critical for task resumption: if a task previously used tools + * with a specific protocol, we must continue using that protocol even + * if the user's NTC settings have changed. + * + * The function searches from the most recent message backwards to find + * the last tool call, which represents the task's current protocol state. + * + * @param messages - The API conversation history to scan + * @returns The detected protocol, or undefined if no tool calls were found + */ +export function detectToolProtocolFromHistory(messages: ApiMessageForDetection[]): ToolProtocol | undefined { + // Find the last assistant message that contains a tool_use block + const lastAssistantWithTool = findLast(messages, (message) => { + if (message.role !== "assistant") { + return false + } + const content = message.content + if (!Array.isArray(content)) { + return false + } + return content.some((block) => block.type === "tool_use") + }) + + if (!lastAssistantWithTool) { + return undefined + } + + // Find the last tool_use block in that message's content + const content = lastAssistantWithTool.content as Anthropic.ContentBlock[] + const lastToolUseIndex = findLastIndex(content, (block) => block.type === "tool_use") + + if (lastToolUseIndex === -1) { + return undefined + } + + const lastToolUse = content[lastToolUseIndex] + + // The presence or absence of `id` determines the protocol: + // - Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks) + // - XML protocol tool calls NEVER have an ID (parsed from XML text) + // This pattern is used in presentAssistantMessage.ts:497-500 + const hasId = "id" in lastToolUse && !!lastToolUse.id + return hasId ? TOOL_PROTOCOL.NATIVE : TOOL_PROTOCOL.XML } diff --git a/webview-ui/src/__tests__/TelemetryClient.spec.ts b/webview-ui/src/__tests__/TelemetryClient.spec.ts index d471facd15..7aae77b708 100644 --- a/webview-ui/src/__tests__/TelemetryClient.spec.ts +++ b/webview-ui/src/__tests__/TelemetryClient.spec.ts @@ -1,6 +1,7 @@ import posthog from "posthog-js" import { telemetryClient } from "@src/utils/TelemetryClient" +import { vscode } from "@src/utils/vscode" vi.mock("posthog-js", () => ({ default: { @@ -11,6 +12,12 @@ vi.mock("posthog-js", () => ({ }, })) +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + describe("TelemetryClient", () => { beforeEach(() => { vi.clearAllMocks() @@ -99,7 +106,13 @@ describe("TelemetryClient", () => { telemetryClient.capture("test_event", { property: "value" }) // Assert - expect(posthog.capture).toHaveBeenCalledWith("test_event", { property: "value" }) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "costrictTelemetry", + values: { + eventName: "test_event", + properties: { property: "value" }, + }, + }) }) it("doesn't capture events when telemetry is disabled", () => { @@ -110,8 +123,15 @@ describe("TelemetryClient", () => { // Act telemetryClient.capture("test_event") - // Assert - expect(posthog.capture).not.toHaveBeenCalled() + // Assert - Even when telemetry is disabled, the capture method still calls vscode.postMessage + // The actual filtering should happen on the extension side + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "costrictTelemetry", + values: { + eventName: "test_event", + properties: undefined, + }, + }) }) /** @@ -126,8 +146,15 @@ describe("TelemetryClient", () => { // Act telemetryClient.capture("test_event", { property: "test value" }) - // Assert - expect(posthog.capture).not.toHaveBeenCalled() + // Assert - Even when telemetry is unset, the capture method still calls vscode.postMessage + // The actual filtering should happen on the extension side + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "costrictTelemetry", + values: { + eventName: "test_event", + properties: { property: "test value" }, + }, + }) }) }) }) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index d4fef705dd..a10df9cb92 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1267,11 +1267,13 @@ export const ChatRowContent = ({ case "api_req_retry_delayed": let body = t(`chat:apiRequest.failed`) let retryInfo, rawError, code, docsURL + docsURL = "costrict://settings?provider=claude-code" + if (message.text !== undefined) { // Check for Claude Code authentication error first if (message.text.includes("Not authenticated with Claude Code")) { body = t("chat:apiRequest.errorMessage.claudeCodeNotAuthenticated") - docsURL = "roocode://settings?provider=claude-code" + docsURL = "costrict://settings?provider=claude-code" } else { // Try to show richer error message for that code, if available const potentialCode = parseInt(message.text.substring(0, 3)) @@ -1289,7 +1291,7 @@ export const ChatRowContent = ({ // } } else { body = t("chat:apiRequest.errorMessage.unknown") - docsURL = "mailto:support@roocode.com?subject=Unknown API Error" + docsURL = "mailto:zgsm@sangfor.com.cn?subject=Unknown API Error" } } else if (message.text.indexOf("Connection error") === 0) { body = t("chat:apiRequest.errorMessage.connection") @@ -1496,14 +1498,26 @@ export const ChatRowContent = ({ ) case "error": - { - /* return */ + // Check if this is a model response error based on marker strings from backend + const isNoToolsUsedError = message.text === "MODEL_NO_TOOLS_USED" + + if (isNoToolsUsedError) { + return ( + + ) } + + // Fallback for generic errors return ( ) diff --git a/webview-ui/src/components/chat/ErrorRow.tsx b/webview-ui/src/components/chat/ErrorRow.tsx index 75c7cf76e7..0911ee2491 100644 --- a/webview-ui/src/components/chat/ErrorRow.tsx +++ b/webview-ui/src/components/chat/ErrorRow.tsx @@ -235,7 +235,7 @@ export const ErrorRow = memo( onClick={(e) => { e.preventDefault() // Handle internal navigation to settings - if (docsURL.startsWith("roocode://settings")) { + if (docsURL.startsWith("costrict://settings")) { vscode.postMessage({ type: "switchTab", tab: "settings", @@ -246,7 +246,7 @@ export const ErrorRow = memo( } }}> - {docsURL.startsWith("roocode://settings") + {docsURL.startsWith("costrict://settings") ? t("chat:apiRequest.errorMessage.goToSettings", { defaultValue: "Settings", }) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 9a6a1c9213..ef8eb404f7 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -477,8 +477,8 @@ const ApiOptions = ({ // Mirrors the simplified logic in resolveToolProtocol.ts: // 1. User preference (toolProtocol) - handled by the select value binding // 2. Model default - use if available - // 3. XML fallback - const defaultProtocol = selectedModelInfo?.defaultToolProtocol || TOOL_PROTOCOL.XML + // 3. Native fallback + const defaultProtocol = selectedModelInfo?.defaultToolProtocol || TOOL_PROTOCOL.NATIVE // Show the tool protocol selector when model supports native tools. // For OpenAI Compatible providers we always show it so users can force XML/native explicitly. diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index a59db84ae2..1638780e09 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -615,7 +615,7 @@ describe("useSelectedModel", () => { expect(result.current.info?.supportsNativeTools).toBe(true) }) - it("should use model info from routerModels when model exists", () => { + it("should merge only native tool defaults with routerModels when model exists", () => { const customModelInfo: ModelInfo = { maxTokens: 16384, contextWindow: 128000, @@ -649,9 +649,15 @@ describe("useSelectedModel", () => { expect(result.current.provider).toBe("litellm") expect(result.current.id).toBe("custom-model") - // Should use the model info from routerModels, not the fallback - expect(result.current.info).toEqual(customModelInfo) + // Should only merge native tool defaults, not prices or other model-specific info + // Router model values override the defaults + const nativeToolDefaults = { + supportsNativeTools: litellmDefaultModelInfo.supportsNativeTools, + defaultToolProtocol: litellmDefaultModelInfo.defaultToolProtocol, + } + expect(result.current.info).toEqual({ ...nativeToolDefaults, ...customModelInfo }) expect(result.current.info?.supportsNativeTools).toBe(true) + expect(result.current.info?.defaultToolProtocol).toBe("native") }) }) }) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 828fc2e2b7..71a8a614be 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -198,7 +198,13 @@ function getSelectedModel({ } case "litellm": { const id = getValidatedModelId(apiConfiguration.litellmModelId, routerModels.litellm, defaultModelId) - const info = routerModels.litellm?.[id] ?? litellmDefaultModelInfo + const routerInfo = routerModels.litellm?.[id] + // Only merge native tool call defaults, not prices or other model-specific info + const nativeToolDefaults = { + supportsNativeTools: litellmDefaultModelInfo.supportsNativeTools, + defaultToolProtocol: litellmDefaultModelInfo.defaultToolProtocol, + } + const info = routerInfo ? { ...nativeToolDefaults, ...routerInfo } : litellmDefaultModelInfo return { id, info } } case "xai": { diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index e73ee3ff21..4dc08f0067 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -291,6 +291,11 @@ }, "taskCompleted": "Task Completed", "error": "Error", + "modelResponseIncomplete": "Model Response Incomplete", + "modelResponseErrors": { + "noToolsUsed": "The model failed to use any tools in its response. This typically happens when the model provides only text/reasoning without calling the required tools to complete the task.", + "noToolsUsedDetails": "The model provided text/reasoning but did not call any of the required tools. This usually indicates the model misunderstood the task or is having difficulty determining which tool to use. The model has been automatically prompted to retry with proper tool usage." + }, "errorDetails": { "title": "Error Details", "copyToClipboard": "Copy to Clipboard", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 80da307604..cd81a7aa2e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -265,6 +265,11 @@ "hasQuestion": "Roo有一个问题" }, "taskCompleted": "任务完成", + "modelResponseIncomplete": "模型响应不完整", + "modelResponseErrors": { + "noToolsUsed": "模型在响应中未使用任何工具。这通常发生在模型仅提供文本/推理而未调用完成任务所需的工具时。", + "noToolsUsedDetails": "模型提供了文本/推理,但未调用任何必需的工具。这通常表明模型误解了任务,或在确定使用哪个工具时遇到困难。系统已自动提示模型使用正确的工具重试。" + }, "errorDetails": { "title": "错误详情", "copyToClipboard": "复制到剪贴板", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index e1f64db2d0..a7561e5d6c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -294,6 +294,11 @@ }, "taskCompleted": "工作完成", "error": "錯誤", + "modelResponseIncomplete": "模型回應不完整", + "modelResponseErrors": { + "noToolsUsed": "模型在回應中未使用任何工具。這通常發生在模型僅提供文字/推理而未呼叫完成工作所需的工具時。", + "noToolsUsedDetails": "模型提供了文字/推理,但未呼叫任何必需的工具。這通常表示模型誤解了工作,或在確定使用哪個工具時遇到困難。系統已自動提示模型使用正確的工具重試。" + }, "diffError": { "title": "編輯失敗" }, diff --git a/webview-ui/src/utils/TelemetryClient.ts b/webview-ui/src/utils/TelemetryClient.ts index e85ae41126..2972a0cead 100644 --- a/webview-ui/src/utils/TelemetryClient.ts +++ b/webview-ui/src/utils/TelemetryClient.ts @@ -1,6 +1,7 @@ import posthog from "posthog-js" import type { TelemetrySetting } from "@roo-code/types" +import { vscode } from "./vscode" class TelemetryClient { private static instance: TelemetryClient @@ -35,13 +36,13 @@ class TelemetryClient { } public capture(eventName: string, properties?: Record) { - if (TelemetryClient.telemetryEnabled) { - try { - posthog.capture(eventName, properties) - } catch (_error) { - // Silently fail if there's an error capturing an event. - } - } + vscode.postMessage({ + type: "costrictTelemetry", + values: { + eventName, + properties, + }, + }) } }