diff --git a/environment/large_output.go b/environment/large_output.go index d833ddf..fd7b423 100644 --- a/environment/large_output.go +++ b/environment/large_output.go @@ -122,21 +122,16 @@ func ShouldSkipForOutputsDir(command string) bool { return strings.Contains(command, OutputsDir+"/") || strings.Contains(command, OutputsDir+"\\") } -// truncateContent creates a truncated preview with optional file reference. +// truncateContent creates a head+tail preview with an optional file reference. +// Showing the first AND last lines (around an elision marker) means a line-rich +// oversized output — a directory listing, a log, a JSON array — surfaces both +// ends rather than only the head, which previously forced the agent to read the +// spilled file just to see the tail. func (p *LargeOutputProcessor) truncateContent(content string, filePath string) string { lines := strings.Split(content, "\n") totalLines := len(lines) + preview, headShown, omitted := p.buildPreview(lines) - // Calculate preview - previewLines := min(p.config.PreviewLines, totalLines) - preview := strings.Join(lines[:previewLines], "\n") - - // Truncate preview if too long - if len(preview) > p.config.PreviewSize { - preview = preview[:p.config.PreviewSize] + "\n... [preview truncated]" - } - - // Build truncation message var sb strings.Builder sb.WriteString("\n") fmt.Fprintf(&sb, "Output too large (%d chars, %d lines).", len(content), totalLines) @@ -147,12 +142,67 @@ func (p *LargeOutputProcessor) truncateContent(content string, filePath string) sb.WriteString(" Could not save full content.\n\n") } - fmt.Fprintf(&sb, "Preview (first %d lines):\n```\n%s\n```\n\n", previewLines, preview) - - if filePath != "" { - fmt.Fprintf(&sb, "To read more: read(\"%s\", offset=%d, limit=100)\n", filePath, previewLines) + if omitted > 0 { + fmt.Fprintf(&sb, "Preview (first %d + last lines; %d lines omitted in the middle):\n```\n%s\n```\n\n", headShown, omitted, preview) + if filePath != "" { + fmt.Fprintf(&sb, "To read the omitted middle: read(\"%s\", offset=%d, limit=%d)\n", filePath, headShown, omitted) + } + } else { + fmt.Fprintf(&sb, "Preview (first %d lines):\n```\n%s\n```\n\n", headShown, preview) + if filePath != "" { + fmt.Fprintf(&sb, "To read more: read(\"%s\", offset=%d, limit=100)\n", filePath, headShown) + } } sb.WriteString("") return sb.String() } + +// buildPreview returns a char-bounded preview of a truncated output. When the +// output has more lines than the line budget it shows a head AND a tail around +// an elision marker — each end gets half the character budget so a few very long +// head lines can't starve the tail. Otherwise (few lines, or one giant line) it +// falls back to a head-only, char-capped preview. Returns the preview text, the +// number of leading lines shown (an accurate read-more offset), and the number +// of lines omitted in the middle (0 when none). +func (p *LargeOutputProcessor) buildPreview(lines []string) (text string, headShown, omitted int) { + total := len(lines) + maxLines := p.config.PreviewLines + maxChars := p.config.PreviewSize + + if total <= maxLines { + head := strings.Join(lines, "\n") + if len(head) > maxChars { + // One or few very long lines: head-only, char-capped. headShown=0 so + // the read-more offset doesn't claim whole lines the cap chopped. + return head[:maxChars] + "\n... [preview truncated]", 0, 0 + } + return head, total, 0 + } + + headN := maxLines / 2 + if headN < 1 { + headN = 1 + } + tailN := maxLines - headN + omitted = total - headN - tailN + head := capHead(strings.Join(lines[:headN], "\n"), maxChars/2) + tail := capTail(strings.Join(lines[total-tailN:], "\n"), maxChars/2) + return head + fmt.Sprintf("\n... [%d lines omitted] ...\n", omitted) + tail, headN, omitted +} + +// capHead keeps at most maxChars characters from the start of s. +func capHead(s string, maxChars int) string { + if len(s) <= maxChars { + return s + } + return s[:maxChars] + " …" +} + +// capTail keeps at most maxChars characters from the end of s. +func capTail(s string, maxChars int) string { + if len(s) <= maxChars { + return s + } + return "… " + s[len(s)-maxChars:] +} diff --git a/environment/large_output_test.go b/environment/large_output_test.go new file mode 100644 index 0000000..b825903 --- /dev/null +++ b/environment/large_output_test.go @@ -0,0 +1,63 @@ +package environment + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func headTailTestProcessor() *LargeOutputProcessor { + // Small budgets so the head+tail paths are exercised deterministically. + // truncateContent ignores ws, so a nil-ws processor is fine here. + return &LargeOutputProcessor{config: LargeOutputConfig{ + MaxOutputSize: 200, + PreviewSize: 120, + PreviewLines: 10, + }} +} + +// A line-rich oversized output shows BOTH a head and a tail around an elision, +// with an accurate omitted-line count and a read-more offset past the head. +func TestTruncateContent_LineRichShowsHeadAndTail(t *testing.T) { + p := headTailTestProcessor() + var b strings.Builder + for i := 0; i < 100; i++ { + fmt.Fprintf(&b, "line-%03d\n", i) + } + out := p.truncateContent(b.String(), ".outputs/x.txt") + + assert.Contains(t, out, "line-000", "head must be shown") + assert.Contains(t, out, "line-099", "tail must be shown") + assert.Contains(t, out, "lines omitted", "must mark the omitted middle") + // PreviewLines=10 → 5 head + 5 tail; split() yields 101 lines (trailing ""), + // so omitted = 101 - 5 - 5 = 91 and the read-more offset starts past the head. + assert.Contains(t, out, "offset=5") + assert.Contains(t, out, "limit=91") +} + +// The head+tail preview stays char-bounded even with long lines: a few very +// long head lines must not starve the tail (both end markers survive). +func TestTruncateContent_HeadTailIsCharBounded(t *testing.T) { + p := headTailTestProcessor() + lines := make([]string, 30) + for i := range lines { + lines[i] = fmt.Sprintf("head%02d-%s-tail%02d", i, strings.Repeat("x", 500), i) + } + out := p.truncateContent(strings.Join(lines, "\n"), ".outputs/x.txt") + + assert.Contains(t, out, "lines omitted") + assert.Contains(t, out, "head00", "first line's head prefix must survive") + assert.Contains(t, out, "tail29", "last line's tail suffix must survive (not starved by long head lines)") +} + +// A single very long line falls back to a head-only, char-capped preview with +// no false "omitted" claim. +func TestTruncateContent_OneGiantLineHeadOnly(t *testing.T) { + p := headTailTestProcessor() + out := p.truncateContent(strings.Repeat("y", 1000), ".outputs/x.txt") + + assert.NotContains(t, out, "lines omitted") + assert.Contains(t, out, "preview truncated") +}