Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions src/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
debugStart,
isDebugEnabled,
} from "../utils/debug";
import { middleTruncate } from "../utils/helpers";

export interface BashOutput {
stdout: string;
Expand Down Expand Up @@ -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);

Expand Down
28 changes: 28 additions & 0 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,31 @@ 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 (!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;

let totalLines = 1;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) totalLines++;
}

return (
`[Total output lines: ${totalLines}]\n\n` +
text.slice(0, headLength) +
`\n\n…${omitted} chars truncated…\n\n` +
text.slice(text.length - tailLength)
);
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
isDebugEnabled,
reinitDebugMode,
} from "./debug";
export { middleTruncate } from "./helpers";
export {
estimateMessagesTokens,
estimateMessageTokens,
Expand Down
14 changes: 11 additions & 3 deletions tests/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ describe("Bash Tool", () => {

assertSuccess<BashOutput>(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 () => {
Expand All @@ -118,7 +124,9 @@ describe("Bash Tool", () => {

assertSuccess<BashOutput>(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 () => {
Expand All @@ -139,7 +147,7 @@ describe("Bash Tool", () => {

assertSuccess<BashOutput>(result);
// Exactly at limit shouldn't truncate
expect(result.stdout).not.toContain("[output truncated,");
expect(result.stdout).not.toContain("chars truncated");
});
});

Expand Down
78 changes: 78 additions & 0 deletions tests/utils/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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…");
});

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);
});
});