Skip to content

Commit 664d799

Browse files
committed
Apply the shared truncation primitive where grep results are returned
ripgrepPlugin answers grep and search_files without calling next, so the result-truncation middleware later in the plugin array never observed those results and an oversized grep reached the model uncapped. Apply the shared primitive at ripgrepPlugin's own return paths, and restore the match-count notice, which reports an omission no size-based pass can infer.
1 parent 1c4dde5 commit 664d799

3 files changed

Lines changed: 94 additions & 54 deletions

File tree

src/plugins/result-truncation-plugin.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,14 @@ const TRUNCATABLE_TOOLS = new Set(["read_file", "grep", "run_shell", "search_fil
44

55
// Characters, not tokens — conversion ratio is roughly 4 chars/token.
66
// 80 000 chars ≈ 20 000 tokens. Keeps a single result from dominating context.
7-
const MAX_RESULT_CHARS = 80_000;
7+
export const MAX_RESULT_CHARS = 80_000;
88

9-
// Shared with the MCP tool runner (src/mcp/plugin.ts), which is not part of
10-
// the posix runner this middleware wraps and so applies the same truncation
11-
// directly. This is the single primitive that produces a truncation notice —
12-
// callers may pass their own threshold but never invent their own wording, so
13-
// a result can never carry two differently-worded "truncated" notices. The
14-
// grep-specific caps in rg-output.ts and ripgrep-plugin.ts deliberately don't
15-
// call this: they trim silently and leave notice duty to this middleware,
16-
// which runs after them in the plugin chain and sees the final content.
9+
// The single primitive for size truncation: callers may pass their own
10+
// threshold but never invent their own wording, so a result can never carry
11+
// two differently-worded "truncated" notices. Called directly by runners this
12+
// middleware does not wrap — the MCP tool runner (src/mcp/plugin.ts), and
13+
// ripgrep-plugin.ts, which answers grep without calling next and so never
14+
// reaches this middleware despite sitting earlier in the same plugin array.
1715
export function truncateToolResultContent(
1816
content: string,
1917
maxChars: number = MAX_RESULT_CHARS,

src/plugins/ripgrep-plugin.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { statSync } from "node:fs";
22
import { dirname, basename } from "node:path";
33
import type { ToolPlugin } from "@intx/tools-posix";
4+
import type { ToolResult } from "@intx/types/runtime";
45

56
import {
67
runBoundedGrep,
78
runBoundedSearchFiles,
89
type BoundedGrepArgs,
910
} from "./bounded-grep-fallback.js";
1011
import { createRgCollector } from "./rg-output.js";
12+
import { truncateToolResultContent } from "./result-truncation-plugin.js";
1113
import { MAX_OUTPUT_BYTES, runRg, type RgLimits, type SpawnRg } from "./rg-run.js";
1214

1315
// A grep over a large tree with the pure-TypeScript walker enumerates the whole
@@ -21,19 +23,28 @@ import { MAX_OUTPUT_BYTES, runRg, type RgLimits, type SpawnRg } from "./rg-run.j
2123
const DEFAULT_GREP_MAX = 500;
2224
const DEFAULT_SEARCH_MAX = 1000;
2325

24-
// Caps the number of matches shown; carries no notice of its own. The final
25-
// tool result still passes through result-truncation-plugin.ts, which is the
26-
// single place a "truncated" notice gets attached — a count-based notice
27-
// here would double up with that pass whenever both conditions are true.
26+
// Dropping matches is a different fact from dropping characters, and the size
27+
// pass below cannot infer it: a run can be well under the char cap and still
28+
// have discarded thousands of matches. That omission is announced here; the
29+
// size cap announces its own.
2830
function capLines(text: string, max: number): string {
2931
const lines = text.split("\n").filter((line) => line.length > 0);
30-
return lines.slice(0, max).join("\n");
32+
if (lines.length <= max) return lines.join("\n");
33+
return `${lines.slice(0, max).join("\n")}\n... (showing first ${max} of ${lines.length}+ matches; narrow path/glob)`;
34+
}
35+
36+
// ripgrepPlugin answers grep and search_files without calling next, so the
37+
// result-truncation middleware sitting later in the chain never sees these
38+
// results. The shared primitive is applied here instead, keeping one wording
39+
// for size truncation on a path that would otherwise return uncapped.
40+
function bounded(callId: string, content: string): ToolResult {
41+
return { callId, content: truncateToolResultContent(content) };
3142
}
3243

3344
// Mirrors read_file's truncate-and-offer behavior: a cap or timeout still
3445
// surfaces whatever matches were collected before it fired, instead of
3546
// discarding them behind a bare error. `notice` is only set for conditions
36-
// result-truncation-plugin.ts can't see, like a run timing out.
47+
// neither cap describes, like a run timing out.
3748
function partialContent(stdout: string, maxResults: number, notice?: string): string {
3849
const capped = capLines(stdout, maxResults);
3950
if (capped.length === 0) {
@@ -103,7 +114,7 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
103114
};
104115
if (glob !== undefined) boundedArgs.glob = glob;
105116
const content = await runBoundedGrep(boundedArgs, signal, rgCwd);
106-
return { callId: call.id, content: boundedContent(content, maxResults, maxBytes) };
117+
return bounded(call.id, boundedContent(content, maxResults, maxBytes));
107118
} catch (err) {
108119
return {
109120
callId: call.id,
@@ -119,9 +130,9 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
119130
return { callId: call.id, content: result.message, isError: true };
120131
}
121132
if (result.kind === "partial") {
122-
return { callId: call.id, content: partialContent(result.stdout, maxResults, result.notice) };
133+
return bounded(call.id, partialContent(result.stdout, maxResults, result.notice));
123134
}
124-
return { callId: call.id, content: capLines(result.stdout, maxResults) };
135+
return bounded(call.id, capLines(result.stdout, maxResults));
125136
}
126137

127138
if (call.name === "search_files") {
@@ -139,7 +150,7 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
139150
signal,
140151
rgCwd,
141152
);
142-
return { callId: call.id, content: boundedContent(content, maxResults, maxBytes) };
153+
return bounded(call.id, boundedContent(content, maxResults, maxBytes));
143154
} catch (err) {
144155
return {
145156
callId: call.id,
@@ -155,9 +166,9 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
155166
return { callId: call.id, content: result.message, isError: true };
156167
}
157168
if (result.kind === "partial") {
158-
return { callId: call.id, content: partialContent(result.stdout, maxResults, result.notice) };
169+
return bounded(call.id, partialContent(result.stdout, maxResults, result.notice));
159170
}
160-
return { callId: call.id, content: capLines(result.stdout, maxResults) };
171+
return bounded(call.id, capLines(result.stdout, maxResults));
161172
}
162173

163174
return next(call, signal);

tests/unit/ripgrep-plugin.test.ts

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import type { ToolCall, ToolResult } from "@intx/types/runtime";
66

7+
import { createPosixTools } from "@intx/tools-posix";
8+
79
import { ripgrepPlugin } from "../../src/plugins/ripgrep-plugin.js";
8-
import { resultTruncationPlugin } from "../../src/plugins/result-truncation-plugin.js";
10+
import { MAX_RESULT_CHARS } from "../../src/plugins/result-truncation-plugin.js";
11+
import { buildCorePosixToolPlugins } from "../../src/agent/posix-tool-plugins.js";
12+
import { createPermissionGate } from "../../src/permission/gate.js";
913
import type { RgChild, SpawnRg } from "../../src/plugins/rg-run.js";
1014

1115
// Repo root derived from this file, not process.cwd(): these cases search real
@@ -151,12 +155,11 @@ test("the output byte cap holds when ripgrep is unavailable", async () => {
151155
});
152156

153157
// A grep run that both breaches the byte cap (rg-output.ts) and matches more
154-
// lines than max_results (ripgrep-plugin.ts's own count cap) used to carry
155-
// two notices: capLines added its own "(showing first N of M+ lines...)"
156-
// text on top of whatever the byte-cap breach had already reported, because
157-
// partialContent concatenated both unconditionally. It must report the
158-
// truncation exactly once.
159-
test("a grep result that hits both the byte cap and the match-count cap carries exactly one truncation notice", async () => {
158+
// lines than max_results (ripgrep-plugin.ts's own count cap) used to stack the
159+
// byte cap's wording on top of the count cap's. The byte cap is silent now, so
160+
// what is left describes the omission the reader cannot otherwise detect: how
161+
// many matches were dropped.
162+
test("a grep result that hits both the byte cap and the match-count cap announces the dropped matches once", async () => {
160163
// 400 matched lines emitted directly, bypassing a real `rg` process so
161164
// nothing upstream of ripgrep-plugin.ts pre-limits the line count.
162165
const result = await run(
@@ -167,37 +170,65 @@ test("a grep result that hits both the byte cap and the match-count cap carries
167170

168171
expect(result.isError).toBeUndefined();
169172
const content = String(result.content);
170-
// Both grep-specific caps fired (byte cap at 200 bytes, line cap at 3
171-
// matches) but neither attaches its own notice — ripgrep-plugin.ts leaves
172-
// that to result-truncation-plugin.ts, which runs later in the real chain
173-
// and sees the final content. A regression that reintroduces either cap's
174-
// own notice text would fail this.
175-
expect(content.split("\n").length).toBeLessThanOrEqual(3);
176-
expect(content).not.toMatch(/showing first|exceeded \d+ bytes|timed out/);
173+
expect(content.split("\n").filter((l) => l.includes("match line here")).length).toBe(3);
174+
expect((content.match(/showing first/g) ?? []).length).toBe(1);
175+
expect(content).not.toMatch(/exceeded \d+ bytes|timed out|\[output truncated/);
177176
});
178177

179-
// The same result, run through the full chain (ripgrepPlugin then
180-
// result-truncation-plugin, matching buildCorePosixToolPlugins in
181-
// src/agent/posix-tool-plugins.ts), still carries at most one notice — the
182-
// grep-specific caps stay silent and result-truncation-plugin.ts's char cap
183-
// is the backstop for content that's still oversized after them.
184-
test("a large grep result carries at most one truncation notice through the plugin chain", async () => {
178+
// Composed through buildCorePosixToolPlugins, not a hand-assembled pair:
179+
// ripgrepPlugin sits at an earlier array index than resultTruncationPlugin and
180+
// answers grep without calling next, so resultTruncationPlugin never sees a
181+
// grep result. Assembling the two by hand in the other order hides that and
182+
// lets an oversized result reach the model uncapped and unannounced.
183+
async function grepThroughRealChain(dir: string): Promise<string> {
184+
const gate = createPermissionGate({
185+
approvals: [],
186+
interactive: false,
187+
skipPermissions: true,
188+
cwd: dir,
189+
});
190+
const runner = createPosixTools({
191+
cwd: dir,
192+
plugins: buildCorePosixToolPlugins({ cwd: dir, permissionGate: gate }),
193+
});
194+
const result = await runner.run(
195+
{ id: "c", name: "grep", arguments: { pattern: "line", path: dir } },
196+
new AbortController().signal,
197+
);
198+
expect(result.isError).not.toBe(true);
199+
return String(result.content);
200+
}
201+
202+
async function writeOversizedHaystack(dir: string): Promise<void> {
203+
const lines = Array.from({ length: 5000 }, (_, i) => `line ${i} ${"x".repeat(300)}`);
204+
await writeFile(join(dir, "big.txt"), lines.join("\n") + "\n");
205+
}
206+
207+
function truncationNoticeCount(content: string): number {
208+
return (content.match(/\[output truncated/g) ?? []).length;
209+
}
210+
211+
test("an oversized grep result is capped and announced once through the real plugin chain", async () => {
185212
await withTempDir(async (dir) => {
186-
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i} ${"x".repeat(300)}`);
187-
await writeFile(join(dir, "big.txt"), lines.join("\n") + "\n");
188-
189-
const grepHandler = ripgrepPlugin(dir).middleware!(fallback);
190-
const handler = resultTruncationPlugin().middleware!(grepHandler);
191-
const result = await handler(
192-
{ id: "c", name: "grep", arguments: { pattern: "line", path: dir } },
193-
new AbortController().signal,
194-
);
213+
await writeOversizedHaystack(dir);
214+
const content = await grepThroughRealChain(dir);
195215

196-
expect(result.isError).toBeUndefined();
197-
const content = String(result.content);
198-
expect(content).toContain("output truncated");
216+
expect(content.length).toBeLessThanOrEqual(MAX_RESULT_CHARS + 200);
217+
expect(truncationNoticeCount(content)).toBe(1);
199218
expect(content).not.toContain("showing first");
200-
expect((content.match(/\[output truncated/g) ?? []).length).toBe(1);
219+
});
220+
});
221+
222+
test("an oversized grep result is capped and announced once when ripgrep is unavailable", async () => {
223+
await withTempDir(async (dir) => {
224+
await writeOversizedHaystack(dir);
225+
await withoutRipgrep(async () => {
226+
const content = await grepThroughRealChain(dir);
227+
228+
expect(content.length).toBeLessThanOrEqual(MAX_RESULT_CHARS + 200);
229+
expect(truncationNoticeCount(content)).toBe(1);
230+
expect(content).not.toContain("showing first");
231+
});
201232
});
202233
});
203234

0 commit comments

Comments
 (0)