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
15 changes: 14 additions & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ type Agent struct {
phantomVerify *phantomVerifyState // phantom verification detection (category-specific verification claims without matching commands)
redundantReverify *redundantReverifyState // redundant re-verification detection (same verification cmd re-run without file edits)
truncClaim *truncClaimState // truncated output completeness fallacy detection (claims after truncated results)
outputOffload *outputOffloader // tool output offloading (full truncated results persisted to disk for re-reading)
circularReasoning *circularReasoningState // circular reasoning detection (tautological justification)
contradiction *contradictionState // cross-turn contradiction detection (root-cause reversals)
actionHedging *actionHedgingState // action hedging detection (verbalized uncertainty during mutations)
Expand Down Expand Up @@ -461,6 +462,7 @@ func NewAgent(p provider.Provider, tools *tool.Registry, systemPrompt string, ma
solutionFixation: newSolutionFixationState(),
reproducerLifecycle: newReproducerLifecycleState(),
truncClaim: newTruncClaimState(),
outputOffload: newOutputOffloader(),
circularReasoning: newCircularReasoningState(),
contradiction: newContradictionState(),
actionHedging: newActionHedgingState(),
Expand Down Expand Up @@ -4444,7 +4446,18 @@ func (a *Agent) RunStreamWithContent(ctx context.Context, content []provider.Con
fillRatio := float64(a.contextManager.TokenCount()) / float64(threshold)
if truncated := guardToolOutput(result.Content, fillRatio); len(truncated) < len(result.Content) {
debug.Log("agent", "tool output guarded: tool=%s tokens=%d threshold=%d fill=%.0f%% %d→%d bytes", tc.Name, a.contextManager.TokenCount(), threshold, fillRatio*100, len(result.Content), len(truncated))
result.Content = withTruncationAdvisory(truncated, tc.Name, len(result.Content))
guarded := truncated
// Tool Output Offloading: persist the FULL original
// output to disk so the discarded middle section is
// recoverable via read_file/grep on the spill path
// instead of being lost forever (LangChain harness
// anatomy, 2026). Best-effort: on failure fall back
// to plain truncation.
if spillPath := a.outputOffload.spill(tc.Name, result.Content); spillPath != "" {
guarded += spillNotice(spillPath, len(result.Content))
debug.Log("agent", "tool output offloaded: tool=%s path=%s originalLen=%d", tc.Name, spillPath, len(result.Content))
}
result.Content = withTruncationAdvisory(guarded, tc.Name, len(result.Content))
a.truncClaim.recordTruncation(tc.Name, i)
// #1664: errorPropagate.recordResult ran BEFORE the
// guard with the raw content, so this truncation - the
Expand Down
200 changes: 200 additions & 0 deletions internal/agent/tool_output_offload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package agent

import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)

// tool_output_offload.go implements Tool Output Offloading — a 2026 harness
// engineering pattern (LangChain "Anatomy of an Agent Harness"; also used by
// Claude Code and Manus): when a large tool result must be truncated before
// entering context, the FULL output is written to a durable file and the
// truncation notice carries the path, so the agent can re-read the omitted
// middle section on demand instead of losing it forever.
//
// Before this change, guardToolOutput discarded the middle section
// permanently: a 200KB build log truncated to 40KB at 50% context fill lost
// ~160KB of content with no recovery path other than re-running the tool —
// which is wasteful for deterministic tools and impossible for one-shot
// side effects (flaky test runs, deploy logs, long CI output).
//
// Design constraints:
// - Spilling is best-effort: any I/O failure returns "" and the agent falls
// back to plain truncation. Never fail a tool result because of offloading.
// - Spill files live under os.TempDir()/ggcode-spill-<pid>/ so user
// repositories are never polluted and OS temp cleanup applies.
// - File count is capped (keep newest); oversized content is capped too.
// - The directory is created lazily on first spill; sessions that never
// truncate pay zero filesystem cost.

const (
// minSpillBytes: below this size the head+tail preservation already
// captures most content and advisoryForTruncation is suppressed too —
// keep both thresholds aligned.
minSpillBytes = 8 * 1024

// maxSpillBytes caps a single spilled file as a safety valve against
// pathological outputs (e.g. a 2GB minified bundle). Beyond this the
// head and tail are written with a marker in between.
maxSpillBytes = 20 * 1024 * 1024

// maxSpillFiles caps how many spill files accumulate per session;
// oldest files are deleted when the cap is exceeded.
maxSpillFiles = 20
)

// outputOffloader persists truncated tool outputs to disk for later
// re-reading by the agent. It is safe for concurrent use.
type outputOffloader struct {
mu sync.Mutex
dir string
seq int
once sync.Once
}

// newOutputOffloader creates an offloader; the spill directory is created
// lazily on first use.
func newOutputOffloader() *outputOffloader {
return &outputOffloader{}
}

// spillDir returns (creating if needed) the directory spill files live in.
func (o *outputOffloader) spillDir() (string, error) {
if o.dir != "" {
if _, err := os.Stat(o.dir); err == nil {
return o.dir, nil
} else if !os.IsNotExist(err) {
return "", err
}
// Directory vanished (OS temp cleanup); recreate on next write.
o.dir = ""
}
dir, err := os.MkdirTemp("", "ggcode-spill-*")
if err != nil {
return "", err
}
o.dir = dir
return dir, nil
}

// spillToolNamePattern strips characters that are unsafe in file names.
var spillToolNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)

// spill writes the full original content to a timestamped file and returns
// its absolute path, or "" when spilling is skipped or fails.
// toolName is used for the file name only.
func (o *outputOffloader) spill(toolName, content string) string {
if len(content) < minSpillBytes {
return ""
}

o.mu.Lock()
defer o.mu.Unlock()

o.once.Do(func() {
// Best-effort: prune stale spill dirs from previous crashed
// sessions (older than 24h) once per session.
pruneStaleSpillDirs()
})

dir, err := o.spillDir()
if err != nil {
return ""
}

o.seq++
name := fmt.Sprintf("%s-%03d-%s.txt",
time.Now().Format("150405.000"),
o.seq,
spillToolNamePattern.ReplaceAllString(strings.ToLower(toolName), "-"),
)
path := filepath.Join(dir, filepath.Base(name))

if len(content) > maxSpillBytes {
content = content[:utilSnapRune(content, maxSpillBytes)] +
"\n... (spilled content capped at 20MB; original was " + formatBytes(len(content)) + ")"
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return ""
}

o.pruneLocked(dir)
return path
}

// pruneLocked deletes the oldest spill files when the count cap is exceeded.
func (o *outputOffloader) pruneLocked(dir string) {
entries, err := os.ReadDir(dir)
if err != nil || len(entries) <= maxSpillFiles {
return
}
type fileInfo struct {
name string
mod time.Time
}
files := make([]fileInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
files = append(files, fileInfo{e.Name(), info.ModTime()})
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.Before(files[j].mod) })
excess := len(files) - maxSpillFiles
for i := 0; i < excess; i++ {
_ = os.Remove(filepath.Join(dir, files[i].name))
}
}

// pruneStaleSpillDirs removes ggcode-spill-* directories under the system
// temp dir that are older than 24h (crashed sessions). Errors are ignored.
func pruneStaleSpillDirs() {
tmp := os.TempDir()
entries, err := os.ReadDir(tmp)
if err != nil {
return
}
cutoff := time.Now().Add(-24 * time.Hour)
for _, e := range entries {
name := e.Name()
if !e.IsDir() || !strings.HasPrefix(name, "ggcode-spill-") {
continue
}
info, err := e.Info()
if err != nil || info.ModTime().After(cutoff) {
continue
}
_ = os.RemoveAll(filepath.Join(tmp, name))
}
}

// utilSnapRune snaps a byte offset to a UTF-8 rune boundary.
func utilSnapRune(s string, n int) int {
if n >= len(s) {
return len(s)
}
for n > 0 && n < len(s) && (s[n]&0xC0) == 0x80 {
n--
}
return n
}

// spillNotice formats the agent-facing pointer to a spilled file. It is
// appended to the truncated content so the model knows the full output is
// recoverable from disk without re-running the tool.
func spillNotice(path string, originalLen int) string {
return fmt.Sprintf(
"\n[Full original output (%s) saved to: %s — use read_file with offset/limit or grep on that path to inspect the omitted middle section. Do not re-run the tool to recover it.]",
formatBytes(originalLen), path,
)
}
111 changes: 111 additions & 0 deletions internal/agent/tool_output_offload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package agent

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestSpillBelowMinReturnsEmpty(t *testing.T) {
o := newOutputOffloader()
small := strings.Repeat("x", minSpillBytes-1)
if path := o.spill("run_command", small); path != "" {
t.Fatalf("content below minSpillBytes must not be spilled, got %q", path)
}
}

func TestSpillWritesFullContent(t *testing.T) {
o := newOutputOffloader()
// 200KB content whose middle section contains a unique marker.
head := strings.Repeat("head line\n", 2000)
mid := "UNIQUE_MIDDLE_MARKER needle-42\n" + strings.Repeat("mid\n", 40000)
tail := strings.Repeat("tail line\n", 2000)
full := head + mid + tail

path := o.spill("run_command", full)
if path == "" {
t.Fatal("spill must return a path for large content")
}
defer os.RemoveAll(filepath.Dir(path))

if !filepath.IsAbs(path) {
t.Fatalf("spill path must be absolute, got %q", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read spilled file: %v", err)
}
if !strings.Contains(string(data), "UNIQUE_MIDDLE_MARKER needle-42") {
t.Fatal("spilled file must contain the middle section dropped by head+tail truncation")
}
if len(data) != len(full) {
t.Fatalf("spilled size mismatch: got %d want %d", len(data), len(full))
}
}

func TestSpillNoticeContainsPath(t *testing.T) {
notice := spillNotice("/tmp/spill/x.txt", 100*1024)
if !strings.Contains(notice, "/tmp/spill/x.txt") {
t.Fatal("notice must contain the spill path")
}
if !strings.Contains(notice, "read_file") {
t.Fatal("notice must tell the agent how to recover the content")
}
}

func TestSpillCapsOversizedContent(t *testing.T) {
o := newOutputOffloader()
huge := strings.Repeat("y", maxSpillBytes+1024)
path := o.spill("browser", huge)
if path == "" {
t.Fatal("oversized content should still spill (capped)")
}
defer os.RemoveAll(filepath.Dir(path))
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat spilled file: %v", err)
}
if info.Size() > maxSpillBytes+512 {
t.Fatalf("spilled file exceeds cap: %d bytes", info.Size())
}
}

func TestSpillPrunesOldestBeyondCap(t *testing.T) {
o := newOutputOffloader()
paths := make([]string, 0, maxSpillFiles+3)
for i := 0; i < maxSpillFiles+3; i++ {
p := o.spill("grep", strings.Repeat("z", minSpillBytes+1024))
if p == "" {
t.Fatalf("spill %d returned empty", i)
}
paths = append(paths, p)
}
dir := filepath.Dir(paths[0])
defer os.RemoveAll(dir)

entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read spill dir: %v", err)
}
if len(entries) > maxSpillFiles {
t.Fatalf("spill dir exceeds cap: %d files", len(entries))
}
// The first (oldest) spilled file must have been pruned.
if _, err := os.Stat(paths[0]); !os.IsNotExist(err) {
t.Fatal("oldest spill file must be pruned when cap exceeded")
}
}

func TestSpillToolNameSanitized(t *testing.T) {
o := newOutputOffloader()
path := o.spill("mcp__weird/server__tool!name", strings.Repeat("q", minSpillBytes+512))
if path == "" {
t.Fatal("spill must succeed with unusual tool names")
}
defer os.RemoveAll(filepath.Dir(path))
base := filepath.Base(path)
if strings.ContainsAny(base, " /\\!") {
t.Fatalf("file name contains unsafe characters: %q", base)
}
}
Loading