From 5af15ffb88230298b07bca3c7422ac75e07ac28a Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 11 Jun 2026 14:50:51 +0800 Subject: [PATCH] fix(env): head+tail preview for truncated tool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A truncated tool result previously showed only the HEAD (first lines), so a line-rich oversized output — a directory listing, a log, a JSON array — hid its tail and forced the agent to read the spilled file just to see the end (an extra round-trip for output only modestly over the 50K spill threshold). buildPreview now shows the first AND last lines around an elision marker, each end bounded to half the char budget so a few very long head lines can't starve the tail; it falls back to the head-only char-capped preview for a single giant line. Also reports an accurate omitted-line count and a read-more offset that points at the omitted middle (the old offset could skip char-capped head lines). Found by the AI-SRE production session audit (audit-2026-06-11, sess_a77jGy9XzY2GQLDnUcd3xf). --- environment/large_output.go | 80 ++++++++++++++++++++++++++------ environment/large_output_test.go | 63 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 environment/large_output_test.go 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") +}