Skip to content

fix: avoid saving routing instruction args as state#1364

Merged
yileicn merged 3 commits into
SciSharp:masterfrom
yileicn:master
Jun 16, 2026
Merged

fix: avoid saving routing instruction args as state#1364
yileicn merged 3 commits into
SciSharp:masterfrom
yileicn:master

Conversation

@yileicn

@yileicn yileicn commented Jun 15, 2026

Copy link
Copy Markdown
Member

No description provided.

@qodo-code-review

qodo-code-review Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1)

Grey Divider


Action required

1. MergeRoutingRules returns incoming reference 📘 Rule violation ☼ Reliability ⭐ New
Description
MergeRoutingRules mutates the caller-provided incomingRules list (and its items) and then
returns the same list instance, which is assigned to record.RoutingRules. This can leak state
across components/requests and allow later mutations of the input list to affect persisted agent
state.
Code

src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[R58-78]

+    private static List<RoutingRule> MergeRoutingRules(List<RoutingRule>? existingRules, List<RoutingRule>? incomingRules)
+    {
+        if (incomingRules == null)
+        {
+            return [];
+        }
+
+        foreach (var incomingRule in incomingRules)
+        {
+            var existingRule = existingRules?.FirstOrDefault(x =>
+                string.Equals(x.Field, incomingRule.Field, StringComparison.OrdinalIgnoreCase) &&
+                string.Equals(x.Type, incomingRule.Type, StringComparison.OrdinalIgnoreCase));
+
+            if (existingRule != null)
+            {
+                incomingRule.AllowLlmFill = existingRule.AllowLlmFill;
+            }
+        }
+
+        return incomingRules;
+    }
Evidence
The checklist requires defensive copies when assigning/storing caller-provided collections into
long-lived objects. The code iterates over incomingRules, mutates its elements, and returns the
same list instance (return incomingRules;), which is then stored as record.RoutingRules,
creating a shared mutable reference.

src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[58-78]
Best Practice: Learned patterns

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

## Issue description
`MergeRoutingRules` modifies and stores the caller-provided `incomingRules` list by reference (`incomingRule.AllowLlmFill = ...` and `return incomingRules;`). This violates the defensive-copy requirement for caller-provided mutable collections and can cause unintended shared-state mutations.

## Issue Context
The assignment `record.RoutingRules = MergeRoutingRules(record.RoutingRules, agent.RoutingRules);` stores whatever `MergeRoutingRules` returns into a long-lived/persisted record. `incomingRules` originates from the caller (`agent.RoutingRules`) and is mutable.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[58-78]

## Implementation notes
- Avoid mutating `incomingRules` directly.
- Create a new `List<RoutingRule>` and clone/copy each incoming rule (e.g., `incomingRule.Clone()`), then apply the merge behavior onto the copies.
- Return the new list and assign that list to `record.RoutingRules`.

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


2. Routing args dropped ✓ Resolved 🐞 Bug ≡ Correctness
Description
RoutingService.InstructLoop no longer persists inst.Arguments into IConversationStateService, so
router-extracted required parameters are not available to later routing/agent execution. This can
cause HasMissingRequiredField to falsely report missing required fields and prevents
agents/templates from seeing values that are normally sourced from conversation state.
Code

src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs[57]

+            //states.SaveStateByArgs(inst.Arguments);         
Evidence
The router is instructed to extract/populate required agent arguments under args, and downstream
routing/prompt logic depends on those values being available via conversation state. With the
SaveStateByArgs(inst.Arguments) call removed, those values are no longer saved to state, so
required-field auto-fill and template render data (which is built from state) will miss them.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs[46-62]
src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/instructions/instruction.liquid[3-9]
src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs[382-410]
src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs[12-68]
src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.Rendering.cs[181-197]

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

## Issue description
`RoutingService.InstructLoop` no longer calls `states.SaveStateByArgs(inst.Arguments)`, which was the only mechanism that propagated the router/planner-produced JSON `args` into conversation state. Router instructions explicitly require populating required agent args under `args`, and downstream logic relies on those values being in state (for prompt rendering and required-field auto-fill).
## Issue Context
- Router instruction requires: “Extract and populate agent required arguments” (these appear under the JSON `args` object).
- `HasMissingRequiredField` uses conversation state to fill missing required fields.
- Agent prompt/template render data is populated from conversation state.
## Fix Focus Areas
Choose one approach, but ensure routing-required parameters from `inst.Arguments` still reach:
1) required-field evaluation (`HasMissingRequiredField`) and
2) agent/template rendering (state-driven).
Recommended options:
- **Option A (preferred with PR intent):** Stop persisting routing args as long-lived state, but update `HasMissingRequiredField` to also read required-field values from nested `args` in the routing instruction payload (e.g., when `message.FunctionArgs` is a serialized `FunctionCallFromLlm`), and merge those values into the working args object for the missing-field check.
- **Option B:** Persist only the agent routing-rule fields from `inst.Arguments` with a limited lifetime (e.g., `activeRounds=1..2`) instead of saving every arg indefinitely; add a new helper API rather than reusing `SaveStateByArgs` if you need TTL.
References:
- src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs[46-62]
- src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs[12-68]
- src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.Rendering.cs[181-197]
- src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs[382-410]
- src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/instructions/instruction.liquid[3-9]

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



