Skip to content

Feature/rule engine parallel execution - #1417

Open
iceljc wants to merge 4 commits into
SciSharp:masterfrom
iceljc:feature/rule-engine-parallel-execution
Open

Feature/rule engine parallel execution#1417
iceljc wants to merge 4 commits into
SciSharp:masterfrom
iceljc:feature/rule-engine-parallel-execution

Conversation

@iceljc

@iceljc iceljc commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

Jicheng Lu and others added 3 commits September 1, 2026 10:32
Each triggered rule now runs in its own DI scope, so the scoped
conversation, state and routing services start clean per run and no
longer bleed between rules or into the caller's scope. The message hub
observers are subscribed per run so rule-triggered conversations emit
the same events as user-initiated ones.

The nested agent/rule loops are flattened into a single list and
dispatched via Parallel.ForEachAsync, throttled by MaxConcurrency
(options -> RuleSettings -> built-in default of 5). Results are written
into an indexed array so conversation ids keep rule order without a
concurrent-add race.

Triggered also takes a CancellationToken: it stops dispatching new
rules, interrupts the inter-rule delay, and propagates to the caller.
Per-rule failures are logged and isolated so one bad rule does not take
down the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cancelled run cannot both return its conversation ids and surface the
cancellation, so the ids now ride along on the exception. Triggered
throws RuleTriggerCanceledException, which derives from
OperationCanceledException (existing handlers keep working) and exposes
the conversations that were already started, so callers can still act on
work that cannot be undone.

Also drops the CancellationToken from the rule trigger endpoint, so a
client disconnect no longer stops rules that are mid-dispatch. The token
stays optional on IRuleEngine.Triggered for other callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Run triggered rules in isolated, cancellable parallel scopes

✨ Enhancement ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

AI Description

• Executes triggered rules concurrently with isolated dependency-injection scopes and configurable
 throttling.
• Preserves ordered conversation IDs while isolating failures and surfacing partial results on
 cancellation.
• Subscribes message observers per rule and updates the built-in rules interpreter model.
Diagram

sequenceDiagram
    actor Caller
    participant Engine as Rule Engine
    participant Agents as Agent Catalog
    participant Config as Rule Settings
    participant Scope as Rule Scope
    participant Hub as Message Hub
    participant Conversation as Conversation Service
    Caller->>Engine: Trigger rules
    Engine->>Agents: Load matching rules
    Engine->>Config: Resolve concurrency
    par Bounded rule runs
        Engine->>Scope: Create isolated scope
        Scope->>Scope: Evaluate criteria
        Scope->>Hub: Subscribe observers
        Scope->>Conversation: Create and send
        Conversation-->>Scope: Conversation ID
        Scope-->>Engine: Store indexed result
    end
    alt Trigger cancelled
        Engine-->>Caller: Cancellation with started IDs
    else Trigger completed
        Engine-->>Caller: Ordered conversation IDs
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Task.WhenAll with SemaphoreSlim
  • ➕ Allows custom scheduling and per-task orchestration
  • ➕ Works on runtimes without Parallel.ForEachAsync
  • ➖ Requires more synchronization and cancellation plumbing
  • ➖ Makes bounded dispatch and exception isolation easier to implement incorrectly
2. Durable background queue
  • ➕ Supports retries, persistence, and workload distribution
  • ➕ Decouples long-running rules from request lifetime
  • ➖ Changes synchronous result semantics
  • ➖ Requires queue infrastructure, workers, and result correlation

Recommendation: Keep the bounded Parallel.ForEachAsync design for in-process rule triggering: it directly models the workload, limits downstream pressure, and pairs safely with per-rule DI scopes. Consider a durable queue only if rules later require persistence, retries, or execution across multiple application instances.

Files changed (9) +194 / -66

Enhancement (4) +175 / -62
IRuleEngine.csAdd cancellation support to the rule-engine contract +4/-12

Add cancellation support to the rule-engine contract

• Extends Triggered with an optional CancellationToken and documents its dispatch and delay cancellation behavior. Removes obsolete commented interface code.

