diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs
index 50bfa530..144edb71 100644
--- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs
+++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStateService.cs
@@ -377,6 +377,17 @@ public class ConversationStateService : IConversationStateService
&& !string.IsNullOrEmpty(_curStates[name].Values.Last().Data);
}
+ ///
+ /// Argument names that must never be written as conversation state. They are BotSharp's own
+ /// LLM-routing keys (read back by CompletionProvider.GetProviderAndModel): a tool whose
+ /// arguments happen to use one of these names would otherwise silently retarget the model for
+ /// the next round. A board tool's provider argument, for example, replaced the resolved
+ /// LLM provider with "forgejo", so the re-invocation after the tool call resolved no completion
+ /// provider and threw a NullReferenceException in RoutingService.InvokeAgent.
+ ///
+ private static readonly HashSet ReservedLlmStateKeys =
+ new(StringComparer.OrdinalIgnoreCase) { "provider", "model", "model_id" };
+
public void SaveStateByArgs(JsonDocument args)
{
if (args == null)
@@ -388,6 +399,9 @@ public class ConversationStateService : IConversationStateService
{
foreach (JsonProperty property in root.EnumerateObject())
{
+ // Never let a function argument shadow the LLM routing state.
+ if (ReservedLlmStateKeys.Contains(property.Name)) continue;
+
var propertyValue = property.Value;
var stateValue = propertyValue.ToString();
if (!string.IsNullOrEmpty(stateValue))