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

234 lines
6.7 KiB
Go

// 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]
}