@@ -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 {
@@ -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)