package agent import ( "fmt" "log/slog" "unicode/utf8" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/pipeline" "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.3 defaultHardClearRatio = 0.5 defaultPruningMode = "cache-ttl" defaultMinPrunableToolChars = 50000 defaultSoftTrimMaxChars = 6000 defaultSoftTrimHeadChars = 3000 defaultSoftTrimTailChars = 3000 defaultHardClearPlaceholder = "[Old tool result content cleared]" defaultCacheTTL = "5m" 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 ) // PruningDefaults mirrors the private pruning const block for external // consumers (e.g. config.defaults RPC). Values are the SSoT the resolver uses // when a user config leaves a field unset. type PruningDefaults struct { KeepLastAssistants int SoftTrimRatio float64 HardClearRatio float64 MinPrunableToolChars int SoftTrimMaxChars int SoftTrimHeadChars int SoftTrimTailChars int HardClearEnabled bool HardClearPlaceholder string TTL string } // DefaultPruningValues returns the private pruning consts packaged for // cross-package consumption. The resolver in resolvePruningSettings uses these // same values. func DefaultPruningValues() PruningDefaults { return PruningDefaults{ KeepLastAssistants: defaultKeepLastAssistants, SoftTrimRatio: defaultSoftTrimRatio, HardClearRatio: defaultHardClearRatio, MinPrunableToolChars: defaultMinPrunableToolChars, SoftTrimMaxChars: defaultSoftTrimMaxChars, SoftTrimHeadChars: defaultSoftTrimHeadChars, SoftTrimTailChars: defaultSoftTrimTailChars, HardClearEnabled: true, HardClearPlaceholder: defaultHardClearPlaceholder, TTL: defaultCacheTTL, } } // 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, stats *pipeline.PruneStats) []providers.Message { // Resolve effective mode: empty defaults to "cache-ttl" (enabled by default). mode := defaultPruningMode if cfg != nil && cfg.Mode != "" { mode = cfg.Mode } if mode == "off" { return msgs } if mode != "cache-ttl" { slog.Warn("context_pruning: unknown mode, disabled", "mode", mode) 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 } // Build tool call name map for media tool detection in soft trim. toolCallNames := buildToolCallNameMap(msgs) // Pass 1: Soft trim long tool results. var result []providers.Message 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, // Preserve ToolName: Gemini needs it to build FunctionResponse.name, // and pruned results are exactly the ones whose assistant tool_call // may already be out of the request window. ToolName: msg.ToolName, } totalTokens += est.estimateTokens(trimmed) - msgTokens if stats != nil { stats.ResultsTrimmed++ } } 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, ToolName: msg.ToolName, } afterTokens := est.estimateTokens(settings.hardClearPlaceholder) totalTokens += afterTokens - beforeTokens ratio = float64(totalTokens) / float64(tokenWindow) if stats != nil { stats.ResultsCleared++ } } 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 }