nub/internal/agent/compact.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

187 lines
5.1 KiB
Go

package agent
import (
"context"
"fmt"
"strings"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tokens"
"nub/internal/tool"
)
const compactionSystemPrompt = `Du fasst einen Ausschnitt einer Coding-Agent-Session zusammen, der aus dem
aktiven Kontext entfernt wird, damit die Konversation fortgesetzt werden kann.
Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene
Entscheidungen, offene Aufgaben, wiederherstellbarer Zustand. Keine Prosa
über den Gesprächsverlauf. Sei präzise und knapp.`
// Compact stößt eine einmalige Compaction unabhängig vom Schwellwert an
// (CLI-Äquivalent zum /compact-TUI-Kommando, 5.8).
func (l *Loop) Compact(ctx context.Context, out chan<- tool.UIEvent) error {
return l.maybeCompact(ctx, out, true)
}
// maybeCompact prüft, ob der aktuelle Pfad das Token-Budget überschreitet
// (5.7), und komprimiert bei Bedarf die ältere Hälfte in einen Summary-Node.
// force=true umgeht die Schwellwertprüfung (manuelles `nub compact`).
func (l *Loop) maybeCompact(ctx context.Context, out chan<- tool.UIEvent, force bool) error {
if l.Session == nil {
return nil
}
nodes := l.Session.PathNodes()
if !force {
maxContext := l.Provider.Caps().MaxContext
if maxContext <= 0 {
return nil
}
estimate := tokens.EstimateMessages(l.System, nodesToMessages(nodes))
if l.Tokens != nil {
estimate = l.Tokens.Adjust(estimate)
}
if float64(estimate) < l.compactAt()*float64(maxContext) {
return nil
}
}
cut := compactionCutIndex(nodes, l.keepTurns())
if cut <= 0 {
return nil // Session ist bereits kürzer als keepTurns -> nichts zu tun
}
older, tail := nodes[:cut], nodes[cut:]
summaryText, err := l.generateSummary(ctx, older)
if err != nil {
return err
}
parentID := older[0].ParentID
replaces := make([]string, len(older))
for i, n := range older {
replaces[i] = n.ID
}
summaryNode := session.NewSummaryNode(parentID, summaryText, replaces, session.NodeMeta{Model: l.Model})
if err := l.Session.Append(summaryNode); err != nil {
return err
}
// Roher Tail wird als neue Nodes an den Summary-Node umgehängt. Die
// Original-Nodes bleiben im Baum — nicht destruktiv, Rewind auf sie
// funktioniert weiterhin (E-08).
parent := summaryNode.ID
for _, n := range tail {
next := session.NewNode(parent, n.Message, n.Meta)
if err := l.Session.Append(next); err != nil {
return err
}
parent = next.ID
}
out <- tool.CompactionEvent{ReplacedNodes: len(older), SummaryTokens: tokens.Estimate(summaryText)}
return nil
}
func nodesToMessages(nodes []*session.Node) []llm.Message {
out := make([]llm.Message, len(nodes))
for i, n := range nodes {
out[i] = n.Message
}
return out
}
// compactionCutIndex findet den Index, ab dem die letzten keepTurns Turns
// beginnen (roh belassen). Eine Turn-Grenze ist eine User-Message mit
// echtem Text — reine Tool-Result-Bündel (auch Role User) zählen nicht,
// sonst würde fast jeder Schritt als eigener Turn gezählt.
func compactionCutIndex(nodes []*session.Node, keepTurns int) int {
boundaries := 0
for i := len(nodes) - 1; i >= 0; i-- {
if isUserTurnBoundary(nodes[i]) {
boundaries++
if boundaries == keepTurns {
return i
}
}
}
return 0
}
func isUserTurnBoundary(n *session.Node) bool {
if n.Message.Role != llm.RoleUser {
return false
}
for _, b := range n.Message.Content {
if b.Kind == llm.KindText {
return true
}
}
return false
}
// generateSummary fragt das Modell separat nach einer Zusammenfassung
// (5.7). Der Ausschnitt wird als reiner Text-Transkript gerendert statt als
// strukturierter Request — sonst würden tool_result-Blöcke auf tool_use-IDs
// verweisen, die außerhalb des Ausschnitts liegen, was manche Provider
// ablehnen.
func (l *Loop) generateSummary(ctx context.Context, nodes []*session.Node) (string, error) {
transcript := renderTranscript(nodes)
req := llm.Request{
Model: l.Model,
System: []llm.Block{{Kind: llm.KindText, Text: compactionSystemPrompt}},
Messages: []llm.Message{{
Role: llm.RoleUser,
Content: []llm.Block{{Kind: llm.KindText, Text: transcript}},
}},
}
events, err := l.Provider.Stream(ctx, req)
if err != nil {
return "", err
}
var text strings.Builder
var streamErr error
events(func(ev llm.Event, err error) bool {
if err != nil {
streamErr = err
return false
}
if d, ok := ev.(llm.BlockDelta); ok {
text.WriteString(d.Text)
}
return true
})
if streamErr != nil {
return "", streamErr
}
if text.Len() == 0 {
return "", fmt.Errorf("empty summary returned")
}
return text.String(), nil
}
func renderTranscript(nodes []*session.Node) string {
var b strings.Builder
for _, n := range nodes {
role := string(n.Message.Role)
for _, blk := range n.Message.Content {
switch blk.Kind {
case llm.KindText, llm.KindThinking:
fmt.Fprintf(&b, "[%s]\n%s\n\n", role, blk.Text)
case llm.KindToolUse:
fmt.Fprintf(&b, "[%s] tool_call %s(%s)\n\n", role, blk.Name, string(blk.Input))
case llm.KindToolResult:
for _, r := range blk.Result {
fmt.Fprintf(&b, "[tool_result]\n%s\n\n", r.Text)
}
}
}
}
return b.String()
}