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)