diff --git a/sdk/packages/core/src/extensions/tools/definitions.ts b/sdk/packages/core/src/extensions/tools/definitions.ts index 28ba921000..f0426a2dfb 100644 --- a/sdk/packages/core/src/extensions/tools/definitions.ts +++ b/sdk/packages/core/src/extensions/tools/definitions.ts @@ -18,6 +18,7 @@ import { MAX_COMMAND_OUTPUT_CHARS, MAX_READ_LINES, MAX_READ_OUTPUT_CHARS, + MAX_SEARCH_OUTPUT_CHARS, } from "./executors/output-limits"; import { formatError, @@ -261,7 +262,8 @@ export function createSearchTool( description: "Perform regex pattern searches across the codebase. " + "Supports multiple parallel searches. When several search patterns could be useful and do not depend on each other, run them together in one call, and call this tool in the same response as other independent tool calls. " + - "Use for finding code patterns, function definitions, class names, imports, etc.", + "Use for finding code patterns, function definitions, class names, imports, etc. " + + `Output beyond ~${Math.round(MAX_SEARCH_OUTPUT_CHARS / 1000)}k characters per query is middle-truncated; narrow patterns beat broad ones.`, inputSchema: zodToJsonSchema(SearchCodebaseInputSchema), timeoutMs: timeoutMs * 2, retryable: true, diff --git a/sdk/packages/core/src/extensions/tools/executors/bash.test.ts b/sdk/packages/core/src/extensions/tools/executors/bash.test.ts index b8cff8253c..e59dd839bf 100644 --- a/sdk/packages/core/src/extensions/tools/executors/bash.test.ts +++ b/sdk/packages/core/src/extensions/tools/executors/bash.test.ts @@ -131,11 +131,11 @@ describe("createBashExecutor", () => { expect(output.length).toBeLessThan(300); }); - it("keeps default-capped output under the MessageBuilder per-result backstop", async () => { - // MessageBuilder re-truncates tool-result strings over 50_000 chars - // (session/services/message-builder.ts), which would replace the - // executor's truncation notice with a generic marker. The default - // cap plus notice must stay below that. + it("keeps default-capped output bounded with the notice in the preserved head/tail", async () => { + // Provider-request building (session/services/message-builder.ts) + // may middle-cut long tool-result strings again with its own + // backstop. The executor keeps its truncation notice in the head and + // tail halves, so the recovery guidance survives any such cut. const bash = createBashExecutor(); const output = await bash( { @@ -216,6 +216,42 @@ describe("createBashExecutor", () => { "aborted", ); }); + + it("flushes a trailing incomplete multibyte sequence instead of dropping it", async () => { + const bash = createBashExecutor(); + // Output ends with the first byte of a two-byte UTF-8 sequence; the + // decoder must flush it at end-of-stream (as U+FFFD) rather than + // silently dropping buffered bytes. + const output = await bash( + { + command: process.execPath, + args: ["-e", "process.stdout.write(Buffer.from([0x61, 0x62, 0xc3]))"], + }, + process.cwd(), + ctx, + ); + expect(output).toHaveLength(3); + expect(output.startsWith("ab")).toBe(true); + }); + + it("honors maxOutputChars and the deprecated maxOutputBytes alias", async () => { + const emit = { + command: process.execPath, + args: ["-e", "process.stdout.write('x'.repeat(500))"], + }; + const renamed = await createBashExecutor({ maxOutputChars: 100 })( + emit, + process.cwd(), + ctx, + ); + const alias = await createBashExecutor({ maxOutputBytes: 100 })( + emit, + process.cwd(), + ctx, + ); + expect(renamed).toContain("output truncated: 500 chars total"); + expect(alias).toContain("output truncated: 500 chars total"); + }); }); describe.runIf(process.platform === "win32")("createWindowsExecutor", () => { diff --git a/sdk/packages/core/src/extensions/tools/executors/bash.ts b/sdk/packages/core/src/extensions/tools/executors/bash.ts index 73b0b33d61..d2ad87a7ad 100644 --- a/sdk/packages/core/src/extensions/tools/executors/bash.ts +++ b/sdk/packages/core/src/extensions/tools/executors/bash.ts @@ -42,11 +42,18 @@ export interface BashExecutorOptions { timeoutMs?: number; /** - * Maximum output size, measured in characters (approximately bytes for - * ASCII-dominant output). Output beyond this is middle-truncated: the - * head and tail are preserved and the middle is elided, since build and - * test failures usually live at the end of the output. - * @default 51_200 (~50KB) + * Maximum output kept, in characters. Output beyond this is + * middle-truncated: the head and tail are preserved and the middle is + * elided, since build and test failures usually live at the end of the + * output. + * @default 48_000 — see MAX_COMMAND_OUTPUT_CHARS in output-limits.ts + */ + maxOutputChars?: number; + + /** + * @deprecated Misnamed — the limit was always enforced in characters, + * not bytes. Use {@link maxOutputChars}; this alias is honored when + * maxOutputChars is not set. */ maxOutputBytes?: number; @@ -83,19 +90,27 @@ function createRollingCollector(maxChars: number) { let tail = ""; let totalChars = 0; + const appendText = (text: string): void => { + if (!text) return; + totalChars += text.length; + const headRoom = headLimit - head.length; + if (headRoom > 0) { + head += text.slice(0, headRoom); + tail = (tail + text.slice(headRoom)).slice(-tailLimit); + return; + } + tail = (tail + text).slice(-tailLimit); + }; + return { append(data: Buffer): void { - const text = decoder.write(data); - totalChars += text.length; - const headRoom = headLimit - head.length; - if (headRoom > 0) { - head += text.slice(0, headRoom); - tail = (tail + text.slice(headRoom)).slice(-tailLimit); - return; - } - tail = (tail + text).slice(-tailLimit); + appendText(decoder.write(data)); }, snapshot() { + // Flush bytes the decoder buffered for an incomplete multibyte + // sequence at end-of-stream; otherwise the final characters of + // non-ASCII output are silently dropped. + appendText(decoder.end()); return { text: head + tail, totalChars, @@ -124,7 +139,7 @@ function spawnAndCollect( config: SpawnConfig, context: AgentToolContext, timeoutMs: number, - maxOutputBytes: number, + maxOutputChars: number, combineOutput: boolean, ): Promise { return new Promise((resolve, reject) => { @@ -142,8 +157,8 @@ function spawnAndCollect( }); const childPid = child.pid; - const stdout = createRollingCollector(maxOutputBytes); - const stderr = createRollingCollector(maxOutputBytes); + const stdout = createRollingCollector(maxOutputChars); + const stderr = createRollingCollector(maxOutputChars); let killed = false; let settled = false; @@ -220,10 +235,10 @@ function spawnAndCollect( const totalChars = combineOutput ? out.totalChars + err.totalChars : out.totalChars; - if (dropped || failureOutput.length > maxOutputBytes) { + if (dropped || failureOutput.length > maxOutputChars) { failureOutput = truncateMiddle( failureOutput, - maxOutputBytes, + maxOutputChars, totalChars, ); } @@ -237,11 +252,11 @@ function spawnAndCollect( ? out.text + (err.text ? `\n[stderr]\n${err.text}` : "") : out.text; const dropped = out.dropped || (combineOutput && err.dropped); - if (dropped || output.length > maxOutputBytes) { + if (dropped || output.length > maxOutputChars) { const totalChars = combineOutput ? out.totalChars + err.totalChars : out.totalChars; - output = truncateMiddle(output, maxOutputBytes, totalChars); + output = truncateMiddle(output, maxOutputChars, totalChars); } settle(() => resolve(output)); } @@ -275,10 +290,13 @@ export function createBashExecutor( const { shell = getDefaultShell(process.platform), timeoutMs = 30000, - maxOutputBytes = MAX_COMMAND_OUTPUT_CHARS, env = {}, combineOutput = true, } = options; + const maxOutputChars = + options.maxOutputChars ?? + options.maxOutputBytes ?? + MAX_COMMAND_OUTPUT_CHARS; return (command, cwd, context) => { const isStructured = typeof command !== "string"; @@ -293,7 +311,7 @@ export function createBashExecutor( }, context, timeoutMs, - maxOutputBytes, + maxOutputChars, combineOutput, ); }; diff --git a/sdk/packages/core/src/extensions/tools/executors/file-read.test.ts b/sdk/packages/core/src/extensions/tools/executors/file-read.test.ts index 2581c793c8..846ae42cc9 100644 --- a/sdk/packages/core/src/extensions/tools/executors/file-read.test.ts +++ b/sdk/packages/core/src/extensions/tools/executors/file-read.test.ts @@ -186,8 +186,9 @@ describe("createFileReadExecutor", () => { Array.from({ length: 1500 }, () => "z".repeat(100)).join("\n"), ); - // Stays under MessageBuilder's 50_000 per-string backstop so the - // pagination notice survives provider-request truncation. + // Bounded by the read window cap; the pagination notice sits at the + // end of the kept window, inside the tail that any downstream + // provider-request middle-cut preserves. expect(result.length).toBeLessThanOrEqual(50_000); expect(result).toContain("of 1500. Use start_line/end_line"); }); diff --git a/sdk/packages/core/src/extensions/tools/executors/output-limits.ts b/sdk/packages/core/src/extensions/tools/executors/output-limits.ts index 308e667455..9c9f667cef 100644 --- a/sdk/packages/core/src/extensions/tools/executors/output-limits.ts +++ b/sdk/packages/core/src/extensions/tools/executors/output-limits.ts @@ -7,10 +7,11 @@ * measure exactly. Executors enforce these caps; tool descriptions * reference them so the model pages or narrows instead of retrying. * - * The character caps sit below MessageBuilder's 50_000 per-string backstop - * (session/services/message-builder.ts) so capped content plus the - * truncation notice passes through provider requests intact instead of - * being re-truncated into a generic marker. + * Truncation notices always live in the preserved head/tail of an entry, + * never in the elided middle. Provider-request building may re-truncate + * long strings with its own (possibly tighter) middle-cut backstop + * (session/services/message-builder.ts); keeping the notices at the edges + * means the recovery guidance survives that cut too. */ /** Max characters of command output kept; beyond this the middle is elided. */ @@ -24,3 +25,6 @@ export const MAX_LINE_CHARS = 2_000; /** Max characters returned per file read window. */ export const MAX_READ_OUTPUT_CHARS = 48_000; + +/** Max characters returned per search query; beyond this the middle is elided. */ +export const MAX_SEARCH_OUTPUT_CHARS = 48_000; diff --git a/sdk/packages/core/src/extensions/tools/executors/search.test.ts b/sdk/packages/core/src/extensions/tools/executors/search.test.ts new file mode 100644 index 0000000000..1643f579f1 --- /dev/null +++ b/sdk/packages/core/src/extensions/tools/executors/search.test.ts @@ -0,0 +1,39 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentToolContext } from "@cline/shared"; +import { describe, expect, it } from "vitest"; +import { MAX_SEARCH_OUTPUT_CHARS } from "./output-limits"; +import { createSearchExecutor } from "./search"; + +const ctx: AgentToolContext = { + agentId: "agent-1", + conversationId: "conv-1", + iteration: 1, +}; + +describe("createSearchExecutor", () => { + it("middle-truncates oversized search output with recovery guidance", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-search-")); + const filePath = path.join(dir, "large.ts"); + await fs.writeFile( + filePath, + `needle ${"x".repeat(MAX_SEARCH_OUTPUT_CHARS * 2)} TAIL`, + "utf-8", + ); + + try { + const search = createSearchExecutor({ contextLines: 0 }); + const result = await search("(?=needle)", dir, ctx); + + expect(result.length).toBeGreaterThan(MAX_SEARCH_OUTPUT_CHARS); + expect(result.length).toBeLessThanOrEqual(50_000); + expect(result).toContain("Found 1 result for pattern"); + expect(result).toContain("search output truncated"); + expect(result).toContain("Narrow the pattern or scope"); + expect(result).toContain("TAIL"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/sdk/packages/core/src/extensions/tools/executors/search.ts b/sdk/packages/core/src/extensions/tools/executors/search.ts index b1bcb0032b..ad854daf0e 100644 --- a/sdk/packages/core/src/extensions/tools/executors/search.ts +++ b/sdk/packages/core/src/extensions/tools/executors/search.ts @@ -10,6 +10,7 @@ import * as path from "node:path"; import type { AgentToolContext } from "@cline/shared"; import { getFileIndex } from "../../../services/workspace"; import type { SearchExecutor } from "../types"; +import { MAX_SEARCH_OUTPUT_CHARS } from "./output-limits"; /** * Options for the search executor @@ -363,7 +364,7 @@ export function createSearchExecutor( ); } - return resultLines.join("\n"); + return capSearchOutput(resultLines.join("\n")); } // Fallback to manual regex search @@ -470,6 +471,27 @@ export function createSearchExecutor( ); } - return resultLines.join("\n"); + return capSearchOutput(resultLines.join("\n")); }; } + +/** + * Middle-truncate oversized search output. Matches with long context lines + * can blow past the per-query cap even within the maxResults bound; the + * head (earliest matches plus the result count) and tail (the refine hint) + * are preserved and the middle is elided with a notice teaching the model + * to narrow the pattern instead of retrying. + */ +function capSearchOutput(text: string): string { + if (text.length <= MAX_SEARCH_OUTPUT_CHARS) { + return text; + } + const headLimit = Math.ceil(MAX_SEARCH_OUTPUT_CHARS / 2); + const tailLimit = Math.max(1, MAX_SEARCH_OUTPUT_CHARS - headLimit); + return ( + `${text.slice(0, headLimit)}\n` + + `[... search output truncated: ${text.length} chars total. ` + + "Narrow the pattern or scope to view the elided matches ...]\n" + + text.slice(-tailLimit) + ); +}