Remediation recommended

3. allow_llm_fill never updates 🐞 Bug ≡ Correctness ⭐ New
Description
AgentService.MergeRoutingRules always overwrites incomingRule.AllowLlmFill with the persisted value
for any matching rule (Field+Type), so API updates to allow_llm_fill on existing routing rules are
silently ignored. This makes the flag effectively immutable and can misconfigure routing behavior
when users attempt to change it.
Code

src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[R65-75]

+        foreach (var incomingRule in incomingRules)
+        {
+            var existingRule = existingRules?.FirstOrDefault(x =>
+                string.Equals(x.Field, incomingRule.Field, StringComparison.OrdinalIgnoreCase) &&
+                string.Equals(x.Type, incomingRule.Type, StringComparison.OrdinalIgnoreCase));
+
+            if (existingRule != null)
+            {
+                incomingRule.AllowLlmFill = existingRule.AllowLlmFill;
+            }
+        }
Evidence
The update API model accepts allow_llm_fill and maps it into the domain object, but UpdateAgent
calls MergeRoutingRules, which forces the incoming rule’s AllowLlmFill back to the existing
stored value for any rule matched by Field and Type. The new unit test explicitly asserts this
overwrite behavior, demonstrating the update value will not be applied for existing rules.

src/Infrastructure/BotSharp.OpenAPI/ViewModels/Agents/Request/RoutingRuleUpdateModel.cs[6-35]
src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[58-78]
BotSharp.Core.UnitTests/Routing/RoutingRuleTests.cs[29-56]

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

## Issue description
`MergeRoutingRules` overwrites `incomingRule.AllowLlmFill` with the stored value whenever it finds a matching existing rule. This prevents callers from updating `allow_llm_fill` for existing rules, even though the OpenAPI update model supports sending it.

## Issue Context
The update DTO (`RoutingRuleUpdateModel`) carries `allow_llm_fill`, but the DTO->domain conversion currently collapses the “not provided” vs “explicitly provided” distinction (nullable -> non-nullable), and the merge function always forces the old value for matches.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[58-78]
- src/Infrastructure/BotSharp.OpenAPI/ViewModels/Agents/Request/RoutingRuleUpdateModel.cs[6-35]

## Suggested direction
- Preserve the existing value **only when** the client did not specify `allow_llm_fill`.
- To achieve that, carry a “specified” signal through the update flow (e.g., make the domain field nullable for updates, or add a separate `AllowLlmFillSpecified`/`HasValue` flag during mapping), and in `MergeRoutingRules`:
 - if matching existing rule AND incoming did **not** specify the flag -> copy from existing
 - else -> keep the incoming value so updates work
- Add a unit test proving that an explicit update (e.g., existing=false, incoming=true with `allow_llm_fill` specified) is persisted.

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


Grey Divider

Previous review results

Review updated until commit 56d1ee7

Results up to commit N/A


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0)


Action required
1. Routing args dropped ✓ Resolved 🐞 Bug ≡ Correctness
Description
RoutingService.InstructLoop no longer persists inst.Arguments into IConversationStateService, so
router-extracted required parameters are not available to later routing/agent execution. This can
cause HasMissingRequiredField to falsely report missing required fields and prevents
agents/templates from seeing values that are normally sourced from conversation state.
Code

src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs[57]

+            //states.SaveStateByArgs(inst.Arguments);         
Evidence
The router is instructed to extract/populate required agent arguments under args, and downstream
routing/prompt logic depends on those values being available via conversation state. With the
SaveStateByArgs(inst.Arguments) call removed, those values are no longer saved to state, so
required-field auto-fill and template render data (which is built from state) will miss them.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs[46-62]
src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/instructions/instruction.liquid[3-9]
src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs[382-410]
src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs[12-68]
src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.Rendering.cs[181-197]

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

## Issue description
`RoutingService.InstructLoop` no longer calls `states.SaveStateByArgs(inst.Arguments)`, which was the only mechanism that propagated the router/planner-produced JSON `args` into conversation state. Router instructions explicitly require populating required agent args under `args`, and downstream logic relies on those values being in state (for prompt rendering and required-field auto-fill).
## Issue Context
- Router instruction requires: “Extract and populate agent required arguments” (these appear under the JSON `args` object).
- `HasMissingRequiredField` uses conversation state to fill missing required fields.
- Agent prompt/template render data is populated from conversation state.
## Fix Focus Areas
Choose one approach, but ensure routing-required parameters from `inst.Arguments` still reach:
1) required-field evaluation (`HasMissingRequiredField`) and
2) agent/template rendering (state-driven).
Recommended options:
- **Option A (preferred with PR intent):** Stop persisting routing args as long-lived state, but update `HasMissingRequiredField` to also read required-field values from nested `args` in the routing instruction payload (e.g., when `message.FunctionArgs` is a serialized `FunctionCallFromLlm`), and merge those values into the working args object for the missing-field check.
- **Option B:** Persist only the agent routing-rule fields from `inst.Arguments` with a limited lifetime (e.g., `activeRounds=1..2`) instead of saving every arg indefinitely; add a new helper API rather than reusing `SaveStateByArgs` if you need TTL.
References:
- src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs[46-62]
- src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs[12-68]
- src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.Rendering.cs[181-197]
- src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs[382-410]
- src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/instructions/instruction.liquid[3-9]

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


