234 lines
6.7 KiB
Go
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]
|
|
}
|