From 2644752be81060855d63b0617258242fdd0852b1 Mon Sep 17 00:00:00 2001 From: James Mtendamema Date: Sat, 2 May 2026 21:00:15 -0600 Subject: [PATCH 1/3] feat: subscription-gated LLM tool result compression --- packages/types/src/global-settings.ts | 36 ++ src/api/index.ts | 9 +- .../__tests__/prompt-caching.spec.ts | 320 +++++++++++++++++ src/api/providers/zoo-gateway.ts | 96 +++++ .../presentAssistantMessage.ts | 203 +++++++++++ .../__tests__/compressToolResults.spec.ts | 327 ++++++++++++++++++ .../context-management/compressToolResults.ts | 204 +++++++++++ .../__tests__/environmentDiff.spec.ts | 222 ++++++++++++ src/core/environment/environmentDiff.ts | 111 ++++++ src/core/task/Task.ts | 84 ++++- src/core/tools/AttemptCompletionTool.ts | 8 +- src/core/tools/CodebaseSearchTool.ts | 3 +- src/core/tools/CompletionPostProcessor.ts | 61 ++++ src/core/tools/ExecuteCommandTool.ts | 25 +- src/core/tools/ListFilesTool.ts | 3 +- src/core/tools/ReadFileTool.ts | 16 +- src/core/tools/SearchFilesTool.ts | 3 +- src/core/tools/ToolResultProcessor.ts | 183 ++++++++++ src/core/tools/ToolResultProcessorConfig.ts | 31 ++ .../__tests__/CompletionPostProcessor.spec.ts | 77 +++++ .../__tests__/ToolResultProcessor.spec.ts | 312 +++++++++++++++++ .../tools/__tests__/compressAndPush.spec.ts | 126 +++++++ .../resolveCompressionHandler.spec.ts | 141 ++++++++ .../tools/__tests__/toolCategories.spec.ts | 176 ++++++++++ src/core/tools/compressAndPush.ts | 34 ++ src/core/tools/resolveCompressionHandler.ts | 65 ++++ src/core/tools/toolCategories.ts | 61 ++++ src/core/webview/webviewMessageHandler.ts | 5 + src/package.json | 10 + .../src/components/settings/SettingsView.tsx | 55 ++- 30 files changed, 2983 insertions(+), 24 deletions(-) create mode 100644 src/api/providers/__tests__/prompt-caching.spec.ts create mode 100644 src/api/providers/zoo-gateway.ts create mode 100644 src/core/context-management/__tests__/compressToolResults.spec.ts create mode 100644 src/core/context-management/compressToolResults.ts create mode 100644 src/core/environment/__tests__/environmentDiff.spec.ts create mode 100644 src/core/environment/environmentDiff.ts create mode 100644 src/core/tools/CompletionPostProcessor.ts create mode 100644 src/core/tools/ToolResultProcessor.ts create mode 100644 src/core/tools/ToolResultProcessorConfig.ts create mode 100644 src/core/tools/__tests__/CompletionPostProcessor.spec.ts create mode 100644 src/core/tools/__tests__/ToolResultProcessor.spec.ts create mode 100644 src/core/tools/__tests__/compressAndPush.spec.ts create mode 100644 src/core/tools/__tests__/resolveCompressionHandler.spec.ts create mode 100644 src/core/tools/__tests__/toolCategories.spec.ts create mode 100644 src/core/tools/compressAndPush.ts create mode 100644 src/core/tools/resolveCompressionHandler.ts create mode 100644 src/core/tools/toolCategories.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 288f6c2118..ccaface7a5 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -232,10 +232,45 @@ export const globalSettingsSchema = z.object({ * Tools in this list will be excluded from prompt generation and rejected at execution time. */ disabledTools: z.array(toolNamesSchema).optional(), + + /** + * Settings for the ToolResultProcessor (tool output compression). + * Controls thresholds for when compression kicks in. + */ + toolResultProcessorSettings: z + .object({ + /** Master switch — false disables all compression */ + enabled: z.boolean(), + /** Compress read_file results above this many characters (default: 1500) */ + readFileCharsAbove: z.number(), + /** Compress search_files results above this many matches (default: 20) */ + searchMatchesAbove: z.number(), + /** Compress list_files results above this many paths (default: 100) */ + listFilesCountAbove: z.number(), + }) + .optional(), + + /** + * Zoo Code API key for subscription features (smart compression). + * Stored in VS Code SecretStorage — never in global state. + * Generate at https://zoocode.dev/dashboard/api-tokens + */ + zooCodeApiKey: z.string().optional(), + + /** + * Zoo Code API base URL for subscription features (smart compression). + * Defaults to "https://zoocode.dev". Override for dev/staging. + */ + zooCodeBaseUrl: z.string().optional(), }) export type GlobalSettings = z.infer +/** + * Settings for the ToolResultProcessor, extracted from GlobalSettings for convenience. + */ +export type ToolResultProcessorSettings = NonNullable + export const GLOBAL_SETTINGS_KEYS = globalSettingsSchema.keyof().options /** @@ -285,6 +320,7 @@ export const SECRET_STATE_KEYS = [ // Global secrets that are part of GlobalSettings (not ProviderSettings) export const GLOBAL_SECRET_KEYS = [ "openRouterImageApiKey", // For image generation + "zooCodeApiKey", // Zoo Code subscription API key ] as const // Type for the actual secret storage keys diff --git a/src/api/index.ts b/src/api/index.ts index 9e0c407822..2dd880feb3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -46,8 +46,9 @@ export interface ApiHandlerCreateMessageMetadata { * Task ID used for tracking and provider-specific features: * - Roo: Sent as X-Roo-Task-ID header * - Requesty: Sent as trace_id + * Optional — not required for internal compression calls (ZooGatewayApiHandler). */ - taskId: string + taskId?: string /** * Current mode slug for provider-specific tracking: * - Requesty: Sent in extra metadata @@ -87,6 +88,12 @@ export interface ApiHandlerCreateMessageMetadata { * Only applies to providers that support function calling restrictions (e.g., Gemini). */ allowedFunctionNames?: string[] + /** + * Name of the tool that produced the result being compressed. + * Used by ZooGatewayApiHandler to pass the originating tool name + * to the compression endpoint for logging and prompt tuning. + */ + toolName?: string } export interface ApiHandler { diff --git a/src/api/providers/__tests__/prompt-caching.spec.ts b/src/api/providers/__tests__/prompt-caching.spec.ts new file mode 100644 index 0000000000..371b975c7b --- /dev/null +++ b/src/api/providers/__tests__/prompt-caching.spec.ts @@ -0,0 +1,320 @@ +// npx vitest run api/providers/__tests__/prompt-caching.spec.ts + +/** + * Regression tests for prompt caching across providers. + * + * Findings summary (audited 2026-05-02): + * + * ANTHROPIC: + * - `cache_control: { type: 'ephemeral' }` is set on the system block for all + * cache-capable models (line 123 of anthropic.ts). + * - The `prompt-caching-2024-07-31` beta header is added via the inner IIFE. + * - `cache_creation_input_tokens` and `cache_read_input_tokens` are extracted + * from the `message_start` event and yielded as `cacheWriteTokens` / + * `cacheReadTokens` in the `ApiStreamUsageChunk`. + * + * BEDROCK: + * - Uses AWS-native cachePoint blocks (not `cache_control`) via MultiPointStrategy. + * - `supportsAwsPromptCache()` gates caching on `supportsPromptCache` AND + * non-empty `cachableFields` in model info. + * - Cache token fields (`cacheReadInputTokens`, `cacheWriteInputTokens`, and + * their `*TokenCount` aliases) are captured from the `metadata.usage` stream + * event and yielded as `cacheReadTokens` / `cacheWriteTokens`. + * + * ROO (OpenAI-compatible proxy): + * - No `cache_control` headers — caching is handled server-side by the proxy. + * - Cache metrics surface as `prompt_tokens_details.cached_tokens` (read) and + * `cache_creation_input_tokens` (write) in the final usage chunk. + * - Both are correctly mapped to `cacheReadTokens` / `cacheWriteTokens` in the + * yielded `ApiStreamUsageChunk`. + * + * STREAM TYPE: + * - `ApiStreamUsageChunk` declares `cacheWriteTokens?: number` and + * `cacheReadTokens?: number` — all providers use these fields consistently. + */ + +import { AnthropicHandler } from "../anthropic" +import { ApiHandlerOptions } from "../../../shared/api" + +// --------------------------------------------------------------------------- +// Shared mock infrastructure +// --------------------------------------------------------------------------- + +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), + }, + }, +})) + +// --------------------------------------------------------------------------- +// Anthropic SDK mock +// --------------------------------------------------------------------------- + +/** Capture what was passed to `messages.create` so tests can assert on it. */ +let lastCreateCall: any = undefined + +const mockCreate = vitest.fn() + +vitest.mock("@anthropic-ai/sdk", () => { + const mockAnthropicConstructor = vitest.fn().mockImplementation(() => ({ + messages: { + create: mockCreate, + }, + })) + return { Anthropic: mockAnthropicConstructor } +}) + +// --------------------------------------------------------------------------- +// Helper: build a minimal streaming response +// --------------------------------------------------------------------------- + +function makeAnthropicStream(cacheCreationTokens: number | undefined, cacheReadTokens: number | undefined) { + return { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 0, + cache_creation_input_tokens: cacheCreationTokens, + cache_read_input_tokens: cacheReadTokens, + }, + }, + } + yield { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "hi" }, + } + yield { + type: "message_delta", + usage: { output_tokens: 5 }, + } + yield { type: "message_stop" } + }, + } +} + +// --------------------------------------------------------------------------- +// Tests: Anthropic provider – system prompt cache_control +// --------------------------------------------------------------------------- + +describe("AnthropicHandler – prompt caching", () => { + const baseOptions: ApiHandlerOptions = { + apiKey: "test-key", + apiModelId: "claude-3-5-sonnet-20241022", + } + + beforeEach(() => { + vitest.clearAllMocks() + lastCreateCall = undefined + }) + + describe("system prompt cache_control", () => { + it("sets cache_control: { type: 'ephemeral' } on the system block for cache-capable models", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(500, 0)) + + const handler = new AnthropicHandler(baseOptions) + const gen = handler.createMessage("System prompt here", [{ role: "user", content: "Hello" }]) + + // Drain the generator. + for await (const _ of gen) { + // consume + } + + expect(mockCreate).toHaveBeenCalledOnce() + const callArgs = mockCreate.mock.calls[0][0] + + // System must be an array with exactly one block. + expect(Array.isArray(callArgs.system)).toBe(true) + expect(callArgs.system).toHaveLength(1) + + const systemBlock = callArgs.system[0] + expect(systemBlock.type).toBe("text") + expect(systemBlock.text).toBe("System prompt here") + // THE KEY ASSERTION: cache_control must be ephemeral. + expect(systemBlock.cache_control).toEqual({ type: "ephemeral" }) + }) + + it("includes the prompt-caching beta header for cache-capable models", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(0, 0)) + + const handler = new AnthropicHandler(baseOptions) + const gen = handler.createMessage("System prompt", [{ role: "user", content: "Hi" }]) + for await (const _ of gen) { + /* drain */ + } + + expect(mockCreate).toHaveBeenCalledOnce() + // The second argument to create() is the request options (headers). + const requestOptions = mockCreate.mock.calls[0][1] + const betaHeader: string = requestOptions?.headers?.["anthropic-beta"] ?? "" + expect(betaHeader).toContain("prompt-caching-2024-07-31") + }) + + it("falls back to the default model (which is cache-capable) when an unknown model ID is supplied", async () => { + // `getModel()` maps unknown IDs to `anthropicDefaultModelId`, which IS in the + // cache-capable switch list. Therefore cache_control is always applied even for + // unknown/legacy model strings, because the fallback model supports caching. + const unknownModelOptions: ApiHandlerOptions = { + ...baseOptions, + apiModelId: "claude-ancient-unsupported-model" as any, + } + + mockCreate.mockReturnValue(makeAnthropicStream(0, 0)) + + const handler = new AnthropicHandler(unknownModelOptions) + for await (const _ of handler.createMessage("System", [{ role: "user", content: "Hi" }])) { + /* drain */ + } + + const callArgs = mockCreate.mock.calls[0][0] + const systemBlock = callArgs.system[0] + + // The fallback model is cache-capable, so cache_control IS set. + // This is the correct / expected behaviour – the default branch is only + // reached when the model ID exactly matches a known non-cached model, + // which currently does not exist in the active anthropicModels list. + expect(systemBlock.type).toBe("text") + expect(systemBlock.text).toBe("System") + // cache_control is present because the fallback model supports caching. + expect(systemBlock.cache_control).toEqual({ type: "ephemeral" }) + }) + }) + + // --------------------------------------------------------------------------- + // Stream processing: cache metric capture + // --------------------------------------------------------------------------- + + describe("stream processing – cache metric capture", () => { + it("yields cacheWriteTokens from cache_creation_input_tokens in message_start", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(1234, 0)) + + const handler = new AnthropicHandler(baseOptions) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("Sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + + // The first usage chunk (from message_start) carries cacheWriteTokens. + const firstUsage = usageChunks[0] + expect(firstUsage.cacheWriteTokens).toBe(1234) + }) + + it("yields cacheReadTokens from cache_read_input_tokens in message_start", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(0, 567)) + + const handler = new AnthropicHandler(baseOptions) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("Sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + + const firstUsage = usageChunks[0] + expect(firstUsage.cacheReadTokens).toBe(567) + }) + + it("yields both cacheWriteTokens and cacheReadTokens when both are present", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(800, 200)) + + const handler = new AnthropicHandler(baseOptions) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("Sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + const firstUsage = chunks.find((c) => c.type === "usage" && c.cacheWriteTokens !== undefined) + expect(firstUsage).toBeDefined() + expect(firstUsage.cacheWriteTokens).toBe(800) + expect(firstUsage.cacheReadTokens).toBe(200) + }) + + it("omits cacheWriteTokens when cache_creation_input_tokens is 0 (falsy → undefined)", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(0, 0)) + + const handler = new AnthropicHandler(baseOptions) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("Sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } + + const firstUsage = chunks.find((c) => c.type === "usage") + // 0 is falsy so the handler maps it to `undefined`. + expect(firstUsage?.cacheWriteTokens).toBeUndefined() + expect(firstUsage?.cacheReadTokens).toBeUndefined() + }) + }) + + // --------------------------------------------------------------------------- + // cache_control on user messages + // --------------------------------------------------------------------------- + + describe("user message cache markers", () => { + it("attaches cache_control to the last and second-to-last user messages", async () => { + mockCreate.mockReturnValue(makeAnthropicStream(0, 0)) + + const handler = new AnthropicHandler(baseOptions) + + const messages: any[] = [ + { role: "user", content: "First user message" }, + { role: "assistant", content: "First assistant reply" }, + { role: "user", content: "Second user message" }, + ] + + for await (const _ of handler.createMessage("System", messages)) { + /* drain */ + } + + const callArgs = mockCreate.mock.calls[0][0] + const sentMessages: any[] = callArgs.messages + + // Message indices: 0 = user, 1 = assistant, 2 = user + // userMsgIndices = [0, 2] → last=2, secondLast=0 + const lastUserMsg = sentMessages[2] + const secondLastUserMsg = sentMessages[0] + + // Last user message content should be an array with cache_control on the last block. + const lastContent = Array.isArray(lastUserMsg.content) ? lastUserMsg.content : null + expect(lastContent).not.toBeNull() + const lastBlock = lastContent![lastContent!.length - 1] + expect(lastBlock.cache_control).toEqual({ type: "ephemeral" }) + + // Second-to-last user message should also carry cache_control. + const secondLastContent = Array.isArray(secondLastUserMsg.content) ? secondLastUserMsg.content : null + expect(secondLastContent).not.toBeNull() + const secondLastBlock = secondLastContent![secondLastContent!.length - 1] + expect(secondLastBlock.cache_control).toEqual({ type: "ephemeral" }) + }) + }) +}) + +// --------------------------------------------------------------------------- +// Tests: ApiStreamUsageChunk type completeness (static/compile-time guard) +// --------------------------------------------------------------------------- + +describe("ApiStreamUsageChunk – type completeness", () => { + it("declares cacheWriteTokens and cacheReadTokens fields", () => { + // This is a compile-time / shape test. If the fields are removed from the + // type definition the TypeScript compilation will fail here. + const chunk = { + type: "usage" as const, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 20, + cacheReadTokens: 10, + } + + // Runtime assertion as a belt-and-suspenders guard. + expect(chunk).toHaveProperty("cacheWriteTokens", 20) + expect(chunk).toHaveProperty("cacheReadTokens", 10) + }) +}) diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts new file mode 100644 index 0000000000..f5fd24fcea --- /dev/null +++ b/src/api/providers/zoo-gateway.ts @@ -0,0 +1,96 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import type { ModelInfo } from "@roo-code/types" + +import type { ApiHandlerCreateMessageMetadata } from "../index" +import { ApiStream } from "../transform/stream" +import { BaseProvider } from "./base-provider" + +const ZOO_GATEWAY_MODEL = "google/gemini-2.5-flash" + +/** + * ZooGatewayApiHandler + * + * Routes compression calls through the Zoo Code website backend, + * which internally uses Vercel AI Gateway + gemini-2.5-flash. + * + * Used exclusively by ToolResultProcessor for LLM-assisted compression. + * NOT a general-purpose provider for user tasks. + */ +export class ZooGatewayApiHandler extends BaseProvider { + private readonly baseUrl: string + private readonly apiKey: string + + constructor(baseUrl: string, apiKey: string) { + super() + this.baseUrl = baseUrl.replace(/\/$/, "") // strip trailing slash + this.apiKey = apiKey + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + // Extract the raw text content from the last user message + const lastUserMsg = [...messages].reverse().find((m: Anthropic.Messages.MessageParam) => m.role === "user") + const rawResult = extractTextContent(lastUserMsg?.content ?? "") + + let compressed = rawResult // fallback + + try { + const response = await fetch(`${this.baseUrl}/api/proxy/internal/compress`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + systemPrompt, + rawResult, + toolName: metadata?.toolName ?? "unknown", + }), + signal: AbortSignal.timeout(15_000), // 15s timeout + }) + + if (response.ok) { + const data = await response.json() + compressed = data.compressed ?? rawResult + } + } catch (err) { + // Network error, timeout, etc. — gracefully fall back to raw + console.warn("[ZooGatewayApiHandler] Compression request failed, using raw result", err) + } + + yield { type: "text", text: compressed } + yield { + type: "usage", + inputTokens: 0, + outputTokens: 0, + } + } + + override getModel(): { id: string; info: ModelInfo } { + return { + id: ZOO_GATEWAY_MODEL, + info: { + maxTokens: 600, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: false, + } as ModelInfo, + } + } + + override async countTokens(_content: Anthropic.Messages.ContentBlockParam[]): Promise { + return 0 // not needed for compression use case + } +} + +function extractTextContent(content: Anthropic.Messages.ContentBlockParam[] | string): string { + if (typeof content === "string") return content + return content + .filter((b) => b.type === "text") + .map((b) => (b as Anthropic.Messages.TextBlockParam).text) + .join("\n") +} diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7f5862be15..9f0931b4ae 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -37,6 +37,7 @@ import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" +import { isReadOnlyTool } from "../tools/toolCategories" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" @@ -675,6 +676,208 @@ export async function presentAssistantMessage(cline: Task) { } } + // Parallel execution of consecutive read-only tools. + // When a complete read-only tool is encountered, look ahead for more consecutive + // complete read-only tool_use blocks. If 2 or more are found, execute them in + // parallel using Promise.all to reduce latency by 50–70%. + // + // Approval (cline.ask) is serialized by the Task messaging system — only one + // approval dialog can be pending at a time — so approvals remain sequential + // even though tool promises are launched concurrently. + if (!block.partial && isReadOnlyTool(block.name)) { + // Collect the current block plus any consecutive complete read-only tool_use blocks + const parallelBatch: any[] = [block] + let lookAheadIdx = cline.currentStreamingContentIndex + 1 + + while (lookAheadIdx < cline.assistantMessageContent.length) { + const nextBlock = cline.assistantMessageContent[lookAheadIdx] + if ( + nextBlock && + nextBlock.type === "tool_use" && + !(nextBlock as any).partial && + isReadOnlyTool((nextBlock as any).name) && + (nextBlock as any).id && + (nextBlock as any).nativeArgs + ) { + parallelBatch.push({ ...(nextBlock as any) }) + lookAheadIdx++ + } else { + break + } + } + + if (parallelBatch.length >= 2) { + // Build per-block callbacks for each tool in the batch. + // Each tool gets its own isolated pushToolResult / handleError / askApproval + // so that results are correctly attributed to the right tool_use_id. + const buildBlockCallbacks = (batchBlock: any) => { + const batchToolCallId = batchBlock.id as string + let batchHasToolResult = false + let batchApprovalFeedback: { text: string; images?: string[] } | undefined + + const batchPushToolResult = (content: ToolResponse) => { + if (batchHasToolResult) { + console.warn( + `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${batchToolCallId}`, + ) + return + } + + let resultContent: string + let imageBlocks: Anthropic.ImageBlockParam[] = [] + + if (typeof content === "string") { + resultContent = content || "(tool did not return anything)" + } else { + const textBlocks = content.filter((item) => item.type === "text") + imageBlocks = content.filter( + (item) => item.type === "image", + ) as Anthropic.ImageBlockParam[] + resultContent = + textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || + "(tool did not return anything)" + } + + if (batchApprovalFeedback) { + const feedbackText = formatResponse.toolApprovedWithFeedback(batchApprovalFeedback.text) + resultContent = `${feedbackText}\n\n${resultContent}` + if (batchApprovalFeedback.images) { + const feedbackImageBlocks = formatResponse.imageBlocks(batchApprovalFeedback.images) + imageBlocks = [...feedbackImageBlocks, ...imageBlocks] + } + } + + cline.pushToolResultToUserContent({ + type: "tool_result", + tool_use_id: sanitizeToolUseId(batchToolCallId), + content: resultContent, + }) + + if (imageBlocks.length > 0) { + cline.userMessageContent.push(...imageBlocks) + } + + batchHasToolResult = true + } + + const batchAskApproval = async ( + type: ClineAsk, + partialMessage?: string, + progressStatus?: ToolProgressStatus, + isProtected?: boolean, + ) => { + const { response, text, images } = await cline.ask( + type, + partialMessage, + false, + progressStatus, + isProtected || false, + ) + + if (response !== "yesButtonClicked") { + if (text) { + await cline.say("user_feedback", text, images) + batchPushToolResult( + formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images), + ) + } else { + batchPushToolResult(formatResponse.toolDenied()) + } + cline.didRejectTool = true + return false + } + + if (text) { + await cline.say("user_feedback", text, images) + batchApprovalFeedback = { text, images } + } + + return true + } + + const batchHandleError = async (action: string, error: Error) => { + if (error instanceof AskIgnoredError) { + return + } + const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}` + await cline.say( + "error", + `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, + ) + batchPushToolResult(formatResponse.toolError(errorString)) + } + + return { + pushToolResult: batchPushToolResult, + askApproval: batchAskApproval, + handleError: batchHandleError, + } + } + + // Map each block to its execution promise. + // cline.ask() (used inside each tool's askApproval) serializes approval dialogs + // so user approvals happen one at a time, while the actual I/O runs concurrently. + const executionPromises = parallelBatch.map((batchBlock) => { + const callbacks = buildBlockCallbacks(batchBlock) + + // Each tool invocation returns a promise that handles its own approval + I/O + const executeOne = async () => { + switch (batchBlock.name) { + case "read_file": + await readFileTool.handle(cline, batchBlock as ToolUse<"read_file">, callbacks) + break + case "list_files": + await listFilesTool.handle(cline, batchBlock as ToolUse<"list_files">, callbacks) + break + case "search_files": + await searchFilesTool.handle( + cline, + batchBlock as ToolUse<"search_files">, + callbacks, + ) + break + case "codebase_search": + await codebaseSearchTool.handle( + cline, + batchBlock as ToolUse<"codebase_search">, + callbacks, + ) + break + // Fallback: should not occur since we only batch known read-only tools + default: + break + } + } + + return executeOne() + }) + + // Execute all tools concurrently; approvals remain sequential via Task.ask() internals + await Promise.all(executionPromises) + + // Advance currentStreamingContentIndex past all batch blocks. + // The block at the current index will be advanced by the normal post-switch + // increment at the bottom of presentAssistantMessage, so we only need to + // pre-advance by (parallelBatch.length - 1) additional steps here. + const extraAdvance = parallelBatch.length - 1 + for (let i = 0; i < extraAdvance; i++) { + cline.currentStreamingContentIndex++ + // Record tool usage and telemetry for each additional (lookahead) block + const advancedBlock = parallelBatch[i + 1] + if (advancedBlock && !advancedBlock.partial) { + cline.recordToolUsage(advancedBlock.name) + TelemetryService.instance.captureToolUsage(cline.taskId, advancedBlock.name) + } + } + + // Fall through to the normal post-switch handling by breaking out of the + // tool_use case — the post-switch code will handle index advancement and + // userMessageContentReady for the last block in the batch. + break + } + // If only 1 read-only tool (no lookahead), fall through to sequential dispatch below. + } + switch (block.name) { case "write_to_file": await checkpointSaveAndMark(cline) diff --git a/src/core/context-management/__tests__/compressToolResults.spec.ts b/src/core/context-management/__tests__/compressToolResults.spec.ts new file mode 100644 index 0000000000..91db9c73e2 --- /dev/null +++ b/src/core/context-management/__tests__/compressToolResults.spec.ts @@ -0,0 +1,327 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { + compressOldToolResults, + generatePlaceholder, + TOOL_RESULT_MIN_CHARS, + TOOL_RESULT_STALE_TURN_THRESHOLD, + COMPRESSIBLE_TOOLS, +} from "../compressToolResults" + +// Helper to build a minimal assistant message with a tool_use block +function assistantMsgWithToolUse(toolName: string, toolUseId: string): Anthropic.Messages.MessageParam { + return { + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: toolName, + input: {}, + }, + ], + } +} + +// Helper to build a user message with a tool_result block +function userMsgWithToolResult(toolUseId: string, content: string): Anthropic.Messages.MessageParam { + return { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content, + }, + ], + } +} + +// Helper to build a user message with a plain text block (no tool_result) +function userTextMsg(text: string): Anthropic.Messages.MessageParam { + return { + role: "user", + content: [{ type: "text", text }], + } +} + +// Helper to build a simple assistant text message +function assistantTextMsg(text: string): Anthropic.Messages.MessageParam { + return { + role: "assistant", + content: [{ type: "text", text }], + } +} + +// A string that is longer than TOOL_RESULT_MIN_CHARS +const LARGE_CONTENT = "x".repeat(TOOL_RESULT_MIN_CHARS + 1) + +// A string that is shorter than TOOL_RESULT_MIN_CHARS +const SMALL_CONTENT = "x".repeat(TOOL_RESULT_MIN_CHARS - 1) + +// Build N turn pairs (assistant + user tool_result) to ensure staleness +// With TOOL_RESULT_STALE_TURN_THRESHOLD = 3, we need > 3 assistant messages to have stale ones +function buildTurnPairs(count: number, toolName: string = "read_file"): Anthropic.Messages.MessageParam[] { + const history: Anthropic.Messages.MessageParam[] = [] + for (let i = 0; i < count; i++) { + const toolUseId = `tool-${i}` + history.push(assistantMsgWithToolUse(toolName, toolUseId)) + history.push(userMsgWithToolResult(toolUseId, LARGE_CONTENT)) + } + return history +} + +describe("generatePlaceholder", () => { + test("read_file placeholder includes char count and line count", () => { + const content = "line1\nline2\nline3" + const result = generatePlaceholder("read_file", content) + expect(result).toContain("read_file") + expect(result).toContain("chars") + expect(result).toContain("lines") + expect(result).toContain("Re-read the file") + }) + + test("search_files placeholder includes char count and matches", () => { + const content = "match1\nmatch2\nmatch3" + const result = generatePlaceholder("search_files", content) + expect(result).toContain("search_files") + expect(result).toContain("matches") + expect(result).toContain("Re-run the search") + }) + + test("codebase_search placeholder includes matches", () => { + const content = "result1\nresult2" + const result = generatePlaceholder("codebase_search", content) + expect(result).toContain("codebase_search") + expect(result).toContain("matches") + }) + + test("list_files placeholder includes path count", () => { + const content = "src/a.ts\nsrc/b.ts\nsrc/c.ts" + const result = generatePlaceholder("list_files", content) + expect(result).toContain("list_files") + expect(result).toContain("paths") + expect(result).toContain("Re-list the directory") + }) + + test("execute_command placeholder references re-run", () => { + const content = "some command output" + const result = generatePlaceholder("execute_command", content) + expect(result).toContain("execute_command") + expect(result).toContain("Re-run the command") + }) + + test("read_command_output placeholder references re-run", () => { + const content = "command output" + const result = generatePlaceholder("read_command_output", content) + expect(result).toContain("Re-run the command") + }) + + test("unknown tool produces generic placeholder", () => { + const content = "some content" + const result = generatePlaceholder("unknown_tool", content) + expect(result).toContain("tool result") + expect(result).toContain("chars") + expect(result).toContain("Call the tool again") + }) + + test("placeholder includes approximate original char count", () => { + const content = "a".repeat(1523) + const result = generatePlaceholder("read_file", content) + // Should contain 1,523 (localized) somewhere + expect(result).toMatch(/1[,.]?523/) + }) +}) + +describe("compressOldToolResults", () => { + test("empty history returns empty array", () => { + const result = compressOldToolResults([]) + expect(result).toEqual([]) + }) + + test("returns a new array, not the same reference", () => { + const history = buildTurnPairs(TOOL_RESULT_STALE_TURN_THRESHOLD + 2) + const result = compressOldToolResults(history) + expect(result).not.toBe(history) + }) + + test("does not mutate the original array", () => { + const history = buildTurnPairs(TOOL_RESULT_STALE_TURN_THRESHOLD + 2) + const originalFirstUserContent = (history[1].content as any[])[0].content + compressOldToolResults(history) + expect((history[1].content as any[])[0].content).toBe(originalFirstUserContent) + }) + + test("history with only assistant messages (no tool results) is untouched", () => { + const history = [ + assistantTextMsg("hello"), + assistantTextMsg("world"), + assistantTextMsg("foo"), + assistantTextMsg("bar"), + ] + const result = compressOldToolResults(history) + expect(result).toEqual(history) + }) + + test("recent tool results within threshold are NOT compressed", () => { + // Build exactly THRESHOLD turns — nothing should be stale + const history = buildTurnPairs(TOOL_RESULT_STALE_TURN_THRESHOLD) + const result = compressOldToolResults(history) + // All tool_result blocks should still have the original LARGE_CONTENT + for (let i = 1; i < result.length; i += 2) { + const block = (result[i].content as any[])[0] + expect(block.content).toBe(LARGE_CONTENT) + } + }) + + test("old tool results exceeding min chars ARE compressed", () => { + // Build THRESHOLD + 2 turns so the first turns are stale + const count = TOOL_RESULT_STALE_TURN_THRESHOLD + 2 + const history = buildTurnPairs(count, "read_file") + const result = compressOldToolResults(history) + + // The first (count - THRESHOLD) user messages should have compressed content + const staleCount = count - TOOL_RESULT_STALE_TURN_THRESHOLD + for (let turn = 0; turn < staleCount; turn++) { + const userMsgIdx = turn * 2 + 1 + const block = (result[userMsgIdx].content as any[])[0] + expect(block.content).not.toBe(LARGE_CONTENT) + expect(block.content).toContain("[Compressed:") + } + }) + + test("old tool results below min chars are left untouched", () => { + // Build turns with small content so nothing should be compressed + const history: Anthropic.Messages.MessageParam[] = [] + const count = TOOL_RESULT_STALE_TURN_THRESHOLD + 2 + for (let i = 0; i < count; i++) { + const toolUseId = `tool-${i}` + history.push(assistantMsgWithToolUse("read_file", toolUseId)) + history.push(userMsgWithToolResult(toolUseId, SMALL_CONTENT)) + } + const result = compressOldToolResults(history) + for (let i = 1; i < result.length; i += 2) { + const block = (result[i].content as any[])[0] + expect(block.content).toBe(SMALL_CONTENT) + } + }) + + test("non-tool_result content blocks are never touched", () => { + const history: Anthropic.Messages.MessageParam[] = [] + // Enough turns to ensure staleness + for (let i = 0; i < TOOL_RESULT_STALE_TURN_THRESHOLD + 2; i++) { + history.push(assistantTextMsg("assistant turn " + i)) + history.push(userTextMsg("user message " + i)) + } + const result = compressOldToolResults(history) + for (let i = 1; i < result.length; i += 2) { + const block = (result[i].content as any[])[0] + expect(block.type).toBe("text") + // text should be unchanged + expect(block.text).toContain("user message") + } + }) + + test("placeholder includes approximate char count from original", () => { + const content = "a".repeat(2000) + const history: Anthropic.Messages.MessageParam[] = [] + const count = TOOL_RESULT_STALE_TURN_THRESHOLD + 1 + for (let i = 0; i < count; i++) { + const toolUseId = `tool-${i}` + history.push(assistantMsgWithToolUse("read_file", toolUseId)) + if (i === 0) { + history.push(userMsgWithToolResult(toolUseId, content)) + } else { + history.push(userMsgWithToolResult(toolUseId, LARGE_CONTENT)) + } + } + const result = compressOldToolResults(history) + const firstBlock = (result[1].content as any[])[0] + // Content was 2000 chars + expect(firstBlock.content).toMatch(/2[,.]?000/) + }) + + test("multiple tool results in one user message — only large old ones compressed", () => { + const toolUseId1 = "tool-1" + const toolUseId2 = "tool-2" + + // Build THRESHOLD extra turns first to push earlier messages into stale zone + const history: Anthropic.Messages.MessageParam[] = [] + // Add a stale turn with two tool results in one user message + history.push({ + role: "assistant", + content: [ + { type: "tool_use", id: toolUseId1, name: "read_file", input: {} }, + { type: "tool_use", id: toolUseId2, name: "read_file", input: {} }, + ], + }) + history.push({ + role: "user", + content: [ + { type: "tool_result", tool_use_id: toolUseId1, content: LARGE_CONTENT }, + { type: "tool_result", tool_use_id: toolUseId2, content: SMALL_CONTENT }, + ], + }) + + // Pad with enough turns to make the above stale + for (let i = 0; i < TOOL_RESULT_STALE_TURN_THRESHOLD; i++) { + const tid = `pad-${i}` + history.push(assistantMsgWithToolUse("read_file", tid)) + history.push(userMsgWithToolResult(tid, LARGE_CONTENT)) + } + + const result = compressOldToolResults(history) + const blocks = result[1].content as any[] + + // First block (large) should be compressed + expect(blocks[0].content).toContain("[Compressed:") + // Second block (small) should be untouched + expect(blocks[1].content).toBe(SMALL_CONTENT) + }) + + test("tool results for non-compressible tools are not compressed", () => { + const history: Anthropic.Messages.MessageParam[] = [] + const count = TOOL_RESULT_STALE_TURN_THRESHOLD + 1 + for (let i = 0; i < count; i++) { + const toolUseId = `tool-${i}` + // Use a tool NOT in COMPRESSIBLE_TOOLS + history.push(assistantMsgWithToolUse("attempt_completion", toolUseId)) + history.push(userMsgWithToolResult(toolUseId, LARGE_CONTENT)) + } + const result = compressOldToolResults(history) + // None should be compressed since attempt_completion is not compressible + for (let i = 1; i < result.length; i += 2) { + const block = (result[i].content as any[])[0] + expect(block.content).toBe(LARGE_CONTENT) + } + }) + + test("tool_result with array content (ContentBlockParam[]) is compressed", () => { + const toolUseId = "tool-arr" + const history: Anthropic.Messages.MessageParam[] = [] + + history.push(assistantMsgWithToolUse("read_file", toolUseId)) + history.push({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: [{ type: "text", text: LARGE_CONTENT }], + }, + ], + }) + + // Pad with enough turns to make the above stale + for (let i = 0; i < TOOL_RESULT_STALE_TURN_THRESHOLD; i++) { + const tid = `pad-${i}` + history.push(assistantMsgWithToolUse("read_file", tid)) + history.push(userMsgWithToolResult(tid, LARGE_CONTENT)) + } + + const result = compressOldToolResults(history) + const block = (result[1].content as any[])[0] + // The block content should now be a compressed string placeholder + expect(typeof block.content).toBe("string") + expect(block.content).toContain("[Compressed:") + }) +}) diff --git a/src/core/context-management/compressToolResults.ts b/src/core/context-management/compressToolResults.ts new file mode 100644 index 0000000000..a97bf43805 --- /dev/null +++ b/src/core/context-management/compressToolResults.ts @@ -0,0 +1,204 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +/** + * Minimum character length for a tool_result to be eligible for compression. + * Results shorter than this are left untouched. + */ +export const TOOL_RESULT_MIN_CHARS = 500 + +/** + * Number of conversation turns (assistant+user pairs) after which a tool_result + * becomes eligible for compression. A value of 3 means the result must be at + * least 3 full turns old. + */ +export const TOOL_RESULT_STALE_TURN_THRESHOLD = 3 + +/** + * Tool names whose results contain file/search content that benefits from compression. + * Other tools (e.g., attempt_completion, ask_followup_question) are left untouched. + */ +export const COMPRESSIBLE_TOOLS = new Set([ + "read_file", + "search_files", + "list_files", + "codebase_search", + "execute_command", + "read_command_output", +]) + +/** + * Counts approximate number of lines/matches in a result string. + * Used to enrich placeholders with useful metadata. + */ +function countApproxLines(content: string): number { + return content.split("\n").length +} + +/** + * Generates a compact placeholder for a compressed tool result. + * Preserves the tool name, key parameters, and approximate size. + */ +export function generatePlaceholder(toolName: string, originalContent: string): string { + const charCount = originalContent.length + const formattedCount = charCount.toLocaleString("en-US") + + switch (toolName) { + case "read_file": { + const lineCount = countApproxLines(originalContent) + return `[Compressed: read_file result — originally ~${formattedCount} chars, ~${lineCount} lines. Re-read the file if you need its contents.]` + } + case "search_files": + case "codebase_search": { + // Estimate matches by counting lines that look like match results + const lineCount = countApproxLines(originalContent) + return `[Compressed: ${toolName} result — originally ~${formattedCount} chars, ~${lineCount} matches. Re-run the search if needed.]` + } + case "list_files": { + // Count approximate number of paths (non-empty lines) + const pathCount = originalContent.split("\n").filter((l) => l.trim().length > 0).length + return `[Compressed: list_files result — originally ~${formattedCount} chars, ~${pathCount} paths. Re-list the directory if needed.]` + } + case "execute_command": + case "read_command_output": { + return `[Compressed: execute_command output — originally ~${formattedCount} chars. Re-run the command if needed.]` + } + default: { + return `[Compressed: tool result — originally ~${formattedCount} chars. Call the tool again if needed.]` + } + } +} + +/** + * Extracts the tool name from a tool_use block in the preceding assistant message, + * matching by tool_use_id. + */ +function extractToolNameFromHistory( + history: Anthropic.Messages.MessageParam[], + messageIndex: number, + toolUseId: string, +): string | null { + // Look backwards through history for an assistant message that has this tool_use_id + for (let i = messageIndex - 1; i >= 0; i--) { + const msg = history[i] + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.id === toolUseId) { + return block.name + } + } + } + } + return null +} + +/** + * Compresses old, large tool_result blocks in conversation history. + * + * Walks the history backwards from the most recent message. Messages within + * the STALE_TURN_THRESHOLD of the current turn are left untouched. + * + * For eligible messages: + * - tool_result blocks with content > TOOL_RESULT_MIN_CHARS are replaced + * with a compact placeholder + * - The original content is NOT preserved (this is a hard truncation) + * + * Returns a new array (does not mutate the input). + */ +export function compressOldToolResults( + history: Anthropic.Messages.MessageParam[], + currentTurnIndex?: number, +): Anthropic.Messages.MessageParam[] { + if (history.length === 0) { + return [] + } + + // Count assistant messages to determine turn boundaries. + // Walk from the END of history backwards, counting turns. + // We protect the last TOOL_RESULT_STALE_TURN_THRESHOLD assistant turns. + + // First, find the indices of assistant messages (turns), from end to start + const assistantIndices: number[] = [] + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].role === "assistant") { + assistantIndices.push(i) + } + } + + // The "stale boundary" is the array index of the THRESHOLD-th-from-last assistant message. + // Everything strictly before that index is stale. + // Example: THRESHOLD=3, 5 assistant turns at [0,2,4,6,8] + // assistantIndices (backwards) = [8,6,4,2,0] + // THRESHOLD-1 = 2 → assistantIndices[2] = 4 → messages at idx < 4 are stale + // If we have fewer than THRESHOLD assistant turns, nothing is stale. + let staleBoundaryIndex: number + if (assistantIndices.length < TOOL_RESULT_STALE_TURN_THRESHOLD) { + // Not enough turns to have anything stale + return history.slice() + } else { + // assistantIndices[THRESHOLD - 1] is the index of the THRESHOLD-th message from the end. + // Everything strictly before that assistant message's index is stale. + staleBoundaryIndex = assistantIndices[TOOL_RESULT_STALE_TURN_THRESHOLD - 1] + } + + // Build a new array, compressing eligible tool_result blocks + return history.map((message, msgIndex) => { + // Only compress user messages strictly before the stale boundary + if (msgIndex >= staleBoundaryIndex) { + return message + } + + if (message.role !== "user" || !Array.isArray(message.content)) { + return message + } + + let modified = false + const newContent = message.content.map((block) => { + if (block.type !== "tool_result") { + return block + } + + // tool_result content may be string or array of content blocks + const rawContent = block.content + let contentStr: string | null = null + + if (typeof rawContent === "string") { + contentStr = rawContent + } else if (Array.isArray(rawContent)) { + // Find the first text block + const textBlock = rawContent.find((b) => b.type === "text") + if (textBlock && textBlock.type === "text") { + contentStr = textBlock.text + } + } + + if (contentStr === null || contentStr.length <= TOOL_RESULT_MIN_CHARS) { + return block + } + + // Try to determine the tool name from the preceding assistant message + const toolName = extractToolNameFromHistory(history, msgIndex, block.tool_use_id) ?? "unknown" + + // Only compress tools in our compressible set (or unknown tools that are large) + if (toolName !== "unknown" && !COMPRESSIBLE_TOOLS.has(toolName)) { + return block + } + + const placeholder = generatePlaceholder(toolName === "unknown" ? "unknown" : toolName, contentStr) + modified = true + + return { + ...block, + content: placeholder, + } + }) + + if (!modified) { + return message + } + + return { + ...message, + content: newContent, + } + }) +} diff --git a/src/core/environment/__tests__/environmentDiff.spec.ts b/src/core/environment/__tests__/environmentDiff.spec.ts new file mode 100644 index 0000000000..6742301c24 --- /dev/null +++ b/src/core/environment/__tests__/environmentDiff.spec.ts @@ -0,0 +1,222 @@ +import { + parseEnvironmentSections, + diffEnvironmentDetails, + assembleEnvironmentDetails, + ALWAYS_INCLUDE_SECTIONS, +} from "../environmentDiff" + +// --------------------------------------------------------------------------- +// parseEnvironmentSections +// --------------------------------------------------------------------------- + +describe("parseEnvironmentSections", () => { + it("returns an empty map for an empty string", () => { + const result = parseEnvironmentSections("") + expect(result.size).toBe(0) + }) + + it("parses a single section with content", () => { + const input = `# Current Time\n2024-01-01T00:00:00Z` + const result = parseEnvironmentSections(input) + expect(result.size).toBe(1) + expect(result.get("Current Time")).toBe("2024-01-01T00:00:00Z") + }) + + it("parses multiple top-level sections", () => { + const input = `# VSCode Visible Files\nfoo.ts\n\n# VSCode Open Tabs\nbar.ts\n\n# Current Time\nnow` + const result = parseEnvironmentSections(input) + expect(result.size).toBe(3) + expect(result.get("VSCode Visible Files")).toBe("foo.ts") + expect(result.get("VSCode Open Tabs")).toBe("bar.ts") + expect(result.get("Current Time")).toBe("now") + }) + + it("groups sub-headers (## ...) under their parent section", () => { + const input = `# Actively Running Terminals\n## Terminal 1 (Active)\n### Working Directory: \`/tmp\`\n### New Output\nhello` + const result = parseEnvironmentSections(input) + expect(result.size).toBe(1) + const content = result.get("Actively Running Terminals") + expect(content).toContain("## Terminal 1 (Active)") + expect(content).toContain("### Working Directory") + expect(content).toContain("hello") + }) + + it("handles a header with no body content", () => { + const input = `# Current Mode` + const result = parseEnvironmentSections(input) + expect(result.size).toBe(1) + expect(result.get("Current Mode")).toBe("") + }) + + it("strips wrapping tags before parsing", () => { + const input = `\n# Current Cost\n$0.00\n` + const result = parseEnvironmentSections(input) + expect(result.size).toBe(1) + expect(result.get("Current Cost")).toBe("$0.00") + }) +}) + +// --------------------------------------------------------------------------- +// diffEnvironmentDetails +// --------------------------------------------------------------------------- + +describe("diffEnvironmentDetails — first call (no previous)", () => { + it("returns all sections when previous is null", () => { + const current = new Map([ + ["VSCode Visible Files", "foo.ts"], + ["Current Time", "now"], + ["Current Cost", "$0.00"], + ]) + const { sections, wasFiltered } = diffEnvironmentDetails(null, current) + expect(wasFiltered).toBe(false) + expect(sections.size).toBe(current.size) + for (const [k, v] of current) { + expect(sections.get(k)).toBe(v) + } + }) +}) + +describe("diffEnvironmentDetails — second call with identical content", () => { + it("returns only ALWAYS_INCLUDE sections when nothing changed", () => { + const content = new Map([ + ["VSCode Visible Files", "foo.ts"], + ["VSCode Open Tabs", "bar.ts"], + ["Current Time", "t1"], + ["Current Cost", "$0.10"], + ["Current Mode", "code"], + ]) + + const { sections, wasFiltered } = diffEnvironmentDetails(new Map(content), content) + + expect(wasFiltered).toBe(true) + // Changed sections should be absent. + expect(sections.has("VSCode Visible Files")).toBe(false) + expect(sections.has("VSCode Open Tabs")).toBe(false) + // ALWAYS_INCLUDE sections should be present. + expect(sections.has("Current Time")).toBe(true) + expect(sections.has("Current Cost")).toBe(true) + expect(sections.has("Current Mode")).toBe(true) + }) +}) + +describe("diffEnvironmentDetails — second call with one changed section", () => { + it("includes the changed section plus ALWAYS_INCLUDE sections", () => { + const previous = new Map([ + ["VSCode Visible Files", "foo.ts"], + ["Current Time", "t1"], + ["Current Cost", "$0.00"], + ]) + const current = new Map([ + ["VSCode Visible Files", "foo.ts bar.ts"], // changed + ["Current Time", "t2"], // ALWAYS_INCLUDE (also changed) + ["Current Cost", "$0.05"], // ALWAYS_INCLUDE (also changed) + ]) + + const { sections, wasFiltered } = diffEnvironmentDetails(previous, current) + + expect(wasFiltered).toBe(false) // all sections are included + expect(sections.get("VSCode Visible Files")).toBe("foo.ts bar.ts") + expect(sections.get("Current Time")).toBe("t2") + expect(sections.get("Current Cost")).toBe("$0.05") + }) + + it("omits unchanged non-always-include sections and sets wasFiltered", () => { + const previous = new Map([ + ["VSCode Visible Files", "foo.ts"], + ["VSCode Open Tabs", "bar.ts"], + ["Current Time", "t1"], + ]) + const current = new Map([ + ["VSCode Visible Files", "foo.ts"], // unchanged, not ALWAYS_INCLUDE + ["VSCode Open Tabs", "baz.ts"], // changed + ["Current Time", "t2"], // ALWAYS_INCLUDE (changed) + ]) + + const { sections, wasFiltered } = diffEnvironmentDetails(previous, current) + + expect(wasFiltered).toBe(true) + expect(sections.has("VSCode Visible Files")).toBe(false) + expect(sections.get("VSCode Open Tabs")).toBe("baz.ts") + expect(sections.get("Current Time")).toBe("t2") + }) +}) + +describe("diffEnvironmentDetails — ALWAYS_INCLUDE_SECTIONS always present", () => { + it("includes ALWAYS_INCLUDE sections even when their content is unchanged", () => { + const sharedContent = new Map() + for (const name of ALWAYS_INCLUDE_SECTIONS) { + sharedContent.set(name, "static-value") + } + + const { sections, wasFiltered: _ } = diffEnvironmentDetails(new Map(sharedContent), sharedContent) + + for (const name of ALWAYS_INCLUDE_SECTIONS) { + expect(sections.has(name)).toBe(true) + } + }) +}) + +describe("diffEnvironmentDetails — new sections in current that are absent from previous", () => { + it("includes brand-new sections (they count as changed)", () => { + const previous = new Map([["Current Time", "t1"]]) + const current = new Map([ + ["Current Time", "t1"], + ["Git Status", "M src/foo.ts"], // new section, not in previous + ]) + + const { sections, wasFiltered } = diffEnvironmentDetails(previous, current) + + expect(sections.has("Git Status")).toBe(true) + // Current Time is ALWAYS_INCLUDE and unchanged — still present. + expect(sections.has("Current Time")).toBe(true) + expect(wasFiltered).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// assembleEnvironmentDetails +// --------------------------------------------------------------------------- + +describe("assembleEnvironmentDetails", () => { + it("wraps output in tags", () => { + const sections = new Map([["Current Time", "now"]]) + const result = assembleEnvironmentDetails(sections, false) + expect(result.startsWith("")).toBe(true) + expect(result.endsWith("")).toBe(true) + }) + + it("includes the omission preamble when wasFiltered is true", () => { + const sections = new Map([["Current Time", "now"]]) + const result = assembleEnvironmentDetails(sections, true) + expect(result).toContain("Unchanged environment sections omitted") + }) + + it("does not include the preamble when wasFiltered is false", () => { + const sections = new Map([["Current Time", "now"]]) + const result = assembleEnvironmentDetails(sections, false) + expect(result).not.toContain("Unchanged environment sections omitted") + }) + + it("renders each section with its # header", () => { + const sections = new Map([ + ["Current Time", "now"], + ["Current Cost", "$1.00"], + ]) + const result = assembleEnvironmentDetails(sections, false) + expect(result).toContain("# Current Time\nnow") + expect(result).toContain("# Current Cost\n$1.00") + }) + + it("round-trips through parseEnvironmentSections", () => { + const original = new Map([ + ["VSCode Visible Files", "foo.ts\nbar.ts"], + ["Current Time", "now"], + ["Current Mode", "code"], + ]) + const assembled = assembleEnvironmentDetails(original, false) + const reparsed = parseEnvironmentSections(assembled) + for (const [k, v] of original) { + expect(reparsed.get(k)).toBe(v) + } + }) +}) diff --git a/src/core/environment/environmentDiff.ts b/src/core/environment/environmentDiff.ts new file mode 100644 index 0000000000..dba776ed5f --- /dev/null +++ b/src/core/environment/environmentDiff.ts @@ -0,0 +1,111 @@ +/** + * Utilities for diffing environment details between turns to reduce token usage. + * + * On turn 1 we send everything. On turn 2+ we send only changed sections plus + * any sections listed in ALWAYS_INCLUDE_SECTIONS. + */ + +/** + * Parses environment details string into named sections. + * Sections start with "# SectionName" headers (single `#`, top-level only). + * The content of each section includes any sub-headers (## ...) until the + * next top-level `# ` header. + */ +export function parseEnvironmentSections(envDetails: string): Map { + const sections = new Map() + + // Strip wrapping tags if present. + const stripped = envDetails.replace(/^\s*/i, "").replace(/\s*<\/environment_details>$/i, "") + + // Split on lines that begin with exactly one `# ` (top-level headers). + // We keep the delimiter via a positive lookahead so we can reconstruct content. + const parts = stripped.split(/(?=^# )/m) + + for (const part of parts) { + const trimmed = part.trim() + if (!trimmed) { + continue + } + + // First line is the header, the rest is content. + const newlineIdx = trimmed.indexOf("\n") + if (newlineIdx === -1) { + // Header with no body. + const header = trimmed.replace(/^# /, "").trim() + sections.set(header, "") + } else { + const header = trimmed.slice(0, newlineIdx).replace(/^# /, "").trim() + const content = trimmed.slice(newlineIdx + 1) + sections.set(header, content) + } + } + + return sections +} + +/** + * Sections that should ALWAYS be included even if unchanged. + * These are cheap (few tokens) and the LLM frequently references them. + */ +export const ALWAYS_INCLUDE_SECTIONS = new Set(["Current Time", "Current Cost", "Current Mode", "REMINDERS"]) + +/** + * Computes a diff between previous and current environment sections. + * + * Returns: + * - `sections`: Map containing only changed sections + ALWAYS_INCLUDE_SECTIONS. + * On the first call (previous === null) all sections are returned. + * - `wasFiltered`: true when at least one section was omitted. + */ +export function diffEnvironmentDetails( + previous: Map | null, + current: Map, +): { sections: Map; wasFiltered: boolean } { + // First turn — send everything. + if (previous === null) { + return { sections: new Map(current), wasFiltered: false } + } + + const result = new Map() + let wasFiltered = false + + for (const [name, content] of current) { + const alwaysInclude = ALWAYS_INCLUDE_SECTIONS.has(name) + const changed = previous.get(name) !== content + + if (alwaysInclude || changed) { + result.set(name, content) + } else { + wasFiltered = true + } + } + + return { sections: result, wasFiltered } +} + +/** + * Reassembles a sections map back into the `` string + * format used by the agent. + * + * @param sections The (possibly filtered) sections to include. + * @param wasFiltered When true, prepends an omission notice. + */ +export function assembleEnvironmentDetails(sections: Map, wasFiltered: boolean): string { + const parts: string[] = [] + + if (wasFiltered) { + parts.push( + "(Unchanged environment sections omitted from this turn. Use tools to check current state if needed.)", + ) + } + + for (const [name, content] of sections) { + if (content) { + parts.push(`# ${name}\n${content}`) + } else { + parts.push(`# ${name}`) + } + } + + return `\n${parts.join("\n\n")}\n` +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6f630b8b8e..3acef7c08d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -14,7 +14,6 @@ import delay from "delay" import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" import { Package } from "../../shared/package" -import { formatToolInvocation } from "../tools/helpers/toolResultFormatting" import { type TaskLike, @@ -98,6 +97,10 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" +import { ToolResultProcessor } from "../tools/ToolResultProcessor" +import { DEFAULT_PROCESSOR_CONFIG, type ToolResultProcessorConfig } from "../tools/ToolResultProcessorConfig" +import { resolveCompressionHandler } from "../tools/resolveCompressionHandler" +import { CompletionPostProcessor } from "../tools/CompletionPostProcessor" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -105,6 +108,7 @@ import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" import { manageContext, willManageContext } from "../context-management" +import { compressOldToolResults } from "../context-management/compressToolResults" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { @@ -116,6 +120,11 @@ import { taskMetadata, } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" +import { + parseEnvironmentSections, + diffEnvironmentDetails, + assembleEnvironmentDetails, +} from "../environment/environmentDiff" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { type CheckpointDiffOptions, @@ -295,6 +304,9 @@ export class Task extends EventEmitter implements TaskLike { } toolRepetitionDetector: ToolRepetitionDetector + public toolResultProcessor: ToolResultProcessor + public toolResultProcessorConfig: ToolResultProcessorConfig + public completionPostProcessor: CompletionPostProcessor rooIgnoreController?: RooIgnoreController rooProtectedController?: RooProtectedController fileContextTracker: FileContextTracker @@ -305,6 +317,10 @@ export class Task extends EventEmitter implements TaskLike { diffStrategy?: DiffStrategy didEditFile: boolean = false + // Environment details diffing — tracks last-sent sections to avoid + // resending unchanged content on every turn. + private lastEnvironmentSections: Map | null = null + // LLM Messages & Chat Messages apiConversationHistory: ApiMessage[] = [] clineMessages: ClineMessage[] = [] @@ -536,6 +552,50 @@ export class Task extends EventEmitter implements TaskLike { this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) + // Build toolResultProcessorConfig by merging user settings over the defaults. + // Note: isSubscriber is intentionally NOT sourced from user settings — it comes + // from subscription status (always false until the subscription proxy lands). + const savedProcessorSettings = provider.getValue?.("toolResultProcessorSettings") + this.toolResultProcessorConfig = { + ...DEFAULT_PROCESSOR_CONFIG, + ...(savedProcessorSettings !== undefined + ? { + enabled: savedProcessorSettings.enabled, + thresholds: { + readFileCharsAbove: + savedProcessorSettings.readFileCharsAbove ?? + DEFAULT_PROCESSOR_CONFIG.thresholds.readFileCharsAbove, + searchMatchesAbove: + savedProcessorSettings.searchMatchesAbove ?? + DEFAULT_PROCESSOR_CONFIG.thresholds.searchMatchesAbove, + listFilesCountAbove: + savedProcessorSettings.listFilesCountAbove ?? + DEFAULT_PROCESSOR_CONFIG.thresholds.listFilesCountAbove, + }, + } + : {}), + } + + // Initialize processors with null (no compression) immediately — updated async below + this.toolResultProcessor = new ToolResultProcessor(null) + this.completionPostProcessor = new CompletionPostProcessor(null) + + // Resolve compression handler asynchronously (checks subscription via Zoo Code API) + const zooCodeApiKey = provider.contextProxy?.getSecret("zooCodeApiKey") as string | undefined + const zooCodeBaseUrl = (provider.getValue?.("zooCodeBaseUrl") as string | undefined) ?? "https://zoocode.dev" + + resolveCompressionHandler(zooCodeApiKey, zooCodeBaseUrl) + .then((handler) => { + this.toolResultProcessor = new ToolResultProcessor(handler) + this.completionPostProcessor = new CompletionPostProcessor(handler) + if (handler) { + this.toolResultProcessorConfig = { ...this.toolResultProcessorConfig, isSubscriber: true } + } + }) + .catch(() => { + // Keep null processors on error — graceful degradation + }) + // Initialize todo list if provided if (initialTodos && initialTodos.length > 0) { this.todoList = initialTodos @@ -2624,7 +2684,16 @@ export class Task extends EventEmitter implements TaskLike { } } - const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) + const rawEnvironmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) + + // Diff environment details to avoid resending unchanged sections on turn 2+. + const currentSections = parseEnvironmentSections(rawEnvironmentDetails) + const { sections: filteredSections, wasFiltered } = diffEnvironmentDetails( + this.lastEnvironmentSections, + currentSections, + ) + this.lastEnvironmentSections = currentSections + const environmentDetails = assembleEnvironmentDetails(filteredSections, wasFiltered) // Remove any existing environment_details blocks before adding fresh ones. // This prevents duplicate environment details when resuming tasks, @@ -4274,12 +4343,15 @@ export class Task extends EventEmitter implements TaskLike { // Reset the flag after using it this.skipPrevResponseIdOnce = false - // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. - const stream = this.api.createMessage( - systemPrompt, + // Before the API call, compress old tool results for token savings. + // We compress a copy (cleanConversationHistory is already derived from the stored history), + // so this.apiConversationHistory remains intact for the UI and persistence. + const compressedHistory = compressOldToolResults( cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[], - metadata, ) + + // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. + const stream = this.api.createMessage(systemPrompt, compressedHistory, metadata) const iterator = stream[Symbol.asyncIterator]() // Set up abort handling - when the signal is aborted, clean up the controller reference diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index a70576d75f..19c71e2419 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -78,7 +78,13 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { task.consecutiveMistakeCount = 0 - await task.say("completion_result", result, undefined, false) + // Post-process the result for display only (display-only, does not affect history). + // The original `result` is preserved for pushToolResult / conversation history. + const displayResult = task.completionPostProcessor?.isAvailable + ? await task.completionPostProcessor.postProcess(result) + : result + + await task.say("completion_result", displayResult, undefined, false) // Check for subtask using parentTaskId (metadata-driven delegation) if (task.parentTaskId) { diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index f0d906fabd..5aed5615e8 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -9,6 +9,7 @@ import { VectorStoreSearchResult } from "../../services/code-index/interfaces" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { compressAndPushToolResult } from "./compressAndPush" interface CodebaseSearchParams { query: string @@ -122,7 +123,7 @@ Code Chunk: ${result.codeChunk} ) .join("\n")}` - pushToolResult(output) + await compressAndPushToolResult("codebase_search", output, query, task, callbacks.pushToolResult) } catch (error: any) { await handleError("codebase_search", error) } diff --git a/src/core/tools/CompletionPostProcessor.ts b/src/core/tools/CompletionPostProcessor.ts new file mode 100644 index 0000000000..da3188ca99 --- /dev/null +++ b/src/core/tools/CompletionPostProcessor.ts @@ -0,0 +1,61 @@ +import type { ApiHandler } from "../../api/index" + +/** + * Post-processes attempt_completion result text into a user-friendly summary. + * + * This is optional and display-only: + * - The full technical result stays in conversation history + * - The post-processed version is shown in the chat UI + * - Only runs for subscribers (requires LLM call) + */ +export class CompletionPostProcessor { + constructor(private readonly apiHandler: ApiHandler | null) {} + + /** + * Whether post-processing is available (requires an API handler). + */ + get isAvailable(): boolean { + return this.apiHandler !== null + } + + /** + * Post-process the completion result text. + * Returns the reformatted text, or the original if post-processing + * is unavailable or fails. + */ + async postProcess(resultText: string): Promise { + if (!this.apiHandler || resultText.length < 200) { + return resultText + } + + try { + const systemPrompt = this.getPostProcessingPrompt() + // Build a single-turn conversation + const messages = [{ role: "user" as const, content: [{ type: "text" as const, text: resultText }] }] + + let output = "" + const stream = this.apiHandler.createMessage(systemPrompt, messages as any) + for await (const chunk of stream) { + if (chunk.type === "text") { + output += chunk.text + } + } + + return output.trim() || resultText + } catch { + // Graceful degradation — return original on any error + return resultText + } + } + + private getPostProcessingPrompt(): string { + return `You are a concise technical writer. Reformat the following task completion summary into a clean, scannable format: +- Use bullet points for multiple items +- Bold key file names and actions +- Remove redundant phrasing +- Keep it under 200 words +- Preserve all technical details (file paths, function names, etc.) +- Do NOT add information that wasn't in the original +Output the reformatted summary only, with no preamble.` + } +} diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 8fcb917b13..08554a6c6f 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -20,6 +20,7 @@ import { Package } from "../../shared/package" import { t } from "../../i18n" import { getTaskDirectoryPath } from "../../utils/storage" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { compressAndPushToolResult } from "./compressAndPush" class ShellIntegrationError extends Error {} @@ -114,7 +115,17 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { task.didRejectTool = true } - pushToolResult(result) + if (typeof result === "string") { + await compressAndPushToolResult( + "execute_command", + result, + canonicalCommand, + task, + callbacks.pushToolResult, + ) + } else { + pushToolResult(result) + } } catch (error: unknown) { const status: CommandExecutionStatus = { executionId, status: "fallback" } provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) @@ -133,7 +144,17 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { task.didRejectTool = true } - pushToolResult(result) + if (typeof result === "string") { + await compressAndPushToolResult( + "execute_command", + result, + canonicalCommand, + task, + callbacks.pushToolResult, + ) + } else { + pushToolResult(result) + } } else { pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) } diff --git a/src/core/tools/ListFilesTool.ts b/src/core/tools/ListFilesTool.ts index 716d7ed784..72e8c2c044 100644 --- a/src/core/tools/ListFilesTool.ts +++ b/src/core/tools/ListFilesTool.ts @@ -10,6 +10,7 @@ import { isPathOutsideWorkspace } from "../../utils/pathUtils" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { compressAndPushToolResult } from "./compressAndPush" interface ListFilesParams { path: string @@ -62,7 +63,7 @@ export class ListFilesTool extends BaseTool<"list_files"> { return } - pushToolResult(result) + await compressAndPushToolResult("list_files", result, relDirPath, task, callbacks.pushToolResult) } catch (error) { await handleError("listing files", error) } diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8ad6a3b33d..6e660c9e03 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -25,6 +25,8 @@ import { readWithIndentation, readWithSlice } from "../../integrations/misc/inde import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" +import { compressAndPushToolResult } from "./compressAndPush" + import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, @@ -240,7 +242,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { task.didToolFailInCurrentTurn = true } - this.buildAndPushResult(task, fileResults, pushToolResult) + await this.buildAndPushResult(task, fileResults, pushToolResult, filePath) } catch (error) { const relPath = filePath || "unknown" const errorMsg = error instanceof Error ? error.message : String(error) @@ -569,7 +571,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Build and push the final result to the tool output. */ - private buildAndPushResult(task: Task, fileResults: FileResult[], pushToolResult: PushToolResult): void { + private async buildAndPushResult( + task: Task, + fileResults: FileResult[], + pushToolResult: PushToolResult, + context: string, + ): Promise { const finalResult = fileResults .filter((r) => r.nativeContent) .map((r) => r.nativeContent) @@ -616,7 +623,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { } } } else { - pushToolResult(finalResult) + await compressAndPushToolResult("read_file", finalResult, context, task, pushToolResult) } } @@ -806,7 +813,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Push combined results - pushToolResult(results.join("\n\n---\n\n")) + const context = fileEntries.map((e) => e.path).join(", ") + await compressAndPushToolResult("read_file", results.join("\n\n---\n\n"), context, task, pushToolResult) } } diff --git a/src/core/tools/SearchFilesTool.ts b/src/core/tools/SearchFilesTool.ts index 3230c043e0..b6e9fe7617 100644 --- a/src/core/tools/SearchFilesTool.ts +++ b/src/core/tools/SearchFilesTool.ts @@ -9,6 +9,7 @@ import { regexSearchFiles } from "../../services/ripgrep" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { compressAndPushToolResult } from "./compressAndPush" interface SearchFilesParams { path: string @@ -65,7 +66,7 @@ export class SearchFilesTool extends BaseTool<"search_files"> { return } - pushToolResult(results) + await compressAndPushToolResult("search_files", results, regex, task, callbacks.pushToolResult) } catch (error) { await handleError("searching files", error as Error) } diff --git a/src/core/tools/ToolResultProcessor.ts b/src/core/tools/ToolResultProcessor.ts new file mode 100644 index 0000000000..fdd27c59dd --- /dev/null +++ b/src/core/tools/ToolResultProcessor.ts @@ -0,0 +1,183 @@ +import type { ApiHandler } from "../../api" +import type { ApiStreamTextChunk } from "../../api/transform/stream" +import type { ToolResultProcessorConfig } from "./ToolResultProcessorConfig" + +/** + * Set of tool names that support LLM-assisted compression. + */ +const COMPRESSIBLE_TOOLS = new Set(["read_file", "search_files", "list_files", "codebase_search", "execute_command"]) + +/** + * ToolResultProcessor intercepts raw tool output before it is stored in + * conversation history, decides whether LLM-assisted compression is warranted, + * and (when an API handler is provided) runs a cheap secondary API call to + * produce a focused result instead of a giant blob. + * + * LLM-assisted compression is subscription-only — free users receive only the + * Phase 1 hard truncation path. + * + * @example + * ```typescript + * const processor = new ToolResultProcessor(compressionHandler) + * if (processor.shouldCompress("read_file", rawResult, config)) { + * const compressed = await processor.compress("read_file", rawResult, taskContext, config) + * } + * ``` + */ +export class ToolResultProcessor { + private readonly compressionApiHandler: ApiHandler | null + + constructor(compressionApiHandler: ApiHandler | null = null) { + this.compressionApiHandler = compressionApiHandler + } + + /** + * Determines whether the given tool result should be compressed via LLM. + * + * Returns `true` only when ALL of the following hold: + * - `config.enabled` is `true` + * - `config.isSubscriber` is `true` (LLM compression is subscription-only) + * - `toolName` is in the supported set + * - The result exceeds the relevant threshold for that tool type + */ + shouldCompress(toolName: string, rawResult: string, config: ToolResultProcessorConfig): boolean { + if (!config.enabled) { + return false + } + + if (!config.isSubscriber) { + return false + } + + if (!COMPRESSIBLE_TOOLS.has(toolName)) { + return false + } + + return this._exceedsThreshold(toolName, rawResult, config) + } + + /** + * Compresses the raw tool result using a cheap LLM call. + * + * - If no API handler was provided at construction time, returns `rawResult` unchanged. + * - If the API call fails for any reason, gracefully degrades and returns `rawResult`. + * + * @param toolName - Name of the tool that produced the result + * @param rawResult - The full raw output from the tool + * @param context - Natural-language description of what the user is trying to do + * @param config - Processor configuration (thresholds, flags) + */ + async compress( + toolName: string, + rawResult: string, + context: string, + config: ToolResultProcessorConfig, + ): Promise { + if (!this.compressionApiHandler) { + return rawResult + } + + if (!this.shouldCompress(toolName, rawResult, config)) { + return rawResult + } + + try { + const systemPrompt = this.getCompressionPrompt(toolName, rawResult, context) + const stream = this.compressionApiHandler.createMessage( + systemPrompt, + [ + { + role: "user", + content: rawResult, + }, + ], + { toolName }, + ) + + let compressed = "" + for await (const chunk of stream) { + if (chunk.type === "text") { + compressed += (chunk as ApiStreamTextChunk).text + } + } + + return compressed.trim() || rawResult + } catch { + // Graceful degradation: return the original result on any error + return rawResult + } + } + + /** + * Returns a tool-specific system prompt for the compression LLM call. + * + * Each supported tool type gets a prompt tuned to its output structure. + * Unknown tool types receive a generic summarisation prompt. + * + * @param toolName - Name of the tool that produced the result + * @param rawResult - The full raw output (available for future prompt-tuning) + * @param context - Natural-language description of what the user is trying to do + */ + getCompressionPrompt(toolName: string, rawResult: string, context: string): string { + switch (toolName) { + case "read_file": + return `Extract the section of this file most relevant to: ${context}. Preserve exact code, line numbers, and structure.` + + case "search_files": + case "codebase_search": + return `From these search results, extract the top 5 most relevant matches with 2 lines of context each for: ${context}` + + case "list_files": + return `Summarize this directory listing into a structural overview, highlighting the most important files for: ${context}` + + case "execute_command": + return `Summarize this command output, preserving errors, warnings, and key information for: ${context}` + + default: + return `Summarize the following tool output, preserving the most important information for: ${context}` + } + } + + // ── private helpers ──────────────────────────────────────────────────────── + + /** + * Checks whether `rawResult` exceeds the threshold configured for `toolName`. + */ + private _exceedsThreshold(toolName: string, rawResult: string, config: ToolResultProcessorConfig): boolean { + const { thresholds } = config + + switch (toolName) { + case "read_file": + return rawResult.length > thresholds.readFileCharsAbove + + case "search_files": + case "codebase_search": + return this._countMatches(rawResult) > thresholds.searchMatchesAbove + + case "list_files": + return this._countPaths(rawResult) > thresholds.listFilesCountAbove + + case "execute_command": + return rawResult.length > thresholds.readFileCharsAbove + + default: + return false + } + } + + /** + * Heuristically counts the number of search matches in a raw result block. + * Each non-empty line is treated as contributing to a match count. + */ + private _countMatches(rawResult: string): number { + return rawResult.split("\n").filter((line) => line.trim().length > 0).length + } + + /** + * Heuristically counts the number of file paths in a directory listing. + * Each non-empty line is treated as one path entry. + */ + private _countPaths(rawResult: string): number { + return rawResult.split("\n").filter((line) => line.trim().length > 0).length + } +} diff --git a/src/core/tools/ToolResultProcessorConfig.ts b/src/core/tools/ToolResultProcessorConfig.ts new file mode 100644 index 0000000000..65a477117f --- /dev/null +++ b/src/core/tools/ToolResultProcessorConfig.ts @@ -0,0 +1,31 @@ +/** + * Configuration for the ToolResultProcessor. + * Controls thresholds for when compression kicks in. + */ +export interface ToolResultProcessorConfig { + /** Master switch — false disables all LLM compression */ + enabled: boolean + + /** Whether the user is a subscriber (LLM compression requires subscription) */ + isSubscriber: boolean + + /** Character thresholds per tool type. Results below these are not compressed. */ + thresholds: { + /** Compress read_file results above this many characters (default: 1500) */ + readFileCharsAbove: number + /** Compress search_files results above this many matches (default: 20) */ + searchMatchesAbove: number + /** Compress list_files results above this many paths (default: 100) */ + listFilesCountAbove: number + } +} + +export const DEFAULT_PROCESSOR_CONFIG: ToolResultProcessorConfig = { + enabled: true, + isSubscriber: false, + thresholds: { + readFileCharsAbove: 1500, + searchMatchesAbove: 20, + listFilesCountAbove: 100, + }, +} diff --git a/src/core/tools/__tests__/CompletionPostProcessor.spec.ts b/src/core/tools/__tests__/CompletionPostProcessor.spec.ts new file mode 100644 index 0000000000..b74420a9cd --- /dev/null +++ b/src/core/tools/__tests__/CompletionPostProcessor.spec.ts @@ -0,0 +1,77 @@ +import { CompletionPostProcessor } from "../CompletionPostProcessor" +import type { ApiHandler } from "../../../api/index" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeHandler(chunks: string[], shouldThrow = false): ApiHandler { + return { + createMessage: (_systemPrompt: string, _messages: unknown) => { + if (shouldThrow) { + throw new Error("API error") + } + // Return an async iterable that yields text chunks + return (async function* () { + for (const text of chunks) { + yield { type: "text" as const, text } + } + })() as any + }, + } as unknown as ApiHandler +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("CompletionPostProcessor", () => { + describe("isAvailable", () => { + it("returns false when handler is null", () => { + const processor = new CompletionPostProcessor(null) + expect(processor.isAvailable).toBe(false) + }) + + it("returns true when handler is provided", () => { + const processor = new CompletionPostProcessor(makeHandler(["ok"])) + expect(processor.isAvailable).toBe(true) + }) + }) + + describe("postProcess", () => { + it("returns original text when handler is null", async () => { + const processor = new CompletionPostProcessor(null) + const text = "A".repeat(300) + const result = await processor.postProcess(text) + expect(result).toBe(text) + }) + + it("returns original text when result is short (< 200 chars)", async () => { + const shortText = "Short result." + const processor = new CompletionPostProcessor(makeHandler(["reformatted"])) + const result = await processor.postProcess(shortText) + expect(result).toBe(shortText) + }) + + it("calls handler and returns reformatted text on success", async () => { + const longText = "A".repeat(200) + const processor = new CompletionPostProcessor(makeHandler(["**File created.** All tests pass."])) + const result = await processor.postProcess(longText) + expect(result).toBe("**File created.** All tests pass.") + }) + + it("concatenates multiple text chunks from stream", async () => { + const longText = "B".repeat(200) + const processor = new CompletionPostProcessor(makeHandler(["chunk1 ", "chunk2 ", "chunk3"])) + const result = await processor.postProcess(longText) + expect(result).toBe("chunk1 chunk2 chunk3") + }) + + it("returns original text on handler error (graceful degradation)", async () => { + const longText = "C".repeat(200) + const processor = new CompletionPostProcessor(makeHandler([], true)) + const result = await processor.postProcess(longText) + expect(result).toBe(longText) + }) + }) +}) diff --git a/src/core/tools/__tests__/ToolResultProcessor.spec.ts b/src/core/tools/__tests__/ToolResultProcessor.spec.ts new file mode 100644 index 0000000000..db893452b5 --- /dev/null +++ b/src/core/tools/__tests__/ToolResultProcessor.spec.ts @@ -0,0 +1,312 @@ +// Run: cd src && npx vitest run core/tools/__tests__/ToolResultProcessor.spec.ts + +import { ToolResultProcessor } from "../ToolResultProcessor" +import type { ToolResultProcessorConfig } from "../ToolResultProcessorConfig" +import { DEFAULT_PROCESSOR_CONFIG } from "../ToolResultProcessorConfig" + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeConfig(overrides: Partial = {}): ToolResultProcessorConfig { + return { + ...DEFAULT_PROCESSOR_CONFIG, + ...overrides, + thresholds: { + ...DEFAULT_PROCESSOR_CONFIG.thresholds, + ...(overrides.thresholds ?? {}), + }, + } +} + +/** Generates a string of `n` repeated characters */ +function repeat(char: string, n: number): string { + return char.repeat(n) +} + +/** Generates a list of `n` file paths (one per line) */ +function makePathList(n: number): string { + return Array.from({ length: n }, (_, i) => `src/file${i}.ts`).join("\n") +} + +/** Generates a list of `n` non-empty search-result lines */ +function makeMatchList(n: number): string { + return Array.from({ length: n }, (_, i) => `src/foo.ts:${i + 1}: match line ${i + 1}`).join("\n") +} + +// ── shouldCompress ──────────────────────────────────────────────────────────── + +describe("ToolResultProcessor.shouldCompress", () => { + const processor = new ToolResultProcessor(null) + + it("returns false when config.enabled is false", () => { + const config = makeConfig({ enabled: false, isSubscriber: true }) + const bigResult = repeat("x", 2000) + expect(processor.shouldCompress("read_file", bigResult, config)).toBe(false) + }) + + it("returns false when config.isSubscriber is false", () => { + const config = makeConfig({ enabled: true, isSubscriber: false }) + const bigResult = repeat("x", 2000) + expect(processor.shouldCompress("read_file", bigResult, config)).toBe(false) + }) + + it("returns false for unsupported tool names", () => { + const config = makeConfig({ enabled: true, isSubscriber: true }) + const bigResult = repeat("x", 2000) + expect(processor.shouldCompress("attempt_completion", bigResult, config)).toBe(false) + expect(processor.shouldCompress("write_to_file", bigResult, config)).toBe(false) + expect(processor.shouldCompress("apply_diff", bigResult, config)).toBe(false) + expect(processor.shouldCompress("unknown_tool", bigResult, config)).toBe(false) + }) + + it("returns true for read_file when result exceeds threshold", () => { + const config = makeConfig({ + enabled: true, + isSubscriber: true, + thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + }) + const bigResult = repeat("x", 1501) + expect(processor.shouldCompress("read_file", bigResult, config)).toBe(true) + }) + + it("returns false for read_file when result is below threshold", () => { + const config = makeConfig({ + enabled: true, + isSubscriber: true, + thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + }) + const smallResult = repeat("x", 1000) + expect(processor.shouldCompress("read_file", smallResult, config)).toBe(false) + }) + + it("returns false for read_file when result is exactly at threshold", () => { + const config = makeConfig({ + enabled: true, + isSubscriber: true, + thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + }) + const exactResult = repeat("x", 1500) + expect(processor.shouldCompress("read_file", exactResult, config)).toBe(false) + }) + + it("returns true for search_files with many matches", () => { + const config = makeConfig({ enabled: true, isSubscriber: true }) + const manyMatches = makeMatchList(21) + expect(processor.shouldCompress("search_files", manyMatches, config)).toBe(true) + }) + + it("returns false for search_files with few matches", () => { + const config = makeConfig({ enabled: true, isSubscriber: true }) + const fewMatches = makeMatchList(10) + expect(processor.shouldCompress("search_files", fewMatches, config)).toBe(false) + }) + + it("returns true for codebase_search with many matches", () => { + const config = makeConfig({ enabled: true, isSubscriber: true }) + const manyMatches = makeMatchList(21) + expect(processor.shouldCompress("codebase_search", manyMatches, config)).toBe(true) + }) + + it("returns true for list_files with many paths", () => { + const config = makeConfig({ enabled: true, isSubscriber: true }) + const manyPaths = makePathList(101) + expect(processor.shouldCompress("list_files", manyPaths, config)).toBe(true) + }) + + it("returns false for list_files with few paths", () => { + const config = makeConfig({ enabled: true, isSubscriber: true }) + const fewPaths = makePathList(50) + expect(processor.shouldCompress("list_files", fewPaths, config)).toBe(false) + }) + + it("returns true for execute_command with large output", () => { + const config = makeConfig({ + enabled: true, + isSubscriber: true, + thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + }) + const bigOutput = repeat("x", 1501) + expect(processor.shouldCompress("execute_command", bigOutput, config)).toBe(true) + }) + + it("returns false for execute_command with small output", () => { + const config = makeConfig({ + enabled: true, + isSubscriber: true, + thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + }) + const smallOutput = repeat("x", 100) + expect(processor.shouldCompress("execute_command", smallOutput, config)).toBe(false) + }) +}) + +// ── compress ────────────────────────────────────────────────────────────────── + +describe("ToolResultProcessor.compress", () => { + it("returns raw result when no API handler is provided", async () => { + const processor = new ToolResultProcessor(null) + const config = makeConfig({ enabled: true, isSubscriber: true }) + const rawResult = repeat("x", 2000) + const result = await processor.compress("read_file", rawResult, "find the main function", config) + expect(result).toBe(rawResult) + }) + + it("returns raw result when shouldCompress would return false (disabled)", async () => { + const mockHandler = { + createMessage: vi.fn(), + getModel: vi.fn(), + countTokens: vi.fn(), + } + const processor = new ToolResultProcessor(mockHandler as any) + const config = makeConfig({ enabled: false, isSubscriber: true }) + const rawResult = repeat("x", 2000) + const result = await processor.compress("read_file", rawResult, "find the main function", config) + expect(result).toBe(rawResult) + expect(mockHandler.createMessage).not.toHaveBeenCalled() + }) + + it("returns raw result when shouldCompress would return false (not subscriber)", async () => { + const mockHandler = { + createMessage: vi.fn(), + getModel: vi.fn(), + countTokens: vi.fn(), + } + const processor = new ToolResultProcessor(mockHandler as any) + const config = makeConfig({ enabled: true, isSubscriber: false }) + const rawResult = repeat("x", 2000) + const result = await processor.compress("read_file", rawResult, "find the main function", config) + expect(result).toBe(rawResult) + expect(mockHandler.createMessage).not.toHaveBeenCalled() + }) + + it("calls the compression API handler when conditions are met", async () => { + const compressed = "compressed result" + + async function* fakeStream() { + yield { type: "text", text: compressed } + } + + const mockHandler = { + createMessage: vi.fn().mockReturnValue(fakeStream()), + getModel: vi.fn(), + countTokens: vi.fn(), + } + const processor = new ToolResultProcessor(mockHandler as any) + const config = makeConfig({ enabled: true, isSubscriber: true }) + const rawResult = repeat("x", 2000) + + const result = await processor.compress("read_file", rawResult, "find the main function", config) + + expect(mockHandler.createMessage).toHaveBeenCalledTimes(1) + expect(result).toBe(compressed) + }) + + it("accumulates multiple text chunks from the API stream", async () => { + async function* fakeStream() { + yield { type: "text", text: "part1 " } + yield { type: "usage", inputTokens: 10, outputTokens: 5 } // non-text chunk + yield { type: "text", text: "part2" } + } + + const mockHandler = { + createMessage: vi.fn().mockReturnValue(fakeStream()), + getModel: vi.fn(), + countTokens: vi.fn(), + } + const processor = new ToolResultProcessor(mockHandler as any) + const config = makeConfig({ enabled: true, isSubscriber: true }) + const rawResult = repeat("x", 2000) + + const result = await processor.compress("read_file", rawResult, "context", config) + expect(result).toBe("part1 part2") + }) + + it("returns raw result on API error (graceful degradation)", async () => { + const mockHandler = { + createMessage: vi.fn().mockImplementation(() => { + throw new Error("API unavailable") + }), + getModel: vi.fn(), + countTokens: vi.fn(), + } + const processor = new ToolResultProcessor(mockHandler as any) + const config = makeConfig({ enabled: true, isSubscriber: true }) + const rawResult = repeat("x", 2000) + + const result = await processor.compress("read_file", rawResult, "find the main function", config) + expect(result).toBe(rawResult) + }) + + it("returns raw result on async iteration error (graceful degradation)", async () => { + async function* failingStream() { + yield { type: "text", text: "partial" } + throw new Error("stream error") + } + + const mockHandler = { + createMessage: vi.fn().mockReturnValue(failingStream()), + getModel: vi.fn(), + countTokens: vi.fn(), + } + const processor = new ToolResultProcessor(mockHandler as any) + const config = makeConfig({ enabled: true, isSubscriber: true }) + const rawResult = repeat("x", 2000) + + const result = await processor.compress("read_file", rawResult, "context", config) + expect(result).toBe(rawResult) + }) +}) + +// ── getCompressionPrompt ────────────────────────────────────────────────────── + +describe("ToolResultProcessor.getCompressionPrompt", () => { + const processor = new ToolResultProcessor(null) + const rawResult = "some raw content" + const context = "find the authentication logic" + + it("returns a read_file-specific prompt that includes the context", () => { + const prompt = processor.getCompressionPrompt("read_file", rawResult, context) + expect(prompt).toContain(context) + expect(prompt).toContain("Extract") + expect(prompt).toContain("line numbers") + }) + + it("returns a search_files-specific prompt that includes the context", () => { + const prompt = processor.getCompressionPrompt("search_files", rawResult, context) + expect(prompt).toContain(context) + expect(prompt).toContain("top 5") + }) + + it("returns the same search prompt for codebase_search as for search_files", () => { + const searchPrompt = processor.getCompressionPrompt("search_files", rawResult, context) + const codebasePrompt = processor.getCompressionPrompt("codebase_search", rawResult, context) + expect(codebasePrompt).toBe(searchPrompt) + }) + + it("returns a list_files-specific prompt that includes the context", () => { + const prompt = processor.getCompressionPrompt("list_files", rawResult, context) + expect(prompt).toContain(context) + expect(prompt).toContain("directory listing") + }) + + it("returns an execute_command-specific prompt that includes the context", () => { + const prompt = processor.getCompressionPrompt("execute_command", rawResult, context) + expect(prompt).toContain(context) + expect(prompt).toContain("errors") + expect(prompt).toContain("warnings") + }) + + it("returns a generic prompt for unknown tool types", () => { + const prompt = processor.getCompressionPrompt("unknown_tool", rawResult, context) + expect(prompt).toContain(context) + expect(typeof prompt).toBe("string") + expect(prompt.length).toBeGreaterThan(0) + }) + + it("includes the context parameter in every supported tool prompt", () => { + const tools = ["read_file", "search_files", "list_files", "codebase_search", "execute_command"] + for (const tool of tools) { + const prompt = processor.getCompressionPrompt(tool, rawResult, context) + expect(prompt).toContain(context) + } + }) +}) diff --git a/src/core/tools/__tests__/compressAndPush.spec.ts b/src/core/tools/__tests__/compressAndPush.spec.ts new file mode 100644 index 0000000000..99637e96e6 --- /dev/null +++ b/src/core/tools/__tests__/compressAndPush.spec.ts @@ -0,0 +1,126 @@ +import { compressAndPushToolResult } from "../compressAndPush" +import { ToolResultProcessor } from "../ToolResultProcessor" +import { DEFAULT_PROCESSOR_CONFIG } from "../ToolResultProcessorConfig" + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a minimal Task-like mock that satisfies the fields accessed by + * compressAndPushToolResult (toolResultProcessor and toolResultProcessorConfig). + */ +function makeTask( + processorOverride?: Partial, + configOverride?: Partial, +) { + const config = { ...DEFAULT_PROCESSOR_CONFIG, ...configOverride } + const processor = new ToolResultProcessor(null) + + // Allow tests to override individual processor methods via a plain object + if (processorOverride) { + Object.assign(processor, processorOverride) + } + + return { toolResultProcessor: processor, toolResultProcessorConfig: config } as any +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("compressAndPushToolResult", () => { + test("when shouldCompress returns false, pushToolResult is called with the raw result", async () => { + const task = makeTask({ + shouldCompress: vi.fn().mockReturnValue(false), + }) + + const pushToolResult = vi.fn().mockResolvedValue(undefined) + const rawResult = "raw tool output" + + await compressAndPushToolResult("read_file", rawResult, "some/path.ts", task, pushToolResult) + + expect(pushToolResult).toHaveBeenCalledTimes(1) + expect(pushToolResult).toHaveBeenCalledWith(rawResult) + }) + + test("when shouldCompress returns true and compress succeeds, pushToolResult is called with the compressed result", async () => { + const compressedResult = "compressed output" + + const task = makeTask({ + shouldCompress: vi.fn().mockReturnValue(true), + compress: vi.fn().mockResolvedValue(compressedResult), + }) + + const pushToolResult = vi.fn().mockResolvedValue(undefined) + const rawResult = "a".repeat(5000) // large raw result + + await compressAndPushToolResult("read_file", rawResult, "src/app.ts", task, pushToolResult) + + expect(pushToolResult).toHaveBeenCalledTimes(1) + expect(pushToolResult).toHaveBeenCalledWith(compressedResult) + }) + + test("when shouldCompress returns true but compress returns raw (graceful degradation), pushToolResult is called with raw result", async () => { + const rawResult = "a".repeat(5000) + + const task = makeTask({ + shouldCompress: vi.fn().mockReturnValue(true), + // compress() falls back to rawResult on error (as ToolResultProcessor does) + compress: vi.fn().mockResolvedValue(rawResult), + }) + + const pushToolResult = vi.fn().mockResolvedValue(undefined) + + await compressAndPushToolResult("read_file", rawResult, "src/app.ts", task, pushToolResult) + + expect(pushToolResult).toHaveBeenCalledTimes(1) + expect(pushToolResult).toHaveBeenCalledWith(rawResult) + }) + + test("the context parameter is correctly passed through to compress()", async () => { + const compressSpy = vi.fn().mockResolvedValue("compressed") + const task = makeTask({ + shouldCompress: vi.fn().mockReturnValue(true), + compress: compressSpy, + }) + + const pushToolResult = vi.fn().mockResolvedValue(undefined) + const context = "the context string from tool params" + + await compressAndPushToolResult("search_files", "some results", context, task, pushToolResult) + + // The context is the third argument to compress() + expect(compressSpy).toHaveBeenCalledWith( + "search_files", + "some results", + context, + task.toolResultProcessorConfig, + ) + }) + + test("works correctly when processor config has compression disabled (shouldCompress=false)", async () => { + // Build a task with the real processor but compression disabled + const task = makeTask(undefined, { enabled: false }) + + const pushToolResult = vi.fn().mockResolvedValue(undefined) + const rawResult = "a".repeat(5000) + + await compressAndPushToolResult("read_file", rawResult, "src/app.ts", task, pushToolResult) + + // shouldCompress returns false when config.enabled is false, so raw result is used + expect(pushToolResult).toHaveBeenCalledTimes(1) + expect(pushToolResult).toHaveBeenCalledWith(rawResult) + }) + + test("works correctly when processor config marks user as non-subscriber (shouldCompress=false)", async () => { + // isSubscriber defaults to false in DEFAULT_PROCESSOR_CONFIG, + // so the real processor should return false from shouldCompress + const task = makeTask() // uses DEFAULT_PROCESSOR_CONFIG with isSubscriber=false + + const pushToolResult = vi.fn().mockResolvedValue(undefined) + const rawResult = "a".repeat(5000) + + await compressAndPushToolResult("read_file", rawResult, "src/app.ts", task, pushToolResult) + + // shouldCompress returns false because isSubscriber=false + expect(pushToolResult).toHaveBeenCalledTimes(1) + expect(pushToolResult).toHaveBeenCalledWith(rawResult) + }) +}) diff --git a/src/core/tools/__tests__/resolveCompressionHandler.spec.ts b/src/core/tools/__tests__/resolveCompressionHandler.spec.ts new file mode 100644 index 0000000000..05e9602dfe --- /dev/null +++ b/src/core/tools/__tests__/resolveCompressionHandler.spec.ts @@ -0,0 +1,141 @@ +// npx vitest run core/tools/__tests__/resolveCompressionHandler.spec.ts + +import { resolveCompressionHandler, clearSubscriptionCache } from "../resolveCompressionHandler" +import { ZooGatewayApiHandler } from "../../../api/providers/zoo-gateway" + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +// AbortSignal.timeout may not be available in the test environment +if (!AbortSignal.timeout) { + AbortSignal.timeout = (_ms: number) => new AbortController().signal +} + +describe("resolveCompressionHandler", () => { + beforeEach(() => { + mockFetch.mockReset() + // Clear the module-level subscription cache before each test + clearSubscriptionCache() + }) + + it("returns null when zooCodeApiKey is undefined", async () => { + const result = await resolveCompressionHandler(undefined) + expect(result).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("returns null when zooCodeApiKey is an empty string", async () => { + const result = await resolveCompressionHandler("") + expect(result).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("returns null when zooCodeApiKey is whitespace only", async () => { + const result = await resolveCompressionHandler(" ") + expect(result).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("returns null when fetch throws a network error (fail open)", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const result = await resolveCompressionHandler("zoo_sk_test") + expect(result).toBeNull() + }) + + it("returns null when subscription API returns non-ok response", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Unauthorized" }), + }) + + const result = await resolveCompressionHandler("zoo_sk_test") + expect(result).toBeNull() + }) + + it("returns null when subscription API returns { isSubscriber: false }", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSubscriber: false }), + }) + + const result = await resolveCompressionHandler("zoo_sk_test") + expect(result).toBeNull() + }) + + it("returns a ZooGatewayApiHandler when subscription API returns { isSubscriber: true }", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSubscriber: true }), + }) + + const result = await resolveCompressionHandler("zoo_sk_test") + expect(result).not.toBeNull() + expect(result).toBeInstanceOf(ZooGatewayApiHandler) + }) + + it("uses the provided baseUrl in the fetch request", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSubscriber: true }), + }) + + await resolveCompressionHandler("zoo_sk_test", "https://custom.example.com") + + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.example.com/api/subscription/status", + expect.objectContaining({ + headers: { Authorization: "Bearer zoo_sk_test" }, + }), + ) + }) + + it("caches subscription status and only fetches once for repeated calls with the same key", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSubscriber: true }), + }) + + const result1 = await resolveCompressionHandler("zoo_sk_cached") + const result2 = await resolveCompressionHandler("zoo_sk_cached") + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(result1).toBeInstanceOf(ZooGatewayApiHandler) + expect(result2).toBeInstanceOf(ZooGatewayApiHandler) + }) + + it("caches non-subscriber status and only fetches once for repeated calls with the same key", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSubscriber: false }), + }) + + const result1 = await resolveCompressionHandler("zoo_sk_free") + const result2 = await resolveCompressionHandler("zoo_sk_free") + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(result1).toBeNull() + expect(result2).toBeNull() + }) + + it("fetches again after clearSubscriptionCache is called for the specific key", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSubscriber: true }), + }) + + await resolveCompressionHandler("zoo_sk_refresh") + clearSubscriptionCache("zoo_sk_refresh") + await resolveCompressionHandler("zoo_sk_refresh") + + expect(mockFetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/core/tools/__tests__/toolCategories.spec.ts b/src/core/tools/__tests__/toolCategories.spec.ts new file mode 100644 index 0000000000..c4d24791e8 --- /dev/null +++ b/src/core/tools/__tests__/toolCategories.spec.ts @@ -0,0 +1,176 @@ +// Run: cd src && npx vitest run core/tools/__tests__/toolCategories.spec.ts + +import { isReadOnlyTool, partitionToolsForExecution, READ_ONLY_TOOLS } from "../toolCategories" + +describe("READ_ONLY_TOOLS", () => { + it("contains the four canonical read-only tools", () => { + expect(READ_ONLY_TOOLS.has("read_file")).toBe(true) + expect(READ_ONLY_TOOLS.has("list_files")).toBe(true) + expect(READ_ONLY_TOOLS.has("search_files")).toBe(true) + expect(READ_ONLY_TOOLS.has("codebase_search")).toBe(true) + }) + + it("does not contain write or interactive tools", () => { + expect(READ_ONLY_TOOLS.has("write_to_file" as any)).toBe(false) + expect(READ_ONLY_TOOLS.has("apply_diff" as any)).toBe(false) + expect(READ_ONLY_TOOLS.has("execute_command" as any)).toBe(false) + expect(READ_ONLY_TOOLS.has("attempt_completion" as any)).toBe(false) + }) +}) + +describe("isReadOnlyTool", () => { + it("returns true for read_file", () => { + expect(isReadOnlyTool("read_file")).toBe(true) + }) + + it("returns true for list_files", () => { + expect(isReadOnlyTool("list_files")).toBe(true) + }) + + it("returns true for search_files", () => { + expect(isReadOnlyTool("search_files")).toBe(true) + }) + + it("returns true for codebase_search", () => { + expect(isReadOnlyTool("codebase_search")).toBe(true) + }) + + it("returns false for write_to_file", () => { + expect(isReadOnlyTool("write_to_file")).toBe(false) + }) + + it("returns false for apply_diff", () => { + expect(isReadOnlyTool("apply_diff")).toBe(false) + }) + + it("returns false for execute_command", () => { + expect(isReadOnlyTool("execute_command")).toBe(false) + }) + + it("returns false for attempt_completion", () => { + expect(isReadOnlyTool("attempt_completion")).toBe(false) + }) + + it("returns false for new_task", () => { + expect(isReadOnlyTool("new_task")).toBe(false) + }) + + it("returns false for use_mcp_tool", () => { + expect(isReadOnlyTool("use_mcp_tool")).toBe(false) + }) + + it("returns false for an unknown/arbitrary string", () => { + expect(isReadOnlyTool("unknown_tool")).toBe(false) + expect(isReadOnlyTool("")).toBe(false) + }) +}) + +describe("partitionToolsForExecution", () => { + it("returns an empty array for an empty input", () => { + expect(partitionToolsForExecution([])).toEqual([]) + }) + + it("returns a single sequential group for a single write tool", () => { + const tools = [{ name: "write_to_file" }] + const result = partitionToolsForExecution(tools) + expect(result).toEqual([{ batch: [{ name: "write_to_file" }], parallel: false }]) + }) + + it("returns a single non-parallel group for a single read-only tool", () => { + const tools = [{ name: "read_file" }] + const result = partitionToolsForExecution(tools) + expect(result).toEqual([{ batch: [{ name: "read_file" }], parallel: false }]) + }) + + it("returns one parallel batch for all read-only tools", () => { + const tools = [ + { name: "read_file" }, + { name: "list_files" }, + { name: "search_files" }, + { name: "codebase_search" }, + ] + const result = partitionToolsForExecution(tools) + expect(result).toHaveLength(1) + expect(result[0].parallel).toBe(true) + expect(result[0].batch).toHaveLength(4) + }) + + it("returns individual sequential groups for all write tools", () => { + const tools = [{ name: "write_to_file" }, { name: "apply_diff" }, { name: "execute_command" }] + const result = partitionToolsForExecution(tools) + expect(result).toHaveLength(3) + expect(result.every((g) => g.parallel === false)).toBe(true) + expect(result.every((g) => g.batch.length === 1)).toBe(true) + }) + + it("batches leading read-only tools, then sequential write, then trailing read-only batch", () => { + // [read_file, read_file, write_to_file, read_file] + const tools = [ + { name: "read_file" }, + { name: "list_files" }, + { name: "write_to_file" }, + { name: "search_files" }, + ] + const result = partitionToolsForExecution(tools) + expect(result).toHaveLength(3) + + // First group: two read-only tools → parallel + expect(result[0].parallel).toBe(true) + expect(result[0].batch.map((t) => t.name)).toEqual(["read_file", "list_files"]) + + // Second group: write tool → sequential + expect(result[1].parallel).toBe(false) + expect(result[1].batch.map((t) => t.name)).toEqual(["write_to_file"]) + + // Third group: single read-only tool → NOT parallel (batch.length === 1) + expect(result[2].parallel).toBe(false) + expect(result[2].batch.map((t) => t.name)).toEqual(["search_files"]) + }) + + it("produces non-parallel group for exactly two consecutive read-only tools separated by writes", () => { + // [read_file, write_to_file, read_file, read_file] + const tools = [ + { name: "read_file" }, + { name: "write_to_file" }, + { name: "list_files" }, + { name: "codebase_search" }, + ] + const result = partitionToolsForExecution(tools) + expect(result).toHaveLength(3) + + // First group: single read-only → not parallel + expect(result[0].parallel).toBe(false) + expect(result[0].batch).toHaveLength(1) + + // Second group: write tool → not parallel + expect(result[1].parallel).toBe(false) + + // Third group: two consecutive read-only → parallel + expect(result[2].parallel).toBe(true) + expect(result[2].batch).toHaveLength(2) + }) + + it("handles write tool surrounded by read-only tools creating three groups", () => { + const tools = [{ name: "codebase_search" }, { name: "apply_diff" }, { name: "read_file" }] + const result = partitionToolsForExecution(tools) + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ batch: [{ name: "codebase_search" }], parallel: false }) + expect(result[1]).toEqual({ batch: [{ name: "apply_diff" }], parallel: false }) + expect(result[2]).toEqual({ batch: [{ name: "read_file" }], parallel: false }) + }) + + it("preserves original tool objects in batches (no mutation)", () => { + const t1 = { name: "read_file", id: "abc" } + const t2 = { name: "list_files", id: "def" } + const result = partitionToolsForExecution([t1, t2]) + expect(result[0].batch[0]).toBe(t1) + expect(result[0].batch[1]).toBe(t2) + }) + + it("correctly handles attempt_completion as sequential", () => { + const tools = [{ name: "attempt_completion" }] + const result = partitionToolsForExecution(tools) + expect(result).toHaveLength(1) + expect(result[0].parallel).toBe(false) + }) +}) diff --git a/src/core/tools/compressAndPush.ts b/src/core/tools/compressAndPush.ts new file mode 100644 index 0000000000..cb82901cc9 --- /dev/null +++ b/src/core/tools/compressAndPush.ts @@ -0,0 +1,34 @@ +import { Task } from "../task/Task" +import type { PushToolResult } from "../../shared/tools" + +/** + * Wraps the pushToolResult callback to optionally compress tool results + * before they enter conversation history. + * + * The raw result is pushed to the UI message (for display), but a compressed + * version is pushed to the API conversation history (what the LLM sees). + * + * @param toolName - The tool that produced this result + * @param rawResult - The full, uncompressed tool result + * @param context - What the user's model was looking for (from tool params) + * @param task - The Task instance (has toolResultProcessor and config) + * @param pushToolResult - The original pushToolResult callback + */ +export async function compressAndPushToolResult( + toolName: string, + rawResult: string, + context: string, + task: Task, + pushToolResult: PushToolResult, +): Promise { + const { toolResultProcessor, toolResultProcessorConfig } = task + + if (toolResultProcessor?.shouldCompress(toolName, rawResult, toolResultProcessorConfig)) { + const compressed = await toolResultProcessor.compress(toolName, rawResult, context, toolResultProcessorConfig) + // Push compressed result (which is what enters conversation history) + await pushToolResult(compressed) + } else { + // Push raw result as-is + await pushToolResult(rawResult) + } +} diff --git a/src/core/tools/resolveCompressionHandler.ts b/src/core/tools/resolveCompressionHandler.ts new file mode 100644 index 0000000000..08b41e52db --- /dev/null +++ b/src/core/tools/resolveCompressionHandler.ts @@ -0,0 +1,65 @@ +import { type ApiHandler } from "../../api/index" +import { ZooGatewayApiHandler } from "../../api/providers/zoo-gateway" + +// 1-hour cache: key → { isSubscriber, expiresAt } +const subscriptionCache = new Map() +const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour + +/** + * Resolves the API handler for LLM-assisted tool result compression. + * + * - Checks subscription status via Zoo Code API (cached 1hr per key) + * - Subscribers → ZooGatewayApiHandler (routes through website → Vercel AI Gateway) + * - Free users or missing key → null (Phase 1 hard truncation only) + */ +export async function resolveCompressionHandler( + zooCodeApiKey: string | undefined, + baseUrl: string = "https://zoocode.dev", +): Promise { + if (!zooCodeApiKey?.trim()) { + return null + } + + const key = zooCodeApiKey.trim() + + // Check cache + const cached = subscriptionCache.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.isSubscriber ? new ZooGatewayApiHandler(baseUrl, key) : null + } + + // Fetch subscription status + try { + const response = await fetch(`${baseUrl}/api/subscription/status`, { + headers: { Authorization: `Bearer ${key}` }, + signal: AbortSignal.timeout(5_000), + }) + + if (!response.ok) { + // 401 = invalid key, 403 = free plan — cache as non-subscriber + subscriptionCache.set(key, { isSubscriber: false, expiresAt: Date.now() + CACHE_TTL_MS }) + return null + } + + const status = await response.json() + const isSubscriber = status.isSubscriber === true + + subscriptionCache.set(key, { isSubscriber, expiresAt: Date.now() + CACHE_TTL_MS }) + return isSubscriber ? new ZooGatewayApiHandler(baseUrl, key) : null + } catch (err) { + // Network error — fail open (don't block task startup) + console.warn("[resolveCompressionHandler] Failed to check subscription status:", err) + return null + } +} + +/** + * Clear the subscription cache for a specific key (call when user saves new key in settings). + */ +export function clearSubscriptionCache(zooCodeApiKey?: string): void { + if (zooCodeApiKey) { + subscriptionCache.delete(zooCodeApiKey) + } else { + subscriptionCache.clear() + } +} diff --git a/src/core/tools/toolCategories.ts b/src/core/tools/toolCategories.ts new file mode 100644 index 0000000000..a92d9a25f1 --- /dev/null +++ b/src/core/tools/toolCategories.ts @@ -0,0 +1,61 @@ +import type { ToolName } from "@roo-code/types" + +/** + * Tools that only READ data and do not modify the workspace. + * These are safe to execute in parallel when called together. + */ +export const READ_ONLY_TOOLS: ReadonlySet = new Set([ + "read_file", + "list_files", + "search_files", + "codebase_search", +]) + +/** + * Check if a tool is read-only and safe for parallel execution. + */ +export function isReadOnlyTool(toolName: string): boolean { + return READ_ONLY_TOOLS.has(toolName as ToolName) +} + +/** + * Given a list of tool blocks, partition them into groups that can + * be executed in parallel. Consecutive read-only tools form a parallel batch. + * Any non-read-only tool breaks the batch and forms its own sequential group. + * + * Example: [read_file, read_file, write_to_file, read_file] + * → [[read_file, read_file], [write_to_file], [read_file]] + * The first two run in parallel, then write_to_file runs alone, then the last read_file. + */ +export function partitionToolsForExecution( + tools: T[], +): { batch: T[]; parallel: boolean }[] { + if (tools.length === 0) { + return [] + } + + const groups: { batch: T[]; parallel: boolean }[] = [] + let currentBatch: T[] = [] + + for (const tool of tools) { + if (isReadOnlyTool(tool.name)) { + // Accumulate consecutive read-only tools into a parallel batch + currentBatch.push(tool) + } else { + // Flush any accumulated read-only batch first + if (currentBatch.length > 0) { + groups.push({ batch: currentBatch, parallel: currentBatch.length > 1 }) + currentBatch = [] + } + // Non-read-only tools always form their own sequential group + groups.push({ batch: [tool], parallel: false }) + } + } + + // Flush any remaining read-only tools + if (currentBatch.length > 0) { + groups.push({ batch: currentBatch, parallel: currentBatch.length > 1 }) + } + + return groups +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 75abd706ec..9acd4088f9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -76,6 +76,7 @@ import { getCommand } from "../../utils/commands" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" +import { clearSubscriptionCache } from "../tools/resolveCompressionHandler" import { setPendingTodoList } from "../tools/UpdateTodoListTool" import { handleListWorktrees, @@ -750,6 +751,10 @@ export const webviewMessageHandler = async ( } await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + + if (key === "zooCodeApiKey") { + clearSubscriptionCache() + } } await provider.postStateToWebview() diff --git a/src/package.json b/src/package.json index 359ba3bdcc..38be56d825 100644 --- a/src/package.json +++ b/src/package.json @@ -414,6 +414,16 @@ "default": false, "description": "%settings.debugProxy.tlsInsecure.description%", "markdownDescription": "%settings.debugProxy.tlsInsecure.description%" + }, + "roo-cline.zooCodeApiKey": { + "type": "string", + "default": "", + "description": "Zoo Code API key for subscription features (smart compression). Generate at https://zoocode.dev/dashboard/api-tokens" + }, + "roo-cline.zooCodeBaseUrl": { + "type": "string", + "default": "https://zoocode.dev", + "description": "Zoo Code API base URL (for development/staging overrides)" } } } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e..c5402df95a 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -31,6 +31,8 @@ import { GraduationCap, } from "lucide-react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + import { type ProviderSettings, type ExperimentId, @@ -332,6 +334,16 @@ const SettingsView = forwardRef(({ onDone, t }) }, []) + const setZooCodeApiKey = useCallback((apiKey: string) => { + setCachedState((prevState) => { + if ((prevState as any).zooCodeApiKey !== apiKey) { + setChangeDetected(true) + } + + return { ...prevState, zooCodeApiKey: apiKey } + }) + }, []) + const setImageGenerationSelectedModel = useCallback((model: string) => { setCachedState((prevState) => { if (prevState.openRouterImageGenerationSelectedModel !== model) { @@ -420,6 +432,7 @@ const SettingsView = forwardRef(({ onDone, t imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + zooCodeApiKey: (cachedState as any).zooCodeApiKey, experiments, customSupportPrompts, }, @@ -919,14 +932,42 @@ const SettingsView = forwardRef(({ onDone, t )} - {/* About Section */} + {/* Zoo Code Subscription Section */} {renderTab === "about" && ( - +
+ Zoo Code Subscription + +
+
+ + setZooCodeApiKey(e.target.value)} + placeholder="zoo_sk_..." + className="w-full" + type="password" + /> +

