parallel toolcalls

This commit is contained in:
Vitali sharp8n 2026-09-29 08:50:29 +03:00
parent 7b05535089
commit 58c85b8dfd
6 changed files with 284 additions and 124 deletions

View file

@ -28,6 +28,16 @@ public class AgentLlmConfig
[JsonPropertyName("max_recursion_depth")]
public int MaxRecursionDepth { get; set; } = 3;
/// <summary>
/// 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 <c>parallel_tool_calls</c>, so a
/// model that supports it is told whether to emit more than one call.
/// </summary>
[JsonPropertyName("parallel_tool_calls")]
public bool ParallelToolCalls { get; set; } = true;
/// <summary>
/// Max output token
/// </summary>

View file

@ -71,6 +71,15 @@ public class RoleDialogModel : ITrackableMessage
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FunctionArgs { get; set; }
/// <summary>
/// Every tool call the model returned in this response (parallel tool calling). <see cref="FunctionName"/>,
/// <see cref="ToolCallId"/> and <see cref="FunctionArgs"/> keep pointing at the FIRST call for backward
/// compatibility with single-call consumers; when the list is empty those fields are authoritative.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("tool_calls")]
public List<ToolCallModel>? ToolCalls { get; set; }
/// <summary>
/// DeepSeek thinking mode: the chain-of-thought text returned by the model in
/// <c>reasoning_content</c>. Must be echoed back to the API in subsequent requests

View file

@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace BotSharp.Abstraction.Conversations.Models;
/// <summary>
/// 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 <c>ToolCalls</c> so the routing loop can execute
/// them, in parallel or one by one, instead of silently dropping all but the first.
/// </summary>
public class ToolCallModel
{
/// <summary>Provider-assigned call id (echoed back on the matching tool result).</summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; set; }
/// <summary>Function name, with any agent-name prefix already stripped.</summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>Raw JSON arguments as produced by the model.</summary>
[JsonPropertyName("arguments")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Arguments { get; set; }
}

View file

@ -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<ToolCallModel>
{
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<RoleDialogModel>();
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<IConversationStateService>();
foreach (var callMessage in pending)
{
states.SaveStateByArgs(callMessage.FunctionArgs?.JsonContent<JsonDocument>());
}
// 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<bool> InvokeFunction(
RoleDialogModel message,
List<RoleDialogModel> dialogs,
InvokeAgentOptions? options = null)
/// <summary>
/// Awaits one tool call and stores its result at <paramref name="index"/>. Used to launch the
/// calls of a round concurrently while keeping the results in the model's order.
/// </summary>
private async Task RunOneAsync(RoleDialogModel message, InvokeAgentOptions? options,
ExecutedFunction?[] results, int index)
{
// execute function
// Save states
var states = _services.GetRequiredService<IConversationStateService>();
states.SaveStateByArgs(message.FunctionArgs?.JsonContent<JsonDocument>());
results[index] = await ExecuteFunctionAsync(message, options);
}
/// <summary>
/// Runs ONE tool call and returns the dialog to append to the history, or <c>null</c> 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.
/// </summary>
private async Task<ExecutedFunction?> ExecuteFunctionAsync(RoleDialogModel message, InvokeAgentOptions? options)
{
var routing = _services.GetRequiredService<IRoutingService>();
// 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<IResponseTemplateService>();
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<IResponseTemplateService>();
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);
}
/// <summary>Outcome of one tool call: the dialog to append plus how the round should continue.</summary>
private sealed record ExecutedFunction(RoleDialogModel Dialog, bool Reinvoke, bool Stop);
}

View file

@ -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;
}
/// <summary>
/// Maps an SDK tool call to the BotSharp model, dropping the agent-name prefix some models add
/// to the function name (e.g. <c>agent.func</c> → <c>func</c>).
/// </summary>
private static ToolCallModel ToToolCall(ChatToolCall call) => new()
{
Id = call.Id,
Name = string.IsNullOrEmpty(call.FunctionName) ? null : call.FunctionName.Split('.').Last(),
Arguments = call.FunctionArguments?.ToString()
};
/// <summary>
/// Extracts <c>reasoning_content</c> (DeepSeek thinking mode) from a non-streaming
/// <see cref="ChatCompletion"/>. 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
};
}

View file

@ -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
};
}
/// <summary>
/// Maps an SDK tool call to the BotSharp model, dropping the agent-name prefix some models add
/// to the function name (e.g. <c>agent.func</c> → <c>func</c>).
/// </summary>
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))