diff --git a/src/Infrastructure/BotSharp.Abstraction/Agents/Models/AgentLlmConfig.cs b/src/Infrastructure/BotSharp.Abstraction/Agents/Models/AgentLlmConfig.cs index be75a01e..a5492eda 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Agents/Models/AgentLlmConfig.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Agents/Models/AgentLlmConfig.cs @@ -28,6 +28,16 @@ public class AgentLlmConfig [JsonPropertyName("max_recursion_depth")] public int MaxRecursionDepth { get; set; } = 3; + /// + /// Whether a single model response may run several tool calls at once (parallel tool calling). + /// When true (the default) every tool call the model returns in one round is executed, and the + /// calls of that round run concurrently; when false only the first call is executed (legacy + /// behaviour). The flag is also forwarded to the provider as parallel_tool_calls, so a + /// model that supports it is told whether to emit more than one call. + /// + [JsonPropertyName("parallel_tool_calls")] + public bool ParallelToolCalls { get; set; } = true; + /// /// Max output token /// diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs index dc3cc24b..819724bc 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs @@ -71,6 +71,15 @@ public class RoleDialogModel : ITrackableMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? FunctionArgs { get; set; } + /// + /// Every tool call the model returned in this response (parallel tool calling). , + /// and keep pointing at the FIRST call for backward + /// compatibility with single-call consumers; when the list is empty those fields are authoritative. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("tool_calls")] + public List? ToolCalls { get; set; } + /// /// DeepSeek thinking mode: the chain-of-thought text returned by the model in /// reasoning_content. Must be echoed back to the API in subsequent requests diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/ToolCallModel.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/ToolCallModel.cs new file mode 100644 index 00000000..a66bdc08 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/ToolCallModel.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace BotSharp.Abstraction.Conversations.Models; + +/// +/// One tool (function) call requested by the model in a single completion. A model may return +/// several of these at once (parallel tool calling — e.g. DeepSeek/OpenAI); the completion provider +/// attaches the full list to the response dialog's ToolCalls so the routing loop can execute +/// them, in parallel or one by one, instead of silently dropping all but the first. +/// +public class ToolCallModel +{ + /// Provider-assigned call id (echoed back on the matching tool result). + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + /// Function name, with any agent-name prefix already stripped. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// Raw JSON arguments as produced by the model. + [JsonPropertyName("arguments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Arguments { get; set; } +} diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs index adb169a4..ccd176a4 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs @@ -48,21 +48,86 @@ public partial class RoutingService if (response.Role == AgentRole.Function) { - message = RoleDialogModel.From(message, role: AgentRole.Function); - if (response.FunctionName != null && response.FunctionName.Contains("/")) - { - response.FunctionName = response.FunctionName.Split("/").Last(); - } - message.ToolCallId = response.ToolCallId; - message.FunctionName = response.FunctionName; - message.FunctionArgs = response.FunctionArgs; - message.ReasoningContent = response.ReasoningContent; - message.Indication = response.Indication; - message.CurrentAgentId = agent.Id; - message.IsStreaming = response.IsStreaming; - message.MessageLabel = response.MessageLabel; + // A single completion may ask for several tools (parallel tool calling). Fall back to the + // legacy single-call fields when the provider did not fill the list. + var calls = response.ToolCalls is { Count: > 0 } + ? response.ToolCalls + : new List + { + new() { Id = response.ToolCallId, Name = response.FunctionName, Arguments = response.FunctionArgs } + }; - await InvokeFunction(message, dialogs, options); + // Providers already drop nameless calls; if nothing usable is left (malformed response), + // keep one entry so the executor reports the unknown function instead of ending the turn + // silently with the user's own message as the last dialog. + var usable = calls.Where(x => !string.IsNullOrEmpty(x.Name)).ToList(); + if (usable.Count == 0) + { + usable = calls.Take(1).ToList(); + } + + var pending = new List(); + foreach (var call in usable) + { + var name = call.Name ?? string.Empty; + if (name.Contains("/")) + { + name = name.Split("/").Last(); + } + + var callMessage = RoleDialogModel.From(message, role: AgentRole.Function); + callMessage.ToolCallId = call.Id; + callMessage.FunctionName = name; + callMessage.FunctionArgs = call.Arguments; + callMessage.ReasoningContent = response.ReasoningContent; + callMessage.Indication = response.Indication; + callMessage.CurrentAgentId = agent.Id; + callMessage.IsStreaming = response.IsStreaming; + callMessage.MessageLabel = response.MessageLabel; + pending.Add(callMessage); + } + + // The conversation state is request-scoped and not thread-safe, so apply every call's + // arguments up front, in the model's order, before the callbacks run. + var states = _services.GetRequiredService(); + foreach (var callMessage in pending) + { + states.SaveStateByArgs(callMessage.FunctionArgs?.JsonContent()); + } + + // Agent setting (default true): run the calls of this round concurrently. A single call, + // or parallel disabled, keeps the historical in-order path. + var parallel = agent.LlmConfig?.ParallelToolCalls ?? true; + var results = new ExecutedFunction?[pending.Count]; + if (parallel && pending.Count > 1) + { + await Task.WhenAll(pending.Select((callMessage, i) => RunOneAsync(callMessage, options, results, i))); + } + else + { + for (var i = 0; i < pending.Count; i++) + { + results[i] = await ExecuteFunctionAsync(pending[i], options); + } + } + + // Append results in the model's order so history stays deterministic regardless of the + // order in which the parallel calls actually finished. + var shouldReinvoke = false; + var stopped = false; + foreach (var result in results.Where(x => x != null)) + { + dialogs.Add(result!.Dialog); + Context.AddDialogs([result.Dialog]); + shouldReinvoke |= result.Reinvoke; + stopped |= result.Stop; + } + + if (shouldReinvoke && !stopped) + { + var curAgentId = Context.GetCurrentAgentId(); + await InvokeAgent(curAgentId, dialogs, options); + } } else { @@ -89,63 +154,52 @@ public partial class RoutingService return true; } - private async Task InvokeFunction( - RoleDialogModel message, - List dialogs, - InvokeAgentOptions? options = null) + /// + /// Awaits one tool call and stores its result at . Used to launch the + /// calls of a round concurrently while keeping the results in the model's order. + /// + private async Task RunOneAsync(RoleDialogModel message, InvokeAgentOptions? options, + ExecutedFunction?[] results, int index) { - // execute function - // Save states - var states = _services.GetRequiredService(); - states.SaveStateByArgs(message.FunctionArgs?.JsonContent()); + results[index] = await ExecuteFunctionAsync(message, options); + } + /// + /// Runs ONE tool call and returns the dialog to append to the history, or null when the + /// call produced nothing to append (i.e. it stopped completion). Does NOT re-invoke the model: + /// the caller batches every call of the round and re-invokes once. + /// + private async Task ExecuteFunctionAsync(RoleDialogModel message, InvokeAgentOptions? options) + { var routing = _services.GetRequiredService(); - // Call functions var funcOptions = options != null ? new InvokeFunctionOptions() { From = options.From } : null; + // Call functions await routing.InvokeFunction(message.FunctionName, message, options: funcOptions); - // Pass execution result to LLM to get response - if (!message.StopCompletion) + if (message.StopCompletion) { - // Find response template - var templateService = _services.GetRequiredService(); - var responseTemplate = await templateService.RenderFunctionResponse(message.CurrentAgentId, message); - if (!string.IsNullOrEmpty(responseTemplate)) - { - var msg = RoleDialogModel.From(message, - role: AgentRole.Assistant, - content: responseTemplate); - dialogs.Add(msg); - Context.AddDialogs([msg]); - } - else - { - // Save to memory dialogs - var msg = RoleDialogModel.From(message, - role: AgentRole.Function, - content: message.Content); - // Preserve images produced by the tool (e.g. `browser_act screenshot`) so the next - // LLM round in this turn can attach them to the tool message for a multimodal model. - // They are not persisted (ConversationStorage ignores dialog.Files). - msg.Files = message.Files; - - dialogs.Add(msg); - Context.AddDialogs([msg]); - - // Send to Next LLM - var curAgentId = routing.Context.GetCurrentAgentId(); - await InvokeAgent(curAgentId, dialogs, options); - } - } - else - { - var msg = RoleDialogModel.From(message, - role: AgentRole.Assistant, - content: message.Content); - dialogs.Add(msg); - Context.AddDialogs([msg]); + var stopMsg = RoleDialogModel.From(message, role: AgentRole.Assistant, content: message.Content); + return new ExecutedFunction(stopMsg, Reinvoke: false, Stop: true); } - return true; + // Find response template + var templateService = _services.GetRequiredService(); + var responseTemplate = await templateService.RenderFunctionResponse(message.CurrentAgentId, message); + if (!string.IsNullOrEmpty(responseTemplate)) + { + var templateMsg = RoleDialogModel.From(message, role: AgentRole.Assistant, content: responseTemplate); + return new ExecutedFunction(templateMsg, Reinvoke: false, Stop: false); + } + + // Save to memory dialogs + var msg = RoleDialogModel.From(message, role: AgentRole.Function, content: message.Content); + // Preserve images produced by the tool (e.g. `browser_act screenshot`) so the next LLM round in + // this turn can attach them to the tool message for a multimodal model. They are not persisted + // (ConversationStorage ignores dialog.Files). + msg.Files = message.Files; + return new ExecutedFunction(msg, Reinvoke: true, Stop: false); } + + /// Outcome of one tool call: the dialog to append plus how the round should continue. + private sealed record ExecutedFunction(RoleDialogModel Dialog, bool Reinvoke, bool Stop); } diff --git a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs index bb684dbc..11a91207 100644 --- a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs @@ -56,23 +56,26 @@ public class ChatCompletionProvider : IChatCompletion RoleDialogModel responseMessage; if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) { - var toolCall = value.ToolCalls.FirstOrDefault(); + // The model may ask for SEVERAL tools in one response. Keep them all on the dialog (the + // routing loop decides whether to run them in parallel); the legacy single fields mirror + // the first call so single-call consumers keep working. + var toolCalls = (value.ToolCalls ?? []) + .Select(ToToolCall) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToList(); + var toolCall = toolCalls.FirstOrDefault(); + responseMessage = new RoleDialogModel(AgentRole.Function, text) { CurrentAgentId = agent.Id, MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, ToolCallId = toolCall?.Id, - FunctionName = toolCall?.FunctionName, - FunctionArgs = toolCall?.FunctionArguments?.ToString(), + FunctionName = toolCall?.Name, + FunctionArgs = toolCall?.Arguments, + ToolCalls = toolCalls.Count > 0 ? toolCalls : null, RenderedInstruction = string.Join("\r\n", renderedInstructions), ReasoningContent = ExtractReasoningContent(value) }; - - // Somethings LLM will generate a function name with agent name. - if (!string.IsNullOrEmpty(responseMessage.FunctionName)) - { - responseMessage.FunctionName = responseMessage.FunctionName.Split('.').Last(); - } } else { @@ -158,26 +161,25 @@ public class ChatCompletionProvider : IChatCompletion if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) { - var toolCall = value.ToolCalls?.FirstOrDefault(); - _logger.LogInformation($"[{agent.Name}]: {toolCall?.FunctionName}({toolCall?.FunctionArguments})"); + var toolCalls = (value.ToolCalls ?? []) + .Select(ToToolCall) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToList(); + var toolCall = toolCalls.FirstOrDefault(); + _logger.LogInformation($"[{agent.Name}]: {toolCall?.Name}({toolCall?.Arguments})"); var funcContextIn = new RoleDialogModel(AgentRole.Function, text) { CurrentAgentId = agent.Id, MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, ToolCallId = toolCall?.Id, - FunctionName = toolCall?.FunctionName, - FunctionArgs = toolCall?.FunctionArguments?.ToString(), + FunctionName = toolCall?.Name, + FunctionArgs = toolCall?.Arguments, + ToolCalls = toolCalls.Count > 0 ? toolCalls : null, RenderedInstruction = string.Join("\r\n", renderedInstructions), ReasoningContent = ExtractReasoningContent(value) }; - // Somethings LLM will generate a function name with agent name. - if (!string.IsNullOrEmpty(funcContextIn.FunctionName)) - { - funcContextIn.FunctionName = funcContextIn.FunctionName.Split('.').Last(); - } - // Execute functions await onFunctionExecuting(funcContextIn); } @@ -311,26 +313,39 @@ public class ChatCompletionProvider : IChatCompletion .OrderBy(g => g.Key) .ToList(); - var namedGroup = groups.FirstOrDefault(g => g.Any(x => !string.IsNullOrEmpty(x.FunctionName))); - var namedUpdate = namedGroup?.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); - - var functionName = namedUpdate?.FunctionName; - var toolCallId = namedUpdate?.ToolCallId; - var functionArgument = namedGroup != null - ? string.Concat(namedGroup.Where(x => x.FunctionArgumentsUpdate != null).Select(x => x.FunctionArgumentsUpdate.ToString())) - : string.Empty; + // One dialog entry per call index — the model may stream several calls in one response. + // The legacy single fields mirror the first call. + var calls = groups + .Select(g => + { + var named = g.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); + var args = string.Concat(g.Where(x => x.FunctionArgumentsUpdate != null) + .Select(x => x.FunctionArgumentsUpdate.ToString())); + return new ToolCallModel + { + Id = named?.ToolCallId, + Name = string.IsNullOrEmpty(named?.FunctionName) + ? null + : named!.FunctionName!.Split('.').Last(), + Arguments = args + }; + }) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToList(); + var toolCall = calls.FirstOrDefault(); #if DEBUG - _logger.LogTrace($"Tool Call (id: {toolCallId}) => {functionName}({functionArgument})"); + _logger.LogTrace($"Tool Call (id: {toolCall?.Id}) => {toolCall?.Name}({toolCall?.Arguments})"); #endif responseMessage = new RoleDialogModel(AgentRole.Function, string.Empty) { CurrentAgentId = agent.Id, MessageId = messageId, - ToolCallId = toolCallId, - FunctionName = functionName, - FunctionArgs = functionArgument, + ToolCallId = toolCall?.Id, + FunctionName = toolCall?.Name, + FunctionArgs = toolCall?.Arguments, + ToolCalls = calls.Count > 0 ? calls : null, ReasoningContent = reasoningContentBuilder.Length > 0 ? reasoningContentBuilder.ToString() : null }; } @@ -380,6 +395,17 @@ public class ChatCompletionProvider : IChatCompletion _model = model; } + /// + /// Maps an SDK tool call to the BotSharp model, dropping the agent-name prefix some models add + /// to the function name (e.g. agent.func → func). + /// + private static ToolCallModel ToToolCall(ChatToolCall call) => new() + { + Id = call.Id, + Name = string.IsNullOrEmpty(call.FunctionName) ? null : call.FunctionName.Split('.').Last(), + Arguments = call.FunctionArguments?.ToString() + }; + /// /// Extracts reasoning_content (DeepSeek thinking mode) from a non-streaming /// . The OpenAI SDK keeps the unknown property in the @@ -732,7 +758,10 @@ public class ChatCompletionProvider : IChatCompletion TopP = topP, MaxOutputTokenCount = maxTokens, ReasoningEffortLevel = reasoningEffortLevel, - WebSearchOptions = webSearchOptions + WebSearchOptions = webSearchOptions, + // Agent setting (default true): let the model emit several tool calls in one response. + // When off, only the first call would be executed, so we ask the model not to bother. + AllowParallelToolCalls = agent?.LlmConfig?.ParallelToolCalls ?? true }; } diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs index cfe655c0..ab60d87d 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs @@ -52,22 +52,24 @@ public class ChatCompletionProvider : IChatCompletion RoleDialogModel responseMessage; if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) { - var toolCall = value.ToolCalls.FirstOrDefault(); + // Keep EVERY tool call of the response (parallel tool calling); the legacy single fields + // mirror the first call for single-call consumers. + var toolCalls = (value.ToolCalls ?? []) + .Select(ToToolCall) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToList(); + var toolCall = toolCalls.FirstOrDefault(); + responseMessage = new RoleDialogModel(AgentRole.Function, text) { CurrentAgentId = agent.Id, MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, ToolCallId = toolCall?.Id, - FunctionName = toolCall?.FunctionName, - FunctionArgs = toolCall?.FunctionArguments?.ToString(), + FunctionName = toolCall?.Name, + FunctionArgs = toolCall?.Arguments, + ToolCalls = toolCalls.Count > 0 ? toolCalls : null, RenderedInstruction = string.Join("\r\n", renderedInstructions) }; - - // Somethings LLM will generate a function name with agent name. - if (!string.IsNullOrEmpty(responseMessage.FunctionName)) - { - responseMessage.FunctionName = responseMessage.FunctionName.Split('.').Last(); - } } else { @@ -154,25 +156,24 @@ public class ChatCompletionProvider : IChatCompletion if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) { - var toolCall = value.ToolCalls?.FirstOrDefault(); - _logger.LogInformation($"[{agent.Name}]: {toolCall?.FunctionName}({toolCall?.FunctionArguments})"); + var toolCalls = (value.ToolCalls ?? []) + .Select(ToToolCall) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToList(); + var toolCall = toolCalls.FirstOrDefault(); + _logger.LogInformation($"[{agent.Name}]: {toolCall?.Name}({toolCall?.Arguments})"); var funcContextIn = new RoleDialogModel(AgentRole.Function, text) { CurrentAgentId = agent.Id, MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, ToolCallId = toolCall?.Id, - FunctionName = toolCall?.FunctionName, - FunctionArgs = toolCall?.FunctionArguments?.ToString(), + FunctionName = toolCall?.Name, + FunctionArgs = toolCall?.Arguments, + ToolCalls = toolCalls.Count > 0 ? toolCalls : null, RenderedInstruction = string.Join("\r\n", renderedInstructions) }; - // Somethings LLM will generate a function name with agent name. - if (!string.IsNullOrEmpty(funcContextIn.FunctionName)) - { - funcContextIn.FunctionName = funcContextIn.FunctionName.Split('.').Last(); - } - // Execute functions await onFunctionExecuting(funcContextIn); } @@ -269,23 +270,41 @@ public class ChatCompletionProvider : IChatCompletion if (choice.FinishReason == ChatFinishReason.ToolCalls || choice.FinishReason == ChatFinishReason.FunctionCall) { - var meta = toolCalls.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); - var functionName = meta?.FunctionName; - var toolCallId = meta?.ToolCallId; - var args = toolCalls.Where(x => x.FunctionArgumentsUpdate != null).Select(x => x.FunctionArgumentsUpdate.ToString()).ToList(); - var functionArguments = string.Join(string.Empty, args); + // Arguments stream as interleaved fragments per call index; accumulate per index so + // several tool calls in one response stay intact (a flat join corrupts their args). + var calls = toolCalls + .GroupBy(x => x.Index) + .OrderBy(g => g.Key) + .Select(g => + { + var named = g.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); + var args = string.Concat(g.Where(x => x.FunctionArgumentsUpdate != null) + .Select(x => x.FunctionArgumentsUpdate.ToString())); + return new ToolCallModel + { + Id = named?.ToolCallId, + Name = string.IsNullOrEmpty(named?.FunctionName) + ? null + : named!.FunctionName!.Split('.').Last(), + Arguments = args + }; + }) + .Where(x => !string.IsNullOrEmpty(x.Name)) + .ToList(); + var toolCall = calls.FirstOrDefault(); #if DEBUG - _logger.LogCritical($"Tool Call (id: {toolCallId}) => {functionName}({functionArguments})"); + _logger.LogCritical($"Tool Call (id: {toolCall?.Id}) => {toolCall?.Name}({toolCall?.Arguments})"); #endif responseMessage = new RoleDialogModel(AgentRole.Function, string.Empty) { CurrentAgentId = agent.Id, MessageId = messageId, - ToolCallId = toolCallId, - FunctionName = functionName, - FunctionArgs = functionArguments + ToolCallId = toolCall?.Id, + FunctionName = toolCall?.Name, + FunctionArgs = toolCall?.Arguments, + ToolCalls = calls.Count > 0 ? calls : null }; } else if (choice.FinishReason.HasValue) @@ -575,10 +594,23 @@ public class ChatCompletionProvider : IChatCompletion TopP = topP, MaxOutputTokenCount = maxTokens, ReasoningEffortLevel = reasoningEffortLevel, - WebSearchOptions = webSearchOptions + WebSearchOptions = webSearchOptions, + // Agent setting (default true): let the model emit several tool calls in one response. + AllowParallelToolCalls = agent?.LlmConfig?.ParallelToolCalls ?? true }; } + /// + /// Maps an SDK tool call to the BotSharp model, dropping the agent-name prefix some models add + /// to the function name (e.g. agent.func → func). + /// + private static ToolCallModel ToToolCall(ChatToolCall call) => new() + { + Id = call.Id, + Name = string.IsNullOrEmpty(call.FunctionName) ? null : call.FunctionName.Split('.').Last(), + Arguments = call.FunctionArguments?.ToString() + }; + private ChatReasoningEffortLevel? ParseReasoningEffortLevel(string? level) { if (string.IsNullOrWhiteSpace(level))