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

146 lines
4 KiB
Go

package agent
import (
"context"
"fmt"
"sync"
"nub/internal/llm"
"nub/internal/permission"
"nub/internal/tool"
)
type toolResult struct {
id string
result tool.Result
}
// runTools startet alle Tool-Calls eines Turns parallel, an einen von ctx
// abgeleiteten Kontext gebunden (Ablauf pro Turn, Schritt 3). Jeder Call
// bekommt garantiert ein Ergebnis — auch bei Timeout oder Abbruch — weil das
// Protokoll für jeden tool_use zwingend einen tool_result verlangt.
//
// Permission-Prüfung (E-11) läuft davor, sequentiell für den ganzen Batch:
// so muss die UI nie mehrere gleichzeitige Rückfragen anzeigen. Erst danach
// starten die tatsächlich erlaubten Calls parallel wie bisher.
func (l *Loop) runTools(ctx context.Context, calls []llm.Block, out chan<- tool.UIEvent) []toolResult {
results := make([]toolResult, len(calls))
runnable := make([]int, 0, len(calls))
for i, call := range calls {
if res, blocked := l.checkPermission(ctx, call); blocked {
results[i] = res
out <- tool.ToolCallOutput{ID: call.ID, Name: call.Name, Result: res.result}
continue
}
runnable = append(runnable, i)
}
var wg sync.WaitGroup
for _, i := range runnable {
call := calls[i]
wg.Add(1)
go func(i int, call llm.Block) {
defer wg.Done()
toolCtx, cancel := context.WithTimeout(ctx, l.maxToolTime())
defer cancel()
env := l.Env
env.Emit = func(ev tool.UIEvent) {
if st, ok := ev.(tool.ToolStream); ok {
st.ID = call.ID
out <- st
return
}
out <- ev
}
env.Ask = l.AskUser
res, err := l.Tools.Run(toolCtx, call.Name, call.Input, env)
if err != nil {
res = tool.Result{ForModel: err.Error(), IsError: true}
}
if toolCtx.Err() == context.DeadlineExceeded && !res.IsError {
res = tool.Result{ForModel: fmt.Sprintf("tool %s timed out after %s", call.Name, l.maxToolTime()), IsError: true}
}
results[i] = toolResult{id: call.ID, result: res}
out <- tool.ToolCallOutput{ID: call.ID, Name: call.Name, Result: res}
}(i, call)
}
wg.Wait()
return results
}
// checkPermission entscheidet, ob ein Tool-Call überhaupt starten darf.
// blocked=true bedeutet: res ist bereits das finale (Fehler-)Ergebnis, der
// Call wird nicht ausgeführt.
func (l *Loop) checkPermission(ctx context.Context, call llm.Block) (res toolResult, blocked bool) {
switch l.Permissions.Check(call.Name, call.Input) {
case permission.ModeDeny:
return toolResult{id: call.ID, result: tool.Result{
ForModel: fmt.Sprintf("permission denied: %s is not allowed by policy", call.Name),
IsError: true,
}}, true
case permission.ModeAsk:
if l.RequestPermission == nil {
return toolResult{id: call.ID, result: tool.Result{
ForModel: fmt.Sprintf("tool %s requires confirmation ('ask'), which this mode does not support", call.Name),
IsError: true,
}}, true
}
if !l.RequestPermission(ctx, call.Name, call.Input) {
return toolResult{id: call.ID, result: tool.Result{
ForModel: fmt.Sprintf("permission denied by user for %s", call.Name),
IsError: true,
}}, true
}
}
return toolResult{}, false
}
func resultsMessage(results []toolResult) llm.Message {
msg := llm.Message{Role: llm.RoleUser}
for _, r := range results {
msg.Content = append(msg.Content, llm.Block{
Kind: llm.KindToolResult,
ToolUseID: r.id,
Result: []llm.Block{{Kind: llm.KindText, Text: r.result.ForModel}},
IsError: r.result.IsError,
})
}
return msg
}
func cancelledResults(calls []llm.Block, reason string) []toolResult {
out := make([]toolResult, len(calls))
for i, c := range calls {
out[i] = toolResult{id: c.ID, result: tool.Result{ForModel: reason, IsError: true}}
}
return out
}
func extractToolUse(msg llm.Message) []llm.Block {
var out []llm.Block
for _, b := range msg.Content {
if b.Kind == llm.KindToolUse {
out = append(out, b)
}
}
return out
}
func turnSignature(calls []llm.Block) string {
var b []byte
for _, c := range calls {
b = append(b, c.Name...)
b = append(b, 0)
b = append(b, c.Input...)
b = append(b, 0x1f)
}
return string(b)
}