Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3540f73
Agent test harness: routing cases, authored history, scope narrowing,…
marsyusms Aug 20, 2026
46a8116
Clear run history: select-all and delete
marsyusms Aug 21, 2026
c8f6319
Merge master, and give the three MainTest hooks a SelfId
marsyusms Aug 21, 2026
dac2c5d
Merge pull request #1411 from yileicn/master
yileicn Aug 21, 2026
0ff995e
Add FloxiAI plugin: core classes, DI, and provider reg
Oceania2018 Aug 21, 2026
ff0330f
Agent test harness: conversational case authoring (POST /agent-test/a…
marsyusms Aug 24, 2026
d47d163
Merge pull request #1409 from yuyixg/feature/agent-test-set-p1
iceljc Aug 24, 2026
4f0de87
Agent test: count utilities as mock targets
marsyusms Aug 26, 2026
f17e941
Let the host adjust the headers an MCP connection is opened with
marsyusms Aug 27, 2026
669d22f
Merge pull request #1413 from yuyixg/feature/mcp-client-header-provider
yileicn Aug 27, 2026
aadb4ef
Pool MCP connections per scope, keyed by the identity they carry
marsyusms Aug 28, 2026
cd1a1df
Merge branch 'SciSharp:master' into feature/mcp-client-header-provider
yuyixg Aug 28, 2026
628cdb0
Share the HTTP connection under MCP clients, not the clients themselves
marsyusms Aug 28, 2026
079846c
Merge pull request #1412 from yuyixg/feature/agent-test-set-p1
yileicn Aug 28, 2026
473d01e
allow execute rules for each agent
Aug 28, 2026
111a824
minor change
Aug 28, 2026
5409af5
Merge pull request #1415 from iceljc/master
iceljc Aug 28, 2026
8e809ee
Merge pull request #1416 from yileicn/master
yileicn Sep 1, 2026
94438c6
Run triggered rules in parallel with per-rule service scopes
Sep 1, 2026
41cc3ec
Carry started conversation ids on rule trigger cancellation
Sep 1, 2026
7074a2e
change agent llm
Sep 1, 2026
6cc200b
Run triggered rules sequentially again
Sep 1, 2026
1f58c19
Report every tool call a reply asked for, not only the first
marsyusms Sep 2, 2026
a2ec1fb
Store what an agent said to itself without showing it
marsyusms Sep 2, 2026
d49f0ee
Merge pull request #1417 from iceljc/feature/rule-engine-parallel-exe…
iceljc Sep 2, 2026
272cd86
GTR-13432
Sep 3, 2026
6be256a
Merge branch 'SciSharp:master' into master
adenchen123 Sep 3, 2026
bfa981a
Merge pull request #1418 from adenchen123/master
yileicn Sep 3, 2026
584bb58
Reuse a server's tool listing instead of listing it on every agent load
marsyusms Sep 3, 2026
6708348
Merge branch 'SciSharp:master' into feature/mcp-client-header-provider
yuyixg Sep 3, 2026
4a42e72
Merge pull request #1414 from yuyixg/feature/mcp-client-header-provider
yileicn Sep 3, 2026
8056ed4
GTR-13432
Sep 3, 2026
d850464
Merge branch 'master' of https://github.com/adenchen123/BotSharp
Sep 3, 2026
f936f92
Merge pull request #1419 from adenchen123/master
yileicn Sep 3, 2026
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
15 changes: 15 additions & 0 deletions BotSharp.sln
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Core.UnitTests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.AgentTesting", "src\Plugins\BotSharp.Plugin.AgentTesting\BotSharp.Plugin.AgentTesting.csproj", "{57CE6EAE-8957-435A-8A14-5A693EA8B520}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.FloxiAI", "src\Plugins\BotSharp.Plugin.FloxiAI\BotSharp.Plugin.FloxiAI.csproj", "{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -983,6 +985,18 @@ Global
{57CE6EAE-8957-435A-8A14-5A693EA8B520}.Release|x64.Build.0 = Release|Any CPU
{57CE6EAE-8957-435A-8A14-5A693EA8B520}.Release|x86.ActiveCfg = Release|Any CPU
{57CE6EAE-8957-435A-8A14-5A693EA8B520}.Release|x86.Build.0 = Release|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Debug|x64.ActiveCfg = Debug|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Debug|x64.Build.0 = Debug|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Debug|x86.ActiveCfg = Debug|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Debug|x86.Build.0 = Debug|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Release|Any CPU.Build.0 = Release|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Release|x64.ActiveCfg = Release|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Release|x64.Build.0 = Release|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Release|x86.ActiveCfg = Release|Any CPU
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1064,6 +1078,7 @@ Global
{19FA8311-69D1-4729-AE9B-D10979C099EC} = {58D3A2C3-F96F-5E57-2C6B-ECE59D6A18FC}
{3585AE68-43CE-B724-72C1-579D9FFE1D6E} = {32FAFFFE-A4CB-4FEE-BF7C-84518BBC6DCC}
{57CE6EAE-8957-435A-8A14-5A693EA8B520} = {58D3A2C3-F96F-5E57-2C6B-ECE59D6A18FC}
{D87D99CE-8B5B-4FAF-8649-39B9BEEAC955} = {D5293208-2BEF-42FC-A64C-5954F61720BA}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A9969D89-C98B-40A5-A12B-FC87E55B3A19}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,11 @@ public static class MessageTypeName
public const string FunctionCall = "function";
public const string Audio = "audio";
public const string Error = "error";

/// <summary>
/// A message that belongs to the conversation record but not to the conversation as the user
/// sees it -- what an agent said to itself on the way to an answer. Stored like any other
/// message and read back into the model's context; skipped when the dialog is rendered.
/// </summary>
public const string Internal = "internal";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace BotSharp.Abstraction.Conversations;

/// <summary>
/// Reports whether a conversation is synthetic -- driven by an automated harness rather than by a
/// person -- so that guards aimed at human overuse can stand aside for it.
///
/// This exists because per-user volume limits count the wrong thing for a test harness. A regression
/// suite legitimately opens one conversation per case per model, at machine speed, and does it under
/// whatever identity the background worker happens to have. Measured as human behaviour that looks
/// like abuse, and the limit then fails every test with a message about conversation quotas -- which
/// says nothing about the agent under test and is indistinguishable, in a report, from the agent
/// having regressed.
///
/// Deliberately a query rather than a flag on the message: a flag would have to be threaded through
/// every call that creates or continues a conversation, and anything that forgot would silently be
/// treated as human traffic. Asking by conversation id keeps the answer in one place.
///
/// Implementations must be cheap and side-effect free -- this is called on the message path -- and
/// must answer false for anything they do not recognise. Several may be registered; a conversation is
/// synthetic if any of them claims it. None being registered is the normal case, and then nothing is
/// exempt.
/// </summary>
public interface ISyntheticConversationProbe
{
/// <summary>
/// True when this conversation is being driven by a harness. Must not throw, and must not treat
/// an unknown or blank id as synthetic: getting this wrong in that direction exempts real user
/// traffic from the very limits it is meant to be held to.
/// </summary>
bool IsSynthetic(string conversationId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ public class RoleDialogModel : ITrackableMessage
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }

/// <summary>
/// Every tool call this reply asked for, in the order the model produced them, or null when
/// it asked for none.
/// </summary>
/// <remarks>
/// <see cref="FunctionName"/>, <see cref="FunctionArgs"/> and <see cref="ToolCallId"/> beside
/// this are the first entry, so a caller that can only run one call keeps working unchanged;
/// a caller that can run several reads this instead. The one difference is name repair: the
/// single field carries the normalized name it always has, while entries here keep the name
Comment on lines +84 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Parallel tool calls ignored 🐞 Bug ≡ Correctness

OpenAI and Anthropic now populate RoleDialogModel.ToolCalls, but RoutingService.InvokeAgent
copies and invokes only the legacy first-call fields. A response requesting multiple independent
tools therefore executes only its first call and silently loses the remainder.
Agent Prompt
## Issue description
Providers now preserve every model-requested tool call in `RoleDialogModel.ToolCalls`, but the routing pipeline still invokes only `FunctionName`, `FunctionArgs`, and `ToolCallId` from the first call.

## Issue Context
Execute all calls and retain the assistant tool-call request plus one correctly identified result per call when constructing the follow-up model context. Preserve compatibility for providers that only populate the scalar fields.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs[51-65]
- src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs[89-130]
- src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs[79-94]
- src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs[38-49]
- src/Plugins/BotSharp.Plugin.AnthropicAI/Providers/ChatCompletionProvider.cs[55-68]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

/// the model actually sent.
/// <para>
/// Deliberately not copied by <see cref="From"/>: this describes one model reply, and a
/// message derived from that reply -- a tool result, an assistant answer -- is not it.
/// </para>
/// </remarks>
public List<LlmToolCall>? ToolCalls { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string?>? Thought { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace BotSharp.Abstraction.Functions.Models;

/// <summary>
/// One tool call in a model's reply.
/// </summary>
/// <remarks>
/// A reply can carry several: models routinely ask for independent lookups at once, and every
/// provider here used to keep only the first. See <see cref="RoleDialogModel.ToolCalls"/> for
/// how the whole set is carried and how it relates to the single-call fields beside it.
/// </remarks>
public class LlmToolCall
{
/// <summary>
/// The provider's id for this call. It is what a tool result has to be sent back under, so
/// results cannot be matched to calls without it.
/// </summary>
public string? Id { get; set; }

/// <summary>
/// The name exactly as the model produced it, with no normalization applied -- a remote MCP
/// tool may legitimately have a name that name repair would rewrite.
/// </summary>
public string? FunctionName { get; set; }

/// <summary>
/// Raw JSON arguments. The model does not always produce valid JSON, so parse defensively.
/// </summary>
public string? FunctionArgs { get; set; }

public LlmToolCall()
{
}

public LlmToolCall(string? id, string? functionName, string? functionArgs)
{
Id = id;
FunctionName = functionName;
FunctionArgs = functionArgs;
}

public override string ToString() => $"{FunctionName}({FunctionArgs}) [{Id}]";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace BotSharp.Abstraction.MCP.Services;

/// <summary>
/// An optional host hook over the HTTP headers used to open one MCP server connection.
/// </summary>
/// <remarks>
/// Nothing registers this by default. With no implementation registered the headers configured
/// under <c>MCP:McpServerConfigs</c> are used verbatim, which is the only behaviour there was
/// before the hook existed — a host that does not implement it sees no change at all.
/// <para>
/// It exists so a host can call a server as whoever is driving the conversation instead of with
/// one fixed credential. That decision belongs to the host: it is the only side that knows what
/// a caller's credential is and which servers may be shown it.
/// </para>
/// </remarks>
public interface IMcpClientHeaderProvider
{
/// <summary>
/// Answers the headers to send to <paramref name="serverId"/>.
/// </summary>
/// <param name="configured">
/// The headers from configuration. This dictionary is shared for the lifetime of the process,
/// so an implementation that changes a header MUST copy it rather than write into it.
/// </param>
/// <returns>
/// The headers to send. Returning <paramref name="configured"/> unchanged is the no-op answer,
/// and is the answer expected for any server the implementation does not recognise.
/// </returns>
Dictionary<string, string>? GetHeaders(string serverId, Dictionary<string, string>? configured);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ public interface IRuleCriteriaEvaluator
/// Evaluate the criteria for a single agent's rule.
/// </summary>
/// <param name="agent">The agent whose rule is being considered</param>
/// <param name="agentRule">
/// The rule being considered. An agent can carry more than one rule on the same trigger, so the
/// evaluator is handed the one under evaluation rather than resolving it from the agent itself.
/// </param>
/// <param name="trigger">The rule trigger</param>
/// <param name="context">The per-request criteria context</param>
/// <returns>
/// True if the rule should be executed for this request, false if it should be skipped,
/// or null when the evaluator could not produce an answer (missing script/template,
/// failed execution, error).
/// </returns>
Task<bool?> EvaluateAsync(Agent agent, IRuleTrigger trigger, RuleCriteriaContext context);
Task<bool?> EvaluateAsync(Agent agent, AgentRule agentRule, IRuleTrigger trigger, RuleCriteriaContext context);
}
16 changes: 4 additions & 12 deletions src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEngine.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Threading;

namespace BotSharp.Abstraction.Rules;

public interface IRuleEngine
Expand All @@ -9,19 +11,9 @@ public interface IRuleEngine
/// <param name="text"></param>
/// <param name="states"></param>
/// <param name="options"></param>
/// <param name="cancellationToken">Stops dispatching further rules and cancels the pause between them.</param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
Task<IEnumerable<string>> Triggered(IRuleTrigger trigger, string text, IEnumerable<MessageState>? states = null, RuleTriggerOptions? options = null)
Task<IEnumerable<string>> Triggered(IRuleTrigger trigger, string text, IEnumerable<MessageState>? states = null, RuleTriggerOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();

///// <summary>
///// Execute rule graph node
///// </summary>
///// <param name="node"></param>
///// <param name="graph"></param>
///// <param name="agentId"></param>
///// <param name="trigger"></param>
///// <param name="options"></param>
///// <returns></returns>
//Task ExecuteGraphNode(FlowNode node, FlowGraph graph, string agentId, IRuleTrigger trigger, RuleNodeExecutionOptions options);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using BotSharp.Abstraction.Repositories.Filters;
using BotSharp.Abstraction.Repositories.Filters;
using BotSharp.Abstraction.Rules.Constants;
using System.Text.Json;

Expand All @@ -15,6 +15,15 @@ public class RuleTriggerOptions
/// Criteria
/// </summary>
public CriteriaOptions? Criteria { get; set; }

/// <summary>
/// How long to pause after sending a message to a triggered agent, before moving on
/// to the next rule. Keeps a burst of triggered rules from hammering the LLM provider
/// all at once. Set to zero to disable the pause.
/// </summary>
public int SendMessageDelayMs { get; set; } = DefaultSendMessageDelayMs;

public const int DefaultSendMessageDelayMs = 200;
}

public class CriteriaOptions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Threading;

namespace BotSharp.Abstraction.Rules;

/// <summary>
/// Thrown when a rule trigger is cancelled part way through. Rules that already started a
/// conversation cannot be undone, so their ids are carried on the exception and the caller
/// can still act on them (or ignore them) while seeing the run as cancelled.
/// </summary>
public class RuleTriggerCanceledException : OperationCanceledException
{
/// <summary>
/// Conversations that were created before the run was cancelled. Never null.
/// </summary>
public IReadOnlyList<string> ConversationIds { get; }

public RuleTriggerCanceledException(
IReadOnlyList<string> conversationIds,
CancellationToken cancellationToken,
Exception? innerException = null)
: base($"Rule trigger was cancelled after starting {conversationIds.Count} conversation(s).", innerException, cancellationToken)
{
ConversationIds = conversationIds;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using BotSharp.Core.Rules.Models;
using BotSharp.Core.Rules.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public CodeCriteriaEvaluator(

public string Type => BuiltInRuleCriteria.PythonScript;

public async Task<bool?> EvaluateAsync(Agent agent, IRuleTrigger trigger, RuleCriteriaContext context)
public async Task<bool?> EvaluateAsync(Agent agent, AgentRule agentRule, IRuleTrigger trigger, RuleCriteriaContext context)
{
var settings = context.Options.GetData<CodeCriteriaSettings>() ?? new();
var provider = settings.CodeProcessor ?? BuiltInCodeProcessor.PyInterpreter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,16 @@ public LlmCriteriaEvaluator(

public string Type => BuiltInRuleCriteria.Llm;

public async Task<bool?> EvaluateAsync(Agent agent, IRuleTrigger trigger, RuleCriteriaContext context)
public async Task<bool?> EvaluateAsync(Agent agent, AgentRule agentRule, IRuleTrigger trigger, RuleCriteriaContext context)
{
var settings = context.Options.GetData<LlmCriteriaSettings>() ?? new();
var rule = agent.Rules.FirstOrDefault(x => x.TriggerName.IsEqualTo(trigger.Name));

// The Rules agent hosts the criteria-check template by default.
var agentId = !string.IsNullOrWhiteSpace(settings.AgentId) ? settings.AgentId! : BuiltInAgentId.RulesInterpreter;
var templateName = !string.IsNullOrWhiteSpace(settings.TemplateName)
? settings.TemplateName! : (agentId == BuiltInAgentId.RulesInterpreter ? DefaultTemplateName : $"{trigger.Name}_criteria");

var input = BuildInput(rule?.CriteriaConfig, settings);
var input = BuildInput(agentRule.CriteriaConfig, settings);
var msg = $"rule trigger ({trigger.Name}) llm criteria (agent {agentId}, template {templateName}).";

try
Expand Down
Loading
Loading