src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEngine.cs

RuleTriggerOptions.csAdd per-call rule concurrency and delay controls +20/-1

Add per-call rule concurrency and delay controls

• Adds SendMessageDelayMs and MaxConcurrency options with defaults of 200 milliseconds and five concurrent rules. Documents the precedence between call-level, configured, and built-in concurrency values.

src/Infrastructure/BotSharp.Abstraction/Rules/Options/RuleTriggerOptions.cs

RuleTriggerCanceledException.csExpose partial conversation results on cancellation +25/-0

Expose partial conversation results on cancellation

• Introduces an OperationCanceledException subtype carrying conversation IDs collected before cancellation. Existing cancellation handlers remain compatible while callers can recover partial results.

src/Infrastructure/BotSharp.Abstraction/Rules/RuleTriggerCanceledException.cs

RuleEngine.csExecute rules concurrently in isolated service scopes +126/-49

Execute rules concurrently in isolated service scopes

• Flattens matching rules into a bounded Parallel.ForEachAsync workload while preserving result order and isolating individual failures. Each run receives its own DI scope and message-hub subscription, supports cancellation, and reports started conversations through RuleTriggerCanceledException.

src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs

Refactor (1) +2 / -1
Using.csImport rule settings across the rules project +2/-1

Import rule settings across the rules project

• Adds RuleSettings to the project's global imports and normalizes file encoding.

src/Infrastructure/BotSharp.Core.Rules/Using.cs

Other (4) +17 / -3
RuleSettings.csDefine global rule concurrency settings +10/-0

Define global rule concurrency settings

• Adds a settings model for configuring the default maximum number of concurrent triggered rules.

src/Infrastructure/BotSharp.Abstraction/Rules/Settings/RuleSettings.cs

RuleController.csNormalize controller source encoding +1/-1

Normalize controller source encoding

• Adds the UTF-8 byte-order marker without changing controller behavior.

src/Infrastructure/BotSharp.Core.Rules/Controllers/RuleController.cs

RulesPlugin.csBind and register rule execution settings +5/-1

Bind and register rule execution settings

• Binds the Rule configuration section to RuleSettings and registers the settings instance as a singleton.

src/Infrastructure/BotSharp.Core.Rules/RulesPlugin.cs

agent.jsonChange the rules interpreter model +1/-1

Change the rules interpreter model

• Switches the built-in rules interpreter agent from gpt-5.4-mini to gpt-5.6-luna.

src/Infrastructure/BotSharp.Core.Rules/data/agents/201e49a2-40b3-4ccd-b8cc-2476565a1b40/agent.json

@qodo-code-review

qodo-code-review Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Cancellation loses conversation IDs 🐞 Bug ≡ Correctness
Description
RunRule waits on a cancellation-aware delay after creating the conversation, but convIds is
assigned only after that delay completes. Cancellation during the delay therefore omits an
already-started conversation from RuleTriggerCanceledException.ConversationIds, defeating the
exception's recovery contract.
Code

src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[R150-153]

