234 lines
8.1 KiB
Go
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[...]"
|
|
}
|