From 5513fb1167375ec0b07ca1e7f2966cac99a91269 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Tue, 20 Jan 2026 14:57:16 +0000 Subject: [PATCH] bash: save large output to file, show first/last lines 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 --- claudetool/bash.go | 67 +++++++++++++++++++++++++++++++------- claudetool/bash_test.go | 72 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/claudetool/bash.go b/claudetool/bash.go index 418dce745a36a6a0a1e7de811fe173d914d8111b..dd21769566e1afbf16e45f40b51b575787312ce9 100644 --- a/claudetool/bash.go +++ b/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 { diff --git a/claudetool/bash_test.go b/claudetool/bash_test.go index d1c0dea6bdfe7d415ad85e304324653d562fd3ae..b3c377827b4d51d3aece7fb2d0022fd37c6a0b02 100644 --- a/claudetool/bash_test.go +++ b/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)