// Package agent enthält den Turn-Loop: Provider streamen, Tools ausführen, // Historie fortschreiben, bis StopEnd. Kein Paket unterhalb von internal/agent // importiert internal/ui (E-04) — jede Ausgabe läuft über tool.UIEvent. package agent import ( "context" "encoding/json" "fmt" "time" "nub/internal/llm" "nub/internal/permission" "nub/internal/session" "nub/internal/tokens" "nub/internal/tool" ) // Input ist eine Nutzer- oder Steering-Nachricht in den Loop hinein. type Input struct { Text string } // Sessioner ist das schmale Interface, das der Loop von der Historie // braucht. *session.Session (reines In-Memory, für Tests) und // *session.Store (mit JSONL-Persistenz) erfüllen es beide. type Sessioner interface { HeadID() string PathToHead() []llm.Message PathNodes() []*session.Node Append(n *session.Node) error } type Loop struct { Provider llm.Provider Tools *tool.Registry System []llm.Block Env tool.Env Model string Session Sessioner Tokens *tokens.Calibrator // optional; nil = keine Kalibrierung // Permissions steuert pro Tool auto/ask/deny (E-11). nil = alles auto. Permissions *permission.Policy // RequestPermission wird für Tools mit Modus "ask" aufgerufen und muss // true (erlauben) oder false (ablehnen) liefern. nil = "ask" ist in // diesem Modus nicht unterstützt und wird als harter Fehler behandelt // (deckt E-11 "ask ist im Print-Modus ein harter Fehler" automatisch ab, // ohne dass der Loop wissen muss, in welchem UI-Modus er läuft). RequestPermission func(ctx context.Context, toolName string, input json.RawMessage) bool // AskUser beantwortet das question-Tool: das Modell entscheidet selbst, // dass es etwas klären will (anders als RequestPermission, das // system-/config-entschieden ist). nil (z.B. Print-Modus) lässt das // question-Tool von sich aus einen klaren Fehler statt einer Blockade // liefern — kein Sonderfall hier im Loop nötig. AskUser func(ctx context.Context, question string, options []string) (string, error) MaxTurns int // Default 50 MaxToolTime time.Duration // Default 120s CompactAt float64 // Anteil von Caps.MaxContext, Default 0.75 KeepTurns int // Default 4 } func (l *Loop) maxTurns() int { if l.MaxTurns > 0 { return l.MaxTurns } return 50 } func (l *Loop) maxToolTime() time.Duration { if l.MaxToolTime > 0 { return l.MaxToolTime } return 120 * time.Second } func (l *Loop) compactAt() float64 { if l.CompactAt > 0 { return l.CompactAt } return 0.75 } func (l *Loop) keepTurns() int { if l.KeepTurns > 0 { return l.KeepTurns } return 4 } // Run liest Input, bis der Channel schließt oder der Kontext endet, und // gibt jede Ausgabe über den zurückgegebenen UIEvent-Channel aus (E-04). func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan tool.UIEvent { out := make(chan tool.UIEvent) go func() { defer close(out) for { select { case <-ctx.Done(): return case msg, ok := <-in: if !ok { return } if err := l.appendMessage(userMessage(msg.Text), llm.Usage{}); err != nil { out <- tool.ErrorEvent{Err: err} continue } l.runTurn(ctx, out, in) } } }() return out } func (l *Loop) appendMessage(msg llm.Message, usage llm.Usage) error { node := session.NewNode(l.Session.HeadID(), msg, session.NodeMeta{Model: l.Model, Usage: usage}) return l.Session.Append(node) } // runTurn treibt die Turn-Schleife (Ablauf pro Turn, 5.2) bis StopEnd, ein // Sicherheitslimit greift, oder der Kontext abbricht. Ein abgebrochener Turn // hinterlässt einen gültigen Baumzustand: entweder bekommen alle tool_use- // Blöcke ein Ergebnis, oder der unvollständige Assistant-Node wird nicht // committet (siehe consume/runTools). Die Historie selbst lebt im Session- // Baum (PathToHead), nicht mehr in einem lokalen Slice. func (l *Loop) runTurn(ctx context.Context, out chan<- tool.UIEvent, in <-chan Input) { var lastSignatures []string for turnN := 0; turnN < l.maxTurns(); turnN++ { if err := l.maybeCompact(ctx, out, false); err != nil { out <- tool.ErrorEvent{Err: fmt.Errorf("compaction: %w", err)} // Weiterlaufen mit unkomprimiertem Kontext ist besser als der Turn // abzubrechen — Compaction ist eine Optimierung, kein Muss. } messages := l.Session.PathToHead() req := llm.Request{ Model: l.Model, System: l.System, Messages: messages, Tools: l.toolDefs(), } promptEstimate := tokens.EstimateMessages(l.System, messages) events, err := l.Provider.Stream(ctx, req) if err != nil { out <- tool.ErrorEvent{Err: err} return } assistantMsg, stop, usage, streamErr := l.consume(events, out) if streamErr != nil { out <- tool.ErrorEvent{Err: streamErr} return // kein unvollständiger Assistant-Node committet } if len(assistantMsg.Content) == 0 { return } if l.Tokens != nil { l.Tokens.Observe(promptEstimate, usage.InputTokens) } if err := l.appendMessage(assistantMsg, usage); err != nil { out <- tool.ErrorEvent{Err: err} return } out <- tool.TurnDone{Stop: stop, Usage: usage} if stop != llm.StopToolUse { return } toolUses := extractToolUse(assistantMsg) if len(toolUses) == 0 { return } sig := turnSignature(toolUses) lastSignatures = append(lastSignatures, sig) if repeatedThrice(lastSignatures) { cancelled := resultsMessage(cancelledResults(toolUses, "repeated identical tool call 3x in a row, aborting")) if err := l.appendMessage(cancelled, llm.Usage{}); err != nil { out <- tool.ErrorEvent{Err: err} return } out <- tool.ErrorEvent{Err: fmt.Errorf("repeated identical tool call detected, turn aborted")} return } results := l.runTools(ctx, toolUses, out) if err := l.appendMessage(resultsMessage(results), llm.Usage{}); err != nil { out <- tool.ErrorEvent{Err: err} return } // Steering: nach Abschluss des aktuellen Tool-Batches nicht-blockierend // prüfen und ggf. als zusätzliche User-Message einschleusen. select { case steer, ok := <-in: if ok { if err := l.appendMessage(userMessage(steer.Text), llm.Usage{}); err != nil { out <- tool.ErrorEvent{Err: err} return } } default: } } out <- tool.ErrorEvent{Err: fmt.Errorf("max_turns (%d) exceeded", l.maxTurns())} } func (l *Loop) toolDefs() []llm.ToolDef { defs := l.Tools.Defs() out := make([]llm.ToolDef, len(defs)) for i, d := range defs { out[i] = llm.ToolDef{Name: d.Name, Description: d.Description, Schema: d.Schema} } return out } func userMessage(text string) llm.Message { return llm.Message{Role: llm.RoleUser, Content: []llm.Block{{Kind: llm.KindText, Text: text}}} } func repeatedThrice(sigs []string) bool { n := len(sigs) if n < 3 { return false } return sigs[n-1] == sigs[n-2] && sigs[n-2] == sigs[n-3] }