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) }