Files
2026-07-13 13:00:08 +08:00

290 lines
8.2 KiB
Go

package agent
import (
"fmt"
"strings"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// Tool-result maintenance is the free half of context management: stale tool
// results are re-derivable (files can be re-read, commands re-run), so rewriting
// them needs no summarizer call and never drops a message. tool_call/result
// pairing and assistant content (including signed reasoning) are untouched.
const (
snippedMarker = "[snipped tool result — "
prunedMarker = "[elided tool result — "
minPruneBytes = 1024
)
type toolResultMaintenanceMode int
const (
toolResultSnip toolResultMaintenanceMode = iota
toolResultPrune
)
// PruneStats reports one maintenance pass.
type PruneStats struct {
Results int
SavedChars int
Archive string
}
// SnipStaleToolResults shortens stale tool-result content older than the
// protected recent tail, archiving the originals first. Idempotent; a no-op
// when compaction is disabled (no context window).
func (a *Agent) SnipStaleToolResults() (PruneStats, error) {
return a.maintainStaleToolResults(toolResultSnip)
}
// PruneStaleToolResults elides stale tool-result content older than the
// protected recent tail, archiving the originals first. It can upgrade already
// snipped results to a shorter placeholder.
func (a *Agent) PruneStaleToolResults() (PruneStats, error) {
return a.maintainStaleToolResults(toolResultPrune)
}
func (a *Agent) maintainStaleToolResults(mode toolResultMaintenanceMode) (PruneStats, error) {
var st PruneStats
if a.contextWindow <= 0 {
return st, nil
}
msgs := a.session.Messages
head, start, ok := a.planCompaction(msgs, 1)
if !ok {
if mode != toolResultPrune {
return st, nil
}
head = 1
start = len(msgs) - a.recentKeep
if start < head {
return st, nil
}
}
var idx []int
for i := head; i < start; i++ {
m := msgs[i]
if !shouldMaintainToolResult(m, mode) {
continue
}
// Honor the keep policy before maintenance: an error:/blocked: tool
// result that KeepErrors would preserve must reach compact() verbatim.
if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) {
continue
}
idx = append(idx, i)
}
if len(idx) == 0 {
return st, nil
}
if a.archiveDir != "" {
originals := make([]provider.Message, 0, len(idx))
for _, i := range idx {
if mode == toolResultPrune && strings.HasPrefix(msgs[i].Content, snippedMarker) {
continue
}
originals = append(originals, msgs[i])
}
if len(originals) > 0 {
path, err := archiveMessages(a.archiveDir, originals)
if err != nil {
return st, fmt.Errorf("archive: %w", err)
}
st.Archive = path
}
}
next := append([]provider.Message(nil), msgs...)
for _, i := range idx {
m := next[i]
replacement := rewriteToolResult(m, mode, st.Archive, a.snipStrategyFor(m.Name))
if replacement == m.Content {
continue
}
st.SavedChars += len(m.Content) - len(replacement)
m.Content = replacement
next[i] = m
st.Results++
}
if st.Results == 0 {
return st, nil
}
a.session.Replace(next)
a.session.IncrementRewrite()
return st, nil
}
func shouldMaintainToolResult(m provider.Message, mode toolResultMaintenanceMode) bool {
if m.Role != provider.RoleTool {
return false
}
if strings.HasPrefix(m.Content, prunedMarker) {
return false
}
if mode == toolResultSnip {
return len(m.Content) >= minPruneBytes && !strings.HasPrefix(m.Content, snippedMarker)
}
if strings.HasPrefix(m.Content, snippedMarker) {
return true
}
return len(m.Content) >= minPruneBytes
}
func rewriteToolResult(m provider.Message, mode toolResultMaintenanceMode, archive string, strategy snipStrategy) string {
if mode == toolResultPrune {
return pruneToolResult(m, archive)
}
return snipToolResult(m, archive, strategy)
}
func pruneToolResult(m provider.Message, archive string) string {
if prior := originalToolArchive(m.Content); prior != "" {
archive = prior
}
if archive == "" {
archive = "not archived"
}
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)
}
func snipToolResult(m provider.Message, archive string, strategy snipStrategy) string {
if archive == "" {
archive = "not archived"
}
lines := strings.Split(m.Content, "\n")
if len(lines) <= strategy.head+strategy.tail {
headChars := minInt(strategy.headChars, len(m.Content)/2)
tailChars := minInt(strategy.tailChars, len(m.Content)/4)
return fmt.Sprintf("%s%s, %d bytes archived to %s; single large line truncated]\n%s\n[... %d bytes omitted ...]\n%s",
snippedMarker, m.Name, len(m.Content), archive,
firstRunes(m.Content, headChars),
omittedBytes(m.Content, headChars, tailChars),
lastRunes(m.Content, tailChars))
}
head := strings.Join(lines[:strategy.head], "\n")
tail := strings.Join(lines[len(lines)-strategy.tail:], "\n")
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",
snippedMarker, m.Name, len(m.Content), archive, strategy.head, strategy.tail,
head, len(lines)-strategy.head-strategy.tail, tail)
}
type snipStrategy struct {
head int
tail int
headChars int
tailChars int
}
// Defaults for tools that do not implement tool.SnipHinter, tiered by side
// effect. A read-only tool's output is front-loaded (the first lines are the
// answer), so it keeps a long head and short tail. A side-effecting tool — bash
// and any plugin — can carry a failure at either end (a build error at the tail,
// the command at the head), so it keeps both ends evenly. These are deliberately
// the only two defaults: a registered tool that fits neither must implement
// SnipHinter, and the contract test fails until it does.
var (
defaultReadOnlySnip = snipStrategy{head: 80, tail: 12, headChars: 10000, tailChars: 2000}
defaultSideEffectingSnip = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
)
// snipStrategyFor resolves the snip geometry for a tool result by asking the
// registered tool itself (tool.SnipHinter), so the policy travels with the tool
// definition and a rename cannot silently desync it from a name-keyed table.
// When the tool is absent (e.g. an MCP server detached after producing the
// result) or declines to hint, it falls back to the ReadOnly-tiered default.
func (a *Agent) snipStrategyFor(name string) snipStrategy {
if a.tools != nil {
if t, ok := a.tools.Get(name); ok {
if h, ok := t.(tool.SnipHinter); ok {
return snipStrategyFromHint(h.SnipHint())
}
if t.ReadOnly() {
return defaultReadOnlySnip
}
return defaultSideEffectingSnip
}
}
return defaultReadOnlySnip
}
func snipStrategyFromHint(h tool.SnipHint) snipStrategy {
return snipStrategy{head: h.Head, tail: h.Tail, headChars: h.HeadChars, tailChars: h.TailChars}
}
func originalToolBytes(content string) int {
if strings.HasPrefix(content, snippedMarker) {
end := strings.Index(content, " bytes archived to ")
if end > len(snippedMarker) {
fields := strings.Fields(content[len(snippedMarker):end])
if len(fields) > 0 {
var n int
if _, err := fmt.Sscanf(fields[len(fields)-1], "%d", &n); err == nil && n > 0 {
return n
}
}
}
}
return len(content)
}
func originalToolArchive(content string) string {
if !strings.HasPrefix(content, snippedMarker) {
return ""
}
start := strings.Index(content, " bytes archived to ")
if start < 0 {
return ""
}
start += len(" bytes archived to ")
end := strings.Index(content[start:], ";")
if end < 0 {
return ""
}
archive := strings.TrimSpace(content[start : start+end])
if archive == "not archived" {
return ""
}
return archive
}
func firstRunes(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !isRuneBoundary(s, n) {
n--
}
return s[:n]
}
func lastRunes(s string, n int) string {
if len(s) <= n {
return s
}
start := len(s) - n
for start < len(s) && !isRuneBoundary(s, start) {
start++
}
return s[start:]
}
func omittedBytes(s string, head, tail int) int {
omitted := len(s) - head - tail
if omitted < 0 {
return 0
}
return omitted
}
func isRuneBoundary(s string, i int) bool {
return i == 0 || i == len(s) || (i > 0 && i < len(s) && (s[i]&0xc0) != 0x80)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}