Qodo Logo

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Fix: stop persisting routing instruction arguments into conversation state
🐞 Bug fix 🕐 10-20 Minutes

Grey Divider

Walkthroughs

Description
• Prevent routing instruction arguments from being stored as conversation state.
• Avoid unintended state pollution/leakage across routing/execution loops.
Diagram
graph TD
  A["RoutingService.InstructLoop"] --> B["HookEmitter (IRoutingHook)"] --> C["RoutingReasoner"] --> D["Executor"] --> E[("ConversationStorage")]
  A -. "no longer saves inst.Arguments" .-> F["ConversationStateService"]

  subgraph Legend
    direction LR
    _svc([Service/Component]) ~~~ _db[(Storage)]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Whitelist/sanitize args before persisting
  • ➕ Keeps useful persistent context while avoiding sensitive/transient fields
  • ➕ More explicit and self-documenting about what becomes state
  • ➖ Requires defining/maintaining an allowlist schema per instruction type
  • ➖ Higher implementation and testing effort than disabling persistence
2. Persist under a namespaced, ephemeral scope (TTL)
  • ➕ Avoids long-lived state pollution while still enabling short-term reuse/debugging
  • ➕ Can be auto-cleaned between loops/conversations
  • ➖ Introduces TTL/cleanup semantics and operational complexity
  • ➖ Still risks leaking sensitive data if scope is misconfigured

Recommendation: Disabling persistence of routing instruction arguments is a safe, minimal bug fix if those args are transient and not intended as durable state. If some arguments are legitimately needed later, prefer a whitelist/sanitization approach (or a short-lived namespaced scope) rather than saving the raw argument bag into global conversation state.

Grey Divider

File Changes

Bug fix (1)
RoutingService.InstructLoop.cs Disable saving routing instruction arguments into conversation state +1/-1

Disable saving routing instruction arguments into conversation state

• Comments out the call that persisted routing instruction arguments as conversation state during the instruction loop, preventing unintended state mutation from transient instruction parameters.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs


Grey Divider

Qodo Logo

Comment thread src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs Outdated
yileicn added 2 commits June 16, 2026 10:12
Allow required routing rules to stay available for validation and
redirects without exposing those fields for router-generated args.
@yileicn

yileicn commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

/review

@qodo-code-review

qodo-code-review Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit 56d1ee7

@yileicn yileicn merged commit 00469d8 into SciSharp:master Jun 16, 2026
4 checks passed
Comment on lines +58 to +78
private static List<RoutingRule> MergeRoutingRules(List<RoutingRule>? existingRules, List<RoutingRule>? incomingRules)
{
if (incomingRules == null)
{
return [];
}

foreach (var incomingRule in incomingRules)
{
var existingRule = existingRules?.FirstOrDefault(x =>
string.Equals(x.Field, incomingRule.Field, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Type, incomingRule.Type, StringComparison.OrdinalIgnoreCase));

if (existingRule != null)
{
incomingRule.AllowLlmFill = existingRule.AllowLlmFill;
}
}

return incomingRules;
}

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. mergeroutingrules returns incoming reference 📘 Rule violation ☼ Reliability

MergeRoutingRules mutates the caller-provided incomingRules list (and its items) and then
returns the same list instance, which is assigned to record.RoutingRules. This can leak state
across components/requests and allow later mutations of the input list to affect persisted agent
state.
Agent Prompt
## Issue description
`MergeRoutingRules` modifies and stores the caller-provided `incomingRules` list by reference (`incomingRule.AllowLlmFill = ...` and `return incomingRules;`). This violates the defensive-copy requirement for caller-provided mutable collections and can cause unintended shared-state mutations.

## Issue Context
The assignment `record.RoutingRules = MergeRoutingRules(record.RoutingRules, agent.RoutingRules);` stores whatever `MergeRoutingRules` returns into a long-lived/persisted record. `incomingRules` originates from the caller (`agent.RoutingRules`) and is mutable.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Agents/Services/AgentService.UpdateAgent.cs[58-78]

## Implementation notes
- Avoid mutating `incomingRules` directly.
- Create a new `List<RoutingRule>` and clone/copy each incoming rule (e.g., `incomingRule.Clone()`), then apply the merge behavior onto the copies.
- Return the new list and assign that list to `record.RoutingRules`.

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

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