diff --git a/src/Infrastructure/BotSharp.Core/Routing/Reasoning/OneStepForwardReasoner.cs b/src/Infrastructure/BotSharp.Core/Routing/Reasoning/OneStepForwardReasoner.cs index 7d396fbb2..9a96cef72 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/Reasoning/OneStepForwardReasoner.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/Reasoning/OneStepForwardReasoner.cs @@ -15,6 +15,7 @@ limitations under the License. ******************************************************************************/ using BotSharp.Abstraction.Infrastructures.Enums; +using BotSharp.Abstraction.MLTasks; using BotSharp.Abstraction.Routing.Models; using BotSharp.Abstraction.Routing.Reasoning; using BotSharp.Abstraction.Templating; @@ -61,9 +62,15 @@ public async Task GetNextInstruction(Agent router, string m MessageId = messageId } }; - var response = await completion.GetChatCompletions(router, dialogs); - var inst = response.Content.JsonContent(); + // Force tool_choice=required so the LLM always returns the instruction as a function call, + // eliminating format drift where the LLM completes with finishReason=stop and returns + // free text or JSON in Content instead of a structured function call. + var response = await GetChatCompletionsWithScopedState(completion, router, dialogs, "tool_choice", "required"); + + var inst = response.FunctionArgs?.JsonContent(); + _logger.LogInformation("[OneStepForwardReasoner] ConversationId: {ConversationId}, MessageId: {MessageId}, Next instruction: {Instruction}", + _services.GetRequiredService().ConversationId, messageId, response.FunctionArgs); // Fix LLM malformed response await ReasonerHelper.FixMalformedResponse(_services, inst); @@ -102,6 +109,30 @@ public async Task AgentExecuted(Agent router, FunctionCallFromLlm inst, Ro return true; } + /// + /// Runs chat completion with a scoped conversation state that is set before the call + /// and guaranteed to be removed afterwards, even if the completion throws. + /// + private async Task GetChatCompletionsWithScopedState( + IChatCompletion completion, + Agent agent, + List dialogs, + string stateKey, + string stateValue) + { + var states = _services.GetRequiredService(); + states.SetState(stateKey, stateValue, source: StateSource.Application); + + try + { + return await completion.GetChatCompletions(agent, dialogs); + } + finally + { + states.RemoveState(stateKey); + } + } + private string GetNextStepPrompt(Agent router) { var template = router.Templates.First(x => x.Name == "reasoner.one-step-forward").Content; diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs index 45682e619..f0a4dd716 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs @@ -405,6 +405,12 @@ private async Task InnerGetChatCompletionsStreamingAsync(Agent } } + // Apply tool_choice only when tools are present; tool_choice is rejected by the API otherwise. + if (!options.Tools.IsNullOrEmpty() && _state.GetState("tool_choice") == "required") + { + options.ToolChoice = ChatToolChoice.CreateRequiredChoice(); + } + if (!string.IsNullOrEmpty(agent.Knowledges)) { messages.Add(new SystemChatMessage(agent.Knowledges));