Files
gnoma/internal/context/window.go
T
vikingowl b0b393517e feat: M6 context intelligence — token tracker + truncation compaction
internal/context/:
- Tracker: monitors token usage with OK/Warning/Critical states
  (thresholds from CC: 20K warning buffer, 13K autocompact buffer)
- TruncateStrategy: drops oldest messages, preserves system prompt +
  recent N turns, adds compaction boundary marker
- Window: manages message history with auto-compaction trigger,
  circuit breaker after 3 consecutive failures

Engine integration:
- Context window tracks usage per turn
- Auto-compacts when critical threshold reached
- History syncs with context window after compaction

TUI status bar:
- Token count with percentage (tokens: 1234 (5%))
- Color-coded: green=ok, yellow=warning, red=critical

Session Status extended: TokensMax, TokenPercent, TokenState.
7 context tests.
2026-04-03 18:46:03 +02:00

134 lines
3.1 KiB
Go

package context
import (
"fmt"
"log/slog"
"somegit.dev/Owlibou/gnoma/internal/message"
)
// Strategy compacts a message history to fit within a token budget.
type Strategy interface {
// Compact reduces the message slice to fit within budget tokens.
// Must preserve the system prompt (first message if role=system).
Compact(messages []message.Message, budget int64) ([]message.Message, error)
}
// Window manages the sliding context window with compaction.
type Window struct {
tracker *Tracker
strategy Strategy
messages []message.Message
logger *slog.Logger
// Circuit breaker: stop retrying after consecutive failures
consecutiveFailures int
maxFailures int
}
type WindowConfig struct {
MaxTokens int64
Strategy Strategy
Logger *slog.Logger
}
func NewWindow(cfg WindowConfig) *Window {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Window{
tracker: NewTracker(cfg.MaxTokens),
strategy: cfg.Strategy,
messages: nil,
logger: logger,
maxFailures: 3,
}
}
// Append adds a message and tracks usage.
func (w *Window) Append(msg message.Message, usage message.Usage) {
w.messages = append(w.messages, msg)
w.tracker.Add(usage)
}
// Messages returns the current message history.
func (w *Window) Messages() []message.Message {
return w.messages
}
// SetMessages replaces the message history (used after compaction).
func (w *Window) SetMessages(msgs []message.Message) {
w.messages = msgs
}
// Tracker returns the token tracker.
func (w *Window) Tracker() *Tracker {
return w.tracker
}
// CompactIfNeeded checks if compaction should trigger and runs it.
// Returns true if compaction was performed.
func (w *Window) CompactIfNeeded() (bool, error) {
if !w.tracker.ShouldCompact() {
return false, nil
}
if w.strategy == nil {
return false, fmt.Errorf("no compaction strategy configured")
}
// Circuit breaker
if w.consecutiveFailures >= w.maxFailures {
w.logger.Warn("compaction circuit breaker open",
"failures", w.consecutiveFailures,
"max", w.maxFailures,
)
return false, nil
}
budget := w.tracker.Remaining() + w.tracker.Used()/2 // target: half of current usage
if budget < 0 {
budget = w.tracker.MaxTokens() / 2
}
w.logger.Info("compacting context",
"messages", len(w.messages),
"used", w.tracker.Used(),
"budget", budget,
"strategy", fmt.Sprintf("%T", w.strategy),
)
compacted, err := w.strategy.Compact(w.messages, budget)
if err != nil {
w.consecutiveFailures++
w.logger.Error("compaction failed",
"error", err,
"consecutive_failures", w.consecutiveFailures,
)
return false, err
}
w.consecutiveFailures = 0
w.messages = compacted
// Rough estimate: reduce tracked tokens proportionally
ratio := float64(len(compacted)) / float64(len(w.messages)+1)
w.tracker.Set(int64(float64(w.tracker.Used()) * ratio))
w.logger.Info("compaction complete",
"messages_before", len(w.messages),
"messages_after", len(compacted),
"tokens_after", w.tracker.Used(),
)
return true, nil
}
// Reset clears all messages and usage.
func (w *Window) Reset() {
w.messages = nil
w.tracker.Reset()
w.consecutiveFailures = 0
}