Description
Summary
When using ApprovalRequiredAIFunction to wrap domain tools in a handoff workflow (AgentWorkflowBuilder.CreateHandoffBuilderWith), the HandoffAgentExecutor does not emit a RequestInfoEvent when FunctionInvokingChatClient produces a ToolApprovalRequestContent. The approval request is silently passed through as an AgentResponseUpdateEvent and the workflow proceeds to HandoffEnd, making it impossible for the consumer to approve/reject the tool call through the standard SendResponseAsync mechanism.
In contrast, AIAgentHostExecutor (used in GroupChat patterns) correctly handles ToolApprovalRequestContent via AIContentExternalHandler<ToolApprovalRequestContent> and emits a RequestInfoEvent that the caller can respond to.
Environment
| Component |
Details |
| OS |
macOS (Apple Silicon / arm64) |
| Runtime |
.NET 8.0 |
Microsoft.Agents.AI |
1.0.0-rc4 |
Microsoft.Agents.AI.Workflows |
1.0.0-rc4 |
Microsoft.Extensions.AI |
10.4.1 |
Microsoft.Extensions.AI.Abstractions |
10.4.1 |
Regression? Partially — The parent issue #1982 was closed as completed via PR #3142 (Jan 26), which added HIL support to AIAgentHostExecutor. However, HandoffAgentExecutor was never updated, so this has been broken since the handoff workflow pattern was introduced.
Steps to Reproduce
-
Create a new .NET 8 console app with the following .csproj:
HandoffApprovalRepro.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-rc4" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-rc4" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.4.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.5" />
</ItemGroup>
</Project>
-
Replace Program.cs with the full repro below (uses a fake IChatClient — no real LLM or Azure credentials needed):
Program.cs — full self-contained repro
// ============================================================================
// Minimal reproduction: ToolApprovalRequestContent not handled in Handoff workflow
// ============================================================================
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is experimental
// ── Logging ──
using var loggerFactory = LoggerFactory.Create(b =>
b.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; })
.SetMinimumLevel(LogLevel.Debug));
var logger = loggerFactory.CreateLogger("Repro");
// ── Fake LLM that simulates calling a tool ──
var fakeLlm = new FakeChatClient(loggerFactory.CreateLogger<FakeChatClient>());
// ── Mutation tool wrapped with ApprovalRequiredAIFunction ──
var sendEmailFn = AIFunctionFactory.Create(
(string to, string subject) =>
{
logger.LogWarning("*** send_email was invoked DIRECTLY (no approval) ***");
return $"Email sent to {to}: {subject}";
},
new AIFunctionFactoryOptions { Name = "send_email", Description = "Sends an email" });
var approvalTool = new ApprovalRequiredAIFunction(sendEmailFn);
var readTool = AIFunctionFactory.Create(
(string id) =>
{
logger.LogInformation("get_status called for {Id}", id);
return "Status: pending";
},
new AIFunctionFactoryOptions { Name = "get_status", Description = "Gets the status of a request (read-only)" });
// ── Build agents ──
var agentA = new ChatClientAgent(
fakeLlm,
new ChatClientAgentOptions
{
Id = "Router",
Name = "Router",
Description = "Routes users to the correct agent",
ChatOptions = new ChatOptions
{
Instructions = "You are a router agent. Transfer to Worker when the user needs an action performed."
}
},
loggerFactory);
var agentB = new ChatClientAgent(
fakeLlm,
new ChatClientAgentOptions
{
Id = "Worker",
Name = "Worker",
Description = "Performs actions on behalf of the user",
ChatOptions = new ChatOptions
{
Instructions = "You are a worker agent. Use send_email when the user asks to send an email.",
Tools = new List<AITool> { readTool, approvalTool }
}
},
loggerFactory);
// ── Handoff workflow: Router → Worker ──
var workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB, "User needs an action performed")
.Build();
// ── Run the workflow ──
logger.LogInformation("=== Starting handoff workflow ===");
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Send an email to alice@example.com with subject 'Hello'.")
};
var run = await InProcessExecution.RunStreamingAsync(workflow, messages);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
var responseText = new StringBuilder();
bool sawRequestInfoEvent = false;
bool sawToolApprovalInUpdate = false;
bool sawToolApprovalInResponse = false;
int eventCount = 0;
await foreach (var evt in run.WatchStreamAsync(CancellationToken.None))
{
eventCount++;
var eventTypeName = evt.GetType().Name;
logger.LogInformation("[Event #{Count}] {Type}", eventCount, eventTypeName);
switch (evt)
{
case RequestInfoEvent requestInfo:
sawRequestInfoEvent = true;
logger.LogInformation(" → RequestInfoEvent received!");
if (requestInfo.Request.TryGetDataAs<ToolApprovalRequestContent>(out var approval))
{
var toolCall = approval.ToolCall as FunctionCallContent;
logger.LogInformation(" → ToolApprovalRequestContent! Tool={Tool}",
toolCall?.Name ?? "unknown");
var innerResponse = approval.CreateResponse(true);
await run.SendResponseAsync(requestInfo.Request.CreateResponse(innerResponse));
logger.LogInformation(" → Approval sent (approved=true)");
}
break;
case AgentResponseUpdateEvent update:
responseText.Append(update.Update.Text);
foreach (var content in update.Update.Contents)
{
if (content is ToolApprovalRequestContent tarc)
{
sawToolApprovalInUpdate = true;
var fc = tarc.ToolCall as FunctionCallContent;
logger.LogWarning(
" → ToolApprovalRequestContent found in AgentResponseUpdateEvent! Tool={Tool}",
fc?.Name ?? "unknown");
}
}
break;
case AgentResponseEvent response:
responseText.Append(response.Response.Text);
foreach (var msg in response.Response.Messages)
{
foreach (var content in msg.Contents)
{
if (content is ToolApprovalRequestContent tarc2)
{
sawToolApprovalInResponse = true;
var fc = tarc2.ToolCall as FunctionCallContent;
logger.LogWarning(
" → ToolApprovalRequestContent found in AgentResponseEvent! Tool={Tool}",
fc?.Name ?? "unknown");
}
}
}
break;
case ExecutorInvokedEvent invoked:
logger.LogInformation(" → Executor invoked: {Id}", invoked.ExecutorId);
break;
case ExecutorCompletedEvent completed:
logger.LogInformation(" → Executor completed: {Id}", completed.ExecutorId);
break;
case SuperStepCompletedEvent:
logger.LogInformation(" → SuperStepCompleted");
break;
case WorkflowOutputEvent:
logger.LogInformation(" → WorkflowOutput");
break;
}
}
await run.DisposeAsync();
// ── Summary ──
Console.WriteLine();
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine("RESULTS SUMMARY:");
Console.WriteLine($" Total events: {eventCount}");
Console.WriteLine($" RequestInfoEvent received: {sawRequestInfoEvent}");
Console.WriteLine($" ToolApproval in streaming update: {sawToolApprovalInUpdate}");
Console.WriteLine($" Response text length: {responseText.Length}");
Console.WriteLine("═══════════════════════════════════════════════════════════════");
if (!sawRequestInfoEvent && (sawToolApprovalInUpdate || sawToolApprovalInResponse))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine();
Console.WriteLine("BUG CONFIRMED: HandoffAgentExecutor does NOT emit RequestInfoEvent");
Console.WriteLine("for ToolApprovalRequestContent.");
Console.ResetColor();
}
else if (sawRequestInfoEvent)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine();
Console.WriteLine("RequestInfoEvent WAS received — the issue may be fixed in your version.");
Console.ResetColor();
}
// ============================================================================
// Fake IChatClient — deterministic LLM simulation
// ============================================================================
sealed class FakeChatClient : IChatClient
{
private readonly ILogger _logger;
public FakeChatClient(ILogger logger) => _logger = logger;
public void Dispose() { }
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public TService? GetService<TService>(object? key = null) where TService : class => null;
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var msgList = messages.ToList();
var tools = options?.Tools?.ToList() ?? [];
_logger.LogDebug("[FakeLLM] {MsgCount} messages, {ToolCount} tools",
msgList.Count, tools.Count);
foreach (var t in tools)
_logger.LogDebug("[FakeLLM] Tool: {Name} ({Type})", t.Name, t.GetType().Name);
var lastMsg = msgList.LastOrDefault();
if (lastMsg?.Role == ChatRole.Tool ||
lastMsg?.Contents.Any(c => c is FunctionResultContent) == true)
{
_logger.LogDebug("[FakeLLM] Tool result → text summary");
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "Done — email sent successfully.")));
}
if (msgList.Any(m => m.Contents.Any(c => c is ToolApprovalResponseContent)))
{
_logger.LogDebug("[FakeLLM] ToolApprovalResponse → text summary");
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "Email approved and sent.")));
}
var handoffTool = tools.FirstOrDefault(t =>
t.Name.StartsWith("handoff_to_", StringComparison.OrdinalIgnoreCase));
if (handoffTool is not null)
{
_logger.LogDebug("[FakeLLM] Handoff tool '{Name}' → FunctionCallContent", handoffTool.Name);
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call_handoff_1", handoffTool.Name,
new Dictionary<string, object?>())
])));
}
var emailTool = tools.FirstOrDefault(t => t.Name == "send_email");
if (emailTool is not null)
{
_logger.LogDebug("[FakeLLM] send_email → FunctionCallContent");
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call_email_1", "send_email",
new Dictionary<string, object?>
{
["to"] = "alice@example.com",
["subject"] = "Hello"
})
])));
}
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "How can I help you?")));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await GetResponseAsync(messages, options, cancellationToken);
foreach (var msg in response.Messages)
foreach (var content in msg.Contents)
yield return new ChatResponseUpdate { Role = msg.Role, Contents = [content] };
}
}
-
Run dotnet run and observe the output
Expected Behavior
When FunctionInvokingChatClient replaces a FunctionCallContent with ToolApprovalRequestContent (because the tool is an ApprovalRequiredAIFunction), HandoffAgentExecutor should:
- Detect the
ToolApprovalRequestContent in the agent's streaming output
- Emit a
RequestInfoEvent containing the ToolApprovalRequestContent
- Wait for the consumer to call
SendResponseAsync with the approval/rejection
- Forward the
ToolApprovalResponseContent back to the FunctionInvokingChatClient so the tool can execute or be cancelled
This is the behavior that AIAgentHostExecutor implements through AIContentExternalHandler<ToolApprovalRequestContent>.
Actual Behavior
FunctionInvokingChatClient correctly produces ToolApprovalRequestContent ✅
HandoffAgentExecutor.HandleAsync() receives the streaming output containing the approval content
HandleAsync only inspects FunctionCallContent matching known handoff function names — it skips ToolApprovalRequestContent entirely
- The approval content flows through as an
AgentResponseUpdateEvent to the consumer ❌
- The workflow immediately proceeds to
HandoffEnd ❌
- No
RequestInfoEvent is ever emitted ❌
Repro Output
20:48:49 info: Repro[0] === Starting handoff workflow ===
...
20:48:49 info: Repro[0] [Event #14] ExecutorInvokedEvent
20:48:49 info: Repro[0] → Executor invoked: Worker_Worker
20:48:49 dbug: FakeChatClient[0] [FakeLLM] Tool: get_status (ReflectionAIFunction)
20:48:49 dbug: FakeChatClient[0] [FakeLLM] Tool: send_email (ApprovalRequiredAIFunction)
20:48:49 dbug: FakeChatClient[0] [FakeLLM] send_email tool found → returning FunctionCallContent
20:48:49 info: Repro[0] [Event #15] AgentResponseUpdateEvent
20:48:49 warn: Repro[0] → ToolApprovalRequestContent found in AgentResponseUpdateEvent! Tool=send_email
20:48:49 info: Repro[0] [Event #16] ExecutorCompletedEvent
20:48:49 info: Repro[0] → Executor completed: Worker_Worker
...
20:48:49 info: Repro[0] [Event #19] ExecutorInvokedEvent
20:48:49 info: Repro[0] → Executor invoked: HandoffEnd
═══════════════════════════════════════════════════════════════
RESULTS SUMMARY:
Total events: 22
RequestInfoEvent received: False ← BUG: should be True
ToolApproval in streaming update: True ← approval content is here, but unactionable
Response text length: 0
═══════════════════════════════════════════════════════════════
BUG CONFIRMED: HandoffAgentExecutor does NOT emit RequestInfoEvent
for ToolApprovalRequestContent.
Root Cause Analysis
Traced through the SDK source code:
HandoffAgentExecutor.HandleAsync() — only checks for handoff FunctionCallContent
In HandoffAgentExecutor.cs, HandleAsync iterates streaming updates and only looks for FunctionCallContent matching handoff function names:
// HandoffAgentExecutor.HandleAsync (simplified)
await foreach (var update in agent.RunStreamingAsync(messagesForAgent, options: _agentOptions))
{
// ... emits AgentResponseUpdateEvent for all content ...
foreach (var fcc in update.Contents.OfType<FunctionCallContent>()
.Where(fcc => this._handoffFunctionNames.Contains(fcc.Name)))
{
requestedHandoff = fcc.Name;
}
}
When FunctionInvokingChatClient replaces a FunctionCallContent with ToolApprovalRequestContent, it arrives in update.Contents as a ToolApprovalRequestContent — not a FunctionCallContent. The .OfType<FunctionCallContent>() filter skips it entirely.
AIAgentHostExecutor — correctly handles it
In contrast, AIAgentHostExecutor.cs uses AIContentExternalHandler<ToolApprovalRequestContent> to intercept approval content and emit RequestInfoEvent, enabling the checkpoint-based human-in-the-loop pattern.
FunctionInvokingChatClient — works correctly
FunctionInvokingChatClient correctly detects ApprovalRequiredAIFunction and replaces FunctionCallContent with ToolApprovalRequestContent. This part of the pipeline works as designed.
Impact
ApprovalRequiredAIFunction is unusable in handoff workflows — the primary multi-agent pattern in the SDK
- Human-in-the-loop tool approval only works with
AIAgentHostExecutor (GroupChat), not HandoffAgentExecutor
- Developers using the handoff pattern (
CreateHandoffBuilderWith) cannot require user confirmation before executing mutation tools
- The CheckpointWithHumanInTheLoop sample only demonstrates GroupChat, not handoff workflows
Related Issues
Note: This issue is distinct from #1973 in that it provides a self-contained repro (no LLM or Azure credentials needed), pinpoints the exact code path in HandoffAgentExecutor.HandleAsync(), and confirms the bug persists in 1.0.0-rc4 despite the parent issue #1982 being closed.
Workaround
Currently, the only workarounds are:
- Don't use
ApprovalRequiredAIFunction in handoff workflows — let tools execute directly and rely on the agent's prompt for conversational confirmation
- Detect
ToolApprovalRequestContent in AgentResponseUpdateEvent from the consumer side, save the tool call details to session state, and programmatically invoke the tool function on the next user turn (bypassing the agent framework's approval mechanism)
- Use GroupChat (
AIAgentHostExecutor) instead of handoff workflows — but this loses the handoff orchestration pattern
The repro is fully self-contained — no Azure OpenAI key or external services needed. The FakeChatClient deterministically simulates the LLM behavior that triggers the issue.
Code Sample
// ============================================================================
// Minimal reproduction: ToolApprovalRequestContent not handled in Handoff workflow
// ============================================================================
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is experimental
// ── Logging ──
using var loggerFactory = LoggerFactory.Create(b =>
b.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; })
.SetMinimumLevel(LogLevel.Debug));
var logger = loggerFactory.CreateLogger("Repro");
// ── Fake LLM that simulates calling a tool ──
var fakeLlm = new FakeChatClient(loggerFactory.CreateLogger<FakeChatClient>());
// ── Mutation tool wrapped with ApprovalRequiredAIFunction ──
var sendEmailFn = AIFunctionFactory.Create(
(string to, string subject) =>
{
logger.LogWarning("*** send_email was invoked DIRECTLY (no approval) ***");
return $"Email sent to {to}: {subject}";
},
new AIFunctionFactoryOptions { Name = "send_email", Description = "Sends an email" });
var approvalTool = new ApprovalRequiredAIFunction(sendEmailFn);
var readTool = AIFunctionFactory.Create(
(string id) =>
{
logger.LogInformation("get_status called for {Id}", id);
return "Status: pending";
},
new AIFunctionFactoryOptions { Name = "get_status", Description = "Gets the status of a request (read-only)" });
// ── Build agents ──
var agentA = new ChatClientAgent(
fakeLlm,
new ChatClientAgentOptions
{
Id = "Router",
Name = "Router",
Description = "Routes users to the correct agent",
ChatOptions = new ChatOptions
{
Instructions = "You are a router agent. Transfer to Worker when the user needs an action performed."
}
},
loggerFactory);
var agentB = new ChatClientAgent(
fakeLlm,
new ChatClientAgentOptions
{
Id = "Worker",
Name = "Worker",
Description = "Performs actions on behalf of the user",
ChatOptions = new ChatOptions
{
Instructions = "You are a worker agent. Use send_email when the user asks to send an email.",
Tools = new List<AITool> { readTool, approvalTool }
}
},
loggerFactory);
// ── Handoff workflow: Router → Worker ──
var workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(agentA)
.WithHandoff(agentA, agentB, "User needs an action performed")
.Build();
// ── Run the workflow ──
logger.LogInformation("=== Starting handoff workflow ===");
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Send an email to alice@example.com with subject 'Hello'.")
};
var run = await InProcessExecution.RunStreamingAsync(workflow, messages);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
var responseText = new StringBuilder();
bool sawRequestInfoEvent = false;
bool sawToolApprovalInUpdate = false;
bool sawToolApprovalInResponse = false;
int eventCount = 0;
await foreach (var evt in run.WatchStreamAsync(CancellationToken.None))
{
eventCount++;
var eventTypeName = evt.GetType().Name;
logger.LogInformation("[Event #{Count}] {Type}", eventCount, eventTypeName);
switch (evt)
{
case RequestInfoEvent requestInfo:
sawRequestInfoEvent = true;
logger.LogInformation(" → RequestInfoEvent received!");
if (requestInfo.Request.TryGetDataAs<ToolApprovalRequestContent>(out var approval))
{
var toolCall = approval.ToolCall as FunctionCallContent;
logger.LogInformation(" → ToolApprovalRequestContent! Tool={Tool}",
toolCall?.Name ?? "unknown");
var innerResponse = approval.CreateResponse(true);
await run.SendResponseAsync(requestInfo.Request.CreateResponse(innerResponse));
logger.LogInformation(" → Approval sent (approved=true)");
}
break;
case AgentResponseUpdateEvent update:
responseText.Append(update.Update.Text);
foreach (var content in update.Update.Contents)
{
if (content is ToolApprovalRequestContent tarc)
{
sawToolApprovalInUpdate = true;
var fc = tarc.ToolCall as FunctionCallContent;
logger.LogWarning(
" → ToolApprovalRequestContent found in AgentResponseUpdateEvent! Tool={Tool}",
fc?.Name ?? "unknown");
}
}
break;
case AgentResponseEvent response:
responseText.Append(response.Response.Text);
foreach (var msg in response.Response.Messages)
{
foreach (var content in msg.Contents)
{
if (content is ToolApprovalRequestContent tarc2)
{
sawToolApprovalInResponse = true;
var fc = tarc2.ToolCall as FunctionCallContent;
logger.LogWarning(
" → ToolApprovalRequestContent found in AgentResponseEvent! Tool={Tool}",
fc?.Name ?? "unknown");
}
}
}
break;
case ExecutorInvokedEvent invoked:
logger.LogInformation(" → Executor invoked: {Id}", invoked.ExecutorId);
break;
case ExecutorCompletedEvent completed:
logger.LogInformation(" → Executor completed: {Id}", completed.ExecutorId);
break;
case SuperStepCompletedEvent:
logger.LogInformation(" → SuperStepCompleted");
break;
case WorkflowOutputEvent:
logger.LogInformation(" → WorkflowOutput");
break;
}
}
await run.DisposeAsync();
// ── Summary ──
Console.WriteLine();
Console.WriteLine("═══════════════════════════════════════════════════════════════");
Console.WriteLine("RESULTS SUMMARY:");
Console.WriteLine($" Total events: {eventCount}");
Console.WriteLine($" RequestInfoEvent received: {sawRequestInfoEvent}");
Console.WriteLine($" ToolApproval in streaming update: {sawToolApprovalInUpdate}");
Console.WriteLine($" Response text length: {responseText.Length}");
Console.WriteLine("═══════════════════════════════════════════════════════════════");
if (!sawRequestInfoEvent && (sawToolApprovalInUpdate || sawToolApprovalInResponse))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine();
Console.WriteLine("BUG CONFIRMED: HandoffAgentExecutor does NOT emit RequestInfoEvent");
Console.WriteLine("for ToolApprovalRequestContent.");
Console.ResetColor();
}
else if (sawRequestInfoEvent)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine();
Console.WriteLine("RequestInfoEvent WAS received — the issue may be fixed in your version.");
Console.ResetColor();
}
// ============================================================================
// Fake IChatClient — deterministic LLM simulation
// ============================================================================
sealed class FakeChatClient : IChatClient
{
private readonly ILogger _logger;
public FakeChatClient(ILogger logger) => _logger = logger;
public void Dispose() { }
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public TService? GetService<TService>(object? key = null) where TService : class => null;
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var msgList = messages.ToList();
var tools = options?.Tools?.ToList() ?? [];
_logger.LogDebug("[FakeLLM] {MsgCount} messages, {ToolCount} tools",
msgList.Count, tools.Count);
foreach (var t in tools)
_logger.LogDebug("[FakeLLM] Tool: {Name} ({Type})", t.Name, t.GetType().Name);
var lastMsg = msgList.LastOrDefault();
if (lastMsg?.Role == ChatRole.Tool ||
lastMsg?.Contents.Any(c => c is FunctionResultContent) == true)
{
_logger.LogDebug("[FakeLLM] Tool result → text summary");
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "Done — email sent successfully.")));
}
if (msgList.Any(m => m.Contents.Any(c => c is ToolApprovalResponseContent)))
{
_logger.LogDebug("[FakeLLM] ToolApprovalResponse → text summary");
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "Email approved and sent.")));
}
var handoffTool = tools.FirstOrDefault(t =>
t.Name.StartsWith("handoff_to_", StringComparison.OrdinalIgnoreCase));
if (handoffTool is not null)
{
_logger.LogDebug("[FakeLLM] Handoff tool '{Name}' → FunctionCallContent", handoffTool.Name);
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call_handoff_1", handoffTool.Name,
new Dictionary<string, object?>())
])));
}
var emailTool = tools.FirstOrDefault(t => t.Name == "send_email");
if (emailTool is not null)
{
_logger.LogDebug("[FakeLLM] send_email → FunctionCallContent");
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call_email_1", "send_email",
new Dictionary<string, object?>
{
["to"] = "alice@example.com",
["subject"] = "Hello"
})
])));
}
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "How can I help you?")));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await GetResponseAsync(messages, options, cancellationToken);
foreach (var msg in response.Messages)
foreach (var content in msg.Contents)
yield return new ChatResponseUpdate { Role = msg.Role, Contents = [content] };
}
}
Error Messages / Stack Traces
20:48:49 info: Repro[0] === Starting handoff workflow ===
...
20:48:49 info: Repro[0] [Event #14] ExecutorInvokedEvent
20:48:49 info: Repro[0] → Executor invoked: Worker_Worker
20:48:49 dbug: FakeChatClient[0] [FakeLLM] Tool: get_status (ReflectionAIFunction)
20:48:49 dbug: FakeChatClient[0] [FakeLLM] Tool: send_email (ApprovalRequiredAIFunction)
20:48:49 dbug: FakeChatClient[0] [FakeLLM] send_email tool found → returning FunctionCallContent
20:48:49 info: Repro[0] [Event #15] AgentResponseUpdateEvent
20:48:49 warn: Repro[0] → ToolApprovalRequestContent found in AgentResponseUpdateEvent! Tool=send_email
20:48:49 info: Repro[0] [Event #16] ExecutorCompletedEvent
20:48:49 info: Repro[0] → Executor completed: Worker_Worker
...
20:48:49 info: Repro[0] [Event #19] ExecutorInvokedEvent
20:48:49 info: Repro[0] → Executor invoked: HandoffEnd
═══════════════════════════════════════════════════════════════
RESULTS SUMMARY:
Total events: 22
RequestInfoEvent received: False ← BUG: should be True
ToolApproval in streaming update: True ← approval content is here, but unactionable
Response text length: 0
═══════════════════════════════════════════════════════════════
BUG CONFIRMED: HandoffAgentExecutor does NOT emit RequestInfoEvent
for ToolApprovalRequestContent.
Package Versions
Microsoft.Agents.AI : 1.0.0-rc4
.NET Version
.NET 8.0
Additional Context
No response
Description
Summary
When using
ApprovalRequiredAIFunctionto wrap domain tools in a handoff workflow (AgentWorkflowBuilder.CreateHandoffBuilderWith), theHandoffAgentExecutordoes not emit aRequestInfoEventwhenFunctionInvokingChatClientproduces aToolApprovalRequestContent. The approval request is silently passed through as anAgentResponseUpdateEventand the workflow proceeds toHandoffEnd, making it impossible for the consumer to approve/reject the tool call through the standardSendResponseAsyncmechanism.In contrast,
AIAgentHostExecutor(used in GroupChat patterns) correctly handlesToolApprovalRequestContentviaAIContentExternalHandler<ToolApprovalRequestContent>and emits aRequestInfoEventthat the caller can respond to.Environment
Microsoft.Agents.AIMicrosoft.Agents.AI.WorkflowsMicrosoft.Extensions.AIMicrosoft.Extensions.AI.AbstractionsRegression? Partially — The parent issue #1982 was closed as completed via PR #3142 (Jan 26), which added HIL support to
AIAgentHostExecutor. However,HandoffAgentExecutorwas never updated, so this has been broken since the handoff workflow pattern was introduced.Steps to Reproduce
Create a new .NET 8 console app with the following
.csproj:HandoffApprovalRepro.csprojReplace
Program.cswith the full repro below (uses a fakeIChatClient— no real LLM or Azure credentials needed):Program.cs— full self-contained reproRun
dotnet runand observe the outputExpected Behavior
When
FunctionInvokingChatClientreplaces aFunctionCallContentwithToolApprovalRequestContent(because the tool is anApprovalRequiredAIFunction),HandoffAgentExecutorshould:ToolApprovalRequestContentin the agent's streaming outputRequestInfoEventcontaining theToolApprovalRequestContentSendResponseAsyncwith the approval/rejectionToolApprovalResponseContentback to theFunctionInvokingChatClientso the tool can execute or be cancelledThis is the behavior that
AIAgentHostExecutorimplements throughAIContentExternalHandler<ToolApprovalRequestContent>.Actual Behavior
FunctionInvokingChatClientcorrectly producesToolApprovalRequestContent✅HandoffAgentExecutor.HandleAsync()receives the streaming output containing the approval contentHandleAsynconly inspectsFunctionCallContentmatching known handoff function names — it skipsToolApprovalRequestContententirelyAgentResponseUpdateEventto the consumer ❌HandoffEnd❌RequestInfoEventis ever emitted ❌Repro Output
Root Cause Analysis
Traced through the SDK source code:
HandoffAgentExecutor.HandleAsync()— only checks for handoffFunctionCallContentIn
HandoffAgentExecutor.cs,HandleAsynciterates streaming updates and only looks forFunctionCallContentmatching handoff function names:When
FunctionInvokingChatClientreplaces aFunctionCallContentwithToolApprovalRequestContent, it arrives inupdate.Contentsas aToolApprovalRequestContent— not aFunctionCallContent. The.OfType<FunctionCallContent>()filter skips it entirely.AIAgentHostExecutor— correctly handles itIn contrast,
AIAgentHostExecutor.csusesAIContentExternalHandler<ToolApprovalRequestContent>to intercept approval content and emitRequestInfoEvent, enabling the checkpoint-based human-in-the-loop pattern.FunctionInvokingChatClient— works correctlyFunctionInvokingChatClientcorrectly detectsApprovalRequiredAIFunctionand replacesFunctionCallContentwithToolApprovalRequestContent. This part of the pipeline works as designed.Impact
ApprovalRequiredAIFunctionis unusable in handoff workflows — the primary multi-agent pattern in the SDKAIAgentHostExecutor(GroupChat), notHandoffAgentExecutorCreateHandoffBuilderWith) cannot require user confirmation before executing mutation toolsRelated Issues
@jozkeeon Nov 7, 2025. Still open.AIAgentHostExecutor(WorkflowBuilder/GroupChat). TheHandoffAgentExecutorpath was not addressed, and sub-issue .NET: Approval requests handling in workflows #1973 remains open.@TaoChenOSU.Workaround
Currently, the only workarounds are:
ApprovalRequiredAIFunctionin handoff workflows — let tools execute directly and rely on the agent's prompt for conversational confirmationToolApprovalRequestContentinAgentResponseUpdateEventfrom the consumer side, save the tool call details to session state, and programmatically invoke the tool function on the next user turn (bypassing the agent framework's approval mechanism)AIAgentHostExecutor) instead of handoff workflows — but this loses the handoff orchestration patternThe repro is fully self-contained — no Azure OpenAI key or external services needed. The
FakeChatClientdeterministically simulates the LLM behavior that triggers the issue.Code Sample
// ============================================================================ // Minimal reproduction: ToolApprovalRequestContent not handled in Handoff workflow // ============================================================================ using System.Runtime.CompilerServices; using System.Text; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; #pragma warning disable MEAI001 // ApprovalRequiredAIFunction is experimental // ── Logging ── using var loggerFactory = LoggerFactory.Create(b => b.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; }) .SetMinimumLevel(LogLevel.Debug)); var logger = loggerFactory.CreateLogger("Repro"); // ── Fake LLM that simulates calling a tool ── var fakeLlm = new FakeChatClient(loggerFactory.CreateLogger<FakeChatClient>()); // ── Mutation tool wrapped with ApprovalRequiredAIFunction ── var sendEmailFn = AIFunctionFactory.Create( (string to, string subject) => { logger.LogWarning("*** send_email was invoked DIRECTLY (no approval) ***"); return $"Email sent to {to}: {subject}"; }, new AIFunctionFactoryOptions { Name = "send_email", Description = "Sends an email" }); var approvalTool = new ApprovalRequiredAIFunction(sendEmailFn); var readTool = AIFunctionFactory.Create( (string id) => { logger.LogInformation("get_status called for {Id}", id); return "Status: pending"; }, new AIFunctionFactoryOptions { Name = "get_status", Description = "Gets the status of a request (read-only)" }); // ── Build agents ── var agentA = new ChatClientAgent( fakeLlm, new ChatClientAgentOptions { Id = "Router", Name = "Router", Description = "Routes users to the correct agent", ChatOptions = new ChatOptions { Instructions = "You are a router agent. Transfer to Worker when the user needs an action performed." } }, loggerFactory); var agentB = new ChatClientAgent( fakeLlm, new ChatClientAgentOptions { Id = "Worker", Name = "Worker", Description = "Performs actions on behalf of the user", ChatOptions = new ChatOptions { Instructions = "You are a worker agent. Use send_email when the user asks to send an email.", Tools = new List<AITool> { readTool, approvalTool } } }, loggerFactory); // ── Handoff workflow: Router → Worker ── var workflow = AgentWorkflowBuilder .CreateHandoffBuilderWith(agentA) .WithHandoff(agentA, agentB, "User needs an action performed") .Build(); // ── Run the workflow ── logger.LogInformation("=== Starting handoff workflow ==="); var messages = new List<ChatMessage> { new(ChatRole.User, "Send an email to alice@example.com with subject 'Hello'.") }; var run = await InProcessExecution.RunStreamingAsync(workflow, messages); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); var responseText = new StringBuilder(); bool sawRequestInfoEvent = false; bool sawToolApprovalInUpdate = false; bool sawToolApprovalInResponse = false; int eventCount = 0; await foreach (var evt in run.WatchStreamAsync(CancellationToken.None)) { eventCount++; var eventTypeName = evt.GetType().Name; logger.LogInformation("[Event #{Count}] {Type}", eventCount, eventTypeName); switch (evt) { case RequestInfoEvent requestInfo: sawRequestInfoEvent = true; logger.LogInformation(" → RequestInfoEvent received!"); if (requestInfo.Request.TryGetDataAs<ToolApprovalRequestContent>(out var approval)) { var toolCall = approval.ToolCall as FunctionCallContent; logger.LogInformation(" → ToolApprovalRequestContent! Tool={Tool}", toolCall?.Name ?? "unknown"); var innerResponse = approval.CreateResponse(true); await run.SendResponseAsync(requestInfo.Request.CreateResponse(innerResponse)); logger.LogInformation(" → Approval sent (approved=true)"); } break; case AgentResponseUpdateEvent update: responseText.Append(update.Update.Text); foreach (var content in update.Update.Contents) { if (content is ToolApprovalRequestContent tarc) { sawToolApprovalInUpdate = true; var fc = tarc.ToolCall as FunctionCallContent; logger.LogWarning( " → ToolApprovalRequestContent found in AgentResponseUpdateEvent! Tool={Tool}", fc?.Name ?? "unknown"); } } break; case AgentResponseEvent response: responseText.Append(response.Response.Text); foreach (var msg in response.Response.Messages) { foreach (var content in msg.Contents) { if (content is ToolApprovalRequestContent tarc2) { sawToolApprovalInResponse = true; var fc = tarc2.ToolCall as FunctionCallContent; logger.LogWarning( " → ToolApprovalRequestContent found in AgentResponseEvent! Tool={Tool}", fc?.Name ?? "unknown"); } } } break; case ExecutorInvokedEvent invoked: logger.LogInformation(" → Executor invoked: {Id}", invoked.ExecutorId); break; case ExecutorCompletedEvent completed: logger.LogInformation(" → Executor completed: {Id}", completed.ExecutorId); break; case SuperStepCompletedEvent: logger.LogInformation(" → SuperStepCompleted"); break; case WorkflowOutputEvent: logger.LogInformation(" → WorkflowOutput"); break; } } await run.DisposeAsync(); // ── Summary ── Console.WriteLine(); Console.WriteLine("═══════════════════════════════════════════════════════════════"); Console.WriteLine("RESULTS SUMMARY:"); Console.WriteLine($" Total events: {eventCount}"); Console.WriteLine($" RequestInfoEvent received: {sawRequestInfoEvent}"); Console.WriteLine($" ToolApproval in streaming update: {sawToolApprovalInUpdate}"); Console.WriteLine($" Response text length: {responseText.Length}"); Console.WriteLine("═══════════════════════════════════════════════════════════════"); if (!sawRequestInfoEvent && (sawToolApprovalInUpdate || sawToolApprovalInResponse)) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(); Console.WriteLine("BUG CONFIRMED: HandoffAgentExecutor does NOT emit RequestInfoEvent"); Console.WriteLine("for ToolApprovalRequestContent."); Console.ResetColor(); } else if (sawRequestInfoEvent) { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(); Console.WriteLine("RequestInfoEvent WAS received — the issue may be fixed in your version."); Console.ResetColor(); } // ============================================================================ // Fake IChatClient — deterministic LLM simulation // ============================================================================ sealed class FakeChatClient : IChatClient { private readonly ILogger _logger; public FakeChatClient(ILogger logger) => _logger = logger; public void Dispose() { } public object? GetService(Type serviceType, object? serviceKey = null) => null; public TService? GetService<TService>(object? key = null) where TService : class => null; public Task<ChatResponse> GetResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { var msgList = messages.ToList(); var tools = options?.Tools?.ToList() ?? []; _logger.LogDebug("[FakeLLM] {MsgCount} messages, {ToolCount} tools", msgList.Count, tools.Count); foreach (var t in tools) _logger.LogDebug("[FakeLLM] Tool: {Name} ({Type})", t.Name, t.GetType().Name); var lastMsg = msgList.LastOrDefault(); if (lastMsg?.Role == ChatRole.Tool || lastMsg?.Contents.Any(c => c is FunctionResultContent) == true) { _logger.LogDebug("[FakeLLM] Tool result → text summary"); return Task.FromResult(new ChatResponse( new ChatMessage(ChatRole.Assistant, "Done — email sent successfully."))); } if (msgList.Any(m => m.Contents.Any(c => c is ToolApprovalResponseContent))) { _logger.LogDebug("[FakeLLM] ToolApprovalResponse → text summary"); return Task.FromResult(new ChatResponse( new ChatMessage(ChatRole.Assistant, "Email approved and sent."))); } var handoffTool = tools.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.OrdinalIgnoreCase)); if (handoffTool is not null) { _logger.LogDebug("[FakeLLM] Handoff tool '{Name}' → FunctionCallContent", handoffTool.Name); return Task.FromResult(new ChatResponse( new ChatMessage(ChatRole.Assistant, [ new FunctionCallContent("call_handoff_1", handoffTool.Name, new Dictionary<string, object?>()) ]))); } var emailTool = tools.FirstOrDefault(t => t.Name == "send_email"); if (emailTool is not null) { _logger.LogDebug("[FakeLLM] send_email → FunctionCallContent"); return Task.FromResult(new ChatResponse( new ChatMessage(ChatRole.Assistant, [ new FunctionCallContent("call_email_1", "send_email", new Dictionary<string, object?> { ["to"] = "alice@example.com", ["subject"] = "Hello" }) ]))); } return Task.FromResult(new ChatResponse( new ChatMessage(ChatRole.Assistant, "How can I help you?"))); } public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var response = await GetResponseAsync(messages, options, cancellationToken); foreach (var msg in response.Messages) foreach (var content in msg.Contents) yield return new ChatResponseUpdate { Role = msg.Role, Contents = [content] }; } }Error Messages / Stack Traces
Package Versions
Microsoft.Agents.AI : 1.0.0-rc4
.NET Version
.NET 8.0
Additional Context
No response