microsoft / microsoft/agent-framework

.NET: [Bug]: Rejected tool approvals are re-requested unless CreateResponse(false, reason) provides a reason (using GPT-5.6-Terra)

Open
#8,503 1 comment 0 reactions 1 assignee Claimed by @westey-m View on GitHub
.NET reproduced
Dominant language
Python
Stars
13.6k
Forks
2.3k
Avg merge
2d 45m
Merged PRs (30d)
358

Description

### Description

When a tool is wrapped in `ApprovalRequiredAIFunction` and the application rejects the approval request with `approvalRequest.CreateResponse(false)` (i.e. **without** a reason), the model immediately asks for approval of **the very same tool call again**. Only after the second rejection does the agent give up and produce a final answer.

Adding a rejection reason - `approvalRequest.CreateResponse(false, "The user denied the permission to run this tool.")` - fixes the issue: the tool is requested only once, and the agent produces the final answer straight away.

The behavior is model dependent:

| Deployment | `CreateResponse(false)` | `CreateResponse(false, reason)` |
| --- | --- | --- |
| `gpt-5.5` | 1 approval round | 1 approval round |
| `gpt-5.6-terra` | **2 approval rounds** | 1 approval round |

Once an approval request has been rejected, the agent loop should not ask for approval of the same tool call again, regardless of whether an optional reason was supplied. `reason` is documented as *"An optional reason for the approval or rejection"*, so the loop should not silently depend on it for correctness.

**Root cause**

In `FunctionInvokingChatClient.GenerateRejectedFunctionResults`, the `FunctionResultContent` returned to the model is built like this:

```csharp
string result = "Tool call invocation rejected.";
if (!string.IsNullOrWhiteSpace(m.Response.Reason))
{
result = $"{result} {m.Response.Reason}";
}
```

So without a reason the model only sees the bare string `"Tool call invocation rejected."`. That sentence does not state **who** rejected the call nor whether the rejection is final, so it reads as a transient/technical failure and the model is free to retry. Newer models (`gpt-5.6-terra`) do retry; `gpt-5.5` does not. With a reason appended, the message becomes unambiguous and the retry disappears.

Note this also means the number of approval round-trips, and therefore the number of prompts shown to the end user, depends on the model, which makes human-in-the-loop UX non-deterministic across deployments.

**Suggested fix**

Make the default rejection message self-explanatory even when no reason is supplied, for example: `"Tool call invocation rejected by the user. Do not attempt to call this tool again."` Alternatively, document clearly that supplying a `reason` is effectively required to keep the human-in-the-loop flow stable.

### Code Sample

```csharp
#!/usr/bin/env dotnet

#:sdk Microsoft.NET.Sdk

#:property OutputType=Exe
#:property TargetFramework=net10.0
#:property ImplicitUsings=enable
#:property Nullable=enable
#:property NoWarn=$(NoWarn);MEAI001;OPENAI001;MAAI001
#:property PublishAot=false

#:package Microsoft.Agents.AI.OpenAI@1.21.0

using System.ClientModel;
using System.ComponentModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;

var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY") ?? throw new InvalidOperationException("AZURE_OPENAI_KEY is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.5";

Console.WriteLine($"Deployment: {deploymentName}");
Console.WriteLine();

await RunAsync(rejectionReason: null);
await RunAsync(rejectionReason: "The user denied the permission to run this tool.");

async Task RunAsync(string? rejectionReason)
{
Console.WriteLine($"--- Rejection reason: {rejectionReason ?? "(none)"} ---");

var chatClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new(endpoint) })
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName);

var agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new()
{
Instructions = "You are a helpful assistant.",
Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetCurrentDateTime, nameof(GetCurrentDateTime)))]
}
});

var session = await agent.CreateSessionAsync();
var response = await agent.RunAsync("What time is it?", session);

var rounds = 0;
var approvalRequests = GetApprovalRequests(response);

while (approvalRequests.Count > 0)
{
rounds++;

foreach (var approvalRequest in approvalRequests)
{
Console.WriteLine($"Round {rounds}: approval requested for '{((FunctionCallContent)approvalRequest.ToolCall).Name}' -> rejecting");
}

// Every request is always rejected.
var approvalResponses = approvalRequests
.ConvertAll(approvalRequest => new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(false, rejectionReason)]));

response = await agent.RunAsync(approvalResponses, session);
approvalRequests = GetApprovalRequests(response);
}

Console.WriteLine($"Total approval rounds: {rounds}");
Console.WriteLine($"Answer: {response.Text}");
Console.WriteLine();
}

static List GetApprovalRequests(AgentResponse response)
=> response.Messages.SelectMany(message => message.Contents).OfType().ToList();

[Description("""
Returns the current date and time.
ALWAYS call this tool FIRST when the question involves ANY time reference, including: current date/time ('today', 'now', 'current year'),
relative periods ('recent', 'last/past X days/months/years', 'in the last decade'), time ranges that depend on today's date ('from 2020 to now', 'since January'),
time calculations ('how long since', 'time elapsed'), or temporal filtering ('latest', 'newest', 'most recent'). You do NOT know the current date - you MUST call this tool to determine it.
""")]
static DateTimeOffset GetCurrentDateTime() => DateTimeOffset.UtcNow;
```

With `AZURE_OPENAI_DEPLOYMENT_NAME=gpt-5.6-terra`:

Deployment: gpt-5.6-terra

--- Rejection reason: (none) ---
Round 1: approval requested for 'GetCurrentDateTime' -> rejecting
Round 2: approval requested for 'GetCurrentDateTime' -> rejecting
Total approval rounds: 2
Answer: Sorry, I'm unable to retrieve the current time right now.

--- Rejection reason: The user denied the permission to run this tool. ---
Round 1: approval requested for 'GetCurrentDateTime' -> rejecting
Total approval rounds: 1
Answer: I can't access the current time because permission to check it was denied.

With `AZURE_OPENAI_DEPLOYMENT_NAME=gpt-5.5` (a single round in both cases, so the bug is not visible):

Deployment: gpt-5.5

--- Rejection reason: (none) ---
Round 1: approval requested for 'GetCurrentDateTime' -> rejecting
Total approval rounds: 1
Answer: I'm unable to access the current time from here. Please check your device's clock for the exact local time.

--- Rejection reason: The user denied the permission to run this tool. ---
Round 1: approval requested for 'GetCurrentDateTime' -> rejecting
Total approval rounds: 1
Answer: I don't have access to the current time because the time tool wasn't available. Please check your device clock for the exact current time.

### Package Versions

Microsoft.Agents.AI.OpenAI: 1.21.0

### .NET Version

.NET 10.0 (SDK 10.0.401)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.