ag-ui-protocol / ag-ui-protocol/ag-ui

[Bug]: (.NET SDK) Server tool approvals are silently bypassed when the client declares any frontend tool

Offen
#2,393 1 Kommentar 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
Vorherrschende Sprache
Python
Sterne
15.9k
Forks
1.4k
Ø Merge
1 T. 17 Std.
Gemergte PRs (30 T.)
163

Beschreibung

### 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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.