Files
iasysadmin/internal/agent/state.go
T
2026-09-27 16:04:02 -06:00

234 lines
8.1 KiB
Go

package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sysagent/internal/ctxmgr"
"sysagent/internal/llm"
"sysagent/internal/memory"
"sysagent/internal/project"
"sysagent/internal/tools"
)
// Umbrales de compactación (§18): se compacta al llegar al 80 % estimado,
// nunca esperando al límite. Si aun así se supera el 95 % (compresión
// fallida o contexto reciente enorme), se recortan salidas de herramientas.
const (
compactAtPct = 80
elideAtPct = 95
// keepRecentPct es la fracción del contexto reservada al contexto
// reciente completo tras compactar.
keepRecentPct = 25
)
var errNothingToCompact = errors.New("nothing to compact")
// journal añade un evento al diario operativo de contexto.mem.
func (a *Agent) journal(e memory.JournalEntry) {
e.Text = a.log.Redact(e.Text)
a.state.AddJournal(e)
}
// journalTool registra el resultado de una herramienta. Los cambios de
// archivos se registran como "change" con sus backups (§88).
func (a *Agent) journalTool(task, name string, res tools.Result) {
kind := "tool"
switch {
case res.Denied:
kind = "denied"
case len(res.FilesChanged) > 0:
kind = "change"
case !res.OK:
kind = "error"
}
text := name + ": " + res.Summary
for i := 0; i+1 < len(res.Audit); i += 2 {
if res.Audit[i] == "reason" && res.Audit[i+1] != "" {
text += " (reason: " + res.Audit[i+1] + ")"
}
}
if res.Diff != "" {
// Valor anterior y nuevo (§88), acotado para no inflar contexto.mem.
text += "\n" + clip(res.Diff, 1500)
} else {
text = clipLine(text, 600)
}
a.journal(memory.JournalEntry{Task: task, Kind: kind, Text: text, Files: res.FilesChanged, Backups: res.Backups})
}
// saveState escribe contexto.mem. Un fallo no detiene al agente, pero se
// advierte: perder el histórico es grave pero no justifica abortar una
// operación en curso.
func (a *Agent) saveState() {
a.state.Recent = append(append([]llm.Message(nil), a.prevRecent...), a.ctx.History()...)
if err := a.state.Save(a.project.Path(project.ContextFile), a.log.Redact); err != nil {
a.log.Warnf("could not save contexto.mem: %v", err)
}
}
// renderPrevious convierte los mensajes recientes de la sesión anterior en
// texto para el prompt de sistema. Se usa texto (y no los mensajes
// originales) porque el protocolo de herramientas o el servidor pueden
// haber cambiado, y algunas plantillas de chat rechazan secuencias de roles
// que no alternan. Se conserva el final si no cabe.
//
// truncated indica que no cupo todo: el agente compactará en la primera
// petición para que lo omitido pase al resumen en vez de perderse.
func renderPrevious(msgs []llm.Message, contextSize int) (text string, truncated bool) {
text = ctxmgr.RenderTranscript(msgs, 800)
limit := 24000 // bytes, con contexto desconocido
if contextSize > 0 {
limit = contextSize * 20 / 100 * 3 // ~20 % del contexto
}
if len(text) > limit {
return "[... earlier messages omitted ...]\n" + text[len(text)-limit:], true
}
return text, false
}
// updateOverhead estima los tokens del esquema de herramientas, que en modo
// nativo viaja en cada petición fuera de los mensajes.
func (a *Agent) updateOverhead() {
if a.toolMode != "native" {
a.ctx.SetOverhead(0)
return
}
b, _ := json.Marshal(a.tools.Specs())
a.ctx.SetOverhead(ctxmgr.EstimateTokens(string(b)))
}
// reloadMemory relee memoria.md y reconstruye el prompt de sistema si una
// herramienta la modificó, para que el conocimiento nuevo esté disponible
// de inmediato.
func (a *Agent) reloadMemory(res tools.Result) {
path := a.mem.Path()
for _, f := range res.FilesChanged {
if f == path {
if content, err := a.mem.Read(); err == nil {
a.memoryMD = content
a.ctx.SetSystem(a.buildSystemPrompt())
}
return
}
}
}
// maybeCompact aplica la política de tamaño antes de cada petición.
func (a *Agent) maybeCompact(ctx context.Context, label string) {
if a.compactPending {
a.compactPending = false
// La compactación usa los mensajes completos (prevRecent), no el
// texto recortado del prompt.
a.prevSession = ctxmgr.RenderTranscript(a.prevRecent, 800)
if err := a.compact(ctx, label); err != nil && !errors.Is(err, errNothingToCompact) {
a.log.Warnf("%s: compaction of previous sessions failed: %v", label, err)
a.prevSession, _ = renderPrevious(a.prevRecent, a.ctx.ContextSize())
}
}
limit := a.ctx.ContextSize()
if limit == 0 {
return // UNKNOWN: se reacciona al error de desbordamiento del servidor
}
used, _ := a.ctx.Usage()
if used*100/limit < compactAtPct {
return
}
a.log.Infof("%s: context ~%d%% (%d/%d tokens, estimated): compacting", label, used*100/limit, used, limit)
if err := a.compact(ctx, label); err != nil && !errors.Is(err, errNothingToCompact) {
a.log.Warnf("%s: context compaction failed: %v", label, err)
}
used, _ = a.ctx.Usage()
if used*100/limit >= elideAtPct {
n := a.ctx.ElideToolOutputs(1)
after, _ := a.ctx.Usage()
a.log.Warnf("%s: context still ~%d%% after compaction; elided %d tool outputs (now ~%d%%)", label, used*100/limit, n, after*100/limit)
}
}
// compact resume el contexto antiguo con el modelo (§19) y lo sustituye por
// el resumen estructurado en el prompt de sistema.
//
// 1. Se separa el historial en parte antigua y parte reciente (Split, sin
// romper pares llamada/resultado).
// 2. Se resume: resumen previo + diario sin resumir + transcripción antigua
// (+ contexto de la sesión anterior, si aún no se había incorporado).
// 3. El resumen reemplaza a la parte antigua; si la parte reciente no
// empieza por un mensaje del usuario, se antepone uno que repite la
// tarea en curso para que el modelo no la pierda.
// 4. Se guarda contexto.mem.
func (a *Agent) compact(ctx context.Context, label string) error {
limit := a.ctx.ContextSize()
keepBudget, chunk, maxOut := 4000, 8000, 2048
if limit > 0 {
keepBudget = limit * keepRecentPct / 100
chunk = max(limit*45/100-ctxmgr.EstimateTokens(a.state.Summary)-1500, 1500)
maxOut = min(max(limit*15/100, 1024), 4096)
}
old, kept, ok := a.ctx.Split(keepBudget)
if !ok && a.prevSession == "" {
return errNothingToCompact
}
if !ok {
kept = a.ctx.History()
}
var transcript strings.Builder
if a.prevSession != "" {
transcript.WriteString("[previous session]\n")
transcript.WriteString(a.prevSession)
transcript.WriteString("\n[current session]\n")
}
transcript.WriteString(ctxmgr.RenderTranscript(old, 1500))
journal := memory.FormatJournal(a.state.Unsummarized())
before, _ := a.ctx.Usage()
a.say(" … compacting context (%d messages into the historical summary)", len(old))
comp := &ctxmgr.Compressor{Client: a.llm, ChunkTokens: chunk, MaxOutputTokens: maxOut}
summary, err := comp.Compress(ctx, a.state.Summary, a.log.Redact(journal), a.log.Redact(transcript.String()))
if err != nil {
a.journal(memory.JournalEntry{Task: label, Kind: "error", Text: "context compaction failed: " + err.Error()})
return err
}
if len(kept) > 0 && kept[0].Role != llm.RoleUser && a.currentTask != "" {
kept = append([]llm.Message{{Role: llm.RoleUser, Content: "[El contexto anterior se compactó en CONTEXTO HISTÓRICO del prompt de sistema.]\n\nTAREA EN CURSO:\n" + a.currentTask}}, kept...)
}
a.state.Summary = summary
a.state.Compactions++
a.state.MarkSummarized()
a.state.Recent = nil
a.prevRecent = nil
a.prevSession = ""
a.ctx.Replace(kept)
a.ctx.SetSystem(a.buildSystemPrompt())
after, _ := a.ctx.Usage()
a.journal(memory.JournalEntry{Task: label, Kind: "compaction", Summarized: true,
Text: fmt.Sprintf("compaction #%d: %d messages summarized, ~%d -> ~%d tokens (estimated)", a.state.Compactions, len(old), before, after)})
a.log.Action("task", label, "action", "compaction", "result", "ok", "messages", fmt.Sprint(len(old)),
"tokens_before", fmt.Sprint(before), "tokens_after", fmt.Sprint(after))
a.saveState()
return nil
}
// taskHash identifica el contenido de una tarea para saber si cambió desde
// la última ejecución.
func taskHash(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func clip(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "\n[...]"
}