ag-ui-protocol / ag-ui-protocol/ag-ui
[Bug]: (.NET SDK) Server tool approvals are silently bypassed when the client declares any frontend tool
- Dominant language
- Python
- Stars
- 15.9k
- Forks
- 1.4k
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 163
Description
### Pre-flight Checklist
- [x] I have searched [existing issues](https://github.com/ag-ui-protocol/ag-ui/issues) and this hasn't been reported yet.
- [x] I am using the **latest** version AG-UI.
### Describe the Bug
When `RunAgentInput.tools` is non-empty, `AGUI.Server` stops raising interrupts for server tools the host gated with `ApprovalRequiredAIFunction`. The run finishes `success`, and on the next round trip `ProcessContinuation` stamps `approved: true` on the still-unanswered call — so the gated operation executes without the user ever being asked.
The guard tests whether a frontend tool was **declared**, not whether one was **called**, so a turn whose only tool call is the gated server one is affected just the same. The client cannot compensate: with no interrupt raised it has nothing to prompt on, and replays the call verbatim.
Hosts gate `ApprovalRequiredAIFunction` on consequential operations — creating users, refunds, deletes. This turns that gate off with no error and no log.
### Steps to Reproduce
1. Add the test below as `sdks/dotnet/tests/AGUI.Server.UnitTests/GatedServerToolInterruptTest.cs`.
2. Run `dotnet test tests/AGUI.Server.UnitTests -p:SignAssembly=false --framework net10.0` from `sdks/dotnet`. (The `SignAssembly` flag is #2165, unrelated to this.)
3. The assertion fails — see **Logs & Errors**.
Remove `Tools` from the input and it passes. That one field is the difference.
```csharp
using System.Text.Json;
using AGUI.Abstractions;
using Microsoft.Extensions.AI;
using Xunit;
namespace AGUI.Server.UnitTests;
public sealed class GatedServerToolInterruptTest
{
private static readonly JsonSerializerOptions SerializerOptions = AIJsonUtilities.DefaultOptions;
[Fact]
public async Task GatedServerTool_WithAClientToolDeclared_ShouldInterrupt()
{
var input = new RunAgentInput
{
ThreadId = "t1",
RunId = "r1",
// A frontend tool is only DECLARED here. It is never called.
Tools = [new AGUITool { Name = "highlightRow", Parameters = EmptySchema }],
Messages =
[
new AGUIUserMessage
{
Id = "m1",
Content = [new AGUITextInputContent { Text = "Create a user" }],
},
],
};
var context = input.ToChatRequestContext(SerializerOptions);
var events = new List();
await foreach (var e in PendingApproval().AsAGUIEventStreamAsync(context).ConfigureAwait(false))
{
events.Add(e);
}
var finished = events.OfType().Single();
Assert.IsType(finished.Outcome);
}
private static JsonElement EmptySchema =>
JsonDocument.Parse("""{"type":"object","properties":{}}""").RootElement;
// The host gated CreateUser with ApprovalRequiredAIFunction, so the run ends on a pending approval.
private static async IAsyncEnumerable PendingApproval()
{
yield return new ChatResponseUpdate(
ChatRole.Assistant,
[new ToolApprovalRequestContent("ficc_c1", new FunctionCallContent("c1", "CreateUser"))]);
await Task.CompletedTask.ConfigureAwait(false);
}
}
```
### Expected Behavior
`RUN_FINISHED` carries `outcome: { "type": "interrupt" }` with a `tool_call` interrupt for `CreateUser`, exactly as it does when no frontend tool is declared, so the client can prompt and resume.
Instead it carries `outcome: { "type": "success" }`, and the call is auto-approved on the following run.
### Environment
```text
AG-UI package(s) & version(s): AGUI.Server / AGUI.Abstractions 0.0.3 and 0.0.5
Also reproduced from source on main @ e05916d
Runtime: .NET 10
Microsoft.Extensions.AI: 10.7.0
Reached via: Microsoft.Agents.AI.Hosting.AGUI.AspNetCore 1.16.0-preview.260730.1
```
### Logs & Errors
```shell
Assert.IsType() Failure: Value is not the exact type
Expected: typeof(AGUI.Abstractions.RunFinishedInterruptOutcome)
Actual: typeof(AGUI.Abstractions.RunFinishedSuccessOutcome)
```
### Additional Context
**Where it happens.** Two guards, both keyed off `clientToolNames` / `isContinuation`.
The interrupt is suppressed — `ChatResponseUpdateAGUIExtensions.cs:465-473`:
```csharp
if (clientToolNames.Contains(toolCall.Name)) { break; } // correct: client tools are the client's
// In mixed invocation (first turn), don't accumulate interrupts.
if (clientToolNames.Count > 0 && !isContinuation) { break; } // also drops host-gated tools
```
Then auto-approved — `RunAgentInputExtensions.cs:298-302`:
```csharp
if (content is FunctionCallContent fcc && !resolvedCallIds.Contains(fcc.CallId))
{
var request = new ToolApprovalRequestContent($"approval_{fcc.CallId}", fcc);
newContents.Add(request);
approvalResponses.Add(request.CreateResponse(approved: true)); // not scoped to clientToolNames
}
```
**Why they exist.** `FunctionInvokingChatClient` converts **every** call in a response to a `ToolApprovalRequestContent` once any one of them is gated — by design, since it cannot return a partial result set to the model (microsoft/agent-framework#3054). So a read-only server tool batched with a client tool is indistinguishable from a genuinely gated one, and `ToolApprovalRequestContent` carries nothing to tell them apart. The current resolution of that ambiguity fails open.
**Possibly relevant while fixing:** `ConfigureForMixedInvocation`'s doc says it "wraps client tools in `ApprovalRequiredAIFunction`", but `AsAITools` yields `AIFunctionDeclaration`s, so `tool is AIFunction` is false and every wire-declared client tool takes the `else` branch unwrapped. That branch appears to be dead for `AGUITool` input — what actually stops the run on a client tool is that a declaration cannot be invoked.
**Related.** Same method and the same kind of unchecked assumption as #2359, but a different code path (`ToolApprovalRequestContent` rather than `FunctionCallContent`).
**Not a workaround:** restricting the model to one tool call per turn does not avoid this — the guard tests whether a frontend tool was declared, not whether one was called. The repro above issues a single call.
Contributor guide
Assessment
This issue has not been assessed yet.