From 613ffb8ca9847fcb3ff99beec6f191b25cd85f9b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 9 Jan 2026 14:28:59 -0800 Subject: [PATCH 1/5] fix: context length fixes, truncation, etc --- .../cli/src/tools/runTerminalCommand.ts | 27 +-- .../cli/src/util/truncateOutput.test.ts | 219 ++++++++++++++++++ extensions/cli/src/util/truncateOutput.ts | 81 +++++++ 3 files changed, 303 insertions(+), 24 deletions(-) create mode 100644 extensions/cli/src/util/truncateOutput.test.ts create mode 100644 extensions/cli/src/util/truncateOutput.ts diff --git a/extensions/cli/src/tools/runTerminalCommand.ts b/extensions/cli/src/tools/runTerminalCommand.ts index ed4a2e49bbb..c130a47a201 100644 --- a/extensions/cli/src/tools/runTerminalCommand.ts +++ b/extensions/cli/src/tools/runTerminalCommand.ts @@ -10,6 +10,7 @@ import { isGitCommitCommand, isPullRequestCommand, } from "../telemetry/utils.js"; +import { truncateOutputFromStart } from "../util/truncateOutput.js"; import { Tool } from "./types.js"; @@ -120,18 +121,7 @@ IMPORTANT: To edit files, use Edit/MultiEdit tools instead of bash commands (sed let output = stdout + (stderr ? `\nStderr: ${stderr}` : ""); output += `\n\n[Command timed out after ${TIMEOUT_MS / 1000} seconds of no output]`; - // Truncate output if it has too many lines - const lines = output.split("\n"); - if (lines.length > 5000) { - const truncatedOutput = lines.slice(0, 5000).join("\n"); - resolve( - truncatedOutput + - `\n\n[Output truncated to first 5000 lines of ${lines.length} total]`, - ); - return; - } - - resolve(output); + resolve(truncateOutputFromStart(output).output); }, TIMEOUT_MS); }; @@ -176,18 +166,7 @@ IMPORTANT: To edit files, use Edit/MultiEdit tools instead of bash commands (sed output = stdout + `\nStderr: ${stderr}`; } - // Truncate output if it has too many lines - const lines = output.split("\n"); - if (lines.length > 5000) { - const truncatedOutput = lines.slice(0, 5000).join("\n"); - resolve( - truncatedOutput + - `\n\n[Output truncated to first 5000 lines of ${lines.length} total]`, - ); - return; - } - - resolve(output); + resolve(truncateOutputFromStart(output).output); }); child.on("error", (error) => { diff --git a/extensions/cli/src/util/truncateOutput.test.ts b/extensions/cli/src/util/truncateOutput.test.ts new file mode 100644 index 00000000000..cf6fb96c6a8 --- /dev/null +++ b/extensions/cli/src/util/truncateOutput.test.ts @@ -0,0 +1,219 @@ +import { + truncateOutputFromStart, + TRUNCATION_MAX_CHARACTERS, + TRUNCATION_MAX_LINES, +} from "./truncateOutput.js"; + +describe("truncateOutputFromStart", () => { + describe("no truncation needed", () => { + it("should return empty string unchanged", () => { + const result = truncateOutputFromStart(""); + expect(result.output).toBe(""); + expect(result.wasTruncated).toBe(false); + }); + + it("should return short output unchanged", () => { + const input = "hello world\nline 2\nline 3"; + const result = truncateOutputFromStart(input); + expect(result.output).toBe(input); + expect(result.wasTruncated).toBe(false); + }); + + it("should return output at exactly max lines unchanged", () => { + const lines = Array.from( + { length: TRUNCATION_MAX_LINES }, + (_, i) => `line ${i + 1}`, + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + expect(result.output).toBe(input); + expect(result.wasTruncated).toBe(false); + }); + + it("should return output at exactly max characters unchanged", () => { + const input = "a".repeat(TRUNCATION_MAX_CHARACTERS); + const result = truncateOutputFromStart(input); + expect(result.output).toBe(input); + expect(result.wasTruncated).toBe(false); + }); + }); + + describe("truncation by line count", () => { + it("should truncate when exceeding max lines", () => { + const totalLines = TRUNCATION_MAX_LINES + 500; + const lines = Array.from( + { length: totalLines }, + (_, i) => `line ${i + 1}`, + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("(previous 500 lines truncated)"); + expect(result.output).toContain("line 501"); + expect(result.output).toContain(`line ${totalLines}`); + expect(result.output).not.toContain("line 500\n"); + }); + + it("should preserve the last max lines when truncating", () => { + const totalLines = TRUNCATION_MAX_LINES * 2; + const lines = Array.from( + { length: totalLines }, + (_, i) => `line ${i + 1}`, + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain( + `(previous ${TRUNCATION_MAX_LINES} lines truncated)`, + ); + + // Check that we have lines (MAX_LINES+1) to (MAX_LINES*2) + const outputLines = result.output.split("\n"); + // First line is the truncation message, second is empty, then content + expect(outputLines[2]).toBe(`line ${TRUNCATION_MAX_LINES + 1}`); + expect(outputLines[outputLines.length - 1]).toBe(`line ${totalLines}`); + }); + + it("should handle exactly max lines + 1", () => { + const totalLines = TRUNCATION_MAX_LINES + 1; + const lines = Array.from( + { length: totalLines }, + (_, i) => `line ${i + 1}`, + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("(previous 1 lines truncated)"); + expect(result.output).toContain("line 2"); + expect(result.output).toContain(`line ${totalLines}`); + expect(result.output).not.toContain("\nline 1\n"); + }); + }); + + describe("truncation by character count", () => { + it("should truncate when exceeding max characters", () => { + // Create output that exceeds character limit but not line limit + const input = "a".repeat(TRUNCATION_MAX_CHARACTERS + 10000); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output.length).toBeLessThanOrEqual( + TRUNCATION_MAX_CHARACTERS + 100 /* header allowance */, + ); + expect(result.output).toContain("characters truncated"); + }); + + it("should truncate at line boundary when possible", () => { + // Create lines that exceed character limit + const line = "x".repeat(100) + "\n"; + const input = line.repeat(600); // 60600 characters, 600 lines + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + // Should start at a line boundary (after truncation header) + const contentStart = result.output.indexOf("\n\n") + 2; + const content = result.output.slice(contentStart); + // Content should start with 'x' (beginning of a line, not mid-line) + expect(content[0]).toBe("x"); + }); + }); + + describe("combined truncation", () => { + it("should apply both line and character truncation with single message", () => { + // Create lines that exceed both limits + // 2x max lines, each 100 chars = way more than max characters + const line = "y".repeat(100); + const lines = Array.from( + { length: TRUNCATION_MAX_LINES * 2 }, + () => line, + ); + const input = lines.join("\n"); + + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + // Should be truncated by lines first, then by characters + expect(result.output.length).toBeLessThanOrEqual( + TRUNCATION_MAX_CHARACTERS + 100 /* header allowance */, + ); + + // Should have a single combined message, not duplicate notes + expect(result.output).toContain("previous output truncated:"); + expect(result.output).toContain(`${TRUNCATION_MAX_LINES} lines`); + expect(result.output).toContain("characters removed"); + + // Should NOT have separate line truncation message + const truncationNoteCount = (result.output.match(/\(previous/g) || []) + .length; + expect(truncationNoteCount).toBe(1); + }); + }); + + describe("edge cases", () => { + it("should handle single very long line without newlines", () => { + const input = "z".repeat(TRUNCATION_MAX_CHARACTERS + 10000); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("characters truncated"); + // Should keep approximately max chars (plus header) + expect(result.output.length).toBeLessThanOrEqual( + TRUNCATION_MAX_CHARACTERS + 100, + ); + expect(result.output.length).toBeGreaterThan(TRUNCATION_MAX_CHARACTERS); + }); + + it("should not snap to line boundary if newline is too far into the text", () => { + // Create a massive line followed by a newline far from the truncation point + const leadingChars = TRUNCATION_MAX_CHARACTERS + 5000; + const input = "a".repeat(leadingChars) + "\n" + "b".repeat(10); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + // Should cut mid-line since newline is beyond snap threshold into kept text + // Result should contain 'a' characters (cut mid-line) + const contentStart = result.output.indexOf("\n\n") + 2; + const content = result.output.slice(contentStart); + expect(content[0]).toBe("a"); + }); + + it("should snap to line boundary if newline is within snap threshold", () => { + // Create text where truncation point lands within snap threshold of a newline + const charsBeforeNewline = TRUNCATION_MAX_CHARACTERS / 5; // Well under max + const input = + "a".repeat(charsBeforeNewline) + + "\n" + + "b".repeat(TRUNCATION_MAX_CHARACTERS); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + // Newline should be within snap threshold, so should start with 'b' + const contentStart = result.output.indexOf("\n\n") + 2; + const content = result.output.slice(contentStart); + expect(content[0]).toBe("b"); + }); + + it("should handle output with only newlines", () => { + const input = "\n".repeat(TRUNCATION_MAX_LINES + 500); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("lines truncated)"); + }); + + it("should handle mixed content with empty lines", () => { + const totalLines = TRUNCATION_MAX_LINES + 200; + const lines = Array.from({ length: totalLines }, (_, i) => + i % 2 === 0 ? `content line ${i}` : "", + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("(previous 200 lines truncated)"); + }); + }); +}); diff --git a/extensions/cli/src/util/truncateOutput.ts b/extensions/cli/src/util/truncateOutput.ts new file mode 100644 index 00000000000..f3f04b3af3f --- /dev/null +++ b/extensions/cli/src/util/truncateOutput.ts @@ -0,0 +1,81 @@ +export const TRUNCATION_MAX_CHARACTERS = 50000; +export const TRUNCATION_MAX_LINES = 1000; +export const TRUNCATION_LINE_SNAP_THRESHOLD = 1000; + +interface TruncationResult { + output: string; + wasTruncated: boolean; +} + +/** + * Truncates output from the beginning to fit within limits, preserving the end. + * + * Limits: max 50000 characters OR 1000 lines, whichever is smaller. + * When truncated, removes content from the beginning and adds a "(previous output truncated)" note. + */ +export function truncateOutputFromStart(output: string): TruncationResult { + if (!output) { + return { output, wasTruncated: false }; + } + + const lines = output.split("\n"); + + // Check if we need to truncate by lines first + if (lines.length > TRUNCATION_MAX_LINES) { + const linesTruncated = lines.length - TRUNCATION_MAX_LINES; + const preservedLines = lines.slice(-TRUNCATION_MAX_LINES); + const contentAfterLineTruncation = preservedLines.join("\n"); + + // After line truncation, check character limit + if (contentAfterLineTruncation.length > TRUNCATION_MAX_CHARACTERS) { + return truncateCharactersFromStart( + contentAfterLineTruncation, + linesTruncated, + ); + } + + return { + output: `(previous ${linesTruncated} lines truncated)\n\n${contentAfterLineTruncation}`, + wasTruncated: true, + }; + } + + // Check character limit + if (output.length > TRUNCATION_MAX_CHARACTERS) { + return truncateCharactersFromStart(output, 0); + } + + return { output, wasTruncated: false }; +} + +function truncateCharactersFromStart( + text: string, + linesTruncated: number, +): TruncationResult { + // Remove characters from the beginning, keeping the end + const truncationPoint = text.length - TRUNCATION_MAX_CHARACTERS; + const textToKeep = text.slice(truncationPoint); + + // Try to start at a clean line boundary, but only if there's a newline + // within a reasonable distance. Otherwise just cut mid-line. + const firstNewline = textToKeep.indexOf("\n"); + const shouldSnapToLine = + firstNewline !== -1 && firstNewline < TRUNCATION_LINE_SNAP_THRESHOLD; + const cleanText = shouldSnapToLine + ? textToKeep.slice(firstNewline + 1) + : textToKeep; + + const charsRemoved = text.length - cleanText.length; + + let prefix: string; + if (linesTruncated > 0) { + prefix = `(previous output truncated: ${linesTruncated} lines and ${charsRemoved} characters removed)\n\n`; + } else { + prefix = `(previous ${charsRemoved} characters truncated)\n\n`; + } + + return { + output: prefix + cleanText, + wasTruncated: true, + }; +} From 9a630a025de85dd11069afb04a37f6295aca8029 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 9 Jan 2026 15:13:22 -0800 Subject: [PATCH 2/5] feat: refined cli bash tool truncation --- extensions/cli/src/commands/chat.ts | 9 +- .../streamChatResponse.autoCompaction.test.ts | 14 +- .../streamChatResponse.autoCompaction.ts | 13 +- .../streamChatResponse.compactionHelpers.ts | 51 +++-- .../cli/src/stream/streamChatResponse.ts | 44 ++-- .../cli/src/ui/hooks/useChat.compaction.ts | 5 +- .../util/tokenizer.contextValidation.test.ts | 6 +- extensions/cli/src/util/tokenizer.test.ts | 27 +-- extensions/cli/src/util/tokenizer.ts | 197 ++++++++++++++++-- .../cli/src/util/truncateOutput.test.ts | 185 +++++++++++++--- extensions/cli/src/util/truncateOutput.ts | 47 ++++- 11 files changed, 482 insertions(+), 116 deletions(-) diff --git a/extensions/cli/src/commands/chat.ts b/extensions/cli/src/commands/chat.ts index 71c1d53a446..6ee33737d01 100644 --- a/extensions/cli/src/commands/chat.ts +++ b/extensions/cli/src/commands/chat.ts @@ -363,7 +363,14 @@ async function processMessage( telemetryService.logUserPrompt(userInput.length, userInput); // Check if auto-compacting is needed BEFORE adding user message - if (shouldAutoCompact(services.chatHistory.getHistory(), model)) { + // Note: This is a preliminary check without tools/systemMessage context. + // The streaming path performs a more accurate check with full context. + if ( + shouldAutoCompact({ + chatHistory: services.chatHistory.getHistory(), + model, + }) + ) { const newIndex = await handleAutoCompaction( chatHistory, model, diff --git a/extensions/cli/src/stream/streamChatResponse.autoCompaction.test.ts b/extensions/cli/src/stream/streamChatResponse.autoCompaction.test.ts index 1e0b6638ad4..20c654c40a2 100644 --- a/extensions/cli/src/stream/streamChatResponse.autoCompaction.test.ts +++ b/extensions/cli/src/stream/streamChatResponse.autoCompaction.test.ts @@ -99,7 +99,12 @@ describe("handleAutoCompaction", () => { wasCompacted: false, }); - expect(shouldAutoCompact).toHaveBeenCalledWith(mockChatHistory, mockModel); + expect(shouldAutoCompact).toHaveBeenCalledWith({ + chatHistory: mockChatHistory, + model: mockModel, + systemMessage: undefined, + tools: undefined, + }); }); it("should perform auto-compaction when context limit is approaching", async () => { @@ -144,7 +149,12 @@ describe("handleAutoCompaction", () => { }, ); - expect(shouldAutoCompact).toHaveBeenCalledWith(mockChatHistory, mockModel); + expect(shouldAutoCompact).toHaveBeenCalledWith({ + chatHistory: mockChatHistory, + model: mockModel, + systemMessage: undefined, + tools: undefined, + }); expect(getAutoCompactMessage).toHaveBeenCalledWith(mockModel); expect(compactChatHistory).toHaveBeenCalledWith( mockChatHistory, diff --git a/extensions/cli/src/stream/streamChatResponse.autoCompaction.ts b/extensions/cli/src/stream/streamChatResponse.autoCompaction.ts index 803e7cb069f..c93f97e78b2 100644 --- a/extensions/cli/src/stream/streamChatResponse.autoCompaction.ts +++ b/extensions/cli/src/stream/streamChatResponse.autoCompaction.ts @@ -1,6 +1,7 @@ import { ModelConfig } from "@continuedev/config-yaml"; import { BaseLlmApi } from "@continuedev/openai-adapters"; import type { ChatHistoryItem } from "core/index.js"; +import type { ChatCompletionTool } from "openai/resources/chat/completions.mjs"; import React from "react"; import { compactChatHistory } from "../compaction.js"; @@ -27,6 +28,7 @@ interface AutoCompactionOptions { format?: "json"; callbacks?: AutoCompactionCallbacks; systemMessage?: string; + tools?: ChatCompletionTool[]; } /** @@ -133,9 +135,18 @@ export async function handleAutoCompaction( isHeadless = false, callbacks, systemMessage: providedSystemMessage, + tools, } = options; - if (!model || !shouldAutoCompact(chatHistory, model)) { + if ( + !model || + !shouldAutoCompact({ + chatHistory, + model, + systemMessage: providedSystemMessage, + tools, + }) + ) { return { chatHistory, compactionIndex: null, wasCompacted: false }; } diff --git a/extensions/cli/src/stream/streamChatResponse.compactionHelpers.ts b/extensions/cli/src/stream/streamChatResponse.compactionHelpers.ts index d1c42517f0e..b1d8fbe9543 100644 --- a/extensions/cli/src/stream/streamChatResponse.compactionHelpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.compactionHelpers.ts @@ -1,6 +1,7 @@ import { ModelConfig } from "@continuedev/config-yaml"; import { BaseLlmApi } from "@continuedev/openai-adapters"; import type { ChatHistoryItem } from "core/index.js"; +import type { ChatCompletionTool } from "openai/resources/chat/completions.mjs"; import { services } from "../services/index.js"; import { ToolCall } from "../tools/index.js"; @@ -17,6 +18,7 @@ export interface CompactionHelperOptions { isHeadless: boolean; callbacks?: StreamCallbacks; systemMessage: string; + tools?: ChatCompletionTool[]; } /** @@ -26,8 +28,15 @@ export async function handlePreApiCompaction( chatHistory: ChatHistoryItem[], options: CompactionHelperOptions, ): Promise<{ chatHistory: ChatHistoryItem[]; wasCompacted: boolean }> { - const { model, llmApi, isCompacting, isHeadless, callbacks, systemMessage } = - options; + const { + model, + llmApi, + isCompacting, + isHeadless, + callbacks, + systemMessage, + tools, + } = options; if (isCompacting) { return { chatHistory, wasCompacted: false }; @@ -41,6 +50,7 @@ export async function handlePreApiCompaction( onContent: callbacks?.onContent, }, systemMessage, + tools, }); if (wasCompacted) { @@ -67,7 +77,8 @@ export async function handlePostToolValidation( chatHistory: ChatHistoryItem[], options: CompactionHelperOptions, ): Promise<{ chatHistory: ChatHistoryItem[]; wasCompacted: boolean }> { - const { model, llmApi, isHeadless, callbacks, systemMessage } = options; + const { model, llmApi, isHeadless, callbacks, systemMessage, tools } = + options; if (toolCalls.length === 0) { return { chatHistory, wasCompacted: false }; @@ -82,21 +93,14 @@ export async function handlePostToolValidation( chatHistory = chatHistorySvc.getHistory(); } - // Validate with system message to catch tool result overflow - const postToolSystemItem: ChatHistoryItem = { - message: { - role: "system", - content: systemMessage, - }, - contextItems: [], - }; - const SAFETY_BUFFER = 100; - const postToolValidation = validateContextLength( - [postToolSystemItem, ...chatHistory], + const postToolValidation = validateContextLength({ + chatHistory, model, - SAFETY_BUFFER, - ); + safetyBuffer: SAFETY_BUFFER, + systemMessage, + tools, + }); // If tool results pushed us over limit, force compaction regardless of threshold if (!postToolValidation.isValid) { @@ -114,6 +118,7 @@ export async function handlePostToolValidation( onContent: callbacks?.onContent, }, systemMessage, + tools, }); if (wasCompacted) { @@ -127,11 +132,13 @@ export async function handlePostToolValidation( } // Verify compaction brought us under the limit - const postCompactionValidation = validateContextLength( - [postToolSystemItem, ...chatHistory], + const postCompactionValidation = validateContextLength({ + chatHistory, model, - SAFETY_BUFFER, - ); + safetyBuffer: SAFETY_BUFFER, + systemMessage, + tools, + }); if (!postCompactionValidation.isValid) { logger.error( @@ -171,7 +178,8 @@ export async function handleNormalAutoCompaction( shouldContinue: boolean, options: CompactionHelperOptions, ): Promise<{ chatHistory: ChatHistoryItem[]; wasCompacted: boolean }> { - const { model, llmApi, isHeadless, callbacks, systemMessage } = options; + const { model, llmApi, isHeadless, callbacks, systemMessage, tools } = + options; if (!shouldContinue) { return { chatHistory, wasCompacted: false }; @@ -193,6 +201,7 @@ export async function handleNormalAutoCompaction( onContent: callbacks?.onContent, }, systemMessage, + tools, }); if (wasCompacted) { diff --git a/extensions/cli/src/stream/streamChatResponse.ts b/extensions/cli/src/stream/streamChatResponse.ts index b63541db14a..e02a59c8696 100644 --- a/extensions/cli/src/stream/streamChatResponse.ts +++ b/extensions/cli/src/stream/streamChatResponse.ts @@ -215,25 +215,17 @@ export async function processStreamingResponse( let chatHistory = options.chatHistory; - // Create temporary system message item for validation - const systemMessageItem: ChatHistoryItem = { - message: { - role: "system", - content: systemMessage, - }, - contextItems: [], - }; - // Safety buffer to account for tokenization estimation errors const SAFETY_BUFFER = 100; - // Validate context length INCLUDING system message - let historyWithSystem = [systemMessageItem, ...chatHistory]; - let validation = validateContextLength( - historyWithSystem, + // Validate context length INCLUDING system message and tools + let validation = validateContextLength({ + chatHistory, model, - SAFETY_BUFFER, - ); + safetyBuffer: SAFETY_BUFFER, + systemMessage, + tools, + }); // Prune last messages until valid (excluding system message) while (chatHistory.length > 1 && !validation.isValid) { @@ -243,9 +235,14 @@ export async function processStreamingResponse( } chatHistory = prunedChatHistory; - // Re-validate with system message - historyWithSystem = [systemMessageItem, ...chatHistory]; - validation = validateContextLength(historyWithSystem, model, SAFETY_BUFFER); + // Re-validate with system message and tools + validation = validateContextLength({ + chatHistory, + model, + safetyBuffer: SAFETY_BUFFER, + systemMessage, + tools, + }); } if (!validation.isValid) { @@ -464,7 +461,10 @@ export async function streamChatResponse( services.toolPermissions.getState().currentMode, ); - // Pre-API auto-compaction checkpoint + // Recompute tools on each iteration to handle mode changes during streaming + const tools = await getRequestTools(isHeadless); + + // Pre-API auto-compaction checkpoint (now includes tools) const preCompactionResult = await handlePreApiCompaction(chatHistory, { model, llmApi, @@ -472,15 +472,13 @@ export async function streamChatResponse( isHeadless, callbacks, systemMessage, + tools, }); chatHistory = preCompactionResult.chatHistory; if (preCompactionResult.wasCompacted) { compactionOccurredThisTurn = true; } - // Recompute tools on each iteration to handle mode changes during streaming - const tools = await getRequestTools(isHeadless); - logger.debug("Tools prepared", { toolCount: tools.length, toolNames: tools.map((t) => t.function.name), @@ -541,6 +539,7 @@ export async function streamChatResponse( isHeadless, callbacks, systemMessage, + tools, }, ); chatHistory = postToolResult.chatHistory; @@ -559,6 +558,7 @@ export async function streamChatResponse( isHeadless, callbacks, systemMessage, + tools, }, ); chatHistory = compactionResult.chatHistory; diff --git a/extensions/cli/src/ui/hooks/useChat.compaction.ts b/extensions/cli/src/ui/hooks/useChat.compaction.ts index 25016549976..8b6c3b90a68 100644 --- a/extensions/cli/src/ui/hooks/useChat.compaction.ts +++ b/extensions/cli/src/ui/hooks/useChat.compaction.ts @@ -137,9 +137,10 @@ export async function handleAutoCompaction({ currentCompactionIndex: number | null; }> { try { - // Check if auto-compaction is needed // Check if auto-compaction is needed using ChatHistoryItem directly - if (!model || !shouldAutoCompact(chatHistory, model)) { + // Note: TUI mode doesn't have access to tools/systemMessage at this point, + // so we use a simplified check. The streaming path has full context. + if (!model || !shouldAutoCompact({ chatHistory, model })) { return { currentChatHistory: chatHistory, currentCompactionIndex: _compactionIndex, diff --git a/extensions/cli/src/util/tokenizer.contextValidation.test.ts b/extensions/cli/src/util/tokenizer.contextValidation.test.ts index 79377ab486d..a9b8dbc2bd9 100644 --- a/extensions/cli/src/util/tokenizer.contextValidation.test.ts +++ b/extensions/cli/src/util/tokenizer.contextValidation.test.ts @@ -31,7 +31,7 @@ describe("validateContextLength", () => { const model = createMockModel(1000, 200); const chatHistory = createMockHistory(5, 40); // ~50 tokens total - const result = validateContextLength(chatHistory, model); + const result = validateContextLength({ chatHistory, model }); expect(result.isValid).toBe(true); expect(result.error).toBeUndefined(); @@ -41,7 +41,7 @@ describe("validateContextLength", () => { const model = createMockModel(1000, 800); const chatHistory = createMockHistory(20, 100); // ~500+ tokens - const result = validateContextLength(chatHistory, model); + const result = validateContextLength({ chatHistory, model }); expect(result.isValid).toBe(false); expect(result.error).toContain("Context length exceeded"); @@ -54,7 +54,7 @@ describe("validateContextLength", () => { const model = createMockModel(200_000, 64_000); // Claude-like model const chatHistory = createMockHistory(100, 1000); // Large conversation - const result = validateContextLength(chatHistory, model); + const result = validateContextLength({ chatHistory, model }); expect(result.contextLimit).toBe(200_000); expect(result.maxTokens).toBe(64_000); diff --git a/extensions/cli/src/util/tokenizer.test.ts b/extensions/cli/src/util/tokenizer.test.ts index 807f1174d26..d982a89fc58 100644 --- a/extensions/cli/src/util/tokenizer.test.ts +++ b/extensions/cli/src/util/tokenizer.test.ts @@ -232,7 +232,7 @@ describe("tokenizer", () => { // Threshold: 800 * 0.8 = 640 const chatHistory = createChatHistory(650); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); it("should not compact when input tokens are below threshold with maxTokens", () => { @@ -241,7 +241,7 @@ describe("tokenizer", () => { // Threshold: 800 * 0.8 = 640 const chatHistory = createChatHistory(600); // Below threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(false); + expect(shouldAutoCompact({ chatHistory, model })).toBe(false); }); it("should use 35% default reservation when maxTokens is not set", () => { @@ -251,7 +251,7 @@ describe("tokenizer", () => { // Threshold: 650 * 0.8 = 520 const chatHistory = createChatHistory(530); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); it("should not compact when below default threshold without maxTokens", () => { @@ -260,14 +260,14 @@ describe("tokenizer", () => { // Threshold: 650 * 0.8 = 520 const chatHistory = createChatHistory(500); // Below threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(false); + expect(shouldAutoCompact({ chatHistory, model })).toBe(false); }); it("should throw error when maxTokens >= contextLength", () => { const model = createModel(1000, 1000); // maxTokens equals contextLength const chatHistory = createChatHistory(1); // Minimal input - expect(() => shouldAutoCompact(chatHistory, model)).toThrow( + expect(() => shouldAutoCompact({ chatHistory, model })).toThrow( "max_tokens is larger than context_length, which should not be possible. Please check your configuration.", ); }); @@ -276,7 +276,7 @@ describe("tokenizer", () => { const model = createModel(1000, 1200); // maxTokens exceeds contextLength const chatHistory = createChatHistory(1); // Minimal input - expect(() => shouldAutoCompact(chatHistory, model)).toThrow( + expect(() => shouldAutoCompact({ chatHistory, model })).toThrow( "max_tokens is larger than context_length, which should not be possible. Please check your configuration.", ); }); @@ -287,17 +287,20 @@ describe("tokenizer", () => { // Threshold: 50 * 0.8 = 40 const chatHistory = createChatHistory(45); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); it("should log debug information correctly", () => { const model = createModel(1000, 200); const chatHistory = createChatHistory(600); - shouldAutoCompact(chatHistory, model); + shouldAutoCompact({ chatHistory, model }); expect(logger.debug).toHaveBeenCalledWith("Context usage check", { inputTokens: expect.any(Number), + historyTokens: expect.any(Number), + systemTokens: expect.any(Number), + toolTokens: expect.any(Number), contextLimit: 1000, maxTokens: 200, reservedForOutput: 200, @@ -313,7 +316,7 @@ describe("tokenizer", () => { // Should use default 35% reservation: 1000 * 0.35 = 350 const chatHistory = createChatHistory(530); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); describe("real-world scenarios", () => { @@ -324,7 +327,7 @@ describe("tokenizer", () => { // Threshold: 196k * 0.8 = 156.8k const chatHistory = createChatHistory(160_000); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); it("should prevent GPT-4 context overflow", () => { @@ -334,7 +337,7 @@ describe("tokenizer", () => { // Threshold: 124k * 0.8 = 99.2k const chatHistory = createChatHistory(100_000); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); it("should handle models with very high max_tokens", () => { @@ -344,7 +347,7 @@ describe("tokenizer", () => { // Threshold: 136k * 0.8 = 108.8k const chatHistory = createChatHistory(110_000); // Above threshold - expect(shouldAutoCompact(chatHistory, model)).toBe(true); + expect(shouldAutoCompact({ chatHistory, model })).toBe(true); }); }); }); diff --git a/extensions/cli/src/util/tokenizer.ts b/extensions/cli/src/util/tokenizer.ts index cccc60ad284..cfacac1e86b 100644 --- a/extensions/cli/src/util/tokenizer.ts +++ b/extensions/cli/src/util/tokenizer.ts @@ -1,6 +1,7 @@ import { ModelConfig } from "@continuedev/config-yaml"; import type { ChatHistoryItem } from "core/index.js"; import { encode } from "gpt-tokenizer"; +import type { ChatCompletionTool } from "openai/resources/chat/completions.mjs"; import { logger } from "./logger.js"; @@ -196,16 +197,158 @@ export function calculateContextUsagePercentage( } /** - * Check if the chat history exceeds the auto-compact threshold - * @param chatHistory The chat history to check - * @param model The model configuration + * Count tokens for a single parameter field in a tool definition. + * @param fields The field definition object + * @returns Token count for this field + */ +function countParameterFieldTokens( + fields: Record | undefined, +): number { + if (!fields) { + return 0; + } + + let tokens = 0; + const fieldType = fields["type"]; + const fieldDesc = fields["description"]; + const fieldEnum = fields["enum"]; + + if (fieldType && typeof fieldType === "string") { + tokens += 2; // Structure overhead for type + tokens += encode(fieldType).length; + } + + if (fieldDesc && typeof fieldDesc === "string") { + tokens += 2; // Structure overhead for description + tokens += encode(fieldDesc).length; + } + + if (fieldEnum && Array.isArray(fieldEnum)) { + tokens -= 3; + for (const e of fieldEnum) { + tokens += 3; + tokens += typeof e === "string" ? encode(e).length : 5; + } + } + + return tokens; +} + +/** + * Count tokens for a single tool's function definition. + * @param tool The ChatCompletionTool to count + * @returns Token count for this tool + */ +function countSingleToolTokens(tool: ChatCompletionTool): number { + let tokens = encode(tool.function.name).length; + + if (tool.function.description) { + tokens += encode(tool.function.description).length; + } + + const params = tool.function.parameters as + | { properties?: Record } + | undefined; + const props = params?.properties; + + if (props) { + for (const key in props) { + tokens += encode(key).length; + tokens += countParameterFieldTokens( + props[key] as Record | undefined, + ); + } + } + + return tokens; +} + +/** + * Count tokens for tool definitions sent to the API. + * Based on OpenAI's token counting for function calling. + * @see https://community.openai.com/t/how-to-calculate-the-tokens-when-using-function-call/266573/10 + * @param tools Array of ChatCompletionTool objects + * @returns Estimated token count for all tool definitions + */ +export function countToolDefinitionTokens(tools: ChatCompletionTool[]): number { + if (!tools || tools.length === 0) { + return 0; + } + + // Base overhead for the tools array structure + let numTokens = 12; + + for (const tool of tools) { + numTokens += countSingleToolTokens(tool); + } + + // Additional overhead for the tools wrapper + return numTokens + 12; +} + +/** + * Parameters for calculating total input tokens including all components + */ +export interface TotalInputTokenParams { + chatHistory: ChatHistoryItem[]; + systemMessage?: string; + tools?: ChatCompletionTool[]; +} + +/** + * Calculate total input tokens including chat history, system message, and tool definitions. + * This provides a complete picture of tokens that will be sent to the API. + * @param params Object containing chatHistory, optional systemMessage, optional tools, and optional modelName + * @returns Total estimated input token count + */ +export function countTotalInputTokens(params: TotalInputTokenParams): number { + const { chatHistory, systemMessage, tools } = params; + + let totalTokens = countChatHistoryTokens(chatHistory); + + // Add system message tokens if provided and not already in history + if (systemMessage) { + const hasSystemInHistory = chatHistory.some( + (item) => item.message.role === "system", + ); + if (!hasSystemInHistory) { + totalTokens += encode(systemMessage).length; + totalTokens += 4; // Message structure overhead (role + formatting) + } + } + + // Add tool definition tokens + if (tools && tools.length > 0) { + totalTokens += countToolDefinitionTokens(tools); + } + + return totalTokens; +} + +/** + * Parameters for auto-compaction check + */ +export interface AutoCompactParams { + chatHistory: ChatHistoryItem[]; + model: ModelConfig; + systemMessage?: string; + tools?: ChatCompletionTool[]; +} + +/** + * Check if the chat history exceeds the auto-compact threshold. + * Accounts for system message and tool definitions in the calculation. + * @param params Object containing chatHistory, model, optional systemMessage, and optional tools * @returns Whether auto-compacting should be triggered */ -export function shouldAutoCompact( - chatHistory: ChatHistoryItem[], - model: ModelConfig, -): boolean { - const inputTokens = countChatHistoryTokens(chatHistory); +export function shouldAutoCompact(params: AutoCompactParams): boolean { + const { chatHistory, model, systemMessage, tools } = params; + + const inputTokens = countTotalInputTokens({ + chatHistory, + systemMessage, + tools, + }); const contextLimit = getModelContextLimit(model); const maxTokens = model.defaultCompletionOptions?.maxTokens || 0; @@ -225,8 +368,14 @@ export function shouldAutoCompact( const usage = inputTokens / availableForInput; + const toolTokens = tools ? countToolDefinitionTokens(tools) : 0; + const systemTokens = systemMessage ? encode(systemMessage).length : 0; + logger.debug("Context usage check", { inputTokens, + historyTokens: countChatHistoryTokens(chatHistory), + systemTokens, + toolTokens, contextLimit, maxTokens, reservedForOutput, @@ -250,24 +399,36 @@ export function getAutoCompactMessage(model: ModelConfig): string { } /** - * Validates that the input tokens + max_tokens don't exceed context limit - * @param chatHistory The chat history to validate - * @param model The model configuration - * @param safetyBuffer Additional token buffer to account for estimation errors (default: 0) + * Parameters for context length validation + */ +export interface ValidateContextLengthParams { + chatHistory: ChatHistoryItem[]; + model: ModelConfig; + safetyBuffer?: number; + systemMessage?: string; + tools?: ChatCompletionTool[]; +} + +/** + * Validates that the input tokens + max_tokens don't exceed context limit. + * Accounts for system message and tool definitions in the calculation. + * @param params Object containing chatHistory, model, optional safetyBuffer, systemMessage, and tools * @returns Validation result with error details if invalid */ -export function validateContextLength( - chatHistory: ChatHistoryItem[], - model: ModelConfig, - safetyBuffer: number = 0, -): { +export function validateContextLength(params: ValidateContextLengthParams): { isValid: boolean; error?: string; inputTokens?: number; contextLimit?: number; maxTokens?: number; } { - const inputTokens = countChatHistoryTokens(chatHistory); + const { chatHistory, model, safetyBuffer = 0, systemMessage, tools } = params; + + const inputTokens = countTotalInputTokens({ + chatHistory, + systemMessage, + tools, + }); const contextLimit = getModelContextLimit(model); const maxTokens = model.defaultCompletionOptions?.maxTokens || 0; diff --git a/extensions/cli/src/util/truncateOutput.test.ts b/extensions/cli/src/util/truncateOutput.test.ts index cf6fb96c6a8..37d990dff02 100644 --- a/extensions/cli/src/util/truncateOutput.test.ts +++ b/extensions/cli/src/util/truncateOutput.test.ts @@ -1,10 +1,24 @@ import { + DEFAULT_MAX_CHARACTERS, + DEFAULT_MAX_LINES, + getMaxCharacters, + getMaxLines, truncateOutputFromStart, - TRUNCATION_MAX_CHARACTERS, - TRUNCATION_MAX_LINES, } from "./truncateOutput.js"; describe("truncateOutputFromStart", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.BASH_MAX_OUTPUT_LENGTH; + delete process.env.BASH_MAX_OUTPUT_LINES; + }); + + afterAll(() => { + process.env = originalEnv; + }); + describe("no truncation needed", () => { it("should return empty string unchanged", () => { const result = truncateOutputFromStart(""); @@ -21,7 +35,7 @@ describe("truncateOutputFromStart", () => { it("should return output at exactly max lines unchanged", () => { const lines = Array.from( - { length: TRUNCATION_MAX_LINES }, + { length: DEFAULT_MAX_LINES }, (_, i) => `line ${i + 1}`, ); const input = lines.join("\n"); @@ -31,7 +45,7 @@ describe("truncateOutputFromStart", () => { }); it("should return output at exactly max characters unchanged", () => { - const input = "a".repeat(TRUNCATION_MAX_CHARACTERS); + const input = "a".repeat(DEFAULT_MAX_CHARACTERS); const result = truncateOutputFromStart(input); expect(result.output).toBe(input); expect(result.wasTruncated).toBe(false); @@ -40,7 +54,7 @@ describe("truncateOutputFromStart", () => { describe("truncation by line count", () => { it("should truncate when exceeding max lines", () => { - const totalLines = TRUNCATION_MAX_LINES + 500; + const totalLines = DEFAULT_MAX_LINES + 500; const lines = Array.from( { length: totalLines }, (_, i) => `line ${i + 1}`, @@ -56,7 +70,7 @@ describe("truncateOutputFromStart", () => { }); it("should preserve the last max lines when truncating", () => { - const totalLines = TRUNCATION_MAX_LINES * 2; + const totalLines = DEFAULT_MAX_LINES * 2; const lines = Array.from( { length: totalLines }, (_, i) => `line ${i + 1}`, @@ -66,18 +80,18 @@ describe("truncateOutputFromStart", () => { expect(result.wasTruncated).toBe(true); expect(result.output).toContain( - `(previous ${TRUNCATION_MAX_LINES} lines truncated)`, + `(previous ${DEFAULT_MAX_LINES} lines truncated)`, ); // Check that we have lines (MAX_LINES+1) to (MAX_LINES*2) const outputLines = result.output.split("\n"); // First line is the truncation message, second is empty, then content - expect(outputLines[2]).toBe(`line ${TRUNCATION_MAX_LINES + 1}`); + expect(outputLines[2]).toBe(`line ${DEFAULT_MAX_LINES + 1}`); expect(outputLines[outputLines.length - 1]).toBe(`line ${totalLines}`); }); it("should handle exactly max lines + 1", () => { - const totalLines = TRUNCATION_MAX_LINES + 1; + const totalLines = DEFAULT_MAX_LINES + 1; const lines = Array.from( { length: totalLines }, (_, i) => `line ${i + 1}`, @@ -96,12 +110,12 @@ describe("truncateOutputFromStart", () => { describe("truncation by character count", () => { it("should truncate when exceeding max characters", () => { // Create output that exceeds character limit but not line limit - const input = "a".repeat(TRUNCATION_MAX_CHARACTERS + 10000); + const input = "a".repeat(DEFAULT_MAX_CHARACTERS + 10000); const result = truncateOutputFromStart(input); expect(result.wasTruncated).toBe(true); expect(result.output.length).toBeLessThanOrEqual( - TRUNCATION_MAX_CHARACTERS + 100 /* header allowance */, + DEFAULT_MAX_CHARACTERS + 100 /* header allowance */, ); expect(result.output).toContain("characters truncated"); }); @@ -126,10 +140,7 @@ describe("truncateOutputFromStart", () => { // Create lines that exceed both limits // 2x max lines, each 100 chars = way more than max characters const line = "y".repeat(100); - const lines = Array.from( - { length: TRUNCATION_MAX_LINES * 2 }, - () => line, - ); + const lines = Array.from({ length: DEFAULT_MAX_LINES * 2 }, () => line); const input = lines.join("\n"); const result = truncateOutputFromStart(input); @@ -137,12 +148,12 @@ describe("truncateOutputFromStart", () => { expect(result.wasTruncated).toBe(true); // Should be truncated by lines first, then by characters expect(result.output.length).toBeLessThanOrEqual( - TRUNCATION_MAX_CHARACTERS + 100 /* header allowance */, + DEFAULT_MAX_CHARACTERS + 100 /* header allowance */, ); // Should have a single combined message, not duplicate notes expect(result.output).toContain("previous output truncated:"); - expect(result.output).toContain(`${TRUNCATION_MAX_LINES} lines`); + expect(result.output).toContain(`${DEFAULT_MAX_LINES} lines`); expect(result.output).toContain("characters removed"); // Should NOT have separate line truncation message @@ -154,21 +165,21 @@ describe("truncateOutputFromStart", () => { describe("edge cases", () => { it("should handle single very long line without newlines", () => { - const input = "z".repeat(TRUNCATION_MAX_CHARACTERS + 10000); + const input = "z".repeat(DEFAULT_MAX_CHARACTERS + 10000); const result = truncateOutputFromStart(input); expect(result.wasTruncated).toBe(true); expect(result.output).toContain("characters truncated"); // Should keep approximately max chars (plus header) expect(result.output.length).toBeLessThanOrEqual( - TRUNCATION_MAX_CHARACTERS + 100, + DEFAULT_MAX_CHARACTERS + 100, ); - expect(result.output.length).toBeGreaterThan(TRUNCATION_MAX_CHARACTERS); + expect(result.output.length).toBeGreaterThan(DEFAULT_MAX_CHARACTERS); }); it("should not snap to line boundary if newline is too far into the text", () => { // Create a massive line followed by a newline far from the truncation point - const leadingChars = TRUNCATION_MAX_CHARACTERS + 5000; + const leadingChars = DEFAULT_MAX_CHARACTERS + 5000; const input = "a".repeat(leadingChars) + "\n" + "b".repeat(10); const result = truncateOutputFromStart(input); @@ -182,11 +193,11 @@ describe("truncateOutputFromStart", () => { it("should snap to line boundary if newline is within snap threshold", () => { // Create text where truncation point lands within snap threshold of a newline - const charsBeforeNewline = TRUNCATION_MAX_CHARACTERS / 5; // Well under max + const charsBeforeNewline = DEFAULT_MAX_CHARACTERS / 5; // Well under max const input = "a".repeat(charsBeforeNewline) + "\n" + - "b".repeat(TRUNCATION_MAX_CHARACTERS); + "b".repeat(DEFAULT_MAX_CHARACTERS); const result = truncateOutputFromStart(input); expect(result.wasTruncated).toBe(true); @@ -197,7 +208,7 @@ describe("truncateOutputFromStart", () => { }); it("should handle output with only newlines", () => { - const input = "\n".repeat(TRUNCATION_MAX_LINES + 500); + const input = "\n".repeat(DEFAULT_MAX_LINES + 500); const result = truncateOutputFromStart(input); expect(result.wasTruncated).toBe(true); @@ -205,7 +216,7 @@ describe("truncateOutputFromStart", () => { }); it("should handle mixed content with empty lines", () => { - const totalLines = TRUNCATION_MAX_LINES + 200; + const totalLines = DEFAULT_MAX_LINES + 200; const lines = Array.from({ length: totalLines }, (_, i) => i % 2 === 0 ? `content line ${i}` : "", ); @@ -216,4 +227,128 @@ describe("truncateOutputFromStart", () => { expect(result.output).toContain("(previous 200 lines truncated)"); }); }); + + describe("environment variable configuration", () => { + describe("getMaxCharacters", () => { + it("should return default when env var is not set", () => { + expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + }); + + it("should return env var value when set to valid number", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "100000"; + expect(getMaxCharacters()).toBe(100000); + }); + + it("should return default when env var is empty string", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = ""; + expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + }); + + it("should return default when env var is not a number", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "not-a-number"; + expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + }); + + it("should return default when env var is zero", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "0"; + expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + }); + + it("should return default when env var is negative", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "-100"; + expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + }); + + it("should parse integer from float string", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "12345.67"; + expect(getMaxCharacters()).toBe(12345); + }); + }); + + describe("getMaxLines", () => { + it("should return default when env var is not set", () => { + expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); + }); + + it("should return env var value when set to valid number", () => { + process.env.BASH_MAX_OUTPUT_LINES = "5000"; + expect(getMaxLines()).toBe(5000); + }); + + it("should return default when env var is empty string", () => { + process.env.BASH_MAX_OUTPUT_LINES = ""; + expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); + }); + + it("should return default when env var is not a number", () => { + process.env.BASH_MAX_OUTPUT_LINES = "invalid"; + expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); + }); + + it("should return default when env var is zero", () => { + process.env.BASH_MAX_OUTPUT_LINES = "0"; + expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); + }); + + it("should return default when env var is negative", () => { + process.env.BASH_MAX_OUTPUT_LINES = "-500"; + expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); + }); + }); + + describe("truncateOutputFromStart with custom limits", () => { + it("should use custom character limit from env var", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "1000"; + const input = "a".repeat(1500); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("characters truncated"); + // Output should be around 1000 chars plus header + expect(result.output.length).toBeLessThanOrEqual(1100); + }); + + it("should use custom line limit from env var", () => { + process.env.BASH_MAX_OUTPUT_LINES = "50"; + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("(previous 50 lines truncated)"); + expect(result.output).toContain("line 51"); + expect(result.output).toContain("line 100"); + }); + + it("should use both custom limits together", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "500"; + process.env.BASH_MAX_OUTPUT_LINES = "100"; + + // Create 200 lines of 10 chars each = 2000+ chars, exceeding both limits + const lines = Array.from({ length: 200 }, () => "x".repeat(10)); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(true); + // Should be truncated by lines first (to 100), then by characters (to ~500) + expect(result.output.length).toBeLessThanOrEqual(600); + }); + + it("should not truncate when output is under custom limits", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "100000"; + process.env.BASH_MAX_OUTPUT_LINES = "10000"; + + // Create output that would exceed default limits but not custom ones + const lines = Array.from( + { length: DEFAULT_MAX_LINES + 500 }, + (_, i) => `line ${i}`, + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input); + + expect(result.wasTruncated).toBe(false); + expect(result.output).toBe(input); + }); + }); + }); }); diff --git a/extensions/cli/src/util/truncateOutput.ts b/extensions/cli/src/util/truncateOutput.ts index f3f04b3af3f..65dfe09e60a 100644 --- a/extensions/cli/src/util/truncateOutput.ts +++ b/extensions/cli/src/util/truncateOutput.ts @@ -1,7 +1,32 @@ -export const TRUNCATION_MAX_CHARACTERS = 50000; -export const TRUNCATION_MAX_LINES = 1000; +export const DEFAULT_MAX_CHARACTERS = 50000; +export const DEFAULT_MAX_LINES = 1000; export const TRUNCATION_LINE_SNAP_THRESHOLD = 1000; +function parseEnvNumber( + envVar: string | undefined, + defaultValue: number, +): number { + if (!envVar) { + return defaultValue; + } + const parsed = parseInt(envVar, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + return defaultValue; + } + return parsed; +} + +export function getMaxCharacters(): number { + return parseEnvNumber( + process.env.BASH_MAX_OUTPUT_LENGTH, + DEFAULT_MAX_CHARACTERS, + ); +} + +export function getMaxLines(): number { + return parseEnvNumber(process.env.BASH_MAX_OUTPUT_LINES, DEFAULT_MAX_LINES); +} + interface TruncationResult { output: string; wasTruncated: boolean; @@ -18,19 +43,22 @@ export function truncateOutputFromStart(output: string): TruncationResult { return { output, wasTruncated: false }; } + const maxLines = getMaxLines(); + const maxCharacters = getMaxCharacters(); const lines = output.split("\n"); // Check if we need to truncate by lines first - if (lines.length > TRUNCATION_MAX_LINES) { - const linesTruncated = lines.length - TRUNCATION_MAX_LINES; - const preservedLines = lines.slice(-TRUNCATION_MAX_LINES); + if (lines.length > maxLines) { + const linesTruncated = lines.length - maxLines; + const preservedLines = lines.slice(-maxLines); const contentAfterLineTruncation = preservedLines.join("\n"); // After line truncation, check character limit - if (contentAfterLineTruncation.length > TRUNCATION_MAX_CHARACTERS) { + if (contentAfterLineTruncation.length > maxCharacters) { return truncateCharactersFromStart( contentAfterLineTruncation, linesTruncated, + maxCharacters, ); } @@ -41,8 +69,8 @@ export function truncateOutputFromStart(output: string): TruncationResult { } // Check character limit - if (output.length > TRUNCATION_MAX_CHARACTERS) { - return truncateCharactersFromStart(output, 0); + if (output.length > maxCharacters) { + return truncateCharactersFromStart(output, 0, maxCharacters); } return { output, wasTruncated: false }; @@ -51,9 +79,10 @@ export function truncateOutputFromStart(output: string): TruncationResult { function truncateCharactersFromStart( text: string, linesTruncated: number, + maxCharacters: number, ): TruncationResult { // Remove characters from the beginning, keeping the end - const truncationPoint = text.length - TRUNCATION_MAX_CHARACTERS; + const truncationPoint = text.length - maxCharacters; const textToKeep = text.slice(truncationPoint); // Try to start at a clean line boundary, but only if there's a newline From 34537d926fca7017c748fe69c9bf888aee0d8b95 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 9 Jan 2026 15:57:28 -0800 Subject: [PATCH 3/5] feat: CLI tool truncation and docs --- docs/cli/configuration.mdx | 29 ++ docs/docs.json | 3 +- extensions/cli/src/tools/fetch.ts | 35 +- extensions/cli/src/tools/readFile.ts | 37 +- .../cli/src/tools/runTerminalCommand.ts | 37 +- extensions/cli/src/tools/searchCode.ts | 33 +- extensions/cli/src/tools/viewDiff.ts | 24 +- extensions/cli/src/util/tokenizer.test.ts | 298 +++++++++++++++ .../cli/src/util/truncateOutput.test.ts | 353 +++++++++++------- extensions/cli/src/util/truncateOutput.ts | 164 ++++++-- 10 files changed, 827 insertions(+), 186 deletions(-) create mode 100644 docs/cli/configuration.mdx diff --git a/docs/cli/configuration.mdx b/docs/cli/configuration.mdx new file mode 100644 index 00000000000..6e20a9ec8d2 --- /dev/null +++ b/docs/cli/configuration.mdx @@ -0,0 +1,29 @@ +--- +title: "CLI Configuration" +description: "Configure Continue CLI behavior with environment variables" +sidebarTitle: "Configuration" +--- + +Continue CLI tools automatically truncate large outputs to prevent excessive token usage. You can customize these limits using environment variables. + +## Environment Variables + +| Environment Variable | Tool | Default | +|---------------------|------|--------:| +| `CONTINUE_CLI_BASH_MAX_OUTPUT_CHARS` | Bash | 50,000 | +| `CONTINUE_CLI_BASH_MAX_OUTPUT_LINES` | Bash | 1,000 | +| `CONTINUE_CLI_READ_FILE_MAX_OUTPUT_CHARS` | Read | 500,000 | +| `CONTINUE_CLI_READ_FILE_MAX_OUTPUT_LINES` | Read | 5,000 | +| `CONTINUE_CLI_FETCH_MAX_OUTPUT_LENGTH` | Fetch | 20,000 | +| `CONTINUE_CLI_DIFF_MAX_OUTPUT_LENGTH` | Diff | 50,000 | +| `CONTINUE_CLI_SEARCH_CODE_MAX_RESULTS` | Search | 100 | +| `CONTINUE_CLI_SEARCH_CODE_MAX_RESULT_CHARS` | Search | 1,000 | + +## Example + +```bash +# Increase limits for verbose build output +export CONTINUE_CLI_BASH_MAX_OUTPUT_CHARS=100000 +export CONTINUE_CLI_BASH_MAX_OUTPUT_LINES=2000 +cn +``` diff --git a/docs/docs.json b/docs/docs.json index 7a350f41def..7c05d90d5ce 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -91,7 +91,8 @@ "cli/overview", "cli/install", "cli/quick-start", - "cli/guides" + "cli/guides", + "cli/configuration" ] }, { diff --git a/extensions/cli/src/tools/fetch.ts b/extensions/cli/src/tools/fetch.ts index eaa7d10564f..fba08948c43 100644 --- a/extensions/cli/src/tools/fetch.ts +++ b/extensions/cli/src/tools/fetch.ts @@ -2,8 +2,23 @@ import type { ContextItem } from "core/index.js"; import { fetchUrlContentImpl } from "core/tools/implementations/fetchUrlContent.js"; import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; +import { + parseEnvNumber, + truncateOutputFromEnd, +} from "../util/truncateOutput.js"; + import { Tool } from "./types.js"; +// Output truncation defaults +const DEFAULT_FETCH_MAX_CHARS = 20000; + +function getFetchMaxChars(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_FETCH_MAX_OUTPUT_LENGTH, + DEFAULT_FETCH_MAX_CHARS, + ); +} + export const fetchTool: Tool = { name: "Fetch", displayName: "Fetch", @@ -54,14 +69,20 @@ export const fetchTool: Tool = { } // Format the results for CLI display - return contextItems - .map((item: ContextItem) => { - if (item.name === "Truncation warning") { - return item.content; - } - return item.content; - }) + const combinedContent = contextItems + .filter((item: ContextItem) => item.name !== "Truncation warning") + .map((item: ContextItem) => item.content) .join("\n\n"); + + // Apply CLI-level truncation + const maxChars = getFetchMaxChars(); + const { output: truncatedOutput } = truncateOutputFromEnd( + combinedContent, + maxChars, + "fetched content", + ); + + return truncatedOutput; } catch (error) { if (error instanceof ContinueError) { throw error; diff --git a/extensions/cli/src/tools/readFile.ts b/extensions/cli/src/tools/readFile.ts index 9bcf1832929..3907fd0c8f9 100644 --- a/extensions/cli/src/tools/readFile.ts +++ b/extensions/cli/src/tools/readFile.ts @@ -3,9 +3,32 @@ import * as fs from "fs"; import { throwIfFileIsSecurityConcern } from "core/indexing/ignore.js"; import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; +import { + parseEnvNumber, + truncateByLinesAndChars, +} from "../util/truncateOutput.js"; + import { formatToolArgument } from "./formatters.js"; import { Tool } from "./types.js"; +// Output truncation defaults +const DEFAULT_READ_FILE_MAX_CHARS = 500000; // ~500KB +const DEFAULT_READ_FILE_MAX_LINES = 5000; + +function getReadFileMaxChars(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_READ_FILE_MAX_OUTPUT_CHARS, + DEFAULT_READ_FILE_MAX_CHARS, + ); +} + +function getReadFileMaxLines(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_READ_FILE_MAX_OUTPUT_LINES, + DEFAULT_READ_FILE_MAX_LINES, + ); +} + // Track files that have been read in the current session export const readFilesSet = new Set(); export function markFileAsRead(filePath: string) { @@ -62,13 +85,17 @@ export const readFileTool: Tool = { // Mark this file as read for the edit tool markFileAsRead(realPath); - const lines = content.split("\n"); - if (lines.length > 5000) { - const truncatedContent = lines.slice(0, 5000).join("\n"); - return `Content of ${filepath} (truncated to first 5000 lines of ${lines.length} total):\n${truncatedContent}`; + const maxLines = getReadFileMaxLines(); + const maxChars = getReadFileMaxChars(); + + const { output: truncatedContent, wasTruncated } = + truncateByLinesAndChars(content, maxLines, maxChars, "file content"); + + if (wasTruncated) { + return `Content of ${filepath} (truncated):\n${truncatedContent}`; } - return `Content of ${filepath}:\n${content}`; + return `Content of ${filepath}:\n${truncatedContent}`; } catch (error) { if (error instanceof ContinueError) { throw error; diff --git a/extensions/cli/src/tools/runTerminalCommand.ts b/extensions/cli/src/tools/runTerminalCommand.ts index c130a47a201..df8a8beeacc 100644 --- a/extensions/cli/src/tools/runTerminalCommand.ts +++ b/extensions/cli/src/tools/runTerminalCommand.ts @@ -10,10 +10,31 @@ import { isGitCommitCommand, isPullRequestCommand, } from "../telemetry/utils.js"; -import { truncateOutputFromStart } from "../util/truncateOutput.js"; +import { + parseEnvNumber, + truncateOutputFromStart, +} from "../util/truncateOutput.js"; import { Tool } from "./types.js"; +// Output truncation defaults +const DEFAULT_BASH_MAX_CHARS = 50000; +const DEFAULT_BASH_MAX_LINES = 1000; + +function getBashMaxChars(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_BASH_MAX_OUTPUT_CHARS, + DEFAULT_BASH_MAX_CHARS, + ); +} + +function getBashMaxLines(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_BASH_MAX_OUTPUT_LINES, + DEFAULT_BASH_MAX_LINES, + ); +} + // Helper function to use login shell on Unix/macOS and PowerShell on Windows function getShellCommand(command: string): { shell: string; args: string[] } { if (process.platform === "win32") { @@ -121,7 +142,12 @@ IMPORTANT: To edit files, use Edit/MultiEdit tools instead of bash commands (sed let output = stdout + (stderr ? `\nStderr: ${stderr}` : ""); output += `\n\n[Command timed out after ${TIMEOUT_MS / 1000} seconds of no output]`; - resolve(truncateOutputFromStart(output).output); + resolve( + truncateOutputFromStart(output, { + maxChars: getBashMaxChars(), + maxLines: getBashMaxLines(), + }).output, + ); }, TIMEOUT_MS); }; @@ -166,7 +192,12 @@ IMPORTANT: To edit files, use Edit/MultiEdit tools instead of bash commands (sed output = stdout + `\nStderr: ${stderr}`; } - resolve(truncateOutputFromStart(output).output); + resolve( + truncateOutputFromStart(output, { + maxChars: getBashMaxChars(), + maxLines: getBashMaxLines(), + }).output, + ); }); child.on("error", (error) => { diff --git a/extensions/cli/src/tools/searchCode.ts b/extensions/cli/src/tools/searchCode.ts index e7fa4e5025f..ab60b1a9394 100644 --- a/extensions/cli/src/tools/searchCode.ts +++ b/extensions/cli/src/tools/searchCode.ts @@ -5,6 +5,8 @@ import * as util from "util"; import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; import { findUp } from "find-up"; +import { parseEnvNumber } from "../util/truncateOutput.js"; + import { Tool } from "./types.js"; const execPromise = util.promisify(child_process.exec); @@ -80,9 +82,23 @@ async function searchWithGrepOrFindstr( return await execPromise(command, { cwd: searchPath }); } -// Default maximum number of results to display -const DEFAULT_MAX_RESULTS = 100; -const MAX_LINE_LENGTH = 1000; +// Output truncation defaults +const DEFAULT_SEARCH_MAX_RESULTS = 100; +const DEFAULT_SEARCH_MAX_RESULT_CHARS = 1000; // Max chars per result line + +function getSearchMaxResults(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_SEARCH_CODE_MAX_RESULTS, + DEFAULT_SEARCH_MAX_RESULTS, + ); +} + +function getSearchMaxResultChars(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_SEARCH_CODE_MAX_RESULT_CHARS, + DEFAULT_SEARCH_MAX_RESULT_CHARS, + ); +} export const searchCodeTool: Tool = { name: "Search", @@ -168,19 +184,22 @@ export const searchCodeTool: Tool = { } // Split the results into lines and limit the number of results + const maxResults = getSearchMaxResults(); + const maxResultChars = getSearchMaxResultChars(); + const splitLines = stdout.split("\n"); - const lines = splitLines.filter((line) => line.length <= MAX_LINE_LENGTH); + const lines = splitLines.filter((line) => line.length <= maxResultChars); if (lines.length === 0) { return `No matches found for pattern "${args.pattern}"${ args.file_pattern ? ` in files matching "${args.file_pattern}"` : "" }.`; } - const truncated = lines.length > DEFAULT_MAX_RESULTS; - const limitedLines = lines.slice(0, DEFAULT_MAX_RESULTS); + const truncated = lines.length > maxResults; + const limitedLines = lines.slice(0, maxResults); const resultText = limitedLines.join("\n"); const truncationMessage = truncated - ? `\n\n[Results truncated: showing ${DEFAULT_MAX_RESULTS} of ${lines.length} matches]` + ? `\n\n[Results truncated: showing ${maxResults} of ${lines.length} matches]` : ""; return `Search results for pattern "${args.pattern}"${ diff --git a/extensions/cli/src/tools/viewDiff.ts b/extensions/cli/src/tools/viewDiff.ts index 8b9f12d1d18..e9461d66c5b 100644 --- a/extensions/cli/src/tools/viewDiff.ts +++ b/extensions/cli/src/tools/viewDiff.ts @@ -2,8 +2,23 @@ import * as child_process from "child_process"; import * as fs from "fs"; import * as util from "util"; +import { + parseEnvNumber, + truncateOutputFromEnd, +} from "../util/truncateOutput.js"; + import { Tool } from "./types.js"; +// Output truncation defaults +const DEFAULT_DIFF_MAX_CHARS = 50000; + +function getDiffMaxChars(): number { + return parseEnvNumber( + process.env.CONTINUE_CLI_DIFF_MAX_OUTPUT_LENGTH, + DEFAULT_DIFF_MAX_CHARS, + ); +} + const execPromise = util.promisify(child_process.exec); export const viewDiffTool: Tool = { @@ -60,7 +75,14 @@ export const viewDiffTool: Tool = { return "No changes detected in the git repository."; } - return `Git diff for repository at ${repoPath}:\n\n${stdout}`; + const maxChars = getDiffMaxChars(); + const { output: truncatedOutput } = truncateOutputFromEnd( + stdout, + maxChars, + "diff output", + ); + + return `Git diff for repository at ${repoPath}:\n\n${truncatedOutput}`; } catch (error) { return `Error running git diff: ${ error instanceof Error ? error.message : String(error) diff --git a/extensions/cli/src/util/tokenizer.test.ts b/extensions/cli/src/util/tokenizer.test.ts index d982a89fc58..2ac81bdc923 100644 --- a/extensions/cli/src/util/tokenizer.test.ts +++ b/extensions/cli/src/util/tokenizer.test.ts @@ -9,6 +9,7 @@ import { countChatHistoryItemTokens, countChatHistoryTokens, countMessageTokens, + countToolDefinitionTokens, getModelContextLimit, shouldAutoCompact, } from "./tokenizer.js"; @@ -351,4 +352,301 @@ describe("tokenizer", () => { }); }); }); + + describe("countToolDefinitionTokens", () => { + it("should return 0 for empty tools array", () => { + expect(countToolDefinitionTokens([])).toBe(0); + }); + + it("should return 0 for undefined-like input", () => { + // TypeScript types prevent undefined, but test defensive behavior + expect(countToolDefinitionTokens([] as never)).toBe(0); + }); + + it("should count tokens for a simple tool with name only", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "get_weather", + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + // Base overhead (12) + tool tokens + wrapper overhead (12) + expect(tokenCount).toBeGreaterThan(24); // At least base overheads + expect(tokenCount).toBe(27); // 12 + 3 (name) + 12 = 27 + }); + + it("should count tokens for a tool with name and description", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather for a location", + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + // 12 (base) + 3 (name) + 10 (description ~40 chars / 4) + 12 (wrapper) = 37 + expect(tokenCount).toBeGreaterThan(27); + }); + + it("should count tokens for a tool with parameters", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { + location: { + type: "string", + description: "The city name", + }, + }, + required: ["location"], + }, + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + expect(tokenCount).toBeGreaterThan(30); // Should include parameter tokens + }); + + it("should count tokens for multiple tools", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "tool_one", + }, + }, + { + type: "function" as const, + function: { + name: "tool_two", + }, + }, + ]; + + const singleToolTokens = countToolDefinitionTokens([tools[0]]); + const doubleToolTokens = countToolDefinitionTokens(tools); + + // Two tools should have more tokens than one + expect(doubleToolTokens).toBeGreaterThan(singleToolTokens); + }); + + it("should count tokens for parameters with enum values", () => { + const toolsWithoutEnum = [ + { + type: "function" as const, + function: { + name: "set_mode", + parameters: { + type: "object", + properties: { + mode: { + type: "string", + description: "The mode to set", + }, + }, + }, + }, + }, + ]; + + const toolsWithEnum = [ + { + type: "function" as const, + function: { + name: "set_mode", + parameters: { + type: "object", + properties: { + mode: { + type: "string", + description: "The mode to set", + enum: ["fast", "slow", "medium"], + }, + }, + }, + }, + }, + ]; + + const tokensWithoutEnum = countToolDefinitionTokens(toolsWithoutEnum); + const tokensWithEnum = countToolDefinitionTokens(toolsWithEnum); + + // Enum values should add tokens + expect(tokensWithEnum).toBeGreaterThan(tokensWithoutEnum); + }); + + it("should count tokens for multiple parameters", () => { + const toolWithOneParam = [ + { + type: "function" as const, + function: { + name: "search", + parameters: { + type: "object", + properties: { + query: { + type: "string", + }, + }, + }, + }, + }, + ]; + + const toolWithMultipleParams = [ + { + type: "function" as const, + function: { + name: "search", + parameters: { + type: "object", + properties: { + query: { + type: "string", + }, + limit: { + type: "number", + }, + offset: { + type: "number", + }, + }, + }, + }, + }, + ]; + + const oneParamTokens = countToolDefinitionTokens(toolWithOneParam); + const multiParamTokens = countToolDefinitionTokens( + toolWithMultipleParams, + ); + + expect(multiParamTokens).toBeGreaterThan(oneParamTokens); + }); + + it("should handle tool with empty parameters object", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "no_params", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + // Should still return a valid count (base overhead + name) + expect(tokenCount).toBeGreaterThan(0); + }); + + it("should handle tool without parameters field", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "simple_tool", + description: "A simple tool without parameters", + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + expect(tokenCount).toBeGreaterThan(0); + }); + + describe("real-world tool definitions", () => { + it("should count tokens for a complex tool like read_file", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "read_file", + description: + "Use this tool if you need to view the contents of an existing file.", + parameters: { + type: "object", + properties: { + filepath: { + type: "string", + description: + "The path of the file to read. Can be a relative path, absolute path, tilde path, or file:// URI", + }, + }, + required: ["filepath"], + }, + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + expect(tokenCount).toBeGreaterThan(40); // Complex tool should have many tokens + }); + + it("should count tokens for a multi-tool set", () => { + const tools = [ + { + type: "function" as const, + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { + filepath: { type: "string", description: "File path" }, + }, + }, + }, + }, + { + type: "function" as const, + function: { + name: "write_file", + description: "Write to a file", + parameters: { + type: "object", + properties: { + filepath: { type: "string", description: "File path" }, + content: { type: "string", description: "Content to write" }, + }, + }, + }, + }, + { + type: "function" as const, + function: { + name: "search", + description: "Search for files", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + }, + }, + }, + }, + ]; + + const tokenCount = countToolDefinitionTokens(tools); + // Multiple tools should have significant token count + expect(tokenCount).toBeGreaterThan(80); + }); + }); + }); }); diff --git a/extensions/cli/src/util/truncateOutput.test.ts b/extensions/cli/src/util/truncateOutput.test.ts index 37d990dff02..02ebf8b185b 100644 --- a/extensions/cli/src/util/truncateOutput.test.ts +++ b/extensions/cli/src/util/truncateOutput.test.ts @@ -1,34 +1,31 @@ import { - DEFAULT_MAX_CHARACTERS, - DEFAULT_MAX_LINES, - getMaxCharacters, - getMaxLines, + parseEnvNumber, + truncateByLinesAndChars, + truncateLinesByCount, + truncateOutputFromEnd, truncateOutputFromStart, } from "./truncateOutput.js"; -describe("truncateOutputFromStart", () => { - const originalEnv = process.env; +// Test constants matching the bash tool defaults +const DEFAULT_MAX_CHARS = 50000; +const DEFAULT_MAX_LINES = 1000; - beforeEach(() => { - process.env = { ...originalEnv }; - delete process.env.BASH_MAX_OUTPUT_LENGTH; - delete process.env.BASH_MAX_OUTPUT_LINES; - }); - - afterAll(() => { - process.env = originalEnv; - }); +const defaultLimits = { + maxChars: DEFAULT_MAX_CHARS, + maxLines: DEFAULT_MAX_LINES, +}; +describe("truncateOutputFromStart", () => { describe("no truncation needed", () => { it("should return empty string unchanged", () => { - const result = truncateOutputFromStart(""); + const result = truncateOutputFromStart("", defaultLimits); expect(result.output).toBe(""); expect(result.wasTruncated).toBe(false); }); it("should return short output unchanged", () => { const input = "hello world\nline 2\nline 3"; - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.output).toBe(input); expect(result.wasTruncated).toBe(false); }); @@ -39,14 +36,14 @@ describe("truncateOutputFromStart", () => { (_, i) => `line ${i + 1}`, ); const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.output).toBe(input); expect(result.wasTruncated).toBe(false); }); it("should return output at exactly max characters unchanged", () => { - const input = "a".repeat(DEFAULT_MAX_CHARACTERS); - const result = truncateOutputFromStart(input); + const input = "a".repeat(DEFAULT_MAX_CHARS); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.output).toBe(input); expect(result.wasTruncated).toBe(false); }); @@ -60,7 +57,7 @@ describe("truncateOutputFromStart", () => { (_, i) => `line ${i + 1}`, ); const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output).toContain("(previous 500 lines truncated)"); @@ -76,7 +73,7 @@ describe("truncateOutputFromStart", () => { (_, i) => `line ${i + 1}`, ); const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output).toContain( @@ -97,7 +94,7 @@ describe("truncateOutputFromStart", () => { (_, i) => `line ${i + 1}`, ); const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output).toContain("(previous 1 lines truncated)"); @@ -110,12 +107,12 @@ describe("truncateOutputFromStart", () => { describe("truncation by character count", () => { it("should truncate when exceeding max characters", () => { // Create output that exceeds character limit but not line limit - const input = "a".repeat(DEFAULT_MAX_CHARACTERS + 10000); - const result = truncateOutputFromStart(input); + const input = "a".repeat(DEFAULT_MAX_CHARS + 10000); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output.length).toBeLessThanOrEqual( - DEFAULT_MAX_CHARACTERS + 100 /* header allowance */, + DEFAULT_MAX_CHARS + 100 /* header allowance */, ); expect(result.output).toContain("characters truncated"); }); @@ -124,7 +121,7 @@ describe("truncateOutputFromStart", () => { // Create lines that exceed character limit const line = "x".repeat(100) + "\n"; const input = line.repeat(600); // 60600 characters, 600 lines - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); // Should start at a line boundary (after truncation header) @@ -143,12 +140,12 @@ describe("truncateOutputFromStart", () => { const lines = Array.from({ length: DEFAULT_MAX_LINES * 2 }, () => line); const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); // Should be truncated by lines first, then by characters expect(result.output.length).toBeLessThanOrEqual( - DEFAULT_MAX_CHARACTERS + 100 /* header allowance */, + DEFAULT_MAX_CHARS + 100 /* header allowance */, ); // Should have a single combined message, not duplicate notes @@ -165,23 +162,21 @@ describe("truncateOutputFromStart", () => { describe("edge cases", () => { it("should handle single very long line without newlines", () => { - const input = "z".repeat(DEFAULT_MAX_CHARACTERS + 10000); - const result = truncateOutputFromStart(input); + const input = "z".repeat(DEFAULT_MAX_CHARS + 10000); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output).toContain("characters truncated"); // Should keep approximately max chars (plus header) - expect(result.output.length).toBeLessThanOrEqual( - DEFAULT_MAX_CHARACTERS + 100, - ); - expect(result.output.length).toBeGreaterThan(DEFAULT_MAX_CHARACTERS); + expect(result.output.length).toBeLessThanOrEqual(DEFAULT_MAX_CHARS + 100); + expect(result.output.length).toBeGreaterThan(DEFAULT_MAX_CHARS); }); it("should not snap to line boundary if newline is too far into the text", () => { // Create a massive line followed by a newline far from the truncation point - const leadingChars = DEFAULT_MAX_CHARACTERS + 5000; + const leadingChars = DEFAULT_MAX_CHARS + 5000; const input = "a".repeat(leadingChars) + "\n" + "b".repeat(10); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); // Should cut mid-line since newline is beyond snap threshold into kept text @@ -193,12 +188,10 @@ describe("truncateOutputFromStart", () => { it("should snap to line boundary if newline is within snap threshold", () => { // Create text where truncation point lands within snap threshold of a newline - const charsBeforeNewline = DEFAULT_MAX_CHARACTERS / 5; // Well under max + const charsBeforeNewline = DEFAULT_MAX_CHARS / 5; // Well under max const input = - "a".repeat(charsBeforeNewline) + - "\n" + - "b".repeat(DEFAULT_MAX_CHARACTERS); - const result = truncateOutputFromStart(input); + "a".repeat(charsBeforeNewline) + "\n" + "b".repeat(DEFAULT_MAX_CHARS); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); // Newline should be within snap threshold, so should start with 'b' @@ -209,7 +202,7 @@ describe("truncateOutputFromStart", () => { it("should handle output with only newlines", () => { const input = "\n".repeat(DEFAULT_MAX_LINES + 500); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output).toContain("lines truncated)"); @@ -221,134 +214,216 @@ describe("truncateOutputFromStart", () => { i % 2 === 0 ? `content line ${i}` : "", ); const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + const result = truncateOutputFromStart(input, defaultLimits); expect(result.wasTruncated).toBe(true); expect(result.output).toContain("(previous 200 lines truncated)"); }); }); - describe("environment variable configuration", () => { - describe("getMaxCharacters", () => { - it("should return default when env var is not set", () => { - expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + describe("with custom limits", () => { + it("should use custom character limit", () => { + const input = "a".repeat(1500); + const result = truncateOutputFromStart(input, { + maxChars: 1000, + maxLines: 10000, }); - it("should return env var value when set to valid number", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "100000"; - expect(getMaxCharacters()).toBe(100000); - }); + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("characters truncated"); + // Output should be around 1000 chars plus header + expect(result.output.length).toBeLessThanOrEqual(1100); + }); - it("should return default when env var is empty string", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = ""; - expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + it("should use custom line limit", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input, { + maxChars: 100000, + maxLines: 50, }); - it("should return default when env var is not a number", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "not-a-number"; - expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); - }); + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("(previous 50 lines truncated)"); + expect(result.output).toContain("line 51"); + expect(result.output).toContain("line 100"); + }); - it("should return default when env var is zero", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "0"; - expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); + it("should use both custom limits together", () => { + // Create 200 lines of 10 chars each = 2000+ chars, exceeding both limits + const lines = Array.from({ length: 200 }, () => "x".repeat(10)); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input, { + maxChars: 500, + maxLines: 100, }); - it("should return default when env var is negative", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "-100"; - expect(getMaxCharacters()).toBe(DEFAULT_MAX_CHARACTERS); - }); + expect(result.wasTruncated).toBe(true); + // Should be truncated by lines first (to 100), then by characters (to ~500) + expect(result.output.length).toBeLessThanOrEqual(600); + }); - it("should parse integer from float string", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "12345.67"; - expect(getMaxCharacters()).toBe(12345); + it("should not truncate when output is under custom limits", () => { + // Create output that would exceed default limits but not custom ones + const lines = Array.from( + { length: DEFAULT_MAX_LINES + 500 }, + (_, i) => `line ${i}`, + ); + const input = lines.join("\n"); + const result = truncateOutputFromStart(input, { + maxChars: 100000, + maxLines: 10000, }); + + expect(result.wasTruncated).toBe(false); + expect(result.output).toBe(input); }); + }); +}); - describe("getMaxLines", () => { - it("should return default when env var is not set", () => { - expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); - }); +describe("parseEnvNumber", () => { + it("should return default when env var is undefined", () => { + expect(parseEnvNumber(undefined, 1000)).toBe(1000); + }); - it("should return env var value when set to valid number", () => { - process.env.BASH_MAX_OUTPUT_LINES = "5000"; - expect(getMaxLines()).toBe(5000); - }); + it("should return env var value when set to valid number", () => { + expect(parseEnvNumber("5000", 1000)).toBe(5000); + }); - it("should return default when env var is empty string", () => { - process.env.BASH_MAX_OUTPUT_LINES = ""; - expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); - }); + it("should return default when env var is empty string", () => { + expect(parseEnvNumber("", 1000)).toBe(1000); + }); - it("should return default when env var is not a number", () => { - process.env.BASH_MAX_OUTPUT_LINES = "invalid"; - expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); - }); + it("should return default when env var is not a number", () => { + expect(parseEnvNumber("not-a-number", 1000)).toBe(1000); + }); - it("should return default when env var is zero", () => { - process.env.BASH_MAX_OUTPUT_LINES = "0"; - expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); - }); + it("should return default when env var is zero", () => { + expect(parseEnvNumber("0", 1000)).toBe(1000); + }); - it("should return default when env var is negative", () => { - process.env.BASH_MAX_OUTPUT_LINES = "-500"; - expect(getMaxLines()).toBe(DEFAULT_MAX_LINES); - }); - }); + it("should return default when env var is negative", () => { + expect(parseEnvNumber("-100", 1000)).toBe(1000); + }); - describe("truncateOutputFromStart with custom limits", () => { - it("should use custom character limit from env var", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "1000"; - const input = "a".repeat(1500); - const result = truncateOutputFromStart(input); + it("should parse integer from float string", () => { + expect(parseEnvNumber("12345.67", 1000)).toBe(12345); + }); +}); - expect(result.wasTruncated).toBe(true); - expect(result.output).toContain("characters truncated"); - // Output should be around 1000 chars plus header - expect(result.output.length).toBeLessThanOrEqual(1100); - }); +describe("truncateOutputFromEnd", () => { + it("should return empty string unchanged", () => { + const result = truncateOutputFromEnd("", 1000); + expect(result.output).toBe(""); + expect(result.wasTruncated).toBe(false); + }); - it("should use custom line limit from env var", () => { - process.env.BASH_MAX_OUTPUT_LINES = "50"; - const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); - const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + it("should return short output unchanged", () => { + const input = "hello world"; + const result = truncateOutputFromEnd(input, 1000); + expect(result.output).toBe(input); + expect(result.wasTruncated).toBe(false); + }); - expect(result.wasTruncated).toBe(true); - expect(result.output).toContain("(previous 50 lines truncated)"); - expect(result.output).toContain("line 51"); - expect(result.output).toContain("line 100"); - }); + it("should truncate from end when exceeding max chars", () => { + const input = "a".repeat(100) + "b".repeat(100); + const result = truncateOutputFromEnd(input, 100); - it("should use both custom limits together", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "500"; - process.env.BASH_MAX_OUTPUT_LINES = "100"; + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("a".repeat(100)); + expect(result.output).not.toContain("b"); + expect(result.output).toContain("characters"); + expect(result.output).toContain("truncated"); + }); - // Create 200 lines of 10 chars each = 2000+ chars, exceeding both limits - const lines = Array.from({ length: 200 }, () => "x".repeat(10)); - const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + it("should include context in truncation message", () => { + const input = "x".repeat(200); + const result = truncateOutputFromEnd(input, 100, "file content"); - expect(result.wasTruncated).toBe(true); - // Should be truncated by lines first (to 100), then by characters (to ~500) - expect(result.output.length).toBeLessThanOrEqual(600); - }); + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("of file content"); + }); - it("should not truncate when output is under custom limits", () => { - process.env.BASH_MAX_OUTPUT_LENGTH = "100000"; - process.env.BASH_MAX_OUTPUT_LINES = "10000"; + it("should snap to line boundary when possible", () => { + const input = "line1\nline2\nline3\nline4\nline5"; + const result = truncateOutputFromEnd(input, 15); - // Create output that would exceed default limits but not custom ones - const lines = Array.from( - { length: DEFAULT_MAX_LINES + 500 }, - (_, i) => `line ${i}`, - ); - const input = lines.join("\n"); - const result = truncateOutputFromStart(input); + expect(result.wasTruncated).toBe(true); + // Should end at a line boundary + expect(result.output).toContain("line1\nline2"); + }); +}); - expect(result.wasTruncated).toBe(false); - expect(result.output).toBe(input); - }); - }); +describe("truncateLinesByCount", () => { + it("should return empty string unchanged", () => { + const result = truncateLinesByCount("", 100); + expect(result.output).toBe(""); + expect(result.wasTruncated).toBe(false); + }); + + it("should return output unchanged when under limit", () => { + const input = "line1\nline2\nline3"; + const result = truncateLinesByCount(input, 10); + expect(result.output).toBe(input); + expect(result.wasTruncated).toBe(false); + }); + + it("should truncate lines from end", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + const input = lines.join("\n"); + const result = truncateLinesByCount(input, 50); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("line 1"); + expect(result.output).toContain("line 50"); + expect(result.output).not.toContain("line 51"); + expect(result.output).toContain("50 lines"); + expect(result.output).toContain("truncated"); + }); + + it("should include context in truncation message", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + const input = lines.join("\n"); + const result = truncateLinesByCount(input, 50, "test output"); + + expect(result.output).toContain("of test output"); + }); +}); + +describe("truncateByLinesAndChars", () => { + it("should return empty string unchanged", () => { + const result = truncateByLinesAndChars("", 100, 1000); + expect(result.output).toBe(""); + expect(result.wasTruncated).toBe(false); + }); + + it("should truncate by lines first", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + const input = lines.join("\n"); + const result = truncateByLinesAndChars(input, 50, 100000); + + expect(result.wasTruncated).toBe(true); + expect(result.output).toContain("line 1"); + expect(result.output).toContain("line 50"); + expect(result.output).not.toContain("line 51"); + }); + + it("should truncate by chars after lines if still too long", () => { + // Create 50 lines of 100 chars each = 5000+ chars + const lines = Array.from({ length: 50 }, () => "x".repeat(100)); + const input = lines.join("\n"); + const result = truncateByLinesAndChars(input, 100, 500); + + expect(result.wasTruncated).toBe(true); + // Output should be around 500 chars plus truncation message + expect(result.output.length).toBeLessThan(700); + }); + + it("should include context in truncation message", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); + const input = lines.join("\n"); + const result = truncateByLinesAndChars(input, 50, 100000, "file content"); + + expect(result.output).toContain("of file content"); }); }); diff --git a/extensions/cli/src/util/truncateOutput.ts b/extensions/cli/src/util/truncateOutput.ts index 65dfe09e60a..2c392425e7e 100644 --- a/extensions/cli/src/util/truncateOutput.ts +++ b/extensions/cli/src/util/truncateOutput.ts @@ -1,8 +1,12 @@ -export const DEFAULT_MAX_CHARACTERS = 50000; -export const DEFAULT_MAX_LINES = 1000; +// Threshold for snapping to line boundaries during character truncation export const TRUNCATION_LINE_SNAP_THRESHOLD = 1000; -function parseEnvNumber( +/** + * Parses an environment variable as a positive integer. + * Returns the default value if the env var is not set, empty, not a number, + * zero, or negative. + */ +export function parseEnvNumber( envVar: string | undefined, defaultValue: number, ): number { @@ -16,35 +20,35 @@ function parseEnvNumber( return parsed; } -export function getMaxCharacters(): number { - return parseEnvNumber( - process.env.BASH_MAX_OUTPUT_LENGTH, - DEFAULT_MAX_CHARACTERS, - ); -} - -export function getMaxLines(): number { - return parseEnvNumber(process.env.BASH_MAX_OUTPUT_LINES, DEFAULT_MAX_LINES); -} - -interface TruncationResult { +export interface TruncationResult { output: string; wasTruncated: boolean; } +export interface TruncationLimits { + maxChars: number; + maxLines: number; +} + /** * Truncates output from the beginning to fit within limits, preserving the end. + * This is the preferred truncation strategy for command output where the most + * recent output is typically most relevant. * - * Limits: max 50000 characters OR 1000 lines, whichever is smaller. * When truncated, removes content from the beginning and adds a "(previous output truncated)" note. + * + * @param output - The string to truncate + * @param limits - The character and line limits to apply */ -export function truncateOutputFromStart(output: string): TruncationResult { +export function truncateOutputFromStart( + output: string, + limits: TruncationLimits, +): TruncationResult { if (!output) { return { output, wasTruncated: false }; } - const maxLines = getMaxLines(); - const maxCharacters = getMaxCharacters(); + const { maxChars, maxLines } = limits; const lines = output.split("\n"); // Check if we need to truncate by lines first @@ -54,11 +58,11 @@ export function truncateOutputFromStart(output: string): TruncationResult { const contentAfterLineTruncation = preservedLines.join("\n"); // After line truncation, check character limit - if (contentAfterLineTruncation.length > maxCharacters) { + if (contentAfterLineTruncation.length > maxChars) { return truncateCharactersFromStart( contentAfterLineTruncation, linesTruncated, - maxCharacters, + maxChars, ); } @@ -69,13 +73,127 @@ export function truncateOutputFromStart(output: string): TruncationResult { } // Check character limit - if (output.length > maxCharacters) { - return truncateCharactersFromStart(output, 0, maxCharacters); + if (output.length > maxChars) { + return truncateCharactersFromStart(output, 0, maxChars); } return { output, wasTruncated: false }; } +/** + * Truncates output from the end to fit within a character limit, preserving the beginning. + * Useful for content where the beginning is most important (e.g., file content, diffs). + * + * @param output - The string to truncate + * @param maxChars - Maximum characters to keep + * @param context - Optional context for the truncation message (e.g., "file content", "diff") + */ +export function truncateOutputFromEnd( + output: string, + maxChars: number, + context?: string, +): TruncationResult { + if (!output || output.length <= maxChars) { + return { output, wasTruncated: false }; + } + + const truncatedContent = output.slice(0, maxChars); + + // Try to end at a clean line boundary + const lastNewline = truncatedContent.lastIndexOf("\n"); + const shouldSnapToLine = + lastNewline !== -1 && + truncatedContent.length - lastNewline < TRUNCATION_LINE_SNAP_THRESHOLD; + const cleanContent = shouldSnapToLine + ? truncatedContent.slice(0, lastNewline) + : truncatedContent; + + const actualCharsRemoved = output.length - cleanContent.length; + const contextStr = context ? ` of ${context}` : ""; + const suffix = `\n\n(${actualCharsRemoved} characters${contextStr} truncated)`; + + return { + output: cleanContent + suffix, + wasTruncated: true, + }; +} + +/** + * Truncates output by line count from the end, preserving the beginning. + * Useful for file content where the start of the file is most relevant. + * + * @param output - The string to truncate + * @param maxLines - Maximum lines to keep + * @param context - Optional context for the truncation message + */ +export function truncateLinesByCount( + output: string, + maxLines: number, + context?: string, +): TruncationResult { + if (!output) { + return { output, wasTruncated: false }; + } + + const lines = output.split("\n"); + if (lines.length <= maxLines) { + return { output, wasTruncated: false }; + } + + const preservedLines = lines.slice(0, maxLines); + const linesTruncated = lines.length - maxLines; + const contextStr = context ? ` of ${context}` : ""; + + return { + output: + preservedLines.join("\n") + + `\n\n(${linesTruncated} lines${contextStr} truncated)`, + wasTruncated: true, + }; +} + +/** + * Combined truncation: first by lines, then by characters, preserving the beginning. + * Used by readFile tool where both limits apply. + * + * @param output - The string to truncate + * @param maxLines - Maximum lines to keep + * @param maxChars - Maximum characters to keep + * @param context - Optional context for the truncation message + */ +export function truncateByLinesAndChars( + output: string, + maxLines: number, + maxChars: number, + context?: string, +): TruncationResult { + if (!output) { + return { output, wasTruncated: false }; + } + + // First truncate by lines + const lineResult = truncateLinesByCount(output, maxLines, context); + + // Then truncate by characters if needed + if (lineResult.output.length > maxChars) { + const charResult = truncateOutputFromEnd( + lineResult.output, + maxChars, + context, + ); + return { + output: charResult.output, + wasTruncated: true, + }; + } + + return lineResult; +} + +// ============================================================================= +// Internal helper functions +// ============================================================================= + function truncateCharactersFromStart( text: string, linesTruncated: number, From 5d7327f8e0f48d2c2f62ee82cb7ea7be3394f78f Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 9 Jan 2026 16:01:07 -0800 Subject: [PATCH 4/5] chore: update read file truncation limit to 25k tokens ish --- docs/cli/configuration.mdx | 2 +- extensions/cli/src/tools/readFile.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli/configuration.mdx b/docs/cli/configuration.mdx index 6e20a9ec8d2..faaebc6bba6 100644 --- a/docs/cli/configuration.mdx +++ b/docs/cli/configuration.mdx @@ -12,7 +12,7 @@ Continue CLI tools automatically truncate large outputs to prevent excessive tok |---------------------|------|--------:| | `CONTINUE_CLI_BASH_MAX_OUTPUT_CHARS` | Bash | 50,000 | | `CONTINUE_CLI_BASH_MAX_OUTPUT_LINES` | Bash | 1,000 | -| `CONTINUE_CLI_READ_FILE_MAX_OUTPUT_CHARS` | Read | 500,000 | +| `CONTINUE_CLI_READ_FILE_MAX_OUTPUT_CHARS` | Read | 100,000 | | `CONTINUE_CLI_READ_FILE_MAX_OUTPUT_LINES` | Read | 5,000 | | `CONTINUE_CLI_FETCH_MAX_OUTPUT_LENGTH` | Fetch | 20,000 | | `CONTINUE_CLI_DIFF_MAX_OUTPUT_LENGTH` | Diff | 50,000 | diff --git a/extensions/cli/src/tools/readFile.ts b/extensions/cli/src/tools/readFile.ts index 3907fd0c8f9..bde647a0774 100644 --- a/extensions/cli/src/tools/readFile.ts +++ b/extensions/cli/src/tools/readFile.ts @@ -12,7 +12,7 @@ import { formatToolArgument } from "./formatters.js"; import { Tool } from "./types.js"; // Output truncation defaults -const DEFAULT_READ_FILE_MAX_CHARS = 500000; // ~500KB +const DEFAULT_READ_FILE_MAX_CHARS = 100000; // ~25k tokens const DEFAULT_READ_FILE_MAX_LINES = 5000; function getReadFileMaxChars(): number { From d7b91f884c2be22fb6805dbcc5a9ad738bb443ee Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 9 Jan 2026 16:07:33 -0800 Subject: [PATCH 5/5] fix: tests and cubic feedback --- extensions/cli/src/tools/fetch.test.ts | 7 ++-- extensions/cli/src/util/tokenizer.test.ts | 47 +++++++++++++++++++++++ extensions/cli/src/util/tokenizer.ts | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/extensions/cli/src/tools/fetch.test.ts b/extensions/cli/src/tools/fetch.test.ts index 9a1aec1057d..cbb19e38b69 100644 --- a/extensions/cli/src/tools/fetch.test.ts +++ b/extensions/cli/src/tools/fetch.test.ts @@ -48,7 +48,7 @@ describe("fetchTool", () => { ); }); - it("should handle truncation warnings from core implementation", async () => { + it("should filter out truncation warnings from core implementation", async () => { const mockContextItems: ContextItem[] = [ { name: "Long Page", @@ -68,9 +68,8 @@ describe("fetchTool", () => { const result = await fetchTool.run({ url: "https://example.com" }); - expect(result).toBe( - "This is the main content that was truncated.\n\nThe content from https://example.com was truncated because it exceeded the 20000 character limit.", - ); + // Truncation warnings are filtered out - only the main content is returned + expect(result).toBe("This is the main content that was truncated."); }); it("should handle multiple content items", async () => { diff --git a/extensions/cli/src/util/tokenizer.test.ts b/extensions/cli/src/util/tokenizer.test.ts index 2ac81bdc923..f1361732ebe 100644 --- a/extensions/cli/src/util/tokenizer.test.ts +++ b/extensions/cli/src/util/tokenizer.test.ts @@ -488,6 +488,53 @@ describe("tokenizer", () => { expect(tokensWithEnum).toBeGreaterThan(tokensWithoutEnum); }); + it("should handle empty enum array without negative token count", () => { + const toolsWithEmptyEnum = [ + { + type: "function" as const, + function: { + name: "set_mode", + parameters: { + type: "object", + properties: { + mode: { + type: "string", + description: "The mode to set", + enum: [], + }, + }, + }, + }, + }, + ]; + + const toolsWithoutEnum = [ + { + type: "function" as const, + function: { + name: "set_mode", + parameters: { + type: "object", + properties: { + mode: { + type: "string", + description: "The mode to set", + }, + }, + }, + }, + }, + ]; + + const tokensWithEmptyEnum = countToolDefinitionTokens(toolsWithEmptyEnum); + const tokensWithoutEnum = countToolDefinitionTokens(toolsWithoutEnum); + + // Empty enum should not subtract tokens - should be equal to no enum + expect(tokensWithEmptyEnum).toBe(tokensWithoutEnum); + // Token count should always be positive + expect(tokensWithEmptyEnum).toBeGreaterThan(0); + }); + it("should count tokens for multiple parameters", () => { const toolWithOneParam = [ { diff --git a/extensions/cli/src/util/tokenizer.ts b/extensions/cli/src/util/tokenizer.ts index cfacac1e86b..68f10c5f07e 100644 --- a/extensions/cli/src/util/tokenizer.ts +++ b/extensions/cli/src/util/tokenizer.ts @@ -223,7 +223,7 @@ function countParameterFieldTokens( tokens += encode(fieldDesc).length; } - if (fieldEnum && Array.isArray(fieldEnum)) { + if (fieldEnum && Array.isArray(fieldEnum) && fieldEnum.length > 0) { tokens -= 3; for (const e of fieldEnum) { tokens += 3;