Description
Combining backend tools (AIFunction) and frontend/client-side tools (declaration-only AIFunctionDeclarations, as used by AG-UI client tools) on the same agent is a supported, documented scenario — see #1894 (closed as done) and the separate "Backend Tool Rendering" / "Frontend Tool Rendering" tutorials. However, neither tutorial exercises a turn where the model calls both kinds of tool in the same completion, and that combination currently breaks.
When FunctionInvokingChatClient receives a response containing both an invocable backend FunctionCallContent and a declaration-only (frontend) FunctionCallContent in the same iteration, ShouldTerminateLoopBasedOnHandleableFunctions terminates the loop the instant it finds the non-invocable tool — before any tool in that iteration is invoked:
// src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs
if (tool is not null)
{
if (tool is not AIFunction)
{
// The tool was found but it's not invocable. Regardless of TerminateOnUnknownCallRequests,
// we need to break out of the loop so that callers can handle all the call requests.
LogNonInvocableFunction(fcc.Name);
return true;
}
}
This check runs unconditionally — it isn't gated by TerminateOnUnknownCalls or any other public setting — and it returns true for the whole iteration, not just the non-invocable call. The result: the backend tool call is never invoked, and both FunctionCallContents (the un-invoked backend one and the frontend one) are returned to the caller.
In an AG-UI hosting scenario (Microsoft.Agents.AI.Hosting.AGUI.AspNetCore + Microsoft.Agents.AI.AGUI client), both FCCs stream to the browser. The client resolves the frontend call and posts back a real FunctionResultContent for it on the next turn — but the backend call was never actually executed server-side, so it has no result. When the browser replays conversation history, OpenAI rejects the next request with:
400 - No tool output found for function call call_XXXXXXXX
...because that backend call's call_id has an orphaned tool-call entry with no paired tool message.
Expected behavior: within a single iteration, FunctionInvokingChatClient should invoke every invocable function call queued in that iteration (honoring AllowConcurrentInvocation for parallelism), and only then break out of the loop, returning the remaining declaration-only FunctionCallContents to the caller for out-of-band resolution — rather than discarding already-runnable backend calls just because a sibling call in the same batch happens to be non-invocable.
I confirmed this is not a version regression: the method body (including this exact branch and comment) is byte-for-byte identical between the Microsoft.Extensions.AI 10.6.0 and 10.7.0 source (commits c8437bc2 and fa0072f1 in dotnet/extensions).
This looks like the same root-cause family as #3425 (Python: Workflow doesn't pause when agent calls a declaration_only tool, causing a similar orphaned-tool-call 400) — declaration-only tool handling doesn't seem to account for other, invocable, tool calls present in the same model turn, across both the Python and .NET implementations.
Code Sample
Minimal repro, combining the two official AG-UI tutorials (backend SearchRestaurants tool + frontend GetUserLocation tool) onto a single agent:
// Server (based on the Backend Tool Rendering tutorial)
[Description("Search for restaurants in a location.")]
static string SearchRestaurants([Description("City to search in")] string location) =>
$"Found 3 great restaurants in {location}.";
AITool[] backendTools = [AIFunctionFactory.Create(SearchRestaurants)];
AIAgent agent = chatClient.AsAIAgent(
name: "MixedToolsAgent",
instructions: "You are a helpful travel assistant.",
tools: backendTools);
app.MapAGUI("/", agent);
// Client (based on the Frontend Tool Rendering tutorial)
[Description("Get the user's current location from GPS.")]
static string GetUserLocation() => "Amsterdam, Netherlands";
AITool[] frontendTools = [AIFunctionFactory.Create(GetUserLocation)];
AIAgent agent = chatClient.AsAIAgent(
name: "agui-client",
description: "AG-UI Client Agent",
tools: frontendTools);
// Prompt designed to elicit both tool calls in one completion, e.g.:
// "Find my current location, then search for restaurants there."
When the model returns both GetUserLocation (frontend, declaration-only on the server) and SearchRestaurants (backend, invocable) function calls in the same response, the server never invokes SearchRestaurants — both calls stream to the client unexecuted, and the next round-trip 400s.
Error Messages / Stack Traces
OpenAI.ClientResultException: 400 (invalid_request_error)
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. The following tool_call_ids did not
have response messages: call_XXXXXXXX
Package Versions
Microsoft.Agents.AI: 1.13.0, Microsoft.Agents.AI.OpenAI: 1.13.0, Microsoft.Agents.AI.Hosting.AGUI.AspNetCore: 1.13.0-preview.260703.1, Microsoft.Agents.AI.AGUI: 1.13.0-preview.260703.1, Microsoft.Extensions.AI.OpenAI: 10.7.0 (Microsoft.Extensions.AI 10.7.0 transitively)
.NET Version
.NET 10.0
Additional Context
Workaround we're currently using: an agent-run middleware that, at the run boundary, rewrites every declaration-only tool in ChatOptions.Tools into an invocable no-op AIFunction stub. The stub's body sets FunctionInvokingChatClient.CurrentContext.Terminate = true and returns a sentinel value; this lets FIC invoke all FCCs in the iteration (with AllowConcurrentInvocation = true, they run in parallel) — including the real backend tool — before the loop ends. A post-processing filter then strips the synthetic sentinel FunctionResultContent from the outbound stream so the original frontend FunctionCallContent still reaches the client for local execution, unaffected. Happy to share the full middleware if useful for a fix or test case.
Related: #1894, #3425
Description
Combining backend tools (
AIFunction) and frontend/client-side tools (declaration-onlyAIFunctionDeclarations, as used by AG-UI client tools) on the same agent is a supported, documented scenario — see #1894 (closed as done) and the separate "Backend Tool Rendering" / "Frontend Tool Rendering" tutorials. However, neither tutorial exercises a turn where the model calls both kinds of tool in the same completion, and that combination currently breaks.When
FunctionInvokingChatClientreceives a response containing both an invocable backendFunctionCallContentand a declaration-only (frontend)FunctionCallContentin the same iteration,ShouldTerminateLoopBasedOnHandleableFunctionsterminates the loop the instant it finds the non-invocable tool — before any tool in that iteration is invoked:This check runs unconditionally — it isn't gated by
TerminateOnUnknownCallsor any other public setting — and it returnstruefor the whole iteration, not just the non-invocable call. The result: the backend tool call is never invoked, and bothFunctionCallContents (the un-invoked backend one and the frontend one) are returned to the caller.In an AG-UI hosting scenario (
Microsoft.Agents.AI.Hosting.AGUI.AspNetCore+Microsoft.Agents.AI.AGUIclient), both FCCs stream to the browser. The client resolves the frontend call and posts back a realFunctionResultContentfor it on the next turn — but the backend call was never actually executed server-side, so it has no result. When the browser replays conversation history, OpenAI rejects the next request with:...because that backend call's
call_idhas an orphaned tool-call entry with no paired tool message.Expected behavior: within a single iteration,
FunctionInvokingChatClientshould invoke every invocable function call queued in that iteration (honoringAllowConcurrentInvocationfor parallelism), and only then break out of the loop, returning the remaining declaration-onlyFunctionCallContents to the caller for out-of-band resolution — rather than discarding already-runnable backend calls just because a sibling call in the same batch happens to be non-invocable.I confirmed this is not a version regression: the method body (including this exact branch and comment) is byte-for-byte identical between the Microsoft.Extensions.AI 10.6.0 and 10.7.0 source (commits
c8437bc2andfa0072f1in dotnet/extensions).This looks like the same root-cause family as #3425 (Python: Workflow doesn't pause when agent calls a
declaration_onlytool, causing a similar orphaned-tool-call 400) — declaration-only tool handling doesn't seem to account for other, invocable, tool calls present in the same model turn, across both the Python and .NET implementations.Code Sample
Minimal repro, combining the two official AG-UI tutorials (backend
SearchRestaurantstool + frontendGetUserLocationtool) onto a single agent:When the model returns both
GetUserLocation(frontend, declaration-only on the server) andSearchRestaurants(backend, invocable) function calls in the same response, the server never invokesSearchRestaurants— both calls stream to the client unexecuted, and the next round-trip 400s.Error Messages / Stack Traces
Package Versions
Microsoft.Agents.AI: 1.13.0, Microsoft.Agents.AI.OpenAI: 1.13.0, Microsoft.Agents.AI.Hosting.AGUI.AspNetCore: 1.13.0-preview.260703.1, Microsoft.Agents.AI.AGUI: 1.13.0-preview.260703.1, Microsoft.Extensions.AI.OpenAI: 10.7.0 (Microsoft.Extensions.AI 10.7.0 transitively)
.NET Version
.NET 10.0
Additional Context
Workaround we're currently using: an agent-run middleware that, at the run boundary, rewrites every declaration-only tool in
ChatOptions.Toolsinto an invocable no-opAIFunctionstub. The stub's body setsFunctionInvokingChatClient.CurrentContext.Terminate = trueand returns a sentinel value; this lets FIC invoke all FCCs in the iteration (withAllowConcurrentInvocation = true, they run in parallel) — including the real backend tool — before the loop ends. A post-processing filter then strips the synthetic sentinelFunctionResultContentfrom the outbound stream so the original frontendFunctionCallContentstill reaches the client for local execution, unaffected. Happy to share the full middleware if useful for a fix or test case.Related: #1894, #3425