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