bash: save large output to file, show first/last lines

Philip Zeyliger and Shelley created

Prompt: Let's make it so that if the output is greater than 50kb, we send back the first 2 lines and the last five lines, and tell the agent where the output file is for the rest. If this output didn't have lines, just tell the agent about the output file. Number the lines that you're outputting. Show me an example of the agent outputs.

When bash output exceeds 50KB:
- Save full output to a temp file
- Return first 2 lines and last 5 lines (numbered)
- Tell agent where to find the full output

For binary-like output (fewer than 3 lines), just indicate the file path.

Falls back to old middle-snip truncation if temp file creation fails.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/bash.go      | 67 ++++++++++++++++++++++++++++++++-------
claudetool/bash_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 127 insertions(+), 12 deletions(-)

Detailed changes

claudetool/bash.go 🔗

@@ -224,7 +224,11 @@ func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
 	return llm.ToolOut{LLMContent: llm.TextContent(out), Display: display}
 }
 
-const maxBashOutputLength = 131072
+const (
+	largeOutputThreshold = 50 * 1024 // 50KB - threshold for saving to file
+	firstLinesCount      = 2
+	lastLinesCount       = 5
+)
 
 func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
 	cmd := exec.CommandContext(ctx, "bash", "-c", command)
@@ -281,8 +285,10 @@ func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.
 
 	err := cmdWait(cmd)
 
-	out := output.String()
-	out = formatForegroundBashOutput(out)
+	out, formatErr := formatForegroundBashOutput(output.String())
+	if formatErr != nil {
+		return "", formatErr
+	}
 
 	if execCtx.Err() == context.DeadlineExceeded {
 		return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
@@ -295,15 +301,52 @@ func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.
 }
 
 // formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
-func formatForegroundBashOutput(out string) string {
-	if len(out) > maxBashOutputLength {
-		const snipSize = 4096
-		out = fmt.Sprintf("[output truncated in middle: got %v, max is %v]\n%s\n\n[snip]\n\n%s",
-			humanizeBytes(len(out)), humanizeBytes(maxBashOutputLength),
-			out[:snipSize], out[len(out)-snipSize:],
-		)
-	}
-	return out
+// If output exceeds largeOutputThreshold, it saves to a file and returns a summary.
+func formatForegroundBashOutput(out string) (string, error) {
+	if len(out) <= largeOutputThreshold {
+		return out, nil
+	}
+
+	// Save full output to a temp file
+	tmpDir, err := os.MkdirTemp("", "shelley-output-")
+	if err != nil {
+		return "", fmt.Errorf("failed to create temp dir for large output: %w", err)
+	}
+
+	outFile := filepath.Join(tmpDir, "output")
+	if err := os.WriteFile(outFile, []byte(out), 0o644); err != nil {
+		os.RemoveAll(tmpDir)
+		return "", fmt.Errorf("failed to write large output to file: %w", err)
+	}
+
+	// Split into lines
+	lines := strings.Split(out, "\n")
+
+	// If fewer than 3 lines total, likely binary or single-line output
+	if len(lines) < 3 {
+		return fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]",
+			humanizeBytes(len(out)), len(lines), outFile), nil
+	}
+
+	var result strings.Builder
+	result.WriteString(fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]\n\n",
+		humanizeBytes(len(out)), len(lines), outFile))
+
+	// First N lines
+	result.WriteString("First lines:\n")
+	firstN := min(firstLinesCount, len(lines))
+	for i := 0; i < firstN; i++ {
+		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, lines[i]))
+	}
+
+	// Last N lines
+	result.WriteString("\n...\n\nLast lines:\n")
+	startIdx := max(0, len(lines)-lastLinesCount)
+	for i := startIdx; i < len(lines); i++ {
+		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, lines[i]))
+	}
+
+	return result.String(), nil
 }
 
 func humanizeBytes(bytes int) string {

claudetool/bash_test.go 🔗

@@ -536,6 +536,78 @@ func TestBashTimeout(t *testing.T) {
 	})
 }
 
+func TestFormatForegroundBashOutput(t *testing.T) {
+	// Test small output (under threshold) - should pass through unchanged
+	t.Run("Small Output", func(t *testing.T) {
+		smallOutput := "line 1\nline 2\nline 3\n"
+		result, err := formatForegroundBashOutput(smallOutput)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+		if result != smallOutput {
+			t.Errorf("Expected small output to pass through unchanged, got %q", result)
+		}
+	})
+
+	// Test large output (over 50KB) - should save to file and return summary
+	t.Run("Large Output With Lines", func(t *testing.T) {
+		// Generate output > 50KB with many lines
+		var lines []string
+		for i := 1; i <= 1000; i++ {
+			lines = append(lines, strings.Repeat("x", 60)+" line "+string(rune('0'+i%10)))
+		}
+		largeOutput := strings.Join(lines, "\n")
+		if len(largeOutput) < largeOutputThreshold {
+			t.Fatalf("Test setup error: output is only %d bytes, need > %d", len(largeOutput), largeOutputThreshold)
+		}
+
+		result, err := formatForegroundBashOutput(largeOutput)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		// Should mention the file
+		if !strings.Contains(result, "saved to:") {
+			t.Errorf("Expected result to mention saved file, got:\n%s", result)
+		}
+
+		// Should have first 2 lines numbered
+		if !strings.Contains(result, "    1:") || !strings.Contains(result, "    2:") {
+			t.Errorf("Expected first 2 numbered lines, got:\n%s", result)
+		}
+
+		// Should have last 5 lines numbered
+		if !strings.Contains(result, "  996:") || !strings.Contains(result, " 1000:") {
+			t.Errorf("Expected last 5 numbered lines, got:\n%s", result)
+		}
+
+		t.Logf("Large output result:\n%s", result)
+	})
+
+	// Test large output with few/no lines (binary-like)
+	t.Run("Large Output No Lines", func(t *testing.T) {
+		// Generate > 50KB of data with no newlines
+		largeOutput := strings.Repeat("x", largeOutputThreshold+1000)
+
+		result, err := formatForegroundBashOutput(largeOutput)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		// Should mention the file
+		if !strings.Contains(result, "saved to:") {
+			t.Errorf("Expected result to mention saved file, got:\n%s", result)
+		}
+
+		// Should indicate line count
+		if !strings.Contains(result, "1 lines") {
+			t.Errorf("Expected result to indicate line count, got:\n%s", result)
+		}
+
+		t.Logf("Large binary-like output result:\n%s", result)
+	})
+}
+
 // waitForFile waits for a file to exist and be non-empty or times out
 func waitForFile(t *testing.T, filepath string) {
 	timeout := time.After(5 * time.Second)