fix(sdk): search output cap + bash executor fixes (follow-up to #11480) (#11504)

* fix(sdk): search output cap + bash executor fixes (follow-up to #11480)

Slimmed from the original revision: the aggregate per-call output budget
is deferred to its own follow-up PR. What remains:

- cap search_codebase output at 48k chars per query with a middle-cut
  notice teaching the model to narrow the pattern (robinnewhouse's
  finding on #11480 — search was the last uncapped tool)
- rename bash executor maxOutputBytes -> maxOutputChars; the limit was
  always enforced in characters. Deprecated alias retained; stale
  @default annotation fixed
- flush the rolling collector's StringDecoder at end-of-stream so
  trailing incomplete multibyte sequences are not silently dropped
  (greptile's finding on #11480)
- decouple output-limits comments from MessageBuilder's specific
  backstop value; the durable invariant is that truncation notices live
  in the preserved head/tail of an entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/ygz2qho62ub6o8v1zhjvdktt

* test(sdk): cover search output cap

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Ara
2026-06-14 19:22:39 -07:00
committed by GitHub
co-authored by Claude Fable 5 Saoud Rizwan
parent e5e1aa3455
commit e91cba4045
7 changed files with 159 additions and 37 deletions
@@ -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,
@@ -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", () => {
@@ -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<string> {
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,
);
};
@@ -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");
});
@@ -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;
@@ -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 });
}
});
});
@@ -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)
);
}