Files
2026-09-27 16:04:02 -06:00

276 lines
8.1 KiB
Go

// Package ctxmgr administra la conversación enviada al modelo y estima
// cuánto contexto ocupa.
//
// Mantiene dos niveles (§20): el prompt de sistema (que incluye el contexto
// histórico comprimido) y el contexto reciente completo. La compresión la
// realiza Compressor (compressor.go) y la orquesta el agente.
//
// Se llama ctxmgr y no "context" para no colisionar con el paquete estándar.
package ctxmgr
import (
"strings"
"sync"
"sysagent/internal/llm"
)
// perMessageOverhead aproxima los tokens de formato (rol, separadores) que
// cada mensaje añade en las plantillas de chat habituales.
const perMessageOverhead = 4
// Manager guarda el prompt de sistema y el historial reciente.
// Es seguro para uso concurrente.
type Manager struct {
mu sync.Mutex
system string
history []llm.Message
// contextSize es el máximo de tokens; 0 significa UNKNOWN.
contextSize int
// overhead son tokens fijos fuera de los mensajes (p.ej. el esquema de
// herramientas en tool calling nativo).
overhead int
// factor calibra la estimación con los prompt_tokens reales reportados
// por el servidor (0 = sin calibrar).
factor float64
// lastReported es el prompt_tokens de la última respuesta (dato real).
lastReported int
}
// New crea un Manager con el prompt de sistema ya construido.
func New(system string, contextSize int) *Manager {
return &Manager{system: system, contextSize: contextSize}
}
// Add añade mensajes al historial reciente.
func (m *Manager) Add(msgs ...llm.Message) {
m.mu.Lock()
defer m.mu.Unlock()
m.history = append(m.history, msgs...)
}
// Messages devuelve una copia de la conversación completa (sistema + historial)
// lista para enviarse al modelo.
func (m *Manager) Messages() []llm.Message {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]llm.Message, 0, len(m.history)+1)
out = append(out, llm.Message{Role: llm.RoleSystem, Content: m.system})
return append(out, m.history...)
}
// SetSystem reemplaza el prompt de sistema (p.ej. al cambiar de protocolo
// de herramientas).
func (m *Manager) SetSystem(system string) {
m.mu.Lock()
defer m.mu.Unlock()
m.system = system
}
// Len devuelve el número de mensajes del historial reciente.
func (m *Manager) Len() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.history)
}
// Truncate recorta el historial a n mensajes (para deshacer un turno fallido).
func (m *Manager) Truncate(n int) {
m.mu.Lock()
defer m.mu.Unlock()
if n >= 0 && n < len(m.history) {
m.history = m.history[:n]
}
}
// ElideToolOutputs reemplaza el contenido de los resultados de herramientas
// antiguos (todos salvo los keep más recientes) por un marcador corto.
//
// Es el último recurso cuando la compresión no es posible o no basta: las
// salidas de comandos son lo más voluminoso y lo que antes pierde valor.
// Devuelve cuántos mensajes se recortaron.
func (m *Manager) ElideToolOutputs(keep int) int {
m.mu.Lock()
defer m.mu.Unlock()
var idx []int
for i, msg := range m.history {
if IsToolResult(msg) {
idx = append(idx, i)
}
}
n := 0
for _, i := range idx[:max(len(idx)-keep, 0)] {
if m.history[i].Content == elided {
continue
}
m.history[i].Content = elided
n++
}
return n
}
const elided = "[tool output elided to save context; run the tool again if you need it]"
// ToolResultPrefix marca los resultados de herramientas enviados como
// mensaje de usuario (protocolo JSON).
const ToolResultPrefix = "TOOL_RESULTS"
// IsToolResult informa si un mensaje contiene resultados de herramientas.
func IsToolResult(msg llm.Message) bool {
return msg.Role == llm.RoleTool || msg.Role == llm.RoleUser && strings.HasPrefix(msg.Content, ToolResultPrefix)
}
// History devuelve una copia del historial reciente, sin el prompt de sistema.
func (m *Manager) History() []llm.Message {
m.mu.Lock()
defer m.mu.Unlock()
return append([]llm.Message(nil), m.history...)
}
// Clear vacía el historial reciente. El prompt de sistema se conserva.
func (m *Manager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.history = nil
}
// Replace sustituye el historial reciente (tras una compactación).
func (m *Manager) Replace(history []llm.Message) {
m.mu.Lock()
defer m.mu.Unlock()
m.history = append([]llm.Message(nil), history...)
}
// SetOverhead fija los tokens estimados fuera de los mensajes.
func (m *Manager) SetOverhead(n int) {
m.mu.Lock()
defer m.mu.Unlock()
m.overhead = n
}
// SetContextSize actualiza el máximo de tokens (p.ej. tras consultar al servidor).
func (m *Manager) SetContextSize(n int) {
m.mu.Lock()
defer m.mu.Unlock()
m.contextSize = n
}
// ContextSize devuelve el máximo configurado o descubierto (0 = UNKNOWN).
func (m *Manager) ContextSize() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.contextSize
}
// RecordUsage calibra la estimación con el consumo real del servidor.
// sentEstimate es la estimación local (sin calibrar) de la petición enviada.
func (m *Manager) RecordUsage(u llm.Usage, sentEstimate int) {
m.mu.Lock()
defer m.mu.Unlock()
if u.PromptTokens <= 0 || sentEstimate <= 0 {
return
}
m.lastReported = u.PromptTokens
f := float64(u.PromptTokens) / float64(sentEstimate)
// Límites de cordura: un servidor con usage erróneo no debe desactivar
// la compactación ni dispararla continuamente.
m.factor = min(max(f, 0.25), 2.0)
}
// RawEstimate estima (sin calibrar) los tokens de la conversación completa.
func (m *Manager) RawEstimate() int {
msgs := m.Messages()
m.mu.Lock()
defer m.mu.Unlock()
return EstimateMessages(msgs) + m.overhead
}
// Usage devuelve el uso estimado de contexto. calibrated indica si la
// estimación está ajustada con datos reales del servidor. Nunca es un
// conteo exacto (§84): el servidor solo informa tras cada petición.
func (m *Manager) Usage() (tokens int, calibrated bool) {
raw := m.RawEstimate()
m.mu.Lock()
defer m.mu.Unlock()
if m.factor > 0 {
return int(float64(raw) * m.factor), true
}
return raw, false
}
// Calibrate convierte una estimación cruda en calibrada.
func (m *Manager) Calibrate(raw int) int {
m.mu.Lock()
defer m.mu.Unlock()
if m.factor > 0 {
return int(float64(raw) * m.factor)
}
return raw
}
// LastReported devuelve prompt_tokens de la última respuesta (0 si no hay).
func (m *Manager) LastReported() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastReported
}
// Split divide el historial para compactarlo: old se resumirá y kept se
// conserva completo. kept cabe en keepTokens (estimación cruda) si es posible.
//
// El corte solo se hace en fronteras seguras: justo antes de un mensaje del
// asistente o de un mensaje del usuario que no sea resultado de
// herramientas. Así nunca se separa una llamada a herramienta de su
// resultado, que la API rechazaría.
// ok=false si no hay nada que compactar.
func (m *Manager) Split(keepTokens int) (old, kept []llm.Message, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
h := m.history
var cuts []int
for i := 1; i < len(h); i++ {
if h[i].Role == llm.RoleAssistant || h[i].Role == llm.RoleUser && !IsToolResult(h[i]) {
cuts = append(cuts, i)
}
}
if len(cuts) == 0 {
return nil, nil, false
}
cut := cuts[len(cuts)-1] // por defecto, conservar el mínimo
for _, c := range cuts {
if EstimateMessages(h[c:]) <= keepTokens {
cut = c
break
}
}
old = append([]llm.Message(nil), h[:cut]...)
kept = append([]llm.Message(nil), h[cut:]...)
return old, kept, true
}
// EstimateMessages estima los tokens de una lista de mensajes.
func EstimateMessages(msgs []llm.Message) int {
total := 0
for _, msg := range msgs {
total += EstimateTokens(msg.Content) + perMessageOverhead
for _, tc := range msg.ToolCalls {
total += EstimateTokens(tc.Function.Name) + EstimateTokens(tc.Function.Arguments)
}
}
return total
}
// EstimateTokens es una estimación conservadora (tiende a sobreestimar).
//
// Los tokenizadores BPE de modelos actuales promedian ~4 bytes por token en
// inglés y algo menos en español o código. Usar 3 bytes por token hace que la
// compactación se dispare antes de tiempo en lugar de tarde, que es el fallo
// menos grave. No es un conteo exacto y nunca se presenta como tal.
func EstimateTokens(s string) int {
if s == "" {
return 0
}
return (len(s) + 2) / 3
}