187 lines
5.1 KiB
Go
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()
|
|
}
|