| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | "reasonix/internal/tool" |
| 9 | ) |
| 10 | |
| 11 | // Tool-result maintenance is the free half of context management: stale tool |
| 12 | // results are re-derivable (files can be re-read, commands re-run), so rewriting |
| 13 | // them needs no summarizer call and never drops a message. tool_call/result |
| 14 | // pairing and assistant content (including signed reasoning) are untouched. |
| 15 | const ( |
| 16 | snippedMarker = "[snipped tool result — " |
| 17 | prunedMarker = "[elided tool result — " |
| 18 | minPruneBytes = 1024 |
| 19 | ) |
| 20 | |
| 21 | type toolResultMaintenanceMode int |
| 22 | |
| 23 | const ( |
| 24 | toolResultSnip toolResultMaintenanceMode = iota |
| 25 | toolResultPrune |
| 26 | ) |
| 27 | |
| 28 | // PruneStats reports one maintenance pass. |
| 29 | type PruneStats struct { |
| 30 | Results int |
| 31 | SavedChars int |
| 32 | Archive string |
| 33 | } |
| 34 | |
| 35 | // SnipStaleToolResults shortens stale tool-result content older than the |
| 36 | // protected recent tail, archiving the originals first. Idempotent; a no-op |
| 37 | // when compaction is disabled (no context window). |
| 38 | func (a *Agent) SnipStaleToolResults() (PruneStats, error) { |
| 39 | return a.maintainStaleToolResults(toolResultSnip) |
| 40 | } |
| 41 | |
| 42 | // PruneStaleToolResults elides stale tool-result content older than the |
| 43 | // protected recent tail, archiving the originals first. It can upgrade already |
| 44 | // snipped results to a shorter placeholder. |
| 45 | func (a *Agent) PruneStaleToolResults() (PruneStats, error) { |
| 46 | return a.maintainStaleToolResults(toolResultPrune) |
| 47 | } |
| 48 | |
| 49 | func (a *Agent) maintainStaleToolResults(mode toolResultMaintenanceMode) (PruneStats, error) { |
| 50 | var st PruneStats |
| 51 | if a.contextWindow <= 0 { |
| 52 | return st, nil |
| 53 | } |
| 54 | msgs := a.session.Messages |
| 55 | head, start, ok := a.planCompaction(msgs, 1) |
| 56 | if !ok { |
| 57 | if mode != toolResultPrune { |
| 58 | return st, nil |
| 59 | } |
| 60 | head = 1 |
| 61 | start = len(msgs) - a.recentKeep |
| 62 | if start < head { |
| 63 | return st, nil |
| 64 | } |
| 65 | } |
| 66 | var idx []int |
| 67 | for i := head; i < start; i++ { |
| 68 | m := msgs[i] |
| 69 | if !shouldMaintainToolResult(m, mode) { |
| 70 | continue |
| 71 | } |
| 72 | // Honor the keep policy before maintenance: an error:/blocked: tool |
| 73 | // result that KeepErrors would preserve must reach compact() verbatim. |
| 74 | if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) { |
| 75 | continue |
| 76 | } |
| 77 | idx = append(idx, i) |
| 78 | } |
| 79 | if len(idx) == 0 { |
| 80 | return st, nil |
| 81 | } |
| 82 | if a.archiveDir != "" { |
| 83 | originals := make([]provider.Message, 0, len(idx)) |
| 84 | for _, i := range idx { |
| 85 | if mode == toolResultPrune && strings.HasPrefix(msgs[i].Content, snippedMarker) { |
| 86 | continue |
| 87 | } |
| 88 | originals = append(originals, msgs[i]) |
| 89 | } |
| 90 | if len(originals) > 0 { |
| 91 | path, err := archiveMessages(a.archiveDir, originals) |
| 92 | if err != nil { |
| 93 | return st, fmt.Errorf("archive: %w", err) |
| 94 | } |
| 95 | st.Archive = path |
| 96 | } |
| 97 | } |
| 98 | next := append([]provider.Message(nil), msgs...) |
| 99 | for _, i := range idx { |
| 100 | m := next[i] |
| 101 | replacement := rewriteToolResult(m, mode, st.Archive, a.snipStrategyFor(m.Name)) |
| 102 | if replacement == m.Content { |
| 103 | continue |
| 104 | } |
| 105 | st.SavedChars += len(m.Content) - len(replacement) |
| 106 | m.Content = replacement |
| 107 | next[i] = m |
| 108 | st.Results++ |
| 109 | } |
| 110 | if st.Results == 0 { |
| 111 | return st, nil |
| 112 | } |
| 113 | reason := "prune" |
| 114 | if mode == toolResultSnip { |
| 115 | reason = "snip" |
| 116 | } |
| 117 | a.session.Rewrite(next, reason) |
| 118 | return st, nil |
| 119 | } |
| 120 | |
| 121 | func shouldMaintainToolResult(m provider.Message, mode toolResultMaintenanceMode) bool { |
| 122 | if m.LocalOnly || m.Role != provider.RoleTool { |
| 123 | return false |
| 124 | } |
| 125 | if strings.HasPrefix(m.Content, prunedMarker) { |
| 126 | return false |
| 127 | } |
| 128 | if mode == toolResultSnip { |
| 129 | return len(m.Content) >= minPruneBytes && !strings.HasPrefix(m.Content, snippedMarker) |
| 130 | } |
| 131 | if strings.HasPrefix(m.Content, snippedMarker) { |
| 132 | return true |
| 133 | } |
| 134 | return len(m.Content) >= minPruneBytes |
| 135 | } |
| 136 | |
| 137 | func rewriteToolResult(m provider.Message, mode toolResultMaintenanceMode, archive string, strategy snipStrategy) string { |
| 138 | if mode == toolResultPrune { |
| 139 | return pruneToolResult(m, archive) |
| 140 | } |
| 141 | return snipToolResult(m, archive, strategy) |
| 142 | } |
| 143 | |
| 144 | func pruneToolResult(m provider.Message, archive string) string { |
| 145 | if prior := originalToolArchive(m.Content); prior != "" { |
| 146 | archive = prior |
| 147 | } |
| 148 | if archive == "" { |
| 149 | archive = "not archived" |
| 150 | } |
| 151 | return fmt.Sprintf("%s%s, %d bytes archived to %s; re-run the tool if the data is needed again]", prunedMarker, m.Name, originalToolBytes(m.Content), archive) |
| 152 | } |
| 153 | |
| 154 | func snipToolResult(m provider.Message, archive string, strategy snipStrategy) string { |
| 155 | if archive == "" { |
| 156 | archive = "not archived" |
| 157 | } |
| 158 | lines := strings.Split(m.Content, "\n") |
| 159 | if len(lines) <= strategy.head+strategy.tail { |
| 160 | headChars := minInt(strategy.headChars, len(m.Content)/2) |
| 161 | tailChars := minInt(strategy.tailChars, len(m.Content)/4) |
| 162 | return fmt.Sprintf("%s%s, %d bytes archived to %s; single large line truncated]\n%s\n[... %d bytes omitted ...]\n%s", |
| 163 | snippedMarker, m.Name, len(m.Content), archive, |
| 164 | firstRunes(m.Content, headChars), |
| 165 | omittedBytes(m.Content, headChars, tailChars), |
| 166 | lastRunes(m.Content, tailChars)) |
| 167 | } |
| 168 | head := strings.Join(lines[:strategy.head], "\n") |
| 169 | tail := strings.Join(lines[len(lines)-strategy.tail:], "\n") |
| 170 | return fmt.Sprintf("%s%s, %d bytes archived to %s; showing first %d lines and last %d lines]\n%s\n[... %d lines omitted ...]\n%s", |
| 171 | snippedMarker, m.Name, len(m.Content), archive, strategy.head, strategy.tail, |
| 172 | head, len(lines)-strategy.head-strategy.tail, tail) |
| 173 | } |
| 174 | |
| 175 | type snipStrategy struct { |
| 176 | head int |
| 177 | tail int |
| 178 | headChars int |
| 179 | tailChars int |
| 180 | } |
| 181 | |
| 182 | // Defaults for tools that do not implement tool.SnipHinter, tiered by side |
| 183 | // effect. A read-only tool's output is front-loaded (the first lines are the |
| 184 | // answer), so it keeps a long head and short tail. A side-effecting tool — bash |
| 185 | // and any plugin — can carry a failure at either end (a build error at the tail, |
| 186 | // the command at the head), so it keeps both ends evenly. These are deliberately |
| 187 | // the only two defaults: a registered tool that fits neither must implement |
| 188 | // SnipHinter, and the contract test fails until it does. |
| 189 | var ( |
| 190 | defaultReadOnlySnip = snipStrategy{head: 80, tail: 12, headChars: 10000, tailChars: 2000} |
| 191 | defaultSideEffectingSnip = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000} |
| 192 | ) |
| 193 | |
| 194 | // snipStrategyFor resolves the snip geometry for a tool result by asking the |
| 195 | // registered tool itself (tool.SnipHinter), so the policy travels with the tool |
| 196 | // definition and a rename cannot silently desync it from a name-keyed table. |
| 197 | // When the tool is absent (e.g. an MCP server detached after producing the |
| 198 | // result) or declines to hint, it falls back to the ReadOnly-tiered default. |
| 199 | func (a *Agent) snipStrategyFor(name string) snipStrategy { |
| 200 | if a.tools != nil { |
| 201 | if t, ok := a.tools.Get(name); ok { |
| 202 | if h, ok := t.(tool.SnipHinter); ok { |
| 203 | return snipStrategyFromHint(h.SnipHint()) |
| 204 | } |
| 205 | if t.ReadOnly() { |
| 206 | return defaultReadOnlySnip |
| 207 | } |
| 208 | return defaultSideEffectingSnip |
| 209 | } |
| 210 | } |
| 211 | return defaultReadOnlySnip |
| 212 | } |
| 213 | |
| 214 | func snipStrategyFromHint(h tool.SnipHint) snipStrategy { |
| 215 | return snipStrategy{head: h.Head, tail: h.Tail, headChars: h.HeadChars, tailChars: h.TailChars} |
| 216 | } |
| 217 | |
| 218 | func originalToolBytes(content string) int { |
| 219 | if strings.HasPrefix(content, snippedMarker) { |
| 220 | end := strings.Index(content, " bytes archived to ") |
| 221 | if end > len(snippedMarker) { |
| 222 | fields := strings.Fields(content[len(snippedMarker):end]) |
| 223 | if len(fields) > 0 { |
| 224 | var n int |
| 225 | if _, err := fmt.Sscanf(fields[len(fields)-1], "%d", &n); err == nil && n > 0 { |
| 226 | return n |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | return len(content) |
| 232 | } |
| 233 | |
| 234 | func originalToolArchive(content string) string { |
| 235 | if !strings.HasPrefix(content, snippedMarker) { |
| 236 | return "" |
| 237 | } |
| 238 | start := strings.Index(content, " bytes archived to ") |
| 239 | if start < 0 { |
| 240 | return "" |
| 241 | } |
| 242 | start += len(" bytes archived to ") |
| 243 | end := strings.Index(content[start:], ";") |
| 244 | if end < 0 { |
| 245 | return "" |
| 246 | } |
| 247 | archive := strings.TrimSpace(content[start : start+end]) |
| 248 | if archive == "not archived" { |
| 249 | return "" |
| 250 | } |
| 251 | return archive |
| 252 | } |
| 253 | |
| 254 | func firstRunes(s string, n int) string { |
| 255 | if len(s) <= n { |
| 256 | return s |
| 257 | } |
| 258 | for n > 0 && !isRuneBoundary(s, n) { |
| 259 | n-- |
| 260 | } |
| 261 | return s[:n] |
| 262 | } |
| 263 | |
| 264 | func lastRunes(s string, n int) string { |
| 265 | if len(s) <= n { |
| 266 | return s |
| 267 | } |
| 268 | start := len(s) - n |
| 269 | for start < len(s) && !isRuneBoundary(s, start) { |
| 270 | start++ |
| 271 | } |
| 272 | return s[start:] |
| 273 | } |
| 274 | |
| 275 | func omittedBytes(s string, head, tail int) int { |
| 276 | omitted := len(s) - head - tail |
| 277 | if omitted < 0 { |
| 278 | return 0 |
| 279 | } |
| 280 | return omitted |
| 281 | } |
| 282 | |
| 283 | func isRuneBoundary(s string, i int) bool { |
| 284 | return i == 0 || i == len(s) || (i > 0 && i < len(s) && (s[i]&0xc0) != 0x80) |
| 285 | } |
| 286 | |
| 287 | func minInt(a, b int) int { |
| 288 | if a < b { |
| 289 | return a |
| 290 | } |
| 291 | return b |
| 292 | } |
| 293 |