+ Get your API key at{" "} + + zoocode.dev/dashboard/api-tokens + +

+
+
+ + {/* About Section */} + +
)} From ade240874bf5431f84777a1c056839aee18c9db6 Mon Sep 17 00:00:00 2001 From: James Mtendamema Date: Sat, 2 May 2026 22:17:49 -0600 Subject: [PATCH 2/3] fixed coderabbit comments --- packages/types/src/global-settings.ts | 10 +++--- src/api/providers/zoo-gateway.ts | 4 ++- src/core/task/Task.ts | 4 +++ src/core/tools/CompletionPostProcessor.ts | 7 ++++ src/core/tools/ToolResultProcessor.ts | 2 +- src/core/tools/ToolResultProcessorConfig.ts | 3 ++ .../__tests__/ToolResultProcessor.spec.ts | 35 ++++++++++++++++--- .../src/components/settings/SettingsView.tsx | 8 +++-- webview-ui/src/i18n/locales/en/settings.json | 5 +++ 9 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index ccaface7a5..4e86ae6417 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -240,13 +240,15 @@ export const globalSettingsSchema = z.object({ toolResultProcessorSettings: z .object({ /** Master switch — false disables all compression */ - enabled: z.boolean(), + enabled: z.boolean().optional(), /** Compress read_file results above this many characters (default: 1500) */ - readFileCharsAbove: z.number(), + readFileCharsAbove: z.number().optional(), /** Compress search_files results above this many matches (default: 20) */ - searchMatchesAbove: z.number(), + searchMatchesAbove: z.number().optional(), /** Compress list_files results above this many paths (default: 100) */ - listFilesCountAbove: z.number(), + listFilesCountAbove: z.number().optional(), + /** Compress execute_command results above this many characters (default: 1500) */ + executeCommandCharsAbove: z.number().optional(), }) .optional(), diff --git a/src/api/providers/zoo-gateway.ts b/src/api/providers/zoo-gateway.ts index f5fd24fcea..5df31c61c9 100644 --- a/src/api/providers/zoo-gateway.ts +++ b/src/api/providers/zoo-gateway.ts @@ -55,7 +55,9 @@ export class ZooGatewayApiHandler extends BaseProvider { if (response.ok) { const data = await response.json() - compressed = data.compressed ?? rawResult + if (typeof data?.compressed === "string") { + compressed = data.compressed + } } } catch (err) { // Network error, timeout, etc. — gracefully fall back to raw diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3acef7c08d..294c303efa 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -571,6 +571,9 @@ export class Task extends EventEmitter implements TaskLike { listFilesCountAbove: savedProcessorSettings.listFilesCountAbove ?? DEFAULT_PROCESSOR_CONFIG.thresholds.listFilesCountAbove, + executeCommandCharsAbove: + savedProcessorSettings.executeCommandCharsAbove ?? + DEFAULT_PROCESSOR_CONFIG.thresholds.executeCommandCharsAbove, }, } : {}), @@ -1087,6 +1090,7 @@ export class Task extends EventEmitter implements TaskLike { async overwriteApiConversationHistory(newHistory: ApiMessage[]) { this.apiConversationHistory = newHistory + this.lastEnvironmentSections = null await this.saveApiConversationHistory() } diff --git a/src/core/tools/CompletionPostProcessor.ts b/src/core/tools/CompletionPostProcessor.ts index da3188ca99..5dec6f855c 100644 --- a/src/core/tools/CompletionPostProcessor.ts +++ b/src/core/tools/CompletionPostProcessor.ts @@ -34,13 +34,20 @@ export class CompletionPostProcessor { const messages = [{ role: "user" as const, content: [{ type: "text" as const, text: resultText }] }] let output = "" + let hadError = false const stream = this.apiHandler.createMessage(systemPrompt, messages as any) for await (const chunk of stream) { if (chunk.type === "text") { output += chunk.text + } else if (chunk.type === "error") { + hadError = true } } + if (hadError) { + return resultText + } + return output.trim() || resultText } catch { // Graceful degradation — return original on any error diff --git a/src/core/tools/ToolResultProcessor.ts b/src/core/tools/ToolResultProcessor.ts index fdd27c59dd..21d425e13b 100644 --- a/src/core/tools/ToolResultProcessor.ts +++ b/src/core/tools/ToolResultProcessor.ts @@ -158,7 +158,7 @@ export class ToolResultProcessor { return this._countPaths(rawResult) > thresholds.listFilesCountAbove case "execute_command": - return rawResult.length > thresholds.readFileCharsAbove + return rawResult.length > thresholds.executeCommandCharsAbove default: return false diff --git a/src/core/tools/ToolResultProcessorConfig.ts b/src/core/tools/ToolResultProcessorConfig.ts index 65a477117f..99d46b0097 100644 --- a/src/core/tools/ToolResultProcessorConfig.ts +++ b/src/core/tools/ToolResultProcessorConfig.ts @@ -17,6 +17,8 @@ export interface ToolResultProcessorConfig { searchMatchesAbove: number /** Compress list_files results above this many paths (default: 100) */ listFilesCountAbove: number + /** Compress execute_command results above this many characters (default: 1500) */ + executeCommandCharsAbove: number } } @@ -27,5 +29,6 @@ export const DEFAULT_PROCESSOR_CONFIG: ToolResultProcessorConfig = { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100, + executeCommandCharsAbove: 1500, }, } diff --git a/src/core/tools/__tests__/ToolResultProcessor.spec.ts b/src/core/tools/__tests__/ToolResultProcessor.spec.ts index db893452b5..1b9fa415c7 100644 --- a/src/core/tools/__tests__/ToolResultProcessor.spec.ts +++ b/src/core/tools/__tests__/ToolResultProcessor.spec.ts @@ -62,7 +62,12 @@ describe("ToolResultProcessor.shouldCompress", () => { const config = makeConfig({ enabled: true, isSubscriber: true, - thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + thresholds: { + readFileCharsAbove: 1500, + searchMatchesAbove: 20, + listFilesCountAbove: 100, + executeCommandCharsAbove: 1500, + }, }) const bigResult = repeat("x", 1501) expect(processor.shouldCompress("read_file", bigResult, config)).toBe(true) @@ -72,7 +77,12 @@ describe("ToolResultProcessor.shouldCompress", () => { const config = makeConfig({ enabled: true, isSubscriber: true, - thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + thresholds: { + readFileCharsAbove: 1500, + searchMatchesAbove: 20, + listFilesCountAbove: 100, + executeCommandCharsAbove: 1500, + }, }) const smallResult = repeat("x", 1000) expect(processor.shouldCompress("read_file", smallResult, config)).toBe(false) @@ -82,7 +92,12 @@ describe("ToolResultProcessor.shouldCompress", () => { const config = makeConfig({ enabled: true, isSubscriber: true, - thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + thresholds: { + readFileCharsAbove: 1500, + searchMatchesAbove: 20, + listFilesCountAbove: 100, + executeCommandCharsAbove: 1500, + }, }) const exactResult = repeat("x", 1500) expect(processor.shouldCompress("read_file", exactResult, config)).toBe(false) @@ -122,7 +137,12 @@ describe("ToolResultProcessor.shouldCompress", () => { const config = makeConfig({ enabled: true, isSubscriber: true, - thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + thresholds: { + readFileCharsAbove: 1500, + searchMatchesAbove: 20, + listFilesCountAbove: 100, + executeCommandCharsAbove: 1500, + }, }) const bigOutput = repeat("x", 1501) expect(processor.shouldCompress("execute_command", bigOutput, config)).toBe(true) @@ -132,7 +152,12 @@ describe("ToolResultProcessor.shouldCompress", () => { const config = makeConfig({ enabled: true, isSubscriber: true, - thresholds: { readFileCharsAbove: 1500, searchMatchesAbove: 20, listFilesCountAbove: 100 }, + thresholds: { + readFileCharsAbove: 1500, + searchMatchesAbove: 20, + listFilesCountAbove: 100, + executeCommandCharsAbove: 1500, + }, }) const smallOutput = repeat("x", 100) expect(processor.shouldCompress("execute_command", smallOutput, config)).toBe(false) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c5402df95a..cdc653a607 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -935,11 +935,13 @@ const SettingsView = forwardRef(({ onDone, t {/* Zoo Code Subscription Section */} {renderTab === "about" && (
- Zoo Code Subscription + {t("settings:zooCodeSubscription.sectionTitle")}
- + setZooCodeApiKey(e.target.value)} @@ -948,7 +950,7 @@ const SettingsView = forwardRef(({ onDone, t type="password" />

- Get your API key at{" "} + {t("settings:zooCodeSubscription.apiKeyDescription")}{" "} Date: Sat, 2 May 2026 22:37:18 -0600 Subject: [PATCH 3/3] fixed translations --- webview-ui/src/i18n/locales/ca/settings.json | 5 +++++ webview-ui/src/i18n/locales/de/settings.json | 5 +++++ webview-ui/src/i18n/locales/es/settings.json | 5 +++++ webview-ui/src/i18n/locales/fr/settings.json | 5 +++++ webview-ui/src/i18n/locales/hi/settings.json | 5 +++++ webview-ui/src/i18n/locales/id/settings.json | 5 +++++ webview-ui/src/i18n/locales/it/settings.json | 5 +++++ webview-ui/src/i18n/locales/ja/settings.json | 5 +++++ webview-ui/src/i18n/locales/ko/settings.json | 5 +++++ webview-ui/src/i18n/locales/nl/settings.json | 5 +++++ webview-ui/src/i18n/locales/pl/settings.json | 5 +++++ webview-ui/src/i18n/locales/pt-BR/settings.json | 5 +++++ webview-ui/src/i18n/locales/ru/settings.json | 5 +++++ webview-ui/src/i18n/locales/tr/settings.json | 5 +++++ webview-ui/src/i18n/locales/vi/settings.json | 5 +++++ webview-ui/src/i18n/locales/zh-CN/settings.json | 5 +++++ webview-ui/src/i18n/locales/zh-TW/settings.json | 5 +++++ 17 files changed, 85 insertions(+) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 19a1d23edb..44553d0a03 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "La descripció ha de tenir com a màxim 1024 caràcters" }, "footer": "Crea les teves pròpies skills amb el mode Skill Writer, disponible al Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Obteniu la vostra clau API a" } } diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 2164d79f9d..a82396200c 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Beschreibung muss 1024 Zeichen oder weniger sein" }, "footer": "Erstelle deine eigenen Skills mit dem Skill Writer Modus, verfügbar im Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Holen Sie sich Ihren API Key unter" } } diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 57f7530bc7..562a00b4cc 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "La descripción debe tener como máximo 1024 caracteres" }, "footer": "Crea tus propias skills con el modo Skill Writer, disponible en el Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Obtén tu API Key en" } } diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c39817c807..e86a348d61 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "La description doit contenir au maximum 1024 caractères" }, "footer": "Créez vos propres skills avec le mode Skill Writer, disponible dans le Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Obtenez votre API Key sur" } } diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index e8072e7a49..e8bc616cb1 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "विवरण 1024 वर्णों से अधिक नहीं होना चाहिए" }, "footer": "Skill Writer मोड के साथ अपने स्वयं के Skills बनाएं, Modes Marketplace में उपलब्ध।" + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "अपनी API Key यहाँ प्राप्त करें" } } diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 4437665da5..86128d0275 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Deskripsi harus 1024 karakter atau kurang" }, "footer": "Buat skills Anda sendiri dengan mode Skill Writer, tersedia di Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Dapatkan API Key Anda di" } } diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4a8ca778e7..ec6f227578 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "La descrizione deve essere di massimo 1024 caratteri" }, "footer": "Crea le tue skill con la modalità Skill Writer, disponibile in Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Ottieni la tua API Key su" } } diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 76a3c32aa2..99bb083909 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "説明は1024文字以内である必要があります" }, "footer": "スキルライターモードで独自のスキルを作成します。モードマーケットプレイスで入手可能です。" + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "API Keyはこちらで取得できます" } } diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 601d47e7cb..2e6c6c2469 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "설명은 1024자 이하여야 합니다" }, "footer": "스킬 작성자 모드로 자신만의 스킬을 만들 수 있습니다. 모드 마켓플레이스에서 사용 가능합니다." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "API Key를 여기서 받으세요" } } diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index a3205275a3..f4991d9350 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Beschrijving moet maximaal 1024 tekens zijn" }, "footer": "Maak je eigen skills met de Skill Writer modus, beschikbaar in de Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Haal uw API Key op bij" } } diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 15003c6214..4a489e08f3 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Opis musi mieć maksymalnie 1024 znaki" }, "footer": "Twórz własne umiejętności za pomocą trybu Skill Writer, dostępnego w Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Uzyskaj swój API Key na" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index e138411297..c3e5b21b40 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "A descrição deve ter no máximo 1024 caracteres" }, "footer": "Crie suas próprias skills com o modo Skill Writer, disponível em Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Obtenha sua API Key em" } } diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5866c06b94..c7635e145c 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Описание должно быть не более 1024 символов" }, "footer": "Создавайте собственные навыки с режимом Skill Writer, доступным в Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Получите свой API Key на" } } diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 4b42b87da1..2b28404e0d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Açıklama en fazla 1024 karakter olmalıdır" }, "footer": "Skill Writer modu ile kendi becerilerinizi oluşturun. Modes Marketplace'de mevcut." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "API Key'inizi şuradan alın" } } diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 80cff34475..70fef1bf29 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "Mô tả phải có tối đa 1024 ký tự" }, "footer": "Tạo các skill của riêng bạn với chế độ Skill Writer, có sẵn tại Modes Marketplace." + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Nhận API Key của bạn tại" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 76885a89ba..d48f6141fa 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "描述不得超过1024个字符" }, "footer": "使用技能编写器模式创建您自己的技能,可在 模式市集 中获得。" + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "在此处获取您的 API Key" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 726fcb13da..cf4d1892eb 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1007,5 +1007,10 @@ "descriptionTooLong": "說明不得超過1024個字元" }, "footer": "使用技能編寫器模式建立您自己的技能,可在 模式市集 中取得。" + }, + "zooCodeSubscription": { + "sectionTitle": "Zoo Code Subscription", + "apiKeyLabel": "API Key", + "apiKeyDescription": "在此處取得您的 API Key" } }