+        var delay = options?.SendMessageDelayMs ?? RuleTriggerOptions.DefaultSendMessageDelayMs;
+        if (delay > 0)
+        {
+            await Task.Delay(delay, cancellationToken);
Evidence
SendMessageToAgent creates and completes the conversation before returning its ID, but RunRule
then performs a cancellable delay and returns the ID only afterward. The parallel callback writes
the ID only when RunRule returns successfully, while the cancellation handler collects only
entries already written to the array; this conflicts with the documented exception property
containing conversations created before cancellation.

src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[68-87]
src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[141-156]
src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[208-242]
src/Infrastructure/BotSharp.Abstraction/Rules/RuleTriggerCanceledException.cs[5-23]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A conversation ID is returned from `SendMessageToAgent`, but it is not stored in `convIds` until `RunRule` finishes its subsequent cancellation-aware delay. If cancellation occurs during that delay, the already-created conversation is missing from `RuleTriggerCanceledException.ConversationIds`.

## Issue Context
Preserve the post-send throttling behavior while ensuring the ID is recorded immediately after message processing succeeds and before any operation that can throw due to cancellation. One option is to move the delay to the parallel callback after assigning the returned ID.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[68-87]
- src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[141-156]

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



Remediation recommended

2. Endpoint ignores request cancellation 🐞 Bug ☼ Reliability
Description
The repository's only IRuleEngine.Triggered caller omits the newly added token, so HTTP request
cancellation cannot stop dispatching rules or cancel their delays. Disconnected requests can
therefore continue launching conversations and consuming downstream LLM capacity until the full
batch completes.
Code

src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEngine.cs[R14-17]

+    /// <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)
Evidence
The interface and implementation now expose cancellation, and the implementation uses it to control
Parallel.ForEachAsync and the post-send delay. However, the sole repository call site invokes
Triggered with only four arguments, causing the default token to be used for every HTTP request.

src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEngine.cs[9-18]
src/Infrastructure/BotSharp.Core.Rules/Controllers/RuleController.cs[25-40]
src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[58-72]
src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[148-154]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new cancellation-aware `Triggered` overload is called by the rule HTTP endpoint without a cancellation token. Consequently, request abortion never reaches the engine and all rule work continues with the default non-cancelable token.

## Issue Context
Pass `HttpContext.RequestAborted`, or accept an action `CancellationToken` and forward it as the final `Triggered` argument. Preserve the existing API response behavior for non-cancelled requests.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.Rules/Controllers/RuleController.cs[25-40]
- src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEngine.cs[9-18]

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


Grey Divider

Context sources
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +150 to +153
var delay = options?.SendMessageDelayMs ?? RuleTriggerOptions.DefaultSendMessageDelayMs;
if (delay > 0)
{
await Task.Delay(delay, cancellationToken);

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

1. Cancellation loses conversation ids 🐞 Bug ≡ Correctness

RunRule waits on a cancellation-aware delay after creating the conversation, but convIds is
assigned only after that delay completes. Cancellation during the delay therefore omits an
already-started conversation from RuleTriggerCanceledException.ConversationIds, defeating the
exception's recovery contract.
Agent Prompt
## Issue description
A conversation ID is returned from `SendMessageToAgent`, but it is not stored in `convIds` until `RunRule` finishes its subsequent cancellation-aware delay. If cancellation occurs during that delay, the already-created conversation is missing from `RuleTriggerCanceledException.ConversationIds`.

## Issue Context
Preserve the post-send throttling behavior while ensuring the ID is recorded immediately after message processing succeeds and before any operation that can throw due to cancellation. One option is to move the delay to the parallel callback after assigning the returned ID.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[68-87]
- src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs[141-156]

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

Comment on lines +14 to +17
/// <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)

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.

Remediation recommended

2. Endpoint ignores request cancellation 🐞 Bug ☼ Reliability

The repository's only IRuleEngine.Triggered caller omits the newly added token, so HTTP request
cancellation cannot stop dispatching rules or cancel their delays. Disconnected requests can
therefore continue launching conversations and consuming downstream LLM capacity until the full
batch completes.
Agent Prompt
## Issue description
The new cancellation-aware `Triggered` overload is called by the rule HTTP endpoint without a cancellation token. Consequently, request abortion never reaches the engine and all rule work continues with the default non-cancelable token.

## Issue Context
Pass `HttpContext.RequestAborted`, or accept an action `CancellationToken` and forward it as the final `Triggered` argument. Preserve the existing API response behavior for non-cancelled requests.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.Rules/Controllers/RuleController.cs[25-40]
- src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEngine.cs[9-18]

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

Drops Parallel.ForEachAsync in favour of a plain loop over the rules.
Conversation ids are appended as each rule finishes, so the indexed
array and its collect helper are no longer needed to keep them in order.

MaxConcurrency only existed to throttle the parallel run, so it goes too,
along with the RuleSettings class and its "Rule" config binding that were
added to configure it. The inter-rule delay falls back to the per-call
option and then the built-in default.

Per-rule service scopes, cancellation and per-rule error isolation are
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant