// Package tooloutput spills oversized tool stdout/results to local files under // the reduction cache tree (tmp/reduction/...), so agents can read_file the // full text after context truncation. package tooloutput import ( "fmt" "os" "path/filepath" "strings" "sync" "unicode/utf8" "github.com/google/uuid" ) const ( defaultRootDir = "tmp/reduction" readFileHint = "read_file" ) // SpillOpts scopes where a trunc file is written (mirrors reduction RootDir layout). type SpillOpts struct { RootDir string // reduction_root_dir or empty → tmp/reduction ProjectID string ConversationID string ExecutionID string // preferred file name; empty → uuid } // SessionRoot returns the conversation/project-scoped reduction cache root. func SessionRoot(configuredBase, projectID, conversationID string) string { base := strings.TrimSpace(configuredBase) if base == "" { base = defaultRootDir } if pid := strings.TrimSpace(projectID); pid != "" { return filepath.Join(base, "projects", sanitizeSegment(pid)) } conv := strings.TrimSpace(conversationID) if conv == "" { conv = "default" } return filepath.Join(base, "conversations", sanitizeSegment(conv)) } // WriteTruncFile writes full content under {sessionRoot}/trunc/{id} and returns // an absolute path suitable for read_file. func WriteTruncFile(opts SpillOpts, content string) (string, error) { session := SessionRoot(opts.RootDir, opts.ProjectID, opts.ConversationID) id := strings.TrimSpace(opts.ExecutionID) if id == "" { id = uuid.NewString() } id = sanitizeSegment(id) dir := filepath.Join(session, "trunc") if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("mkdir tool output trunc dir: %w", err) } path := filepath.Join(dir, id) if abs, err := filepath.Abs(path); err == nil { path = abs } if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return "", fmt.Errorf("write tool output trunc file: %w", err) } return path, nil } // BoundWithSpill truncates full text into a notice after // spilling the original to disk. The returned string is always ≤ maxBytes when // maxBytes > 0. On spill failure it falls back to a prefix + marker (no path). func BoundWithSpill(full string, maxBytes int, opts SpillOpts) string { if maxBytes <= 0 || len(full) <= maxBytes { return full } path, err := WriteTruncFile(opts, full) if err != nil { return boundPrefixOnly(full, maxBytes, len(full), "") } return FormatPersistedOutput(full, path, maxBytes) } // FormatPersistedOutput builds a reduction-compatible notice with head/tail // previews that fits in maxBytes. func FormatPersistedOutput(full, filePath string, maxBytes int) string { return formatPersisted(len(full), filePath, full, maxBytes) } // FormatPersistedFromFile builds the notice using previews read from an already // spilled file (streaming collectors that never kept the full string in memory). func FormatPersistedFromFile(filePath string, originalSize, maxBytes int) string { previewSrc := "" if data, err := os.ReadFile(filePath); err == nil { previewSrc = string(data) if originalSize <= 0 { originalSize = len(data) } } return formatPersisted(originalSize, filePath, previewSrc, maxBytes) } func formatPersisted(originalSize int, filePath, previewSrc string, maxBytes int) string { if maxBytes <= 0 { maxBytes = 12000 } // Always keep the absolute path readable for read_file, even under tight budgets. minimal := fmt.Sprintf( "\nOutput too large (%d). Full output saved to: %s\nUse %s to read.\n", originalSize, filePath, readFileHint, ) if len(minimal) > maxBytes { core := fmt.Sprintf("Full output saved to: %s", filePath) if len(core) <= maxBytes { return core } // Path longer than budget: keep as much of the path as possible after a short prefix. prefix := "Full output saved to: " suffix := "" room := maxBytes - len(prefix) - len(suffix) if room <= 0 { return clampPrefix(core, maxBytes) } return prefix + clampSuffix(filePath, room) + suffix } previewBudget := maxBytes - len(minimal) + 32 // approximate room beyond minimal shell if previewBudget > 4000 { previewBudget = 4000 } if previewBudget < 0 { previewBudget = 0 } for previewBudget >= 0 { half := previewBudget / 2 head := clampPrefix(previewSrc, half) tail := clampSuffix(previewSrc, previewBudget-half) notice := fmt.Sprintf( "\nOutput too large (%d). Full output saved to: %s\nUse %s with offset/limit to read parts of the file.\nPreview (first %d):\n%s\n\nPreview (last %d):\n%s\n\n", originalSize, filePath, readFileHint, len(head), head, len(tail), tail, ) if len(notice) <= maxBytes { return notice } if previewBudget == 0 { return minimal } previewBudget = previewBudget * 3 / 4 } return minimal } func boundPrefixOnly(full string, maxBytes, originalSize int, filePath string) string { marker := fmt.Sprintf("\n\n...[tool output truncated: original %d bytes, kept %d bytes]...", originalSize, maxBytes) if filePath != "" { marker = fmt.Sprintf("\n\n...[tool output truncated: original %d bytes, kept %d bytes; full output: %s]...", originalSize, maxBytes, filePath) } budget := maxBytes - len(marker) if budget < 0 { return clampPrefix(marker, maxBytes) } return clampPrefix(full, budget) + marker } func clampPrefix(s string, n int) string { if n <= 0 { return "" } if len(s) <= n { return s } for n > 0 && !utf8.RuneStart(s[n]) { n-- } return s[:n] } func clampSuffix(s string, n int) string { if n <= 0 { return "" } if len(s) <= n { return s } start := len(s) - n for start < len(s) && !utf8.RuneStart(s[start]) { start++ } return s[start:] } func sanitizeSegment(s string) string { s = strings.TrimSpace(s) if s == "" { return "default" } s = strings.ReplaceAll(s, string(filepath.Separator), "-") s = strings.ReplaceAll(s, "/", "-") s = strings.ReplaceAll(s, "\\", "-") s = strings.ReplaceAll(s, "..", "__") if len(s) > 180 { s = s[:180] } return s } // Tee writes every byte to a trunc file while callers keep only a bounded // in-memory prefix. Safe for concurrent stdout/stderr writers. type Tee struct { mu sync.Mutex opts SpillOpts file *os.File path string err error open bool } // NewTee prepares a lazy spill file (created on first Write). func NewTee(opts SpillOpts) *Tee { return &Tee{opts: opts} } // Write appends to the spill file, creating it on first use. func (t *Tee) Write(p []byte) (int, error) { if t == nil { return len(p), nil } t.mu.Lock() defer t.mu.Unlock() if err := t.ensureOpenLocked(); err != nil { return len(p), nil // best-effort: never fail the tool pipe } if t.file == nil { return len(p), nil } _, _ = t.file.Write(p) return len(p), nil } func (t *Tee) ensureOpenLocked() error { if t.open || t.err != nil { return t.err } t.open = true session := SessionRoot(t.opts.RootDir, t.opts.ProjectID, t.opts.ConversationID) id := strings.TrimSpace(t.opts.ExecutionID) if id == "" { id = uuid.NewString() } id = sanitizeSegment(id) dir := filepath.Join(session, "trunc") if err := os.MkdirAll(dir, 0o755); err != nil { t.err = err return err } path := filepath.Join(dir, id) if abs, err := filepath.Abs(path); err == nil { path = abs } f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { t.err = err return err } t.file = f t.path = path return nil } // Path returns the absolute spill path after any Write (may be empty if unused/failed). func (t *Tee) Path() string { if t == nil { return "" } t.mu.Lock() defer t.mu.Unlock() return t.path } // Close flushes and closes the spill file. func (t *Tee) Close() error { if t == nil { return nil } t.mu.Lock() defer t.mu.Unlock() if t.file == nil { return nil } err := t.file.Close() t.file = nil return err }