The Error
Your Semantic Kernel application fails with this exception during a multi-turn conversation:
Azure.RequestFailedException: 400 (Bad Request)
{
"error": {
"code": "model_error",
"message": "tool_calls must be followed by tool messages"
}
}
This is not a transient error — it is a message ordering violation in your ChatHistory. Azure OpenAI enforces a strict sequence requirement: every assistant message that contains a tool call must be immediately followed by a tool result message. Any other message in between produces this 400.
When It Triggers
The error surfaces in multi-turn conversations that use FunctionChoiceBehavior.Auto(). Here is the typical sequence that breaks:
- User sends message — SK calls Azure OpenAI
- Model responds with a tool call — SK adds the assistant message to
ChatHistory - SK executes the function and adds the tool result —
ChatHistorynow has the correct pairing - User sends a follow-up message before the conversation continues — SK adds this new user message to
ChatHistory - SK calls Azure OpenAI again —
ChatHistorynow has a user message sitting after an old tool call → tool result block that was not properly closed out in a previous turn
The history is malformed from Azure OpenAI’s perspective, even though the SK auto-invoke appeared to handle it correctly in the moment. The corruption accumulates over multiple turns.
This is tracked as SK Issue #7626 on the Semantic Kernel GitHub repository.
Root Cause: Message Ordering Requirements
Azure OpenAI enforces a strict message sequence for function calling conversations. The required order is:
Turn 1: User → "What's the weather in London?"
Turn 2: Assistant → [tool_call: get_weather(city="London")]
Turn 3: Tool → [result: "15°C, partly cloudy"]
Turn 4: Assistant → "The weather in London is 15°C..."
Visualized as roles in ChatHistory:
User → Assistant (with tool call) → Tool (result) → Assistant (final answer) → User → ...
The constraint is: a Tool message must immediately follow the Assistant message that produced the tool call. If anything else — another User message, a System message, another Assistant message — appears between them, the 400 error fires.
SK’s auto-invoke path can violate this constraint in complex multi-turn scenarios, particularly when user input arrives asynchronously relative to function execution.
Workaround 1: Manual Function Call Loop
The most reliable fix is to disable SK’s auto-invoke and handle the function call loop yourself. This gives you direct control over message ordering:
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
var chatHistory = new ChatHistory("You are a helpful assistant.");
chatHistory.AddUserMessage(userMessage);
var settings = new OpenAIPromptExecutionSettings
{
// Disable auto-invoke — we'll handle function calls manually
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
};
var chatService = kernel.GetRequiredService<IChatCompletionService>();
while (true)
{
var response = await chatService.GetChatMessageContentAsync(
chatHistory, settings, kernel);
chatHistory.Add(response);
// Check if the model wants to call a function
if (response.Content?.Contains("tool_calls") == true ||
response.Items?.OfType<FunctionCallContent>().Any() == true)
{
// Process each function call
foreach (var call in response.Items?.OfType<FunctionCallContent>() ?? [])
{
var result = await kernel.InvokeAsync(
call.PluginName, call.FunctionName, call.Arguments);
// Add tool result immediately after the assistant message
chatHistory.Add(new ChatMessageContent(AuthorRole.Tool,
result.GetValue<string>() ?? string.Empty));
}
// Continue the loop to get the final response
}
else
{
// Model gave a final text response — we're done
Console.WriteLine(response.Content);
break;
}
}
The key discipline here: tool result messages are added to ChatHistory immediately after processing each function call, before any user input is accepted. The loop structure enforces the correct ordering invariant.
For a deeper look at how function calling and tool use works with Semantic Kernel, including plugin registration and argument handling, see the university guide.
Workaround 2: FunctionInvokingChatClient from MEAI
Microsoft.Extensions.AI provides FunctionInvokingChatClient, a middleware layer that manages the function calling loop — including correct message ordering — independent of SK’s auto-invoke path:
using Microsoft.Extensions.AI;
// Register with FunctionInvokingChatClient middleware
builder.Services.AddAzureOpenAIChatClient(
new Uri(endpoint), new AzureKeyCredential(apiKey))
.UseFunctionInvocation(); // Handles tool call ↔ tool result ordering correctly
// Register kernel functions as tools
builder.Services.AddChatOptions(options =>
{
options.Tools = [AIFunctionFactory.Create(MyPlugin.GetWeather)];
});
FunctionInvokingChatClient wraps the underlying IChatClient and intercepts responses that contain tool calls, executes them, appends the results with correct message ordering, and continues the conversation — all before returning to your application code. The ordering bug that affects SK’s auto-invoke path does not occur because MEAI controls the entire message sequence.
For a comparison of MEAI’s abstractions versus Semantic Kernel’s approach and when to use each, see Microsoft.Extensions.AI vs Semantic Kernel vs Agent Framework.
Workaround 3: ChatHistory Validation and Repair
If you cannot change the function calling approach immediately, add a validation pass before every multi-turn AI request to detect and remove orphaned tool call messages:
public static void ValidateAndRepairToolCallOrdering(ChatHistory history)
{
for (int i = 0; i < history.Count - 1; i++)
{
var current = history[i];
var next = history[i + 1];
bool currentHasToolCall = current.Role == AuthorRole.Assistant &&
current.Items?.OfType<FunctionCallContent>().Any() == true;
bool nextIsNotTool = next.Role != AuthorRole.Tool;
if (currentHasToolCall && nextIsNotTool)
{
// Remove the orphaned tool call to prevent the 400 error
history.RemoveAt(i);
i--; // Recheck current position
}
}
}
// Call before every multi-turn AI request
ValidateAndRepairToolCallOrdering(chatHistory);
This approach removes the malformed assistant-with-tool-call message, which means the conversation loses that turn’s context. The result is a valid ChatHistory that Azure OpenAI can process without errors, at the cost of that tool call’s exchange being dropped from context. This is a mitigation, not a fix — use it as a safety net while migrating to Workaround 1 or 2.
Diagnosing the Error in Production
Because this error is intermittent and depends on the exact sequence of operations in a multi-turn conversation, it is difficult to reproduce in unit tests. Add structured logging of ChatHistory message roles before each API call:
_logger.LogDebug(
"ChatHistory roles before API call: {Roles}",
string.Join(" → ", chatHistory.Select(m => m.Role.ToString())));
When the 400 error occurs, the log entry immediately before it will show the role sequence that caused the violation. Look for Assistant → User or Assistant → Assistant patterns following an assistant message — those indicate an ordering violation.
Tracking the Fix
SK Issue #7626 is an open issue in the Semantic Kernel repository. Recent releases (1.50.0+) have included partial fixes for specific ordering scenarios, but the issue can still occur in complex multi-turn flows as of 1.54.0. Check the SK GitHub releases changelog for entries mentioning function calling or tool call ordering.