Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 142 additions & 46 deletions src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task<bool> InvokeAgent(
model = agentSettings.LlmConfig.Model;
}

var chatCompletion = CompletionProvider.GetChatCompletion(_services,
var chatCompletion = CompletionProvider.GetChatCompletion(_services,
provider: provider,
model: model);

Expand All @@ -48,21 +48,10 @@ public async Task<bool> InvokeAgent(
response = await chatCompletion.GetChatCompletions(agent, conversationDialogs);
}

if (response.Role == AgentRole.Function && !string.IsNullOrEmpty(response.FunctionName))
var toolCalls = GetToolCalls(response);
if (!toolCalls.IsNullOrEmpty())
{
message = RoleDialogModel.From(message, role: AgentRole.Function);
response.FunctionName = response.FunctionName.NormalizeFunctionName();
message.ToolCallId = response.ToolCallId;
message.FunctionName = response.FunctionName;
message.FunctionArgs = response.FunctionArgs;
message.Thought = response.Thought != null ? new(response.Thought) : null;
message.MetaData = response.MetaData != null ? new(response.MetaData) : null;
message.Indication = response.Indication;
message.CurrentAgentId = agent.Id;
message.IsStreaming = response.IsStreaming;
message.MessageLabel = response.MessageLabel;

await InvokeFunction(message, dialogs, options);
await InvokeToolCalls(toolCalls, response, message, agent, dialogs, options);
}
else
{
Expand All @@ -86,6 +75,118 @@ public async Task<bool> InvokeAgent(
return true;
}

/// <summary>
/// Every tool call one reply asked for, in the order the model produced them.
/// </summary>
/// <remarks>
/// <see cref="RoleDialogModel.ToolCalls"/> is where a provider reports the whole set. The
/// single FunctionName/FunctionArgs/ToolCallId fields beside it are a view of its first entry,
/// kept for callers that can only run one call, so a provider that fills only those is read
/// through the second branch and needs no change to work here.
/// </remarks>
private static List<LlmToolCall> GetToolCalls(RoleDialogModel response)
{
if (!response.ToolCalls.IsNullOrEmpty())
{
// A call with no name is not something that can be dispatched. The single-call path
// has always guarded on the name being present, and the batch drops it the same way
// rather than handing an empty name to the executor factory.
return response.ToolCalls!
.Where(x => !string.IsNullOrEmpty(x.FunctionName))
.ToList();
}

if (response.Role == AgentRole.Function && !string.IsNullOrEmpty(response.FunctionName))
{
return [new LlmToolCall(response.ToolCallId, response.FunctionName, response.FunctionArgs)];
}

return [];
}

/// <summary>
/// Runs every call the reply asked for, in order, and then hands the whole set of results back
/// to the model in one round.
/// </summary>
/// <remarks>
/// The recursion into <see cref="InvokeAgent"/> belongs here rather than inside
/// <see cref="InvokeFunction"/>: a per-call recursion spends the turn on the first call, and
/// the calls after it never run at all. Deciding once for the whole batch is what lets a model
/// ask for three independent lookups and get three answers in a single round trip.
/// </remarks>
private async Task InvokeToolCalls(
List<LlmToolCall> toolCalls,
RoleDialogModel response,
RoleDialogModel source,
Agent agent,
List<RoleDialogModel> dialogs,
InvokeAgentOptions? options)
{
if (toolCalls.Count > 1)
{
// Worth a line of its own. Until now a reply like this ran one call and dropped the
// rest, so an agent whose prompt was tuned against that behaviour changes the moment
// this ships. This is where to look when one does.
_logger.LogInformation($"Agent {agent.Name} asked for {toolCalls.Count} tool calls in one reply: " +
$"{string.Join(", ", toolCalls.Select(x => x.FunctionName))}");
}

var completed = true;

for (var i = 0; i < toolCalls.Count; i++)
{
var toolCall = toolCalls[i];

// Each call in the batch answers the same message. They are siblings of one reply, not
// a chain, so every one is built from the dialog that reply responded to rather than
// from the result the previous call just appended.
var message = RoleDialogModel.From(source, role: AgentRole.Function);
message.ToolCallId = toolCall.Id;
message.FunctionName = toolCall.FunctionName.NormalizeFunctionName();
message.FunctionArgs = toolCall.FunctionArgs;
message.Thought = response.Thought != null ? new(response.Thought) : null;
message.MetaData = response.MetaData != null ? new(response.MetaData) : null;
message.Indication = response.Indication;
message.CurrentAgentId = agent.Id;
message.IsStreaming = response.IsStreaming;
message.MessageLabel = response.MessageLabel;

completed = await InvokeFunction(message, dialogs, options);
if (!completed)
{
var skipped = toolCalls.Count - i - 1;
if (skipped > 0)
{
// The model asked for these without knowing an earlier call would end the
// turn. Running them into a finished turn produces results nothing reads, so
// they are dropped -- but not silently, which is how everything beyond the
// first call used to disappear.
_logger.LogInformation($"{message.FunctionName} ended the turn, skipping the remaining {skipped} tool call(s) of this reply: " +
$"{string.Join(", ", toolCalls.Skip(i + 1).Select(x => x.FunctionName))}");
}
break;
}
}

if (completed)
{
// One round trip for the whole batch: the model sees every result at once. The agent
// is read after the last call because a routing tool may have changed it.
var routing = _services.GetRequiredService<IRoutingService>();
var curAgentId = routing.Context.GetCurrentAgentId();
await InvokeAgent(curAgentId, dialogs, options);
}
}

/// <summary>
/// Executes one tool call and appends its result to the dialogs.
/// </summary>
/// <returns>
/// Whether the turn should continue. False when this call ended it -- either the function set
/// <see cref="RoleDialogModel.StopCompletion"/> and answered the user itself, or a response
/// template answered in its place. Both have always ended the turn; what is new is that the
/// caller is the one told about it, instead of this method deciding by recursing or not.
/// </returns>
private async Task<bool> InvokeFunction(
RoleDialogModel message,
List<RoleDialogModel> dialogs,
Expand All @@ -101,37 +202,7 @@ private async Task<bool> InvokeFunction(
var funcOptions = options != null ? new InvokeFunctionOptions() { From = options.From } : null;
await routing.InvokeFunction(message.FunctionName, message, options: funcOptions);

// Pass execution result to LLM to get response
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 and to storage
var msg = RoleDialogModel.From(message,
role: AgentRole.Function,
content: message.Content);

dialogs.Add(msg);
Context.AddDialogs([msg]);
await Persist(msg);

// Send to Next LLM
var curAgentId = routing.Context.GetCurrentAgentId();
await InvokeAgent(curAgentId, dialogs, options);
}
}
else
if (message.StopCompletion)
{
// The function wrote the reply itself. The call is still worth a record, but kept out
// of context, since the assistant message that follows carries the same text.
Expand All @@ -144,8 +215,33 @@ private async Task<bool> InvokeFunction(
content: message.Content);
dialogs.Add(msg);
Context.AddDialogs([msg]);

return false;
}

// 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]);

return false;
}

// Save to memory dialogs and to storage
var functionMsg = RoleDialogModel.From(message,
role: AgentRole.Function,
content: message.Content);

dialogs.Add(functionMsg);
Context.AddDialogs([functionMsg]);
await Persist(functionMsg);

return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@
<ProjectReference Include="..\..\Infrastructure\BotSharp.Core\BotSharp.Core.csproj" />
</ItemGroup>

<ItemGroup>
<!-- ReconstructToolCalls is tested directly. It rebuilds a streamed reply's tool calls from
fragments that arrive interleaved, and driving that through a live stream is not a test. -->
<InternalsVisibleTo Include="BotSharp.Core.UnitTests" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ private static List<LlmToolCall> ToLlmToolCalls(IEnumerable<ChatToolCall>? toolC
/// Concatenating every fragment into a single string, as this did before, produced one
/// malformed argument blob as soon as the model asked for more than one tool at a time.
/// </remarks>
private static List<LlmToolCall> ReconstructToolCalls(List<StreamingChatToolCallUpdate> updates)
internal static List<LlmToolCall> ReconstructToolCalls(List<StreamingChatToolCallUpdate> updates)
{
var calls = new List<LlmToolCall>();
var args = new List<StringBuilder>();
Expand Down
2 changes: 2 additions & 0 deletions tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
<ProjectReference Include="..\..\src\Plugins\BotSharp.Plugin.AgentTesting\BotSharp.Plugin.AgentTesting.csproj" />
<!-- For ChatEventDispatcherTests: chat events must never make their producer wait on a client. -->
<ProjectReference Include="..\..\src\Plugins\BotSharp.Plugin.ChatHub\BotSharp.Plugin.ChatHub.csproj" />
<!-- For StreamingToolCallReconstructionTests: the streamed tool-call reconstruction lives here. -->
<ProjectReference Include="..\..\src\Plugins\BotSharp.Plugin.OpenAI\BotSharp.Plugin.OpenAI.csproj" />
</ItemGroup>

</Project>
Loading
Loading