290 lines
8.2 KiB
Go
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
|
|
}
|