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[...]" }