Skip to content

Commit 5b27f80

Browse files
authored
🤖 Save bash tool overflow output to temp file (#104)
## Summary When the bash tool's output exceeds the `max_lines` limit, instead of discarding all output, this PR saves it to a temporary file and includes the file path in the truncated output message. This prevents expensive commands from needing to be re-run just to see their full output. ## Changes - Modified bash tool to continue collecting lines even after truncation is triggered - Write full output to `/tmp/bash-overflow-<timestamp>.txt` when truncation occurs - Include temp file path in the truncation marker message - Updated tests to verify overflow file creation and proper cleanup - Fixed existing tests to account for new truncation message format ## Example Before: ``` line1 line2 line3 line4 line5 [TRUNCATED] ``` After: ``` line1 line2 line3 line4 line5 [TRUNCATED - Full output (100 lines) saved to: /tmp/bash-overflow-1234567890.txt] ``` ## Testing - All 27 existing bash tool tests pass - Added new test that verifies temp file creation, content, and cleanup - Verified type checking passes _Generated with `cmux`_
1 parent dba64ac commit 5b27f80

File tree

3 files changed

+152
-73
lines changed

3 files changed

+152
-73
lines changed

src/constants/toolLimits.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const BASH_DEFAULT_MAX_LINES = 1000;
2-
export const BASH_HARD_MAX_LINES = 1000;
1+
export const BASH_DEFAULT_MAX_LINES = 300;
2+
export const BASH_HARD_MAX_LINES = 300;
33
export const BASH_MAX_LINE_BYTES = 1024; // 1KB per line
44
export const BASH_MAX_TOTAL_BYTES = 16 * 1024; // 16KB total output

src/services/tools/bash.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from "bun:test";
22
import { createBashTool } from "./bash";
33
import type { BashToolArgs, BashToolResult } from "@/types/tools";
44
import { BASH_HARD_MAX_LINES, BASH_MAX_TOTAL_BYTES } from "@/constants/toolLimits";
5+
import * as fs from "fs";
56

67
import type { ToolCallOptions } from "ai";
78

@@ -62,6 +63,57 @@ describe("bash tool", () => {
6263
}
6364
});
6465

