From 297f3595905b9db4cf4ce17b5d987b9431408322 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 19:01:25 -0700 Subject: [PATCH] Make truncation marker honest before the size-cap blob spill Tool results are truncated to MAX_RESULT_CHARS before the reactor's size-cap transform spills the (already-truncated) text as a blob and tells the model the full output is at tool-output:///{id}. For oversized outputs the promised remainder never existed, so the model reads the blob, finds it cut, and re-runs the command. Rework the truncation marker to say the discarded remainder is not retrievable anywhere, so the lie doesn't survive the blob spill. Sized to stay within the ripgrep integration test's MAX_RESULT_CHARS + 200 allowance. --- src/plugins/result-truncation-plugin.test.ts | 61 ++++++++++++++++++++ src/plugins/result-truncation-plugin.ts | 11 +++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/plugins/result-truncation-plugin.test.ts diff --git a/src/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts new file mode 100644 index 000000000..008c9fdbb --- /dev/null +++ b/src/plugins/result-truncation-plugin.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { createSizeCapTransform } from "@intx/inference"; +import type { StrategyContext, ToolResult } from "@intx/types/runtime"; +import { MAX_RESULT_CHARS, truncateToolResultContent } from "./result-truncation-plugin.js"; + +describe("truncateToolResultContent", () => { + test("within-cap content passes through unchanged", () => { + const content = "x".repeat(100); + expect(truncateToolResultContent(content)).toBe(content); + }); + + test("oversized content gets a marker that never promises retrievable remainder", () => { + const content = "x".repeat(MAX_RESULT_CHARS + 500); + const truncated = truncateToolResultContent(content); + + expect(truncated).toContain("[output truncated"); + expect(truncated).toContain("NOT retrievable"); + // The pre-cap discard must never be described as recoverable elsewhere. + expect(truncated).not.toContain("see the rest"); + expect(truncated).not.toContain("Full output available"); + }); + + test("truncation marker survives the size-cap blob spill", async () => { + // Reproduce the production pipeline for an output over MAX_RESULT_CHARS: + // truncation runs first (at the tool), size-cap spills the already-cut + // text to a blob and tells the model the blob holds the full output. The + // blob's tail must therefore carry the honest "discarded, NOT retrievable" + // marker so the model does not loop re-running the command. + const original = "x".repeat(MAX_RESULT_CHARS + 500); + const truncated = truncateToolResultContent(original); + + const blobs = new Map(); + const transform = createSizeCapTransform({ + maxChars: 10_000, + contextStore: { + writeBlob: async (key: string, bytes: Uint8Array) => { + blobs.set(key, new TextDecoder().decode(bytes)); + }, + }, + }); + + const result: ToolResult = { + callId: "call-1", + content: truncated, + isError: false, + }; + const { output } = await transform.apply( + { call: { id: "call-1", name: "run_shell", arguments: {} }, result }, + {} as StrategyContext, + ); + + const spilled = blobs.get("call-1"); + expect(spilled).toBe(truncated); + // The blob's tail tells the truth about the pre-spill discard. + expect(spilled).toContain("NOT retrievable"); + expect(spilled?.endsWith("Use offset/limit or a narrower query.]")).toBe(true); + // The inline marker's blob promise is now genuine: the blob really does + // hold everything that still exists. + expect(output.content).toContain("tool-output:///call-1"); + }); +}); diff --git a/src/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 18d53c246..c77796813 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -20,10 +20,17 @@ export function truncateToolResultContent( if (content.length <= maxChars) return content; const remaining = content.length - maxChars; + // Truncation happens here, at the source — before the reactor's size-cap + // transform spills to a tool-output:/// blob. The blob therefore holds only + // this already-truncated text, so the marker must say the remainder is gone: + // a "see the blob for the rest" promise would send the model chasing content + // that does not exist and re-running the command in a loop. return ( content.slice(0, maxChars) + - `\n[output truncated — ${remaining.toLocaleString()} characters omitted. ` + - `Use offset/limit params or a more targeted query to see the rest.]` + `\n[output truncated at ${maxChars.toLocaleString()} chars — ` + + `${remaining.toLocaleString()} chars discarded, NOT retrievable ` + + `(no tool-output URI has them; re-running gives the same cut). ` + + `Use offset/limit or a narrower query.]` ); }