Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 additions & 15 deletions environment/large_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<output_truncated>\n")
fmt.Fprintf(&sb, "Output too large (%d chars, %d lines).", len(content), totalLines)
Expand All @@ -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("</output_truncated>")
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:]
}
63 changes: 63 additions & 0 deletions environment/large_output_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading