diff --git a/sdk/packages/core/src/extensions/tools/executors/search.test.ts b/sdk/packages/core/src/extensions/tools/executors/search.test.ts index 1643f579f1..c3528b0185 100644 --- a/sdk/packages/core/src/extensions/tools/executors/search.test.ts +++ b/sdk/packages/core/src/extensions/tools/executors/search.test.ts @@ -16,22 +16,51 @@ 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", + // Many matching lines so the joined output exceeds the cap even though + // each line stays under the per-line truncation limit. + const rows = Array.from( + { length: 200 }, + (_, i) => `needle ${"x".repeat(900)} row-${i}`, ); + await fs.writeFile(filePath, rows.join("\n"), "utf-8"); try { const search = createSearchExecutor({ contextLines: 0 }); + // Lookahead is unsupported by ripgrep, forcing the fallback scan. 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("Found 100 results 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 }); + } + }); + + it("returns bounded output when a match lands in a giant single-line file", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-search-")); + // Simulates a serialized trace dump. Buffering ripgrep's --json events + // for such files unbounded previously crashed the host process once + // accumulated stdout passed the engine's max string length. + await fs.writeFile( + path.join(dir, "trace.json"), + `{"trace": "${"x".repeat(20 * 1024 * 1024)}"}`, + "utf-8", + ); + await fs.writeFile( + path.join(dir, "small.ts"), + "const trace = 1;\n", + "utf-8", + ); + + try { + const search = createSearchExecutor(); + const result = await search("trace", dir, ctx); + + expect(result.length).toBeLessThanOrEqual(50_000); + expect(result).toContain("small.ts"); } 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 ad854daf0e..5f1e1c51a0 100644 --- a/sdk/packages/core/src/extensions/tools/executors/search.ts +++ b/sdk/packages/core/src/extensions/tools/executors/search.ts @@ -10,7 +10,17 @@ 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"; +import { MAX_LINE_CHARS, MAX_SEARCH_OUTPUT_CHARS } from "./output-limits"; + +/** + * Cap on buffered `rg --json` stdout. Each event embeds the full text of its + * matched line, so one match in a giant single-line file (e.g. a serialized + * trace dump) can produce a multi-hundred-MB event; buffering unbounded can + * exceed the engine's max string length and crash the whole process with an + * uncaught RangeError from the stream data handler. Results are capped to + * MAX_SEARCH_OUTPUT_CHARS anyway, so output past this is never shown. + */ +const MAX_RG_STDOUT_CHARS = 10 * 1024 * 1024; /** * Options for the search executor @@ -213,6 +223,9 @@ function searchWithRipgrep( }); child.stdout.on("data", (chunk: Buffer | string) => { + if (stdout.length > MAX_RG_STDOUT_CHARS) { + return; + } stdout += chunk.toString(); }); @@ -224,7 +237,11 @@ function searchWithRipgrep( if (code === 0 || code === 1) { try { const matches: SearchMatch[] = []; - const lines = stdout.split("\n").filter((line) => line.trim()); + // Drop the trailing partial event left behind by the stdout cap. + const lines = stdout + .slice(0, stdout.lastIndexOf("\n") + 1) + .split("\n") + .filter((line) => line.trim()); for (const line of lines) { if (matches.length >= maxResults) break; @@ -427,7 +444,9 @@ export function createSearchExecutor( for (let i = contextStart; i <= contextEnd; i++) { const prefix = i === lineIdx ? ">" : " "; - contextLinesArr.push(`${prefix} ${i + 1}: ${lines[i]}`); + contextLinesArr.push( + `${prefix} ${i + 1}: ${lines[i].slice(0, MAX_LINE_CHARS)}`, + ); } matches.push({