返回 DeepSeek-Reasonix
prune.go
根目录 / internal / agent / prune.go
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
293 lines GO