package loop import ( "context" "encoding/json" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // runToolCalls executes each requested tool call, appending a RoleTool result // message (success or failure) so the model can continue. It returns the updated // message slice and the number of tool calls invoked. Invocation is delegated to // the dispatched tool, which applies the per-tool deadline; a missing dispatcher // is fed back as an error so the loop never panics or crashes on an unhandled // call. func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls []agent.ToolCall) ([]model.Message, int, error) { d := l.dispatcher() out := messages executed := 0 for _, tc := range calls { var ( result any invokeErr error ) if d == nil { invokeErr = fmt.Errorf("no tool dispatcher configured for %q", tc.Name) } else { _, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool) result, invokeErr = d.Invoke(ctx, tc.Name, tc.Args) if invokeErr != nil { l.spanProp(toolSpan, "error", invokeErr.Error()) l.finishSpan(toolSpan, trace.SpanStatusError) } else { l.spanProp(toolSpan, "result", result) l.finishSpan(toolSpan, trace.SpanStatusOK) } } executed++ out = append(out, toolResultMessage(tc, result, invokeErr)) } return out, executed, nil } // dispatcher returns the loop's tool dispatcher, falling back to the agent's own // when the loop was not given one. func (l *Loop) dispatcher() *tool.Dispatcher { if l.tools != nil { return l.tools } if l.agent != nil { return l.agent.Tools() } return nil } // toolResultMessage renders a tool call's outcome as a RoleTool message ready to // be fed back to the model. Errors are included in the text so the LLM can // react to a failed or timed-out call. func toolResultMessage(tc agent.ToolCall, result any, invokeErr error) model.Message { text := fmt.Sprintf("tool %s result: %s", tc.Name, formatToolResult(result)) if invokeErr != nil { text = fmt.Sprintf("tool %s failed: %v", tc.Name, invokeErr) } return model.Message{Role: model.RoleTool, ToolCallName: tc.Name, Content: []model.Content{{Type: model.ContentTypeText, Text: text}}} } // formatToolResult renders a tool return value as text for the model. Strings are // passed through (an empty string becomes "empty"); everything else is JSON // encoded, falling back to a plain %v render on an encoding error. func formatToolResult(result any) string { switch v := result.(type) { case nil: return "null" case string: if v == "" { return "empty" } return v default: b, err := json.Marshal(v) if err != nil { return fmt.Sprintf("%v", v) } return string(b) } }