diff --git a/.changeset/fluffy-apples-attack.md b/.changeset/fluffy-apples-attack.md deleted file mode 100644 index 924a1b2505..0000000000 --- a/.changeset/fluffy-apples-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Handle really long text in the ChatRow similar to TaskHeader diff --git a/.changeset/orange-zoos-train.md b/.changeset/orange-zoos-train.md deleted file mode 100644 index 76c16f4567..0000000000 --- a/.changeset/orange-zoos-train.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Support multiple files in drag-and-drop diff --git a/.changeset/tender-cycles-help.md b/.changeset/tender-cycles-help.md deleted file mode 100644 index d43e423ee6..0000000000 --- a/.changeset/tender-cycles-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Better OpenRouter error handling diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a4a30cbd..9622ce0c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Roo Code Changelog +## [3.7.8] + +- Add Vertex AI prompt caching support for Claude models (thanks @aitoroses and @lupuletic!) +- Add gpt-4.5-preview +- Add an advanced feature to customize the system prompt + +## [3.7.7] + +- Graduate checkpoints out of beta +- Fix enhance prompt button when using Thinking Sonnet +- Add tooltips to make what buttons do more obvious + +## [3.7.6] + +- Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!) +- Support multiple files in drag-and-drop +- Truncate search_file output to avoid crashing the extension +- Better OpenRouter error handling (no more "Provider Error") +- Add slider to control max output tokens for thinking models + ## [3.7.5] - Fix context window truncation math (see [#1173](https://github.com/RooVetGit/Roo-Code/issues/1173)) diff --git a/package-lock.json b/package-lock.json index a6c75bd69b..950769b39b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "roo-cline", - "version": "3.7.5", + "version": "3.7.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "3.7.5", + "version": "3.7.8", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", - "@anthropic-ai/vertex-sdk": "^0.4.1", + "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.706.0", "@google/generative-ai": "^0.18.0", "@mistralai/mistralai": "^1.3.6", @@ -150,11 +150,11 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/@anthropic-ai/vertex-sdk": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.4.3.tgz", - "integrity": "sha512-2Uef0C5P2Hx+T88RnUSRA3u4aZqmqnrRSOb2N64ozgKPiSUPTM5JlggAq2b32yWMj5d3MLYa6spJXKMmHXOcoA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.7.0.tgz", + "integrity": "sha512-zNm3hUXgYmYDTyveIxOyxbcnh5VXFkrLo4bSnG6LAfGzW7k3k2iCNDSVKtR9qZrK2BCid7JtVu7jsEKaZ/9dSw==", "dependencies": { - "@anthropic-ai/sdk": ">=0.14 <1", + "@anthropic-ai/sdk": ">=0.35 <1", "google-auth-library": "^9.4.2" } }, diff --git a/package.json b/package.json index 40bb6a545d..fe64af16d6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Roo Code (prev. Roo Cline)", "description": "A whole dev team of AI agents in your editor.", "publisher": "RooVeterinaryInc", - "version": "3.7.5", + "version": "3.7.8", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", @@ -305,7 +305,7 @@ "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", - "@anthropic-ai/vertex-sdk": "^0.4.1", + "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.706.0", "@google/generative-ai": "^0.18.0", "@mistralai/mistralai": "^1.3.6", diff --git a/src/__mocks__/fs/promises.ts b/src/__mocks__/fs/promises.ts index d5f076247a..e496a7fa51 100644 --- a/src/__mocks__/fs/promises.ts +++ b/src/__mocks__/fs/promises.ts @@ -140,7 +140,6 @@ const mockFs = { currentPath += "/" + parts[parts.length - 1] mockDirectories.add(currentPath) return Promise.resolve() - return Promise.resolve() }), access: jest.fn().mockImplementation(async (path: string) => { diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 6bd00e9567..836279bfe4 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({ }), }, })) + +// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts +// This is needed because the production code expects strings to have this method +// Note: In production, this is added via import in the entry point (extension.ts) +export {} + +declare global { + interface String { + toPosix(): string + } +} + +// Implementation that matches src/utils/path.ts +function toPosixPath(p: string) { + // Extended-Length Paths in Windows start with "\\?\" to allow longer paths + // and bypass usual parsing. If detected, we return the path unmodified. + const isExtendedLengthPath = p.startsWith("\\\\?\\") + + if (isExtendedLengthPath) { + return p + } + + return p.replace(/\\/g, "/") +} + +if (!String.prototype.toPosix) { + String.prototype.toPosix = function (this: string): string { + return toPosixPath(this) + } +} diff --git a/src/api/providers/__tests__/anthropic.test.ts b/src/api/providers/__tests__/anthropic.test.ts index ff7bdb4054..82e098f65f 100644 --- a/src/api/providers/__tests__/anthropic.test.ts +++ b/src/api/providers/__tests__/anthropic.test.ts @@ -153,7 +153,7 @@ describe("AnthropicHandler", () => { }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + mockCreate.mockRejectedValueOnce(new Error("Anthropic completion error: API Error")) await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Anthropic completion error: API Error") }) diff --git a/src/api/providers/__tests__/openai-native.test.ts b/src/api/providers/__tests__/openai-native.test.ts index d6a855849c..eda744c335 100644 --- a/src/api/providers/__tests__/openai-native.test.ts +++ b/src/api/providers/__tests__/openai-native.test.ts @@ -357,7 +357,7 @@ describe("OpenAiNativeHandler", () => { const modelInfo = handler.getModel() expect(modelInfo.id).toBe(mockOptions.apiModelId) expect(modelInfo.info).toBeDefined() - expect(modelInfo.info.maxTokens).toBe(4096) + expect(modelInfo.info.maxTokens).toBe(16384) expect(modelInfo.info.contextWindow).toBe(128_000) }) diff --git a/src/api/providers/__tests__/vertex.test.ts b/src/api/providers/__tests__/vertex.test.ts index ebe60ba0c6..9cf92f0a16 100644 --- a/src/api/providers/__tests__/vertex.test.ts +++ b/src/api/providers/__tests__/vertex.test.ts @@ -2,8 +2,10 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" import { VertexHandler } from "../vertex" +import { ApiStreamChunk } from "../../transform/stream" // Mock Vertex SDK jest.mock("@anthropic-ai/vertex-sdk", () => ({ @@ -128,7 +130,7 @@ describe("VertexHandler", () => { ;(handler["client"].messages as any).create = mockCreate const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) @@ -158,8 +160,29 @@ describe("VertexHandler", () => { model: "claude-3-5-sonnet-v2@20241022", max_tokens: 8192, temperature: 0, - system: systemPrompt, - messages: mockMessages, + system: [ + { + type: "text", + text: "You are a helpful assistant", + cache_control: { type: "ephemeral" }, + }, + ], + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Hello", + cache_control: { type: "ephemeral" }, + }, + ], + }, + { + role: "assistant", + content: "Hi there!", + }, + ], stream: true, }) }) @@ -196,7 +219,7 @@ describe("VertexHandler", () => { ;(handler["client"].messages as any).create = mockCreate const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks = [] + const chunks: ApiStreamChunk[] = [] for await (const chunk of stream) { chunks.push(chunk) @@ -230,6 +253,315 @@ describe("VertexHandler", () => { } }).rejects.toThrow("Vertex API error") }) + + it("should handle prompt caching for supported models", async () => { + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 2, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "Hello", + }, + }, + { + type: "content_block_delta", + delta: { + type: "text_delta", + text: " world!", + }, + }, + { + type: "message_delta", + usage: { + output_tokens: 5, + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, [ + { + role: "user", + content: "First message", + }, + { + role: "assistant", + content: "Response", + }, + { + role: "user", + content: "Second message", + }, + ]) + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify usage information + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks).toHaveLength(2) + expect(usageChunks[0]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 0, + cacheWriteTokens: 3, + cacheReadTokens: 2, + }) + expect(usageChunks[1]).toEqual({ + type: "usage", + inputTokens: 0, + outputTokens: 5, + }) + + // Verify text content + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Hello") + expect(textChunks[1].text).toBe(" world!") + + // Verify cache control was added correctly + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + system: [ + { + type: "text", + text: "You are a helpful assistant", + cache_control: { type: "ephemeral" }, + }, + ], + messages: [ + expect.objectContaining({ + role: "user", + content: [ + { + type: "text", + text: "First message", + cache_control: { type: "ephemeral" }, + }, + ], + }), + expect.objectContaining({ + role: "assistant", + content: "Response", + }), + expect.objectContaining({ + role: "user", + content: [ + { + type: "text", + text: "Second message", + cache_control: { type: "ephemeral" }, + }, + ], + }), + ], + }), + ) + }) + + it("should handle cache-related usage metrics", async () => { + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 3, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "Hello", + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Check for cache-related metrics in usage chunk + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0]).toHaveProperty("cacheWriteTokens", 5) + expect(usageChunks[0]).toHaveProperty("cacheReadTokens", 3) + }) + }) + + describe("thinking functionality", () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + ] + + const systemPrompt = "You are a helpful assistant" + + it("should handle thinking content blocks and deltas", async () => { + const mockStream = [ + { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "thinking", + thinking: "Let me think about this...", + }, + }, + { + type: "content_block_delta", + delta: { + type: "thinking_delta", + thinking: " I need to consider all options.", + }, + }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "text", + text: "Here's my answer:", + }, + }, + ] + + // Setup async iterator for mock stream + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify thinking content is processed correctly + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks).toHaveLength(2) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + expect(reasoningChunks[1].text).toBe(" I need to consider all options.") + + // Verify text content is processed correctly + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) // One for the text block, one for the newline + expect(textChunks[0].text).toBe("\n") + expect(textChunks[1].text).toBe("Here's my answer:") + }) + + it("should handle multiple thinking blocks with line breaks", async () => { + const mockStream = [ + { + type: "content_block_start", + index: 0, + content_block: { + type: "thinking", + thinking: "First thinking block", + }, + }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "thinking", + thinking: "Second thinking block", + }, + }, + ] + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator) + ;(handler["client"].messages as any).create = mockCreate + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBe(3) + expect(chunks[0]).toEqual({ + type: "reasoning", + text: "First thinking block", + }) + expect(chunks[1]).toEqual({ + type: "reasoning", + text: "\n", + }) + expect(chunks[2]).toEqual({ + type: "reasoning", + text: "Second thinking block", + }) + }) }) describe("completePrompt", () => { @@ -240,7 +572,13 @@ describe("VertexHandler", () => { model: "claude-3-5-sonnet-v2@20241022", max_tokens: 8192, temperature: 0, - messages: [{ role: "user", content: "Test prompt" }], + system: "", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }], + }, + ], stream: false, }) }) @@ -295,4 +633,109 @@ describe("VertexHandler", () => { expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") // Default model }) }) + + describe("thinking model configuration", () => { + it("should configure thinking for models with :thinking suffix", () => { + const thinkingHandler = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + modelMaxThinkingTokens: 4096, + }) + + const modelInfo = thinkingHandler.getModel() + + // Verify thinking configuration + expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") + expect(modelInfo.thinking).toBeDefined() + const thinkingConfig = modelInfo.thinking as { type: "enabled"; budget_tokens: number } + expect(thinkingConfig.type).toBe("enabled") + expect(thinkingConfig.budget_tokens).toBe(4096) + expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0 + }) + + it("should calculate thinking budget correctly", () => { + // Test with explicit thinking budget + const handlerWithBudget = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + modelMaxThinkingTokens: 5000, + }) + + expect((handlerWithBudget.getModel().thinking as any).budget_tokens).toBe(5000) + + // Test with default thinking budget (80% of max tokens) + const handlerWithDefaultBudget = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 10000, + }) + + expect((handlerWithDefaultBudget.getModel().thinking as any).budget_tokens).toBe(8000) // 80% of 10000 + + // Test with minimum thinking budget (should be at least 1024) + const handlerWithSmallMaxTokens = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 1000, // This would result in 800 tokens for thinking, but minimum is 1024 + }) + + expect((handlerWithSmallMaxTokens.getModel().thinking as any).budget_tokens).toBe(1024) + }) + + it("should pass thinking configuration to API", async () => { + const thinkingHandler = new VertexHandler({ + apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + modelMaxTokens: 16384, + modelMaxThinkingTokens: 4096, + }) + + const mockCreate = jest.fn().mockImplementation(async (options) => { + if (!options.stream) { + return { + id: "test-completion", + content: [{ type: "text", text: "Test response" }], + role: "assistant", + model: options.model, + usage: { + input_tokens: 10, + output_tokens: 5, + }, + } + } + return { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 5, + }, + }, + } + }, + } + }) + ;(thinkingHandler["client"].messages as any).create = mockCreate + + await thinkingHandler + .createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) + .next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + thinking: { type: "enabled", budget_tokens: 4096 }, + temperature: 1.0, // Thinking requires temperature 1.0 + }), + ) + }) + }) }) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index ad58a1cf6b..fc0b99c59b 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -30,21 +30,7 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { let stream: AnthropicStream const cacheControl: CacheControlEphemeral = { type: "ephemeral" } - let { id: modelId, info: modelInfo } = this.getModel() - const maxTokens = modelInfo.maxTokens || 8192 - let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE - let thinking: BetaThinkingConfigParam | undefined = undefined - - // Anthropic "Thinking" models require a temperature of 1.0. - if (modelId === "claude-3-7-sonnet-20250219:thinking") { - // The `:thinking` variant is a virtual identifier for the - // `claude-3-7-sonnet-20250219` model with a thinking budget. - // We can handle this more elegantly in the future. - modelId = "claude-3-7-sonnet-20250219" - const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024) - thinking = { type: "enabled", budget_tokens: budgetTokens } - temperature = 1.0 - } + let { id: modelId, temperature, maxTokens, thinking } = this.getModel() switch (modelId) { case "claude-3-7-sonnet-20250219": @@ -194,40 +180,62 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { } } - getModel(): { id: AnthropicModelId; info: ModelInfo } { + getModel() { const modelId = this.options.apiModelId + let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE + let thinking: BetaThinkingConfigParam | undefined = undefined if (modelId && modelId in anthropicModels) { - const id = modelId as AnthropicModelId - return { id, info: anthropicModels[id] } + let id = modelId as AnthropicModelId + const info: ModelInfo = anthropicModels[id] + + // The `:thinking` variant is a virtual identifier for the + // `claude-3-7-sonnet-20250219` model with a thinking budget. + // We can handle this more elegantly in the future. + if (id === "claude-3-7-sonnet-20250219:thinking") { + id = "claude-3-7-sonnet-20250219" + } + + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + if (info.thinking) { + // Anthropic "Thinking" models require a temperature of 1.0. + temperature = 1.0 + + // Clamp the thinking budget to be at most 80% of max tokens and at + // least 1024 tokens. + const maxBudgetTokens = Math.floor(maxTokens * 0.8) + const budgetTokens = Math.max( + Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens), + 1024, + ) + + thinking = { type: "enabled", budget_tokens: budgetTokens } + } + + return { id, info, temperature, maxTokens, thinking } } - return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } + const id = anthropicDefaultModelId + const info: ModelInfo = anthropicModels[id] + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + return { id, info, temperature, maxTokens, thinking } } - async completePrompt(prompt: string): Promise { - try { - const response = await this.client.messages.create({ - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens || 8192, - temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + async completePrompt(prompt: string) { + let { id: modelId, temperature, maxTokens, thinking } = this.getModel() - const content = response.content[0] + const message = await this.client.messages.create({ + model: modelId, + max_tokens: maxTokens, + temperature, + thinking, + messages: [{ role: "user", content: prompt }], + stream: false, + }) - if (content.type === "text") { - return content.text - } - - return "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`Anthropic completion error: ${error.message}`) - } - - throw error - } + const content = message.content.find(({ type }) => type === "text") + return content?.type === "text" ? content.text : "" } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 0a9488e816..82c02e20a7 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -108,12 +108,19 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler { topP = 0.95 } + const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens let temperature = this.options.modelTemperature ?? defaultTemperature let thinking: BetaThinkingConfigParam | undefined = undefined if (modelInfo.thinking) { - const maxTokens = modelInfo.maxTokens || 8192 - const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024) + // Clamp the thinking budget to be at most 80% of max tokens and at + // least 1024 tokens. + const maxBudgetTokens = Math.floor((maxTokens || 8192) * 0.8) + const budgetTokens = Math.max( + Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens), + 1024, + ) + thinking = { type: "enabled", budget_tokens: budgetTokens } temperature = 1.0 } @@ -271,7 +278,7 @@ export async function getOpenRouterModels() { modelInfo.supportsPromptCache = true modelInfo.cacheWritesPrice = 3.75 modelInfo.cacheReadsPrice = 0.3 - modelInfo.maxTokens = 16384 + modelInfo.maxTokens = 64_000 break case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"): modelInfo.supportsPromptCache = true diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 0ee22e5893..a25fad07ee 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,9 +1,97 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming" import { ApiHandler, SingleCompletionHandler } from "../" +import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api" import { ApiStream } from "../transform/stream" +// Types for Vertex SDK + +/** + * Vertex API has specific limitations for prompt caching: + * 1. Maximum of 4 blocks can have cache_control + * 2. Only text blocks can be cached (images and other content types cannot) + * 3. Cache control can only be applied to user messages, not assistant messages + * + * Our caching strategy: + * - Cache the system prompt (1 block) + * - Cache the last text block of the second-to-last user message (1 block) + * - Cache the last text block of the last user message (1 block) + * This ensures we stay under the 4-block limit while maintaining effective caching + * for the most relevant context. + */ + +interface VertexTextBlock { + type: "text" + text: string + cache_control?: { type: "ephemeral" } +} + +interface VertexImageBlock { + type: "image" + source: { + type: "base64" + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp" + data: string + } +} + +type VertexContentBlock = VertexTextBlock | VertexImageBlock + +interface VertexUsage { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +interface VertexMessage extends Omit { + content: string | VertexContentBlock[] +} + +interface VertexMessageCreateParams { + model: string + max_tokens: number + temperature: number + system: string | VertexTextBlock[] + messages: VertexMessage[] + stream: boolean +} + +interface VertexMessageResponse { + content: Array<{ type: "text"; text: string }> +} + +interface VertexMessageStreamEvent { + type: "message_start" | "message_delta" | "content_block_start" | "content_block_delta" + message?: { + usage: VertexUsage + } + usage?: { + output_tokens: number + } + content_block?: + | { + type: "text" + text: string + } + | { + type: "thinking" + thinking: string + } + index?: number + delta?: + | { + type: "text_delta" + text: string + } + | { + type: "thinking_delta" + thinking: string + } +} + // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class VertexHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions @@ -18,37 +106,122 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { }) } + private formatMessageForCache(message: Anthropic.Messages.MessageParam, shouldCache: boolean): VertexMessage { + // Assistant messages are kept as-is since they can't be cached + if (message.role === "assistant") { + return message as VertexMessage + } + + // For string content, we convert to array format with optional cache control + if (typeof message.content === "string") { + return { + ...message, + content: [ + { + type: "text" as const, + text: message.content, + // For string content, we only have one block so it's always the last + ...(shouldCache && { cache_control: { type: "ephemeral" } }), + }, + ], + } + } + + // For array content, find the last text block index once before mapping + const lastTextBlockIndex = message.content.reduce( + (lastIndex, content, index) => (content.type === "text" ? index : lastIndex), + -1, + ) + + // Then use this pre-calculated index in the map function + return { + ...message, + content: message.content.map((content, contentIndex) => { + // Images and other non-text content are passed through unchanged + if (content.type === "image") { + return content as VertexImageBlock + } + + // Check if this is the last text block using our pre-calculated index + const isLastTextBlock = contentIndex === lastTextBlockIndex + + return { + type: "text" as const, + text: (content as { text: string }).text, + ...(shouldCache && isLastTextBlock && { cache_control: { type: "ephemeral" } }), + } + }), + } + } + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const stream = await this.client.messages.create({ - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens || 8192, - temperature: this.options.modelTemperature ?? 0, - system: systemPrompt, - messages, + const model = this.getModel() + let { id, info, temperature, maxTokens, thinking } = model + const useCache = model.info.supportsPromptCache + + // Find indices of user messages that we want to cache + // We only cache the last two user messages to stay within the 4-block limit + // (1 block for system + 1 block each for last two user messages = 3 total) + const userMsgIndices = useCache + ? messages.reduce((acc, msg, i) => (msg.role === "user" ? [...acc, i] : acc), [] as number[]) + : [] + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + // Create the stream with appropriate caching configuration + const params = { + model: id, + max_tokens: maxTokens, + temperature, + thinking, + // Cache the system prompt if caching is enabled + system: useCache + ? [ + { + text: systemPrompt, + type: "text" as const, + cache_control: { type: "ephemeral" }, + }, + ] + : systemPrompt, + messages: messages.map((message, index) => { + // Only cache the last two user messages + const shouldCache = useCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex) + return this.formatMessageForCache(message, shouldCache) + }), stream: true, - }) + } + + const stream = (await this.client.messages.create( + params as Anthropic.Messages.MessageCreateParamsStreaming, + )) as unknown as AnthropicStream + + // Process the stream chunks for await (const chunk of stream) { switch (chunk.type) { - case "message_start": - const usage = chunk.message.usage + case "message_start": { + const usage = chunk.message!.usage yield { type: "usage", inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, } break - case "message_delta": + } + case "message_delta": { yield { type: "usage", inputTokens: 0, - outputTokens: chunk.usage.output_tokens || 0, + outputTokens: chunk.usage!.output_tokens || 0, } break - - case "content_block_start": - switch (chunk.content_block.type) { - case "text": - if (chunk.index > 0) { + } + case "content_block_start": { + switch (chunk.content_block!.type) { + case "text": { + if (chunk.index! > 0) { yield { type: "text", text: "\n", @@ -56,43 +229,124 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler { } yield { type: "text", - text: chunk.content_block.text, + text: chunk.content_block!.text, } break + } + case "thinking": { + if (chunk.index! > 0) { + yield { + type: "reasoning", + text: "\n", + } + } + yield { + type: "reasoning", + text: (chunk.content_block as any).thinking, + } + break + } } break - case "content_block_delta": - switch (chunk.delta.type) { - case "text_delta": + } + case "content_block_delta": { + switch (chunk.delta!.type) { + case "text_delta": { yield { type: "text", - text: chunk.delta.text, + text: chunk.delta!.text, } break + } + case "thinking_delta": { + yield { + type: "reasoning", + text: (chunk.delta as any).thinking, + } + break + } } break + } } } } - getModel(): { id: VertexModelId; info: ModelInfo } { + getModel(): { + id: VertexModelId + info: ModelInfo + temperature: number + maxTokens: number + thinking?: BetaThinkingConfigParam + } { const modelId = this.options.apiModelId + let temperature = this.options.modelTemperature ?? 0 + let thinking: BetaThinkingConfigParam | undefined = undefined + if (modelId && modelId in vertexModels) { const id = modelId as VertexModelId - return { id, info: vertexModels[id] } + const info: ModelInfo = vertexModels[id] + + // The `:thinking` variant is a virtual identifier for thinking-enabled models + // Similar to how it's handled in the Anthropic provider + let actualId = id + if (id.endsWith(":thinking")) { + actualId = id.replace(":thinking", "") as VertexModelId + } + + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + if (info.thinking) { + temperature = 1.0 // Thinking requires temperature 1.0 + const maxBudgetTokens = Math.floor(maxTokens * 0.8) + const budgetTokens = Math.max( + Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens), + 1024, + ) + thinking = { type: "enabled", budget_tokens: budgetTokens } + } + + return { id: actualId, info, temperature, maxTokens, thinking } } - return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] } + + const id = vertexDefaultModelId + const info = vertexModels[id] + const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192 + + return { id, info, temperature, maxTokens, thinking } } async completePrompt(prompt: string): Promise { try { - const response = await this.client.messages.create({ - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens || 8192, - temperature: this.options.modelTemperature ?? 0, - messages: [{ role: "user", content: prompt }], + let { id, info, temperature, maxTokens, thinking } = this.getModel() + const useCache = info.supportsPromptCache + + const params = { + model: id, + max_tokens: maxTokens, + temperature, + thinking, + system: "", // No system prompt needed for single completions + messages: [ + { + role: "user", + content: useCache + ? [ + { + type: "text" as const, + text: prompt, + cache_control: { type: "ephemeral" }, + }, + ] + : prompt, + }, + ], stream: false, - }) + } + + const response = (await this.client.messages.create( + params as Anthropic.Messages.MessageCreateParamsNonStreaming, + )) as unknown as VertexMessageResponse const content = response.content[0] if (content.type === "text") { diff --git a/src/core/Cline.ts b/src/core/Cline.ts index f1b4d2f4d4..17c7fd12e2 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -93,6 +93,7 @@ export class Cline { // a flag that indicated if this Cline instance is paused (waiting for provider to resume it after subtask completion) private isPaused: boolean = false private pausedModeSlug: string = defaultModeSlug + readonly apiConfiguration: ApiConfiguration api: ApiHandler private terminalManager: TerminalManager private urlContentFetcher: UrlContentFetcher @@ -120,7 +121,7 @@ export class Cline { isInitialized = false // checkpoints - checkpointsEnabled: boolean = false + enableCheckpoints: boolean = false private checkpointService?: CheckpointService // streaming @@ -155,6 +156,7 @@ export class Cline { this.taskId = crypto.randomUUID() this.taskNumber = -1 + this.apiConfiguration = apiConfiguration this.api = buildApiHandler(apiConfiguration) this.terminalManager = new TerminalManager() this.urlContentFetcher = new UrlContentFetcher(provider.context) @@ -164,7 +166,7 @@ export class Cline { this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0 this.providerRef = new WeakRef(provider) this.diffViewProvider = new DiffViewProvider(cwd) - this.checkpointsEnabled = enableCheckpoints ?? false + this.enableCheckpoints = enableCheckpoints ?? false if (historyItem) { this.taskId = historyItem.id @@ -1018,13 +1020,21 @@ export class Cline { cacheWrites = 0, cacheReads = 0, }: ClineApiReqInfo = JSON.parse(previousRequest) + const totalTokens = tokensIn + tokensOut + cacheWrites + cacheReads - const trimmedMessages = truncateConversationIfNeeded( - this.apiConversationHistory, + const modelInfo = this.api.getModel().info + const maxTokens = modelInfo.thinking + ? this.apiConfiguration.modelMaxTokens || modelInfo.maxTokens + : modelInfo.maxTokens + const contextWindow = modelInfo.contextWindow + + const trimmedMessages = truncateConversationIfNeeded({ + messages: this.apiConversationHistory, totalTokens, - this.api.getModel().info, - ) + maxTokens, + contextWindow, + }) if (trimmedMessages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(trimmedMessages) @@ -3436,7 +3446,7 @@ export class Cline { // Checkpoints private async getCheckpointService() { - if (!this.checkpointsEnabled) { + if (!this.enableCheckpoints) { throw new Error("Checkpoints are disabled") } @@ -3477,7 +3487,7 @@ export class Cline { commitHash: string mode: "full" | "checkpoint" }) { - if (!this.checkpointsEnabled) { + if (!this.enableCheckpoints) { return } @@ -3516,12 +3526,12 @@ export class Cline { ) } catch (err) { this.providerRef.deref()?.log("[checkpointDiff] disabling checkpoints for this task") - this.checkpointsEnabled = false + this.enableCheckpoints = false } } public async checkpointSave({ isFirst }: { isFirst: boolean }) { - if (!this.checkpointsEnabled) { + if (!this.enableCheckpoints) { return } @@ -3542,7 +3552,7 @@ export class Cline { } } catch (err) { this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task") - this.checkpointsEnabled = false + this.enableCheckpoints = false } } @@ -3555,7 +3565,7 @@ export class Cline { commitHash: string mode: "preview" | "restore" }) { - if (!this.checkpointsEnabled) { + if (!this.enableCheckpoints) { return } @@ -3610,7 +3620,7 @@ export class Cline { this.providerRef.deref()?.cancelTask() } catch (err) { this.providerRef.deref()?.log("[checkpointRestore] disabling checkpoints for this task") - this.checkpointsEnabled = false + this.enableCheckpoints = false } } } diff --git a/src/core/prompts/__tests__/custom-system-prompt.test.ts b/src/core/prompts/__tests__/custom-system-prompt.test.ts new file mode 100644 index 0000000000..7594c13e6d --- /dev/null +++ b/src/core/prompts/__tests__/custom-system-prompt.test.ts @@ -0,0 +1,172 @@ +import { SYSTEM_PROMPT } from "../system" +import { defaultModeSlug, modes } from "../../../shared/modes" +import * as vscode from "vscode" +import * as fs from "fs/promises" + +// Mock the fs/promises module +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), + mkdir: jest.fn().mockResolvedValue(undefined), + access: jest.fn().mockResolvedValue(undefined), +})) + +// Get the mocked fs module +const mockedFs = fs as jest.Mocked + +// Mock the fileExistsAtPath function +jest.mock("../../../utils/fs", () => ({ + fileExistsAtPath: jest.fn().mockResolvedValue(true), + createDirectoriesForFile: jest.fn().mockResolvedValue([]), +})) + +// Create a mock ExtensionContext with relative paths instead of absolute paths +const mockContext = { + extensionPath: "mock/extension/path", + globalStoragePath: "mock/storage/path", + storagePath: "mock/storage/path", + logPath: "mock/log/path", + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + setKeysForSync: () => {}, + }, + extensionUri: { fsPath: "mock/extension/path" }, + globalStorageUri: { fsPath: "mock/settings/path" }, + asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, +} as unknown as vscode.ExtensionContext + +describe("File-Based Custom System Prompt", () => { + const experiments = {} + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks() + + // Default behavior: file doesn't exist + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + }) + + it("should use default generation when no file-based system prompt is found", async () => { + const customModePrompts = { + [defaultModeSlug]: { + roleDefinition: "Test role definition", + }, + } + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + customModePrompts, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain default sections + expect(prompt).toContain("TOOL USE") + expect(prompt).toContain("CAPABILITIES") + expect(prompt).toContain("MODES") + expect(prompt).toContain("Test role definition") + }) + + it("should use file-based custom system prompt when available", async () => { + // Mock the readFile to return content from a file + const fileCustomSystemPrompt = "Custom system prompt from file" + // When called with utf-8 encoding, return a string + mockedFs.readFile.mockImplementation((filePath, options) => { + if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") { + return Promise.resolve(fileCustomSystemPrompt) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + undefined, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain role definition and file-based system prompt + expect(prompt).toContain(modes[0].roleDefinition) + expect(prompt).toContain(fileCustomSystemPrompt) + + // Should not contain any of the default sections + expect(prompt).not.toContain("TOOL USE") + expect(prompt).not.toContain("CAPABILITIES") + expect(prompt).not.toContain("MODES") + }) + + it("should combine file-based system prompt with role definition and custom instructions", async () => { + // Mock the readFile to return content from a file + const fileCustomSystemPrompt = "Custom system prompt from file" + mockedFs.readFile.mockImplementation((filePath, options) => { + if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") { + return Promise.resolve(fileCustomSystemPrompt) + } + return Promise.reject({ code: "ENOENT" }) + }) + + // Define custom role definition + const customRoleDefinition = "Custom role definition" + const customModePrompts = { + [defaultModeSlug]: { + roleDefinition: customRoleDefinition, + }, + } + + const prompt = await SYSTEM_PROMPT( + mockContext, + "test/path", // Using a relative path without leading slash + false, + undefined, + undefined, + undefined, + defaultModeSlug, + customModePrompts, + undefined, + undefined, + undefined, + undefined, + experiments, + true, + ) + + // Should contain custom role definition and file-based system prompt + expect(prompt).toContain(customRoleDefinition) + expect(prompt).toContain(fileCustomSystemPrompt) + + // Should not contain any of the default sections + expect(prompt).not.toContain("TOOL USE") + expect(prompt).not.toContain("CAPABILITIES") + expect(prompt).not.toContain("MODES") + }) +}) diff --git a/src/core/prompts/sections/custom-system-prompt.ts b/src/core/prompts/sections/custom-system-prompt.ts new file mode 100644 index 0000000000..eca2b98b8d --- /dev/null +++ b/src/core/prompts/sections/custom-system-prompt.ts @@ -0,0 +1,60 @@ +import fs from "fs/promises" +import path from "path" +import { Mode } from "../../../shared/modes" +import { fileExistsAtPath } from "../../../utils/fs" + +/** + * Safely reads a file, returning an empty string if the file doesn't exist + */ +async function safeReadFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + // When reading with "utf-8" encoding, content should be a string + return content.trim() + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code + if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { + throw err + } + return "" + } +} + +/** + * Get the path to a system prompt file for a specific mode + */ +export function getSystemPromptFilePath(cwd: string, mode: Mode): string { + return path.join(cwd, ".roo", `system-prompt-${mode}`) +} + +/** + * Loads custom system prompt from a file at .roo/system-prompt-[mode slug] + * If the file doesn't exist, returns an empty string + */ +export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise { + const filePath = getSystemPromptFilePath(cwd, mode) + return safeReadFile(filePath) +} + +/** + * Ensures the .roo directory exists, creating it if necessary + */ +export async function ensureRooDirectory(cwd: string): Promise { + const rooDir = path.join(cwd, ".roo") + + // Check if directory already exists + if (await fileExistsAtPath(rooDir)) { + return + } + + // Create the directory + try { + await fs.mkdir(rooDir, { recursive: true }) + } catch (err) { + // If directory already exists (race condition), ignore the error + const errorCode = (err as NodeJS.ErrnoException).code + if (errorCode !== "EEXIST") { + throw err + } + } +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 91bbd07387..90791f6358 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -23,6 +23,7 @@ import { getModesSection, addCustomInstructions, } from "./sections" +import { loadSystemPromptFile } from "./sections/custom-system-prompt" import fs from "fs/promises" import path from "path" @@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async ( return undefined } + // Try to load custom system prompt from file + const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode) + // Check if it's a custom mode const promptComponent = getPromptComponent(customModePrompts?.[mode]) + // Get full mode config from custom modes or fall back to built-in modes const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0] + // If a file-based custom system prompt exists, use it + if (fileCustomSystemPrompt) { + const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition + return `${roleDefinition} + +${fileCustomSystemPrompt} + +${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}` + } + // If diff is disabled, don't pass the diffStrategy const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined diff --git a/src/core/sliding-window/__tests__/sliding-window.test.ts b/src/core/sliding-window/__tests__/sliding-window.test.ts index 3dcf9e5fd2..cb897aa8cb 100644 --- a/src/core/sliding-window/__tests__/sliding-window.test.ts +++ b/src/core/sliding-window/__tests__/sliding-window.test.ts @@ -119,11 +119,21 @@ describe("getMaxTokens", () => { // Max tokens = 100000 - 50000 = 50000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 49999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 49999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 50001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 50001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -133,11 +143,21 @@ describe("getMaxTokens", () => { // Max tokens = 100000 - (100000 * 0.2) = 80000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 79999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 79999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 80001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 80001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -147,11 +167,21 @@ describe("getMaxTokens", () => { // Max tokens = 50000 - 10000 = 40000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 39999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 39999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 40001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 40001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -161,11 +191,21 @@ describe("getMaxTokens", () => { // Max tokens = 200000 - 30000 = 170000 // Below max tokens - no truncation - const result1 = truncateConversationIfNeeded(messages, 169999, modelInfo) + const result1 = truncateConversationIfNeeded({ + messages, + totalTokens: 169999, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result1).toEqual(messages) // Above max tokens - truncate - const result2 = truncateConversationIfNeeded(messages, 170001, modelInfo) + const result2 = truncateConversationIfNeeded({ + messages, + totalTokens: 170001, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result2).not.toEqual(messages) expect(result2.length).toBe(3) // Truncated with 0.5 fraction }) @@ -194,7 +234,12 @@ describe("truncateConversationIfNeeded", () => { const maxTokens = 100000 - 30000 // 70000 const totalTokens = 69999 // Below threshold - const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo) + const result = truncateConversationIfNeeded({ + messages, + totalTokens, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result).toEqual(messages) // No truncation occurs }) @@ -207,7 +252,12 @@ describe("truncateConversationIfNeeded", () => { // With 4 messages after the first, 0.5 fraction means remove 2 messages const expectedResult = [messages[0], messages[3], messages[4]] - const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo) + const result = truncateConversationIfNeeded({ + messages, + totalTokens, + contextWindow: modelInfo.contextWindow, + maxTokens: modelInfo.maxTokens, + }) expect(result).toEqual(expectedResult) }) @@ -218,14 +268,38 @@ describe("truncateConversationIfNeeded", () => { // Test below threshold const belowThreshold = 69999 - expect(truncateConversationIfNeeded(messages, belowThreshold, modelInfo1)).toEqual( - truncateConversationIfNeeded(messages, belowThreshold, modelInfo2), + expect( + truncateConversationIfNeeded({ + messages, + totalTokens: belowThreshold, + contextWindow: modelInfo1.contextWindow, + maxTokens: modelInfo1.maxTokens, + }), + ).toEqual( + truncateConversationIfNeeded({ + messages, + totalTokens: belowThreshold, + contextWindow: modelInfo2.contextWindow, + maxTokens: modelInfo2.maxTokens, + }), ) // Test above threshold const aboveThreshold = 70001 - expect(truncateConversationIfNeeded(messages, aboveThreshold, modelInfo1)).toEqual( - truncateConversationIfNeeded(messages, aboveThreshold, modelInfo2), + expect( + truncateConversationIfNeeded({ + messages, + totalTokens: aboveThreshold, + contextWindow: modelInfo1.contextWindow, + maxTokens: modelInfo1.maxTokens, + }), + ).toEqual( + truncateConversationIfNeeded({ + messages, + totalTokens: aboveThreshold, + contextWindow: modelInfo2.contextWindow, + maxTokens: modelInfo2.maxTokens, + }), ) }) }) diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index a0fff05ea5..8b646f933b 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -1,7 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { ModelInfo } from "../../shared/api" - /** * Truncates a conversation by removing a fraction of the messages. * @@ -26,28 +24,29 @@ export function truncateConversation( } /** - * Conditionally truncates the conversation messages if the total token count exceeds the model's limit. + * Conditionally truncates the conversation messages if the total token count + * exceeds the model's limit. * * @param {Anthropic.Messages.MessageParam[]} messages - The conversation messages. * @param {number} totalTokens - The total number of tokens in the conversation. - * @param {ModelInfo} modelInfo - Model metadata including context window size. + * @param {number} contextWindow - The context window size. + * @param {number} maxTokens - The maximum number of tokens allowed. * @returns {Anthropic.Messages.MessageParam[]} The original or truncated conversation messages. */ -export function truncateConversationIfNeeded( - messages: Anthropic.Messages.MessageParam[], - totalTokens: number, - modelInfo: ModelInfo, -): Anthropic.Messages.MessageParam[] { - return totalTokens < getMaxTokens(modelInfo) ? messages : truncateConversation(messages, 0.5) + +type TruncateOptions = { + messages: Anthropic.Messages.MessageParam[] + totalTokens: number + contextWindow: number + maxTokens?: number } -/** - * Calculates the maximum allowed tokens - * - * @param {ModelInfo} modelInfo - The model information containing the context window size. - * @returns {number} The maximum number of tokens allowed - */ -function getMaxTokens(modelInfo: ModelInfo): number { - // The buffer needs to be at least as large as `modelInfo.maxTokens`, or 20% of the context window if for some reason it's not set. - return modelInfo.contextWindow - (modelInfo.maxTokens || modelInfo.contextWindow * 0.2) +export function truncateConversationIfNeeded({ + messages, + totalTokens, + contextWindow, + maxTokens, +}: TruncateOptions): Anthropic.Messages.MessageParam[] { + const allowedTokens = contextWindow - (maxTokens || contextWindow * 0.2) + return totalTokens < allowedTokens ? messages : truncateConversation(messages, 0.5) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7f412eefde..19e385d246 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -64,7 +64,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private clineStack: Cline[] = [] private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected - private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement + private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement configManager: ConfigManager customModesManager: CustomModesManager private lastTaskNumber = -1 @@ -402,7 +402,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customModePrompts, diffEnabled, - checkpointsEnabled, + enableCheckpoints, fuzzyMatchThreshold, mode, customInstructions: globalInstructions, @@ -417,7 +417,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customInstructions: effectiveInstructions, enableDiff: diffEnabled, - enableCheckpoints: checkpointsEnabled, + enableCheckpoints, fuzzyMatchThreshold, task, images, @@ -433,7 +433,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customModePrompts, diffEnabled, - checkpointsEnabled, + enableCheckpoints, fuzzyMatchThreshold, mode, customInstructions: globalInstructions, @@ -448,7 +448,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customInstructions: effectiveInstructions, enableDiff: diffEnabled, - enableCheckpoints: checkpointsEnabled, + enableCheckpoints, fuzzyMatchThreshold, historyItem, experiments, @@ -1110,9 +1110,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("diffEnabled", diffEnabled) await this.postStateToWebview() break - case "checkpointsEnabled": - const checkpointsEnabled = message.bool ?? false - await this.updateGlobalState("checkpointsEnabled", checkpointsEnabled) + case "enableCheckpoints": + const enableCheckpoints = message.bool ?? true + await this.updateGlobalState("enableCheckpoints", enableCheckpoints) await this.postStateToWebview() break case "browserViewportSize": @@ -1751,7 +1751,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, - anthropicThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -1771,6 +1770,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + modelMaxTokens, + modelMaxThinkingTokens, } = apiConfiguration await Promise.all([ this.updateGlobalState("apiProvider", apiProvider), @@ -1799,7 +1800,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("lmStudioModelId", lmStudioModelId), this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl), this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl), - this.updateGlobalState("anthropicThinking", anthropicThinking), this.storeSecret("geminiApiKey", geminiApiKey), this.storeSecret("openAiNativeApiKey", openAiNativeApiKey), this.storeSecret("deepSeekApiKey", deepSeekApiKey), @@ -1819,6 +1819,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("requestyModelId", requestyModelId), this.updateGlobalState("requestyModelInfo", requestyModelInfo), this.updateGlobalState("modelTemperature", modelTemperature), + this.updateGlobalState("modelMaxTokens", modelMaxTokens), + this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens), ]) if (this.getCurrentCline()) { this.getCurrentCline()!.api = buildApiHandler(apiConfiguration) @@ -2023,12 +2025,13 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.deleteTaskFromState(id) // check if checkpoints are enabled - const { checkpointsEnabled } = await this.getState() + const { enableCheckpoints } = await this.getState() // get the base directory of the project const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) // delete checkpoints branch from project git repo - if (checkpointsEnabled && baseDir) { + if (enableCheckpoints && baseDir) { + const branchSummary = await simpleGit(baseDir) .branch(["-D", `roo-code-checkpoints-${id}`]) .catch(() => undefined) @@ -2077,7 +2080,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowModeSwitch, soundEnabled, diffEnabled, - checkpointsEnabled, + enableCheckpoints, taskHistory, soundVolume, browserViewportSize, @@ -2126,7 +2129,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), soundEnabled: soundEnabled ?? false, diffEnabled: diffEnabled ?? true, - checkpointsEnabled: checkpointsEnabled ?? false, + enableCheckpoints: enableCheckpoints ?? true, shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands, soundVolume: soundVolume ?? 0.5, @@ -2230,7 +2233,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, - anthropicThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -2254,7 +2256,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { allowedCommands, soundEnabled, diffEnabled, - checkpointsEnabled, + enableCheckpoints, soundVolume, browserViewportSize, fuzzyMatchThreshold, @@ -2285,6 +2287,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + modelMaxTokens, + modelMaxThinkingTokens, maxOpenTabsContext, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, @@ -2313,7 +2317,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("lmStudioModelId") as Promise, this.getGlobalState("lmStudioBaseUrl") as Promise, this.getGlobalState("anthropicBaseUrl") as Promise, - this.getGlobalState("anthropicThinking") as Promise, this.getSecret("geminiApiKey") as Promise, this.getSecret("openAiNativeApiKey") as Promise, this.getSecret("deepSeekApiKey") as Promise, @@ -2337,7 +2340,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("allowedCommands") as Promise, this.getGlobalState("soundEnabled") as Promise, this.getGlobalState("diffEnabled") as Promise, - this.getGlobalState("checkpointsEnabled") as Promise, + this.getGlobalState("enableCheckpoints") as Promise, this.getGlobalState("soundVolume") as Promise, this.getGlobalState("browserViewportSize") as Promise, this.getGlobalState("fuzzyMatchThreshold") as Promise, @@ -2368,6 +2371,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestyModelId") as Promise, this.getGlobalState("requestyModelInfo") as Promise, this.getGlobalState("modelTemperature") as Promise, + this.getGlobalState("modelMaxTokens") as Promise, + this.getGlobalState("anthropicThinking") as Promise, this.getGlobalState("maxOpenTabsContext") as Promise, ]) @@ -2413,7 +2418,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { lmStudioModelId, lmStudioBaseUrl, anthropicBaseUrl, - anthropicThinking, geminiApiKey, openAiNativeApiKey, deepSeekApiKey, @@ -2433,6 +2437,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + modelMaxTokens, + modelMaxThinkingTokens, }, lastShownAnnouncementId, customInstructions, @@ -2446,7 +2452,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { allowedCommands, soundEnabled: soundEnabled ?? false, diffEnabled: diffEnabled ?? true, - checkpointsEnabled: checkpointsEnabled ?? false, + enableCheckpoints: enableCheckpoints ?? true, soundVolume, browserViewportSize: browserViewportSize ?? "900x600", screenshotQuality: screenshotQuality ?? 75, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index bb646ccef8..987baeefaf 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -373,7 +373,7 @@ describe("ClineProvider", () => { uriScheme: "vscode", soundEnabled: false, diffEnabled: false, - checkpointsEnabled: false, + enableCheckpoints: false, writeDelayMs: 1000, browserViewportSize: "900x600", fuzzyMatchThreshold: 1.0, @@ -712,7 +712,7 @@ describe("ClineProvider", () => { }, mode: "code", diffEnabled: true, - checkpointsEnabled: false, + enableCheckpoints: false, fuzzyMatchThreshold: 1.0, experiments: experimentDefault, } as any) diff --git a/src/services/ripgrep/__tests__/index.test.ts b/src/services/ripgrep/__tests__/index.test.ts new file mode 100644 index 0000000000..7c3549a827 --- /dev/null +++ b/src/services/ripgrep/__tests__/index.test.ts @@ -0,0 +1,51 @@ +// npx jest src/services/ripgrep/__tests__/index.test.ts + +import { describe, expect, it } from "@jest/globals" +import { truncateLine } from "../index" + +describe("Ripgrep line truncation", () => { + // The default MAX_LINE_LENGTH is 500 in the implementation + const MAX_LINE_LENGTH = 500 + + it("should truncate lines longer than MAX_LINE_LENGTH", () => { + const longLine = "a".repeat(600) // Line longer than MAX_LINE_LENGTH + const truncated = truncateLine(longLine) + + expect(truncated).toContain("[truncated...]") + expect(truncated.length).toBeLessThan(longLine.length) + expect(truncated.length).toEqual(MAX_LINE_LENGTH + " [truncated...]".length) + }) + + it("should not truncate lines shorter than MAX_LINE_LENGTH", () => { + const shortLine = "Short line of text" + const truncated = truncateLine(shortLine) + + expect(truncated).toEqual(shortLine) + expect(truncated).not.toContain("[truncated...]") + }) + + it("should correctly truncate a line at exactly MAX_LINE_LENGTH characters", () => { + const exactLine = "a".repeat(MAX_LINE_LENGTH) + const exactPlusOne = exactLine + "x" + + // Should not truncate when exactly MAX_LINE_LENGTH + expect(truncateLine(exactLine)).toEqual(exactLine) + + // Should truncate when exceeding MAX_LINE_LENGTH by even 1 character + expect(truncateLine(exactPlusOne)).toContain("[truncated...]") + }) + + it("should handle empty lines without errors", () => { + expect(truncateLine("")).toEqual("") + }) + + it("should allow custom maximum length", () => { + const customLength = 100 + const line = "a".repeat(customLength + 50) + + const truncated = truncateLine(line, customLength) + + expect(truncated.length).toEqual(customLength + " [truncated...]".length) + expect(truncated).toContain("[truncated...]") + }) +}) diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index b48c60b5b2..770c897e52 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -58,7 +58,19 @@ interface SearchResult { afterContext: string[] } +// Constants const MAX_RESULTS = 300 +const MAX_LINE_LENGTH = 500 + +/** + * Truncates a line if it exceeds the maximum length + * @param line The line to truncate + * @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH) + * @returns The truncated line, or the original line if it's shorter than maxLength + */ +export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string { + return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line +} async function getBinPath(vscodeAppRoot: string): Promise { const checkPath = async (pkgFolder: string) => { @@ -140,7 +152,8 @@ export async function regexSearchFiles( let output: string try { output = await execRipgrep(rgPath, args) - } catch { + } catch (error) { + console.error("Error executing ripgrep:", error) return "No results found" } const results: SearchResult[] = [] @@ -154,19 +167,28 @@ export async function regexSearchFiles( if (currentResult) { results.push(currentResult as SearchResult) } + + // Safety check: truncate extremely long lines to prevent excessive output + const matchText = parsed.data.lines.text + const truncatedMatch = truncateLine(matchText) + currentResult = { file: parsed.data.path.text, line: parsed.data.line_number, column: parsed.data.submatches[0].start, - match: parsed.data.lines.text, + match: truncatedMatch, beforeContext: [], afterContext: [], } } else if (parsed.type === "context" && currentResult) { + // Apply the same truncation logic to context lines + const contextText = parsed.data.lines.text + const truncatedContext = truncateLine(contextText) + if (parsed.data.line_number < currentResult.line!) { - currentResult.beforeContext!.push(parsed.data.lines.text) + currentResult.beforeContext!.push(truncatedContext) } else { - currentResult.afterContext!.push(parsed.data.lines.text) + currentResult.afterContext!.push(truncatedContext) } } } catch (error) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index e87edffed1..34abd38dbf 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -111,7 +111,7 @@ export interface ExtensionState { soundEnabled?: boolean soundVolume?: number diffEnabled?: boolean - checkpointsEnabled: boolean + enableCheckpoints: boolean browserViewportSize?: string screenshotQuality?: number fuzzyMatchThreshold?: number diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index fde7442cc1..8d3a114e65 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -52,7 +52,7 @@ export interface WebviewMessage { | "soundEnabled" | "soundVolume" | "diffEnabled" - | "checkpointsEnabled" + | "enableCheckpoints" | "browserViewportSize" | "screenshotQuality" | "openMcpSettings" diff --git a/src/shared/__tests__/checkExistApiConfig.test.ts b/src/shared/__tests__/checkExistApiConfig.test.ts index 62517d6958..c99ddddbc4 100644 --- a/src/shared/__tests__/checkExistApiConfig.test.ts +++ b/src/shared/__tests__/checkExistApiConfig.test.ts @@ -32,7 +32,7 @@ describe("checkExistKey", () => { apiKey: "test-key", apiProvider: undefined, anthropicBaseUrl: undefined, - anthropicThinking: undefined, + modelMaxThinkingTokens: undefined, } expect(checkExistKey(config)).toBe(true) }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 5d4b8b120d..f88bb5e8b5 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -21,7 +21,6 @@ export interface ApiHandlerOptions { apiModelId?: string apiKey?: string // anthropic anthropicBaseUrl?: string - anthropicThinking?: number vsCodeLmModelSelector?: vscode.LanguageModelChatSelector glamaModelId?: string glamaModelInfo?: ModelInfo @@ -68,6 +67,8 @@ export interface ApiHandlerOptions { requestyModelId?: string requestyModelInfo?: ModelInfo modelTemperature?: number + modelMaxTokens?: number + modelMaxThinkingTokens?: number } export type ApiConfiguration = ApiHandlerOptions & { @@ -92,19 +93,13 @@ export interface ModelInfo { thinking?: boolean } -export const THINKING_BUDGET = { - step: 1024, - min: 1024, - default: 8 * 1024, -} - // Anthropic // https://docs.anthropic.com/en/docs/about-claude/models export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219" export const anthropicModels = { "claude-3-7-sonnet-20250219:thinking": { - maxTokens: 16384, + maxTokens: 64_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, @@ -116,7 +111,7 @@ export const anthropicModels = { thinking: true, }, "claude-3-7-sonnet-20250219": { - maxTokens: 16384, + maxTokens: 64_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, @@ -441,55 +436,80 @@ export const openRouterDefaultModelInfo: ModelInfo = { export type VertexModelId = keyof typeof vertexModels export const vertexDefaultModelId: VertexModelId = "claude-3-7-sonnet@20250219" export const vertexModels = { + "claude-3-7-sonnet@20250219:thinking": { + maxTokens: 64000, + contextWindow: 200_000, + supportsImages: true, + supportsComputerUse: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + thinking: true, + }, "claude-3-7-sonnet@20250219": { maxTokens: 8192, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + thinking: false, }, "claude-3-5-sonnet-v2@20241022": { maxTokens: 8192, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, }, "claude-3-5-sonnet@20240620": { maxTokens: 8192, contextWindow: 200_000, supportsImages: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 3.0, outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, }, "claude-3-5-haiku@20241022": { maxTokens: 8192, contextWindow: 200_000, supportsImages: false, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 1.0, outputPrice: 5.0, + cacheWritesPrice: 1.25, + cacheReadsPrice: 0.1, }, "claude-3-opus@20240229": { maxTokens: 4096, contextWindow: 200_000, supportsImages: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 15.0, outputPrice: 75.0, + cacheWritesPrice: 18.75, + cacheReadsPrice: 1.5, }, "claude-3-haiku@20240307": { maxTokens: 4096, contextWindow: 200_000, supportsImages: true, - supportsPromptCache: false, + supportsPromptCache: true, inputPrice: 0.25, outputPrice: 1.25, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.03, }, } as const satisfies Record @@ -671,8 +691,16 @@ export const openAiNativeModels = { inputPrice: 1.1, outputPrice: 4.4, }, + "gpt-4.5-preview": { + maxTokens: 16_384, + contextWindow: 128_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 75, + outputPrice: 150, + }, "gpt-4o": { - maxTokens: 4_096, + maxTokens: 16_384, contextWindow: 128_000, supportsImages: true, supportsPromptCache: false, diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 7b6b4f8274..aabc77cc01 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -42,7 +42,6 @@ export type GlobalStateKey = | "lmStudioModelId" | "lmStudioBaseUrl" | "anthropicBaseUrl" - | "anthropicThinking" | "azureApiVersion" | "openAiStreamingEnabled" | "openRouterModelId" @@ -53,7 +52,7 @@ export type GlobalStateKey = | "soundEnabled" | "soundVolume" | "diffEnabled" - | "checkpointsEnabled" + | "enableCheckpoints" | "browserViewportSize" | "screenshotQuality" | "fuzzyMatchThreshold" @@ -81,5 +80,7 @@ export type GlobalStateKey = | "requestyModelInfo" | "unboundModelInfo" | "modelTemperature" + | "modelMaxTokens" + | "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`. | "mistralCodestralUrl" | "maxOpenTabsContext" diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index a2e96606ef..031e801628 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -1,8 +1,5 @@ import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { memo } from "react" -// import VSCodeButtonLink from "./VSCodeButtonLink" -// import { getOpenRouterAuthUrl } from "./ApiOptions" -// import { vscode } from "../utils/vscode" interface AnnouncementProps { version: string @@ -25,39 +22,42 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => { -

🎉{" "}Introducing Roo Code 3.2

+

🎉{" "}Automatic Checkpoints Now Enabled

- Our biggest update yet is here - we're officially changing our name from Roo Cline to Roo Code! After - growing beyond 50,000 installations, we're ready to chart our own course. Our heartfelt thanks to - everyone in the Cline community who helped us reach this milestone. + We're thrilled to announce that our experimental Checkpoints feature is now enabled by default for all + users. This powerful feature automatically tracks your project changes during a task, allowing you to + quickly review or revert to earlier states if needed.

-

Custom Modes: Celebrating Our New Identity

+

What's New

- To mark this new chapter, we're introducing the power to shape Roo Code into any role you need! Create - specialized personas and create an entire team of agents with deeply customized prompts: + Automatic Checkpoints provide you with:

    -
  • QA Engineers who write thorough test cases and catch edge cases
  • -
  • Product Managers who excel at user stories and feature prioritization
  • -
  • UI/UX Designers who craft beautiful, accessible interfaces
  • -
  • Code Reviewers who ensure quality and maintainability
  • +
  • Peace of mind when making significant changes
  • +
  • Ability to visually inspect changes between steps
  • +
  • Easy rollback if you're not satisfied with certain code modifications
  • +
  • Improved navigation through complex task execution
- Just click the icon to - get started with Custom Modes!

-

Join Us for the Next Chapter

+

Customize Your Experience

- We can't wait to see how you'll push Roo Code's potential even further! Share your custom modes and join - the discussion at{" "} - - reddit.com/r/RooCode - - . + While we recommend keeping this feature enabled, you can disable it if needed.{" "} + { + e.preventDefault() + window.postMessage({ type: "action", action: "settingsButtonClicked" }, "*") + }} + style={{ display: "inline", padding: "0 2px" }}> + Open Settings + {" "} + and look for the "Enable automatic checkpoints" option in the Advanced Settings section.

) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index be2b2a9798..dcbe085147 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -798,6 +798,7 @@ const ChatTextArea = forwardRef( { const value = e.target.value if (value === "settings-action") { @@ -915,6 +917,7 @@ const ChatTextArea = forwardRef( role="button" aria-label="enhance prompt" data-testid="enhance-prompt-button" + title="Enhance prompt with additional context" className={`input-icon-button ${ textAreaDisabled ? "disabled" : "" } codicon codicon-sparkle`} @@ -927,11 +930,13 @@ const ChatTextArea = forwardRef( className={`input-icon-button ${ shouldDisableImages ? "disabled" : "" } codicon codicon-device-camera`} + title="Add images to message" onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 98369cf095..fcd1ba9a3b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1077,7 +1077,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie onClick={() => { scrollToBottomSmooth() disableAutoScrollRef.current = false - }}> + }} + title="Scroll to bottom of chat"> @@ -1101,6 +1102,25 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flex: secondaryButtonText ? 1 : 2, marginRight: secondaryButtonText ? "6px" : "0", }} + title={ + primaryButtonText === "Retry" + ? "Try the operation again" + : primaryButtonText === "Save" + ? "Save the file changes" + : primaryButtonText === "Approve" + ? "Approve this action" + : primaryButtonText === "Run Command" + ? "Execute this command" + : primaryButtonText === "Start New Task" + ? "Begin a new task" + : primaryButtonText === "Resume Task" + ? "Continue the current task" + : primaryButtonText === "Proceed Anyways" + ? "Continue despite warnings" + : primaryButtonText === "Proceed While Running" + ? "Continue while command executes" + : undefined + } onClick={(e) => handlePrimaryButtonClick(inputValue, selectedImages)}> {primaryButtonText} @@ -1113,6 +1133,17 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flex: isStreaming ? 2 : 1, marginLeft: isStreaming ? 0 : "6px", }} + title={ + isStreaming + ? "Cancel the current operation" + : secondaryButtonText === "Start New Task" + ? "Begin a new task" + : secondaryButtonText === "Reject" + ? "Reject this action" + : secondaryButtonText === "Terminate" + ? "End the current task" + : undefined + } onClick={(e) => handleSecondaryButtonClick(inputValue, selectedImages)}> {isStreaming ? "Cancel" : secondaryButtonText} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index ad4ef4a8ee..0008e34298 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -183,7 +183,11 @@ const TaskHeader: React.FC = ({ ${totalCost?.toFixed(4)} )} - + @@ -351,13 +355,18 @@ export const highlightMentions = (text?: string, withShadow = true) => { const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
- {!!item?.size && item.size > 0 && (
+ + {/* Custom System Prompt Disclosure */} +
+ + + {isSystemPromptDisclosureOpen && ( +
+ You can completely replace the system prompt for this mode (aside from the role + definition and custom instructions) by creating a file at{" "} + { + const currentMode = getCurrentMode() + if (!currentMode) return + + // Open or create an empty file + vscode.postMessage({ + type: "openFile", + text: `./.roo/system-prompt-${currentMode.slug}`, + values: { + create: true, + content: "", + }, + }) + }}> + .roo/system-prompt-{getCurrentMode()?.slug || "code"} + {" "} + in your workspace. This is a very advanced feature that bypasses built-in safeguards and + consistency checks (especially around tool usage), so be careful! +
+ )} +
(({ onDone }, alwaysAllowWrite, alwaysApproveResubmit, browserViewportSize, - checkpointsEnabled, + enableCheckpoints, diffEnabled, experiments, fuzzyMatchThreshold, @@ -143,7 +143,7 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }) vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) - vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled }) + vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) @@ -706,6 +706,25 @@ const SettingsView = forwardRef(({ onDone },

+
+ { + setCachedStateField("enableCheckpoints", e.target.checked) + }}> + Enable automatic checkpoints + +

+ When enabled, Roo will automatically create checkpoints during task execution, making it + easy to review changes or revert to earlier states. +

+
+
(({ onDone },
)} -
-
- ⚠️ - { - setCachedStateField("checkpointsEnabled", e.target.checked) - }}> - Enable experimental checkpoints - -
-

- When enabled, Roo will save a checkpoint whenever a file in the workspace is modified, - added or deleted, letting you easily revert to a previous state. -

-
- {Object.entries(experimentConfigsMap) .filter((config) => config[0] !== "DIFF_STRATEGY") .map((config) => ( diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index efaa90dc39..557a69538d 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -1,29 +1,73 @@ +import { useEffect, useMemo } from "react" +import { ApiProvider } from "../../../../src/shared/api" import { Slider } from "@/components/ui" -import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api" +import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api" interface ThinkingBudgetProps { apiConfiguration: ApiConfiguration setApiConfigurationField: (field: K, value: ApiConfiguration[K]) => void modelInfo?: ModelInfo + provider?: ApiProvider } -export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => { - const budget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default +export const ThinkingBudget = ({ + apiConfiguration, + setApiConfigurationField, + modelInfo, + provider, +}: ThinkingBudgetProps) => { + const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000 + const tokensMin = 8192 + const tokensMax = modelInfo?.maxTokens || 64_000 - return modelInfo && modelInfo.thinking ? ( -
-
Thinking Budget
-
- setApiConfigurationField("anthropicThinking", value[0])} - /> -
{budget}
+ // Get the appropriate thinking tokens based on provider + const thinkingTokens = useMemo(() => { + const value = apiConfiguration?.modelMaxThinkingTokens + return value || Math.min(Math.floor(0.8 * tokens), 8192) + }, [apiConfiguration, tokens]) + + const thinkingTokensMin = 1024 + const thinkingTokensMax = Math.floor(0.8 * tokens) + + useEffect(() => { + if (thinkingTokens > thinkingTokensMax) { + setApiConfigurationField("modelMaxThinkingTokens", thinkingTokensMax) + } + }, [thinkingTokens, thinkingTokensMax, setApiConfigurationField]) + + if (!modelInfo?.thinking) { + return null + } + + return ( +
+
+
Max Tokens
+
+ setApiConfigurationField("modelMaxTokens", value)} + /> +
{tokens}
+
+
+
+
Max Thinking Tokens
+
+ setApiConfigurationField("modelMaxThinkingTokens", value)} + /> +
{thinkingTokens}
+
- ) : null + ) } diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx index 73394bae10..06ed95585a 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx @@ -46,6 +46,16 @@ jest.mock("../TemperatureControl", () => ({ ), })) +// Mock ThinkingBudget component +jest.mock("../ThinkingBudget", () => ({ + ThinkingBudget: ({ apiConfiguration, setApiConfigurationField, modelInfo, provider }: any) => + modelInfo?.thinking ? ( +
+ +
+ ) : null, +})) + describe("ApiOptions", () => { const renderApiOptions = (props = {}) => { render( @@ -72,5 +82,45 @@ describe("ApiOptions", () => { expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument() }) - //TODO: More test cases needed + describe("thinking functionality", () => { + it("should show ThinkingBudget for Anthropic models that support thinking", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + apiModelId: "claude-3-7-sonnet-20250219:thinking", + }, + }) + + expect(screen.getByTestId("thinking-budget")).toBeInTheDocument() + expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "anthropic") + }) + + it("should show ThinkingBudget for Vertex models that support thinking", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "vertex", + apiModelId: "claude-3-7-sonnet@20250219:thinking", + }, + }) + + expect(screen.getByTestId("thinking-budget")).toBeInTheDocument() + expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "vertex") + }) + + it("should not show ThinkingBudget for models that don't support thinking", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + apiModelId: "claude-3-opus-20240229", + modelInfo: { thinking: false }, // Non-thinking model + }, + }) + + expect(screen.queryByTestId("thinking-budget")).not.toBeInTheDocument() + }) + + // Note: We don't need to test the actual ThinkingBudget component functionality here + // since we have separate tests for that component. We just need to verify that + // it's included in the ApiOptions component when appropriate. + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx new file mode 100644 index 0000000000..212316ea9a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.test.tsx @@ -0,0 +1,145 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { ThinkingBudget } from "../ThinkingBudget" +import { ApiProvider, ModelInfo } from "../../../../../src/shared/api" + +// Mock Slider component +jest.mock("@/components/ui", () => ({ + Slider: ({ value, onValueChange, min, max }: any) => ( + onValueChange([parseInt(e.target.value)])} + /> + ), +})) + +describe("ThinkingBudget", () => { + const mockModelInfo: ModelInfo = { + thinking: true, + maxTokens: 16384, + contextWindow: 200000, + supportsPromptCache: true, + supportsImages: true, + } + const defaultProps = { + apiConfiguration: {}, + setApiConfigurationField: jest.fn(), + modelInfo: mockModelInfo, + provider: "anthropic" as ApiProvider, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should render nothing when model doesn't support thinking", () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("should render sliders when model supports thinking", () => { + render() + + expect(screen.getAllByTestId("slider")).toHaveLength(2) + }) + + it("should use modelMaxThinkingTokens field for Anthropic provider", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + const sliders = screen.getAllByTestId("slider") + fireEvent.change(sliders[1], { target: { value: "5000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000) + }) + + it("should use modelMaxThinkingTokens field for Vertex provider", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + const sliders = screen.getAllByTestId("slider") + fireEvent.change(sliders[1], { target: { value: "5000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000) + }) + + it("should cap thinking tokens at 80% of max tokens", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + // Effect should trigger and cap the value + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 8000) // 80% of 10000 + }) + + it("should use default thinking tokens if not provided", () => { + render() + + // Default is 80% of max tokens, capped at 8192 + const sliders = screen.getAllByTestId("slider") + expect(sliders[1]).toHaveValue("8000") // 80% of 10000 + }) + + it("should use min thinking tokens of 1024", () => { + render() + + const sliders = screen.getAllByTestId("slider") + expect(sliders[1].getAttribute("min")).toBe("1024") + }) + + it("should update max tokens when slider changes", () => { + const setApiConfigurationField = jest.fn() + + render( + , + ) + + const sliders = screen.getAllByTestId("slider") + fireEvent.change(sliders[0], { target: { value: "12000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 12000) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ae5c5b9539..3dfc87de75 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -32,7 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState { setSoundEnabled: (value: boolean) => void setSoundVolume: (value: number) => void setDiffEnabled: (value: boolean) => void - setCheckpointsEnabled: (value: boolean) => void + setEnableCheckpoints: (value: boolean) => void setBrowserViewportSize: (value: string) => void setFuzzyMatchThreshold: (value: number) => void preferredLanguage: string @@ -79,7 +79,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode soundEnabled: false, soundVolume: 0.5, diffEnabled: false, - checkpointsEnabled: false, + enableCheckpoints: true, fuzzyMatchThreshold: 1.0, preferredLanguage: "English", writeDelayMs: 1000, @@ -219,7 +219,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })), setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })), setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), - setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })), + setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })), setBrowserViewportSize: (value: string) => setState((prevState) => ({ ...prevState, browserViewportSize: value })), setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),