mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
The v3 pipeline compacts session history mid-loop (prune_stage + final-request guard) but only mutates the run's message buffer, never the session store. Each turn reloads full history and re-compacts from scratch: message_tokens climb 129k->156k across turns while every turn compacts back down to ~60k. The lossy compaction differs per run, degrading the agent. The same missing persistence stalls episodic memory: the cumulative compaction count never advances, so the episodic worker's idempotency key (sessionKey:count) is pinned and every cycle after the first is skipped. Observed on live traffic: 8 run.completed since deploy, 0 new episodic. Fixes, all reusing existing machinery (no new store methods, no migrations): - Bug A: emitSessionCompleted reads cumulative GetCompactionCount (matching the legacy v2 path) instead of the per-run counter that resets to 0. - Bug B/anti-loop: finalize passes state.Prune.MidLoopCompacted into maybeSummarize; under pressure it lowers the trigger to a unit-aligned threshold (compactionInputCap - overhead, same MaxRequestShare the guard uses) so the compaction is PERSISTED via the existing TruncateHistory + IncrementCompaction path. Defensive floor prevents over-compaction on pathological config; tool-result-only bloat still skips (history-only). - Bug C: SourceID embeds the count (sessionKey:count) so the eventbus dedup key advances per compaction cycle instead of swallowing rapid same-session turns within the 5m TTL. Tests: episodic compaction, maybe_summarize pressure, request budget. go build (PG + sqliteonly), go vet, go test -race all green.
102 lines
3.7 KiB
Go
102 lines
3.7 KiB
Go
package tokencount
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// FallbackCounter is the BEST-EFFORT heuristic counter used when tiktoken-go is
|
|
// unavailable or the model is unknown (e.g. 9router brand models that match no
|
|
// registry prefix). It is NOT a proven token bound: real tokenizers vary by
|
|
// language, and dense scripts like Vietnamese tokenize at ~2 chars/token or
|
|
// worse. Because the pre-transport guard treats this count as its ceiling, the
|
|
// heuristic deliberately uses a CONSERVATIVE chars-per-token ratio so it errs
|
|
// toward over-counting (compact/block early) rather than under-counting (send an
|
|
// oversized request). The only way to a provable ceiling for an un-tokenizable
|
|
// model is to register it with a real tokenizer or use the provider's own token
|
|
// count.
|
|
type FallbackCounter struct{}
|
|
|
|
func NewFallbackCounter() *FallbackCounter { return &FallbackCounter{} }
|
|
|
|
// fallbackCharsPerToken is the conservative chars-per-token divisor for the
|
|
// guard-facing count methods. Lower than a naive ~3-4 chars/token so mixed
|
|
// Vietnamese/code content is over-counted rather than under-counted. This is a
|
|
// safety heuristic, not an exact tokenization.
|
|
const fallbackCharsPerToken = 2
|
|
|
|
func (c *FallbackCounter) Count(_ string, text string) int {
|
|
return utf8.RuneCountInString(text) / fallbackCharsPerToken
|
|
}
|
|
|
|
func (c *FallbackCounter) CountMessages(_ string, msgs []providers.Message) int {
|
|
total := 0
|
|
for _, m := range msgs {
|
|
total += utf8.RuneCountInString(m.Content)/fallbackCharsPerToken + PerMessageOverhead
|
|
// Match tiktokenCounter.CountMessages coverage so the fallback path
|
|
// applies the same best-effort ceiling (thinking, tool-result id, raw
|
|
// blocks, tool args, media all count toward the wire payload).
|
|
total += utf8.RuneCountInString(m.Thinking) / fallbackCharsPerToken
|
|
total += utf8.RuneCountInString(m.ToolCallID) / fallbackCharsPerToken
|
|
total += utf8.RuneCountInString(string(m.RawAssistantContent)) / fallbackCharsPerToken
|
|
for _, tc := range m.ToolCalls {
|
|
total += utf8.RuneCountInString(tc.ID)/fallbackCharsPerToken + utf8.RuneCountInString(tc.Name)/fallbackCharsPerToken
|
|
if b, err := json.Marshal(tc.Arguments); err == nil {
|
|
total += utf8.RuneCountInString(string(b)) / fallbackCharsPerToken
|
|
}
|
|
}
|
|
total += fallbackMediaTokenCost(m)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// fallbackMediaTokenCost mirrors mediaTokenCost for the heuristic counter.
|
|
func fallbackMediaTokenCost(m providers.Message) int {
|
|
const perInlineMediaItem = 1600
|
|
cost := 0
|
|
for _, img := range m.Images {
|
|
if img.Data != "" || img.URL != "" {
|
|
cost += perInlineMediaItem
|
|
}
|
|
}
|
|
for _, vid := range m.Videos {
|
|
if vid.Data != "" || vid.URL != "" {
|
|
cost += perInlineMediaItem
|
|
}
|
|
}
|
|
return cost
|
|
}
|
|
|
|
// CountToolSchemas returns a conservative heuristic count for the JSON-serialised
|
|
// tool list. Returns 0 for nil or empty slice.
|
|
func (c *FallbackCounter) CountToolSchemas(_ string, tools []providers.ToolDefinition) int {
|
|
if len(tools) == 0 {
|
|
return 0
|
|
}
|
|
blob, _ := json.Marshal(tools)
|
|
return utf8.RuneCountInString(string(blob)) / fallbackCharsPerToken
|
|
}
|
|
|
|
// ModelContextWindow uses longest-prefix-match to avoid ambiguity
|
|
// (e.g., "gpt-4o" must match before "gpt-4").
|
|
func (c *FallbackCounter) ModelContextWindow(model string) int {
|
|
// Sort prefixes longest-first for correct matching.
|
|
keys := make([]string, 0, len(DefaultRegistry))
|
|
for k := range DefaultRegistry {
|
|
keys = append(keys, k)
|
|
}
|
|
slices.SortFunc(keys, func(a, b string) int { return cmp.Compare(len(b), len(a)) })
|
|
|
|
for _, prefix := range keys {
|
|
if strings.HasPrefix(model, prefix) {
|
|
return DefaultRegistry[prefix].ContextWindow
|
|
}
|
|
}
|
|
return 200_000 // conservative default
|
|
}
|