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() }