Skip to content

.NET: [Bug]: FunctionInvokingChatClient drops sibling backend tool calls when a frontend (declaration-only) tool call appears in the same iteration #6922

Description

@pjmagee

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

Metadata

Metadata

Assignees

Labels

.NETUsage: [Issues, PRs], Target: .Net

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions