-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Python: .NET: [Bug]: HandoffAgentExecutor does not emit RequestInfoEvent for ToolApprovalRequestContent — ApprovalRequiredAIFunction unusable in handoff workflows #5035
Description
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.cswith the full repro below (uses a fakeIChatClient— 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 runand observe the output
Expected Behavior
When FunctionInvokingChatClient replaces a FunctionCallContent with ToolApprovalRequestContent (because the tool is an ApprovalRequiredAIFunction), HandoffAgentExecutor should:
- Detect the
ToolApprovalRequestContentin the agent's streaming output - Emit a
RequestInfoEventcontaining theToolApprovalRequestContent - Wait for the consumer to call
SendResponseAsyncwith the approval/rejection - Forward the
ToolApprovalResponseContentback to theFunctionInvokingChatClientso the tool can execute or be cancelled
This is the behavior that AIAgentHostExecutor implements through AIContentExternalHandler<ToolApprovalRequestContent>.
Actual Behavior
FunctionInvokingChatClientcorrectly producesToolApprovalRequestContent✅HandoffAgentExecutor.HandleAsync()receives the streaming output containing the approval contentHandleAsynconly inspectsFunctionCallContentmatching known handoff function names — it skipsToolApprovalRequestContententirely- The approval content flows through as an
AgentResponseUpdateEventto the consumer ❌ - The workflow immediately proceeds to
HandoffEnd❌ - No
RequestInfoEventis 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
ApprovalRequiredAIFunctionis unusable in handoff workflows — the primary multi-agent pattern in the SDK- Human-in-the-loop tool approval only works with
AIAgentHostExecutor(GroupChat), notHandoffAgentExecutor - 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
- #1973 — ".NET: Approval requests handling in workflows" — Reports the same underlying problem (approval requests not handled in workflows). Filed by
@jozkeeon Nov 7, 2025. Still open. - #1982 — "Workflows should automatically map AIAgent HIL to and from ExternalRequest" — Parent tracking epic. Closed as completed on Jan 26 via PR #3142, but that PR only fixed
AIAgentHostExecutor(WorkflowBuilder/GroupChat). TheHandoffAgentExecutorpath was not addressed, and sub-issue .NET: Approval requests handling in workflows #1973 remains open. - #4411 — "Python: HandoffBuilder + OpenAIChatClient tool approval causes duplicate tool_calls messages" — Related Python SDK bug in the same area (handoff + tool approval). Still open, assigned to
@TaoChenOSU.
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 in1.0.0-rc4despite the parent issue #1982 being closed.
Workaround
Currently, the only workarounds are:
- Don't use
ApprovalRequiredAIFunctionin handoff workflows — let tools execute directly and rely on the agent's prompt for conversational confirmation - Detect
ToolApprovalRequestContentinAgentResponseUpdateEventfrom 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
Metadata
Metadata
Assignees
Labels
Type
Projects
Status