From a15dfad90f7d83e575f52ff594b49d71faeceeb3 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Thu, 17 Sep 2026 00:35:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20tool=20output=20offloading=20?= =?UTF-8?q?=E2=80=94=20persist=20truncated=20results=20to=20disk=20for=20r?= =?UTF-8?q?ecovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 2026 "Tool Output Offloading" harness-engineering pattern (LangChain "Anatomy of an Agent Harness"; practiced by Claude Code and Manus): when guardToolOutput truncates a large tool result under context pressure, the FULL original output is now persisted to a spill file under the OS temp dir, and the truncation notice carries the absolute path with recovery instructions (read_file offset/limit or grep), so the agent can inspect the omitted middle section instead of losing it forever or re-running the tool (wasteful for deterministic tools, impossible for one-shot side effects like flaky test runs or deploy logs). Details: - internal/agent/tool_output_offload.go: lazy, concurrency-safe offloader; spills <8KB skipped (head-tail already suffices); 20MB per-file cap; max 20 files per session (oldest pruned); stale spill dirs from crashed sessions pruned after 24h; best-effort — I/O failure falls back to plain truncation and never fails the tool result path. - agent.go: spill hook in the guardToolOutput truncation branch; spill notice appended before the tool-specific truncation advisory, so the model sees the recovery path adjacent to the truncation marker. Research basis: LangChain "The Anatomy of an Agent Harness" (2026), Anthropic "Effective Harnesses for Long-Running Agents" — context management strategy #3 "Tool Output Offloading: storing large tool results to the filesystem rather than injecting them directly into context. Reduces noise while keeping the data accessible." Co-Authored-By: ggcode Co-Authored-By: ggcode --- internal/agent/agent.go | 15 +- internal/agent/tool_output_offload.go | 200 +++++++++++++++++++++ internal/agent/tool_output_offload_test.go | 111 ++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 internal/agent/tool_output_offload.go create mode 100644 internal/agent/tool_output_offload_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 3f78445e8..0c45a711c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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) @@ -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(), @@ -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 diff --git a/internal/agent/tool_output_offload.go b/internal/agent/tool_output_offload.go new file mode 100644 index 000000000..807540786 --- /dev/null +++ b/internal/agent/tool_output_offload.go @@ -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-/ 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, + ) +} diff --git a/internal/agent/tool_output_offload_test.go b/internal/agent/tool_output_offload_test.go new file mode 100644 index 000000000..caeb995e5 --- /dev/null +++ b/internal/agent/tool_output_offload_test.go @@ -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) + } +}