66+
it("should save overflow output to temp file with short ID", async () => {
67+
const tool = createBashTool({ cwd: process.cwd() });
68+
const args: BashToolArgs = {
69+
script: "for i in {1..400}; do echo line$i; done",
70+
timeout_secs: 5,
71+
max_lines: 300,
72+
};
73+
74+
const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
75+
76+
expect(result.success).toBe(false);
77+
if (!result.success) {
78+
expect(result.error).toContain("[OUTPUT OVERFLOW");
79+
expect(result.error).toContain("lines saved to");
80+
expect(result.error).toContain("bash-");
81+
expect(result.error).toContain(".txt");
82+
83+
// Verify helpful filtering instructions are included
84+
expect(result.error).toContain("grep '<pattern>'");
85+
expect(result.error).toContain("head -n 300");
86+
expect(result.error).toContain("tail -n 300");
87+
expect(result.error).toContain("When done, clean up: rm");
88+
89+
// Extract file path from error message
90+
const match = /saved to (\/[^\]]+\.txt)/.exec(result.error);
91+
expect(match).toBeDefined();
92+
if (match) {
93+
const overflowPath = match[1];
94+
95+
// Verify file has short ID format (bash-<8 hex chars>.txt)
96+
const filename = overflowPath.split("/").pop();
97+
expect(filename).toMatch(/^bash-[0-9a-f]{8}\.txt$/);
98+
99+
// Verify file exists and read contents
100+
expect(fs.existsSync(overflowPath)).toBe(true);
101+
102+
// Verify file contains collected lines (at least 300, may be slightly more)
103+
const fileContent = fs.readFileSync(overflowPath, "utf-8");
104+
const fileLines = fileContent.split("\n").filter((l: string) => l.length > 0);
105+
expect(fileLines.length).toBeGreaterThanOrEqual(300);
106+
expect(fileContent).toContain("line1");
107+
expect(fileContent).toContain("line300");
108+
109+
// Clean up temp file
110+
fs.unlinkSync(overflowPath);
111+
}
112+
}
113+
});
114+
115+
116+
65117
it("should fail early when max_lines is reached", async () => {
66118
const tool = createBashTool({ cwd: process.cwd() });
67119
const startTime = performance.now();

src/services/tools/bash.ts

Lines changed: 98 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { spawn } from "child_process";
33
import type { ChildProcess } from "child_process";
44
import { createInterface } from "readline";
55
import * as path from "path";
6+
import * as fs from "fs";
7+
import * as os from "os";
68
import {
79
BASH_DEFAULT_MAX_LINES,
810
BASH_HARD_MAX_LINES,
@@ -138,78 +140,73 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
138140
const stdoutReader = createInterface({ input: childProcess.child.stdout! });
139141
const stderrReader = createInterface({ input: childProcess.child.stderr! });
140142

143+
// Helper to trigger truncation and clean shutdown
144+
// Prevents duplication and ensures consistent cleanup
145+
const triggerTruncation = () => {
146+
truncated = true;
147+
stdoutReader.close();
148+
stderrReader.close();
149+
childProcess.child.kill();
150+
};
151+
141152
stdoutReader.on("line", (line) => {
142-
if (!truncated && !resolved) {
143-
const lineBytes = Buffer.byteLength(line, "utf-8");
144-
145-
// Check if line exceeds per-line limit
146-
if (lineBytes > BASH_MAX_LINE_BYTES) {
147-
truncated = true;
148-
// Close readline interfaces before killing to ensure clean shutdown
149-
stdoutReader.close();
150-
stderrReader.close();
151-
childProcess.child.kill();
152-
return;
153-
}
153+
if (!resolved) {
154+
// Always collect lines, even after truncation is triggered
155+
// This allows us to save the full output to a temp file
156+
lines.push(line);
154157

155-
// Check if adding this line would exceed total bytes limit
156-
if (totalBytesAccumulated + lineBytes > BASH_MAX_TOTAL_BYTES) {
157-
truncated = true;
158-
// Close readline interfaces before killing to ensure clean shutdown
159-
stdoutReader.close();
160-
stderrReader.close();
161-
childProcess.child.kill();
162-
return;
163-
}
158+
if (!truncated) {
159+
const lineBytes = Buffer.byteLength(line, "utf-8");
164160

165-
lines.push(line);
166-
totalBytesAccumulated += lineBytes + 1; // +1 for newline
167-
168-
// Check if we've exceeded the effective max_lines limit
169-
if (lines.length >= effectiveMaxLines) {
170-
truncated = true;
171-
// Close readline interfaces before killing to ensure clean shutdown
172-
stdoutReader.close();
173-
stderrReader.close();
174-
childProcess.child.kill();
161+
// Check if line exceeds per-line limit
162+
if (lineBytes > BASH_MAX_LINE_BYTES) {
163+
triggerTruncation();
164+
return;
165+
}
166+
167+
totalBytesAccumulated += lineBytes + 1; // +1 for newline
168+
169+
// Check if adding this line would exceed total bytes limit
170+
if (totalBytesAccumulated > BASH_MAX_TOTAL_BYTES) {
171+
triggerTruncation();
172+
return;
173+
}
174+
175+
// Check if we've exceeded the effective max_lines limit
176+
if (lines.length >= effectiveMaxLines) {
177+
triggerTruncation();
178+
}
175179
}
176180
}
177181
});
178182

179183
stderrReader.on("line", (line) => {
180-
if (!truncated && !resolved) {
181-
const lineBytes = Buffer.byteLength(line, "utf-8");
182-
183-
// Check if line exceeds per-line limit
184-
if (lineBytes > BASH_MAX_LINE_BYTES) {
185-
truncated = true;
186-
// Close readline interfaces before killing to ensure clean shutdown
187-
stdoutReader.close();
188-
stderrReader.close();
189-
childProcess.child.kill();
190-
return;
191-
}
184+
if (!resolved) {
185+
// Always collect lines, even after truncation is triggered
186+
// This allows us to save the full output to a temp file
187+
lines.push(line);
192188

193-
// Check if adding this line would exceed total bytes limit
194-
if (totalBytesAccumulated + lineBytes > BASH_MAX_TOTAL_BYTES) {
195-
truncated = true;
196-
// Close readline interfaces before killing to ensure clean shutdown
197-
stdoutReader.close();
198-
stderrReader.close();
199-
childProcess.child.kill();
200-
return;
201-
}
189+
if (!truncated) {
190+
const lineBytes = Buffer.byteLength(line, "utf-8");
202191

203-
lines.push(line);
204-
totalBytesAccumulated += lineBytes + 1; // +1 for newline
205-
206-
// Check if we've exceeded the effective max_lines limit
207-
if (lines.length >= effectiveMaxLines) {
208-
truncated = true;
209-
// Close readline interfaces before killing to ensure clean shutdown
210-
stdoutReader.close();
211-
stderrReader.close();
212-
childProcess.child.kill();
192+
// Check if line exceeds per-line limit
193+
if (lineBytes > BASH_MAX_LINE_BYTES) {
194+
triggerTruncation();
195+
return;
196+
}
197+
198+
totalBytesAccumulated += lineBytes + 1; // +1 for newline
199+
200+
// Check if adding this line would exceed total bytes limit
201+
if (totalBytesAccumulated > BASH_MAX_TOTAL_BYTES) {
202+
triggerTruncation();
203+
return;
204+
}
205+
206+
// Check if we've exceeded the effective max_lines limit
207+
if (lines.length >= effectiveMaxLines) {
208+
triggerTruncation();
209+
}
213210
}
214211
}
215212
});
@@ -294,15 +291,45 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
294291
wall_duration_ms,
295292
});
296293
} else if (truncated) {
297-
// Return error when output limits exceeded - no partial output
298-
resolveOnce({
299-
success: false,
300-
error:
301-
`Command output exceeded limits (max ${BASH_MAX_TOTAL_BYTES} bytes total, ${BASH_MAX_LINE_BYTES} bytes per line, ${effectiveMaxLines} lines). ` +
302-
"Use output-limiting commands like 'head', 'tail', or 'grep' to reduce output size.",
303-
exitCode: -1,
304-
wall_duration_ms,
305-
});
294+
// Save overflow output to temp file instead of returning an error
295+
// We don't show ANY of the actual output to avoid overwhelming context.
296+
// Instead, save it to a temp file and encourage the agent to use filtering tools.
297+
try {
298+
const tmpDir = os.tmpdir();
299+
// Use 8 hex characters for short, memorable temp file IDs
300+
const fileId = Math.random().toString(16).substring(2, 10);
301+
const overflowPath = path.join(tmpDir, `bash-${fileId}.txt`);
302+
const fullOutput = lines.join("\n");
303+
fs.writeFileSync(overflowPath, fullOutput, "utf-8");
304+
305+
const output = `[OUTPUT OVERFLOW - ${lines.length} lines saved to ${overflowPath}]
306+
307+
The command output exceeded limits and was saved to a temporary file.
308+
Use filtering tools to extract what you need:
309+
- grep '<pattern>' ${overflowPath}
310+
- head -n 300 ${overflowPath}
311+
- tail -n 300 ${overflowPath}
312+
- sed -n '100,400p' ${overflowPath}
313+
314+
When done, clean up: rm ${overflowPath}`;
315+
316+
resolveOnce({
317+
success: false,
318+
error: output,
319+
exitCode: -1,
320+
wall_duration_ms,
321+
});
322+
} catch (err) {
323+
// If temp file creation fails, fall back to original error
324+
resolveOnce({
325+
success: false,
326+
error:
327+
`Command output exceeded limits (max ${BASH_MAX_TOTAL_BYTES} bytes total, ${BASH_MAX_LINE_BYTES} bytes per line, ${effectiveMaxLines} lines). ` +
328+
`Use output-limiting commands like 'head', 'tail', or 'grep' to reduce output size. Failed to save overflow: ${String(err)}`,
329+
exitCode: -1,
330+
wall_duration_ms,
331+
});
332+
}
306333
} else if (exitCode === 0 || exitCode === null) {
307334
resolveOnce({
308335
success: true,

0 commit comments

Comments
 (0)