From c2c9076a1bbea91881977a44e6c81e8c571757d9 Mon Sep 17 00:00:00 2001 From: jbreite Date: Sun, 1 Mar 2026 11:03:23 -0500 Subject: [PATCH 1/2] add: middle truncation for tool outputs (50/50 head/tail split) Replace front-only truncation in the Bash tool with middleTruncate, which preserves both the beginning and end of long output. This ensures the agent sees actionable content like test failure summaries and install errors that appear at the tail. Co-Authored-By: Claude Opus 4.6 --- src/tools/bash.ts | 22 +++---------- src/utils/helpers.ts | 23 ++++++++++++++ src/utils/index.ts | 1 + tests/tools/bash.test.ts | 14 +++++++-- tests/utils/helpers.test.ts | 62 +++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 21 deletions(-) create mode 100644 tests/utils/helpers.test.ts diff --git a/src/tools/bash.ts b/src/tools/bash.ts index cb31f17..6f15976 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -8,6 +8,7 @@ import { debugStart, isDebugEnabled, } from "../utils/debug"; +import { middleTruncate } from "../utils/helpers"; export interface BashOutput { stdout: string; @@ -125,24 +126,9 @@ export function createBashTool(sandbox: Sandbox, config?: ToolConfig) { timeout: effectiveTimeout, }); - // Truncate output if needed - let stdout = result.stdout; - let stderr = result.stderr; - - if (stdout.length > maxOutputLength) { - stdout = - stdout.slice(0, maxOutputLength) + - `\n[output truncated, ${ - stdout.length - maxOutputLength - } chars omitted]`; - } - if (stderr.length > maxOutputLength) { - stderr = - stderr.slice(0, maxOutputLength) + - `\n[output truncated, ${ - stderr.length - maxOutputLength - } chars omitted]`; - } + // Middle-truncate output if needed (preserves head + tail) + const stdout = middleTruncate(result.stdout, maxOutputLength); + const stderr = middleTruncate(result.stderr, maxOutputLength); const durationMs = Math.round(performance.now() - startTime); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 1465c0a..09fb0d9 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -29,3 +29,26 @@ export function isToolResultPart(part: unknown): part is ToolResultPart { "output" in part ); } + +/** + * Middle-truncates text, keeping the first half and last half with a + * marker in between. Preserves both the beginning context and the + * actionable end (error summaries, test failures). + * + * Inspired by OpenAI Codex's truncate.rs — 50/50 head/tail split. + */ +export function middleTruncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + + const headLength = Math.floor(maxLength / 2); + const tailLength = maxLength - headLength; + const omitted = text.length - headLength - tailLength; + const totalLines = text.split("\n").length; + + return ( + `[Total output lines: ${totalLines}]\n\n` + + text.slice(0, headLength) + + `\n\n…${omitted} chars truncated…\n\n` + + text.slice(text.length - tailLength) + ); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index b2aabfa..223fb95 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -35,6 +35,7 @@ export { isDebugEnabled, reinitDebugMode, } from "./debug"; +export { middleTruncate } from "./helpers"; export { estimateMessagesTokens, estimateMessageTokens, diff --git a/tests/tools/bash.test.ts b/tests/tools/bash.test.ts index edafaf0..e91eed3 100644 --- a/tests/tools/bash.test.ts +++ b/tests/tools/bash.test.ts @@ -97,7 +97,13 @@ describe("Bash Tool", () => { assertSuccess(result); expect(result.stdout.length).toBeLessThan(longOutput.length); - expect(result.stdout).toContain("[output truncated,"); + expect(result.stdout).toContain("chars truncated"); + // Middle truncation preserves the tail + expect(result.stdout).toContain( + longOutput.slice(longOutput.length - 500), + ); + // Header shows total line count + expect(result.stdout).toContain("[Total output lines:"); }); it("should truncate stderr when exceeding maxOutputLength", async () => { @@ -118,7 +124,9 @@ describe("Bash Tool", () => { assertSuccess(result); expect(result.stderr.length).toBeLessThan(longError.length); - expect(result.stderr).toContain("[output truncated,"); + expect(result.stderr).toContain("chars truncated"); + // Middle truncation preserves the tail + expect(result.stderr).toContain(longError.slice(longError.length - 500)); }); it("should use default maxOutputLength of 30000", async () => { @@ -139,7 +147,7 @@ describe("Bash Tool", () => { assertSuccess(result); // Exactly at limit shouldn't truncate - expect(result.stdout).not.toContain("[output truncated,"); + expect(result.stdout).not.toContain("chars truncated"); }); }); diff --git a/tests/utils/helpers.test.ts b/tests/utils/helpers.test.ts new file mode 100644 index 0000000..254fc9e --- /dev/null +++ b/tests/utils/helpers.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { middleTruncate } from "@/utils/helpers"; + +describe("middleTruncate", () => { + it("should return text unchanged when shorter than maxLength", () => { + const text = "hello world"; + expect(middleTruncate(text, 100)).toBe(text); + }); + + it("should return text unchanged when exactly at maxLength", () => { + const text = "x".repeat(1000); + expect(middleTruncate(text, 1000)).toBe(text); + }); + + it("should preserve first 50% and last 50% of content", () => { + const text = "A".repeat(500) + "B".repeat(500); + const result = middleTruncate(text, 100); + + // Head: first 50 chars (all A's) + expect(result).toContain("A".repeat(50)); + // Tail: last 50 chars (all B's) + expect(result).toContain("B".repeat(50)); + }); + + it("should show correct omitted char count in marker", () => { + const text = "x".repeat(1000); + const result = middleTruncate(text, 100); + + // 1000 total - 50 head - 50 tail = 900 omitted + expect(result).toContain("…900 chars truncated…"); + }); + + it("should prepend total output lines header when truncating", () => { + const text = "line1\nline2\nline3\n" + "x".repeat(1000); + const result = middleTruncate(text, 100); + + // 4 lines total (3 newlines = 4 lines) + expect(result).toMatch(/^\[Total output lines: \d+\]/); + }); + + it("should work correctly with multi-line content", () => { + const lines = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`); + const text = lines.join("\n"); + const result = middleTruncate(text, 500); + + expect(result).toContain("[Total output lines: 200]"); + expect(result).toContain("chars truncated"); + // Should contain beginning lines + expect(result).toContain("line 1"); + // Should contain ending lines + expect(result).toContain("line 200"); + }); + + it("should handle odd maxLength correctly", () => { + const text = "x".repeat(100); + const result = middleTruncate(text, 51); + + // head = floor(51/2) = 25, tail = 51 - 25 = 26 + // omitted = 100 - 25 - 26 = 49 + expect(result).toContain("…49 chars truncated…"); + }); +}); From ac78f1905c5a332d9fd62bcfbfba4aa153d5bccf Mon Sep 17 00:00:00 2001 From: jbreite Date: Sun, 1 Mar 2026 11:14:18 -0500 Subject: [PATCH 2/2] fix: harden middleTruncate with input validation and efficient line counting Replace text.split("\n").length with a charCodeAt loop for O(1) space line counting, and guard against invalid maxLength values (negative, NaN, Infinity). Co-Authored-By: Claude Opus 4.6 --- src/utils/helpers.ts | 7 ++++++- tests/utils/helpers.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 09fb0d9..1801385 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -38,12 +38,17 @@ export function isToolResultPart(part: unknown): part is ToolResultPart { * Inspired by OpenAI Codex's truncate.rs — 50/50 head/tail split. */ export function middleTruncate(text: string, maxLength: number): string { + if (!Number.isFinite(maxLength) || maxLength < 0) return text; if (text.length <= maxLength) return text; const headLength = Math.floor(maxLength / 2); const tailLength = maxLength - headLength; const omitted = text.length - headLength - tailLength; - const totalLines = text.split("\n").length; + + let totalLines = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) totalLines++; + } return ( `[Total output lines: ${totalLines}]\n\n` + diff --git a/tests/utils/helpers.test.ts b/tests/utils/helpers.test.ts index 254fc9e..c940d45 100644 --- a/tests/utils/helpers.test.ts +++ b/tests/utils/helpers.test.ts @@ -59,4 +59,20 @@ describe("middleTruncate", () => { // omitted = 100 - 25 - 26 = 49 expect(result).toContain("…49 chars truncated…"); }); + + it("should return text unchanged for negative maxLength", () => { + const text = "hello world"; + expect(middleTruncate(text, -1)).toBe(text); + expect(middleTruncate(text, -100)).toBe(text); + }); + + it("should return text unchanged for NaN maxLength", () => { + const text = "hello world"; + expect(middleTruncate(text, NaN)).toBe(text); + }); + + it("should return text unchanged for Infinity maxLength", () => { + const text = "hello world"; + expect(middleTruncate(text, Infinity)).toBe(text); + }); });