Skip to content

Commit 4cff873

Browse files
committed
🤖 feat: Save bash overflow to temp file with short IDs
When bash tool output exceeds limits (100 lines, 16KB, or 1KB/line), save the full output to a temp file instead of failing with no output. This prevents expensive commands from needing to be re-run. Key design decisions: - NO output shown when truncated - avoids overwhelming context - Short 8-char hex file IDs (bash-a1b2c3d4.txt) for easy recall - Helpful filtering examples (grep, head, tail, sed) - Reminder to clean up temp file when done - Reduce max_lines to 100 (from 1000) to trigger overflow more reasonably Changes: - Continue collecting lines even after truncation triggered - Write full output to /tmp/bash-<8hex>.txt on overflow - Return error with file path and filtering instructions - Update tests to verify overflow file creation and format - Reduce BASH_DEFAULT_MAX_LINES and BASH_HARD_MAX_LINES to 100 _Generated with `cmux`_
1 parent 0a0cf31 commit 4cff873

File tree

3 files changed

+169
-75
lines changed

3 files changed

+169
-75
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 = 100;
2+
export const BASH_HARD_MAX_LINES = 100;
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
@@ -62,6 +62,58 @@ describe("bash tool", () => {
6262
}
6363
});
6464

65+
it("should save overflow output to temp file with short ID", async () => {
66+
const tool = createBashTool({ cwd: process.cwd() });
67+
const args: BashToolArgs = {
68+
script: "for i in {1..150}; do echo line$i; done",
69+
timeout_secs: 5,
70+
max_lines: 100,
71+
};
72+
73+
const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
74+
75+
expect(result.success).toBe(false);
76+
if (!result.success) {
77+
expect(result.error).toContain("[OUTPUT OVERFLOW");
78+
expect(result.error).toContain("lines saved to");
79+
expect(result.error).toContain("bash-");
80+
expect(result.error).toContain(".txt");
81+
82+
// Verify helpful filtering instructions are included
83+
expect(result.error).toContain("grep '<pattern>'");
84+
expect(result.error).toContain("head -n 100");
85+
expect(result.error).toContain("tail -n 100");
86+
expect(result.error).toContain("When done, clean up: rm");
87+
88+
// Extract file path from error message
89+
const match = result.error.match(/saved to (\/[^\]]+\.txt)/);
90+
expect(match).toBeDefined();
91+
if (match) {
92+
const overflowPath = match[1];
93+
94+
// Verify file has short ID format (bash-<8 hex chars>.txt)
95+
const filename = overflowPath.split("/").pop();
96+
expect(filename).toMatch(/^bash-[0-9a-f]{8}\.txt$/);
97+
98+
// Verify file exists
99+
const fs = require("fs");
100+
expect(fs.existsSync(overflowPath)).toBe(true);
101+
102+
// Verify file contains collected lines (at least 100, 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(100);
106+
expect(fileContent).toContain("line1");
107+
expect(fileContent).toContain("line100");
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: 115 additions & 73 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,
@@ -139,77 +141,87 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
139141
const stderrReader = createInterface({ input: childProcess.child.stderr! });
140142

141143
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-
}
154-
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-
}
164-
144+
if (!resolved) {
145+
// Always collect lines, even after truncation is triggered
146+
// This allows us to save the full output to a temp file
165147
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();
148+
149+
if (!truncated) {
150+
const lineBytes = Buffer.byteLength(line, "utf-8");
151+
152+
// Check if line exceeds per-line limit
153+
if (lineBytes > BASH_MAX_LINE_BYTES) {
154+
truncated = true;
155+
// Close readline interfaces before killing to ensure clean shutdown
156+
stdoutReader.close();
157+
stderrReader.close();
158+
childProcess.child.kill();
159+
return;
160+
}
161+
162+
totalBytesAccumulated += lineBytes + 1; // +1 for newline
163+
164+
// Check if adding this line would exceed total bytes limit
165+
if (totalBytesAccumulated > BASH_MAX_TOTAL_BYTES) {
166+
truncated = true;
167+
// Close readline interfaces before killing to ensure clean shutdown
168+
stdoutReader.close();
169+
stderrReader.close();
170+
childProcess.child.kill();
171+
return;
172+
}
173+
174+
// Check if we've exceeded the effective max_lines limit
175+
if (lines.length >= effectiveMaxLines) {
176+
truncated = true;
177+
// Close readline interfaces before killing to ensure clean shutdown
178+
stdoutReader.close();
179+
stderrReader.close();
180+
childProcess.child.kill();
181+
}
175182
}
176183
}
177184
});
178185

179186
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-
}
192-
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-
}
202-
187+
if (!resolved) {
188+
// Always collect lines, even after truncation is triggered
189+
// This allows us to save the full output to a temp file
203190
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();
191+
192+
if (!truncated) {
193+
const lineBytes = Buffer.byteLength(line, "utf-8");
194+
195+
// Check if line exceeds per-line limit
196+
if (lineBytes > BASH_MAX_LINE_BYTES) {
197+
truncated = true;
198+
// Close readline interfaces before killing to ensure clean shutdown
199+
stdoutReader.close();
200+
stderrReader.close();
201+
childProcess.child.kill();
202+
return;
203+
}
204+
205+
totalBytesAccumulated += lineBytes + 1; // +1 for newline
206+
207+
// Check if adding this line would exceed total bytes limit
208+
if (totalBytesAccumulated > BASH_MAX_TOTAL_BYTES) {
209+
truncated = true;
210+
// Close readline interfaces before killing to ensure clean shutdown
211+
stdoutReader.close();
212+
stderrReader.close();
213+
childProcess.child.kill();
214+
return;
215+
}
216+
217+
// Check if we've exceeded the effective max_lines limit
218+
if (lines.length >= effectiveMaxLines) {
219+
truncated = true;
220+
// Close readline interfaces before killing to ensure clean shutdown
221+
stdoutReader.close();
222+
stderrReader.close();
223+
childProcess.child.kill();
224+
}
213225
}
214226
}
215227
});
@@ -294,15 +306,45 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
294306
wall_duration_ms,
295307
});
296308
} 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-
});
309+
// Save overflow output to temp file instead of returning an error
310+
// We don't show ANY of the actual output to avoid overwhelming context.
311+
// Instead, save it to a temp file and encourage the agent to use filtering tools.
312+
try {
313+
const tmpDir = os.tmpdir();
314+
// Use 8 hex characters for short, memorable temp file IDs
315+
const fileId = Math.random().toString(16).substring(2, 10);
316+
const overflowPath = path.join(tmpDir, `bash-${fileId}.txt`);
317+
const fullOutput = lines.join("\n");
318+
fs.writeFileSync(overflowPath, fullOutput, "utf-8");
319+
320+
const output = `[OUTPUT OVERFLOW - ${lines.length} lines saved to ${overflowPath}]
321+
322+
The command output exceeded limits and was saved to a temporary file.
323+
Use filtering tools to extract what you need:
324+
- grep '<pattern>' ${overflowPath}
325+
- head -n 100 ${overflowPath}
326+
- tail -n 100 ${overflowPath}
327+
- sed -n '100,200p' ${overflowPath}
328+
329+
When done, clean up: rm ${overflowPath}`;
330+
331+
resolveOnce({
332+
success: false,
333+
error: output,
334+
exitCode: -1,
335+
wall_duration_ms,
336+
});
337+
} catch (err) {
338+
// If temp file creation fails, fall back to original error
339+
resolveOnce({
340+
success: false,
341+
error:
342+
`Command output exceeded limits (max ${BASH_MAX_TOTAL_BYTES} bytes total, ${BASH_MAX_LINE_BYTES} bytes per line, ${effectiveMaxLines} lines). ` +
343+
`Use output-limiting commands like 'head', 'tail', or 'grep' to reduce output size. Failed to save overflow: ${err}`,
344+
exitCode: -1,
345+
wall_duration_ms,
346+
});
347+
}
306348
} else if (exitCode === 0 || exitCode === null) {
307349
resolveOnce({
308350
success: true,

0 commit comments

Comments
 (0)