Files
goclaw/internal/agent/pruning.go
T
viettranx 21cc208813 fix(agent): use tiktoken for context pruning and protect media tool results
- Replace char-based heuristic (chars/4) with tiktoken BPE for accurate
  token counting, especially for non-ASCII content (Vietnamese/Chinese)
- Add pruningEstimator wrapper with tiktoken/fallback dual-path
- Raise default soft trim budget from 3K to 6K chars (3K head + 3K tail)
- Media tools (read_image, read_document, read_audio, read_video) get
  higher soft trim budget (8K: 4K head + 4K tail) and skip hard clear
  entirely — their vision/audio descriptions are irreplaceable
- Add per-result context guard (Pass 0): force-trim any single tool
  result exceeding 30% of context window
2026-04-13 19:11:30 +07:00

423 lines
13 KiB
Go

package agent
import (
"fmt"
"unicode/utf8"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
)
// Context pruning defaults matching TS DEFAULT_CONTEXT_PRUNING_SETTINGS.
const (
defaultKeepLastAssistants = 3
defaultSoftTrimRatio = 0.25
defaultHardClearRatio = 0.5
defaultMinPrunableToolChars = 50000
defaultSoftTrimMaxChars = 6000
defaultSoftTrimHeadChars = 3000
defaultSoftTrimTailChars = 3000
defaultHardClearPlaceholder = "[Old tool result content cleared]"
charsPerTokenEstimate = 4
// Media tool results contain irreplaceable vision/audio descriptions
// from dedicated providers (Gemini, Anthropic) — re-generating requires
// another LLM call. Give them a higher soft trim budget.
mediaSoftTrimHeadChars = 4000
mediaSoftTrimTailChars = 4000
)
// pruningEstimator wraps either a tiktoken counter or the legacy char-based heuristic.
// When counter is nil, falls back to rune_count / charsPerTokenEstimate.
type pruningEstimator struct {
counter tokencount.TokenCounter
model string
}
// estimateTokens returns an integer unit comparable to tokenWindow.
// When counter is set: returns actual token count (tiktoken).
// When nil (fallback): returns rune count, to be compared against charWindow = tokens * charsPerTokenEstimate.
func (e *pruningEstimator) estimateTokens(content string) int {
if e.counter != nil {
return e.counter.Count(e.model, content)
}
return utf8.RuneCountInString(content)
}
// mediaToolNames identifies builtin tools whose results use a higher soft trim budget.
var mediaToolNames = map[string]bool{
"read_image": true,
"read_document": true,
"read_audio": true,
"read_video": true,
}
// effectivePruningSettings holds resolved pruning settings with defaults applied.
type effectivePruningSettings struct {
keepLastAssistants int
softTrimRatio float64
hardClearRatio float64
minPrunableToolChars int
softTrimMaxChars int
softTrimHeadChars int
softTrimTailChars int
hardClearEnabled bool
hardClearPlaceholder string
}
// resolvePruningSettings applies defaults to user config.
func resolvePruningSettings(cfg *config.ContextPruningConfig) *effectivePruningSettings {
s := &effectivePruningSettings{
keepLastAssistants: defaultKeepLastAssistants,
softTrimRatio: defaultSoftTrimRatio,
hardClearRatio: defaultHardClearRatio,
minPrunableToolChars: defaultMinPrunableToolChars,
softTrimMaxChars: defaultSoftTrimMaxChars,
softTrimHeadChars: defaultSoftTrimHeadChars,
softTrimTailChars: defaultSoftTrimTailChars,
hardClearEnabled: true,
hardClearPlaceholder: defaultHardClearPlaceholder,
}
if cfg == nil {
return s
}
if cfg.KeepLastAssistants > 0 {
s.keepLastAssistants = cfg.KeepLastAssistants
}
if cfg.SoftTrimRatio > 0 && cfg.SoftTrimRatio <= 1 {
s.softTrimRatio = cfg.SoftTrimRatio
}
if cfg.HardClearRatio > 0 && cfg.HardClearRatio <= 1 {
s.hardClearRatio = cfg.HardClearRatio
}
if cfg.MinPrunableToolChars > 0 {
s.minPrunableToolChars = cfg.MinPrunableToolChars
}
if cfg.SoftTrim != nil {
if cfg.SoftTrim.MaxChars > 0 {
s.softTrimMaxChars = cfg.SoftTrim.MaxChars
}
if cfg.SoftTrim.HeadChars > 0 {
s.softTrimHeadChars = cfg.SoftTrim.HeadChars
}
if cfg.SoftTrim.TailChars > 0 {
s.softTrimTailChars = cfg.SoftTrim.TailChars
}
}
if cfg.HardClear != nil {
if cfg.HardClear.Enabled != nil {
s.hardClearEnabled = *cfg.HardClear.Enabled
}
if cfg.HardClear.Placeholder != "" {
s.hardClearPlaceholder = cfg.HardClear.Placeholder
}
}
return s
}
// pruneContextMessages trims old tool results to reduce context window usage.
// Matching TS src/agents/pi-extensions/context-pruning/pruner.ts.
//
// Two-pass approach:
// 1. Soft trim: keep head + tail of long tool results, drop middle.
// 2. Hard clear: replace entire tool result with placeholder.
//
// Only tool results older than keepLastAssistants are eligible for pruning.
// Returns a new slice if any changes were made, otherwise the original.
//
// When tc is non-nil, token counting uses tiktoken for accuracy (especially
// for non-ASCII content like Vietnamese/Chinese). When nil, falls back to the
// legacy rune_count/charsPerTokenEstimate heuristic so existing tests pass.
func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg *config.ContextPruningConfig, tc tokencount.TokenCounter, model string) []providers.Message {
// Pruning runs by default for all providers. Only skip when explicitly disabled.
if cfg != nil && cfg.Mode == "off" {
return msgs
}
if contextWindowTokens <= 0 || len(msgs) == 0 {
return msgs
}
est := &pruningEstimator{counter: tc, model: model}
settings := resolvePruningSettings(cfg)
// tokenWindow is contextWindowTokens when using tiktoken (est.counter != nil),
// or charWindow (tokens * 4) when using the char-based fallback.
tokenWindow := contextWindowTokens
if tc == nil {
tokenWindow = contextWindowTokens * charsPerTokenEstimate
}
// softTrimMaxTokens is the threshold for the estimator's unit.
// When tiktoken (tc != nil): convert chars → tokens (3000 chars / 4 ≈ 750 tokens).
// When char fallback (tc == nil): keep as rune count (estimateTokens returns rune count).
softTrimMaxTokens := settings.softTrimMaxChars
mediaSoftTrimMaxTokens := mediaSoftTrimHeadChars + mediaSoftTrimTailChars
if tc != nil {
softTrimMaxTokens = settings.softTrimMaxChars / charsPerTokenEstimate
mediaSoftTrimMaxTokens = mediaSoftTrimMaxTokens / charsPerTokenEstimate
}
// Find cutoff: protect last N assistant messages.
cutoffIndex := findAssistantCutoff(msgs, settings.keepLastAssistants)
if cutoffIndex < 0 {
return msgs
}
// Find first user message — never prune before it (protects bootstrap reads).
pruneStart := len(msgs)
for i, m := range msgs {
if m.Role == "user" {
pruneStart = i
break
}
}
// Estimate total tokens (or chars in fallback mode).
totalTokens := 0
for _, m := range msgs {
totalTokens += est.estimateTokens(m.Content)
}
ratio := float64(totalTokens) / float64(tokenWindow)
if ratio < settings.softTrimRatio {
return msgs // context is small enough
}
// Collect prunable tool result indexes.
var prunableIndexes []int
for i := pruneStart; i < cutoffIndex; i++ {
if msgs[i].Role == "tool" && msgs[i].Content != "" {
prunableIndexes = append(prunableIndexes, i)
}
}
if len(prunableIndexes) == 0 {
return msgs
}
// Pass 0: Per-result context guard — force-trim any single tool result
// exceeding 30% of the context window. Catches outlier outputs even
// when overall context ratio is low.
maxSingleResultTokens := tokenWindow * 3 / 10
// Char budget for actual string slicing (always char-based regardless of estimator).
maxSingleResultChars := maxSingleResultTokens
if tc != nil {
maxSingleResultChars = maxSingleResultTokens * charsPerTokenEstimate
}
var result []providers.Message
for _, idx := range prunableIndexes {
msgTokens := est.estimateTokens(msgs[idx].Content)
if msgTokens > maxSingleResultTokens {
if result == nil {
result = make([]providers.Message, len(msgs))
copy(result, msgs)
}
msg := msgs[idx]
head := takeHead(msg.Content, maxSingleResultChars*7/10)
tail := takeTail(msg.Content, maxSingleResultChars*3/10)
msgChars := utf8.RuneCountInString(msg.Content)
trimmed := fmt.Sprintf("%s\n\n⚠️ [... middle content omitted ...]\n\n%s\n\n[Single tool result trimmed: %d chars exceeded per-result limit of %d chars.]",
head, tail, msgChars, maxSingleResultChars)
result[idx] = providers.Message{
Role: msg.Role,
Content: trimmed,
ToolCallID: msg.ToolCallID,
}
totalTokens += est.estimateTokens(trimmed) - msgTokens
}
}
if result != nil {
msgs = result
result = nil
// Re-check ratio after per-result guard.
ratio = float64(totalTokens) / float64(tokenWindow)
if ratio < settings.softTrimRatio {
return msgs
}
}
// Build tool call name map for media tool detection in soft trim.
toolCallNames := buildToolCallNameMap(msgs)
// Pass 1: Soft trim long tool results.
for i := range prunableIndexes {
idx := prunableIndexes[i]
msg := msgs[idx]
msgTokens := est.estimateTokens(msg.Content)
// Media tool results (read_image, etc.) get a higher trim budget because
// their content is an irreplaceable description from a dedicated vision provider.
isMedia := mediaToolNames[toolCallNames[msg.ToolCallID]]
trimThreshold := softTrimMaxTokens
if isMedia {
trimThreshold = mediaSoftTrimMaxTokens
}
if msgTokens <= trimThreshold {
continue
}
// Lazy copy
if result == nil {
result = make([]providers.Message, len(msgs))
copy(result, msgs)
}
// Tail-aware split: if tail has important content (errors, summaries),
// use dynamic 70/30 split. Otherwise use configured head/tail sizes.
// String slicing always uses chars (not tokens).
headChars := settings.softTrimHeadChars
tailChars := settings.softTrimTailChars
if isMedia {
headChars = mediaSoftTrimHeadChars
tailChars = mediaSoftTrimTailChars
}
if hasImportantTail(msg.Content) {
totalBudget := headChars + tailChars
tailChars = totalBudget * 7 / 10
headChars = totalBudget - tailChars
}
head := takeHead(msg.Content, headChars)
tail := takeTail(msg.Content, tailChars)
msgChars := utf8.RuneCountInString(msg.Content)
trimmed := fmt.Sprintf("%s\n...\n%s\n\n[Tool result trimmed: kept first %d chars and last %d chars of %d chars.]",
head, tail, headChars, tailChars, msgChars)
result[idx] = providers.Message{
Role: msg.Role,
Content: trimmed,
ToolCallID: msg.ToolCallID,
}
totalTokens += est.estimateTokens(trimmed) - msgTokens
}
output := msgs
if result != nil {
output = result
}
// Re-check ratio after soft trim.
ratio = float64(totalTokens) / float64(tokenWindow)
if ratio < settings.hardClearRatio || !settings.hardClearEnabled {
return output
}
// Check min prunable chars threshold (always char-based per config).
prunableChars := 0
for _, idx := range prunableIndexes {
prunableChars += utf8.RuneCountInString(output[idx].Content)
}
if prunableChars < settings.minPrunableToolChars {
return output
}
// Pass 2: Hard clear — replace entire tool results with placeholder.
if result == nil {
result = make([]providers.Message, len(msgs))
copy(result, msgs)
output = result
}
for _, idx := range prunableIndexes {
if ratio < settings.hardClearRatio {
break
}
msg := output[idx]
// Media tool results (read_image, etc.) are never hard-cleared because
// they contain irreplaceable vision/audio descriptions — re-generating
// requires another LLM call. Soft trim already caps them at 8K chars.
if mediaToolNames[toolCallNames[msg.ToolCallID]] {
continue
}
beforeTokens := est.estimateTokens(msg.Content)
output[idx] = providers.Message{
Role: msg.Role,
Content: settings.hardClearPlaceholder,
ToolCallID: msg.ToolCallID,
}
afterTokens := est.estimateTokens(settings.hardClearPlaceholder)
totalTokens += afterTokens - beforeTokens
ratio = float64(totalTokens) / float64(tokenWindow)
}
return output
}
// findAssistantCutoff returns the index of the Nth-from-last assistant message.
// Messages at or after this index are protected from pruning.
// Returns -1 if not enough assistant messages exist.
func findAssistantCutoff(msgs []providers.Message, keepLast int) int {
if keepLast <= 0 {
return len(msgs)
}
remaining := keepLast
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" {
remaining--
if remaining == 0 {
return i
}
}
}
return -1
}
// estimateMessageChars returns the character count of a message's content.
func estimateMessageChars(m providers.Message) int {
return utf8.RuneCountInString(m.Content)
}
// hasImportantTail checks if the last ~500 chars of content contain error/summary keywords.
func hasImportantTail(content string) bool {
runes := []rune(content)
checkLen := min(500, len(runes))
tail := string(runes[len(runes)-checkLen:])
return importantTailRe.MatchString(tail)
}
// takeHead returns the first n runes of s.
func takeHead(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n])
}
// takeTail returns the last n runes of s.
func takeTail(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[len(runes)-n:])
}
// buildToolCallNameMap creates a mapping from tool_call_id → tool name
// by scanning assistant messages for their ToolCalls.
func buildToolCallNameMap(msgs []providers.Message) map[string]string {
m := make(map[string]string)
for _, msg := range msgs {
for _, tc := range msg.ToolCalls {
m[tc.ID] = tc.Name
}
}
return m
}