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.