Skip to content

Python: .NET: [Bug]: HandoffAgentExecutor does not emit RequestInfoEvent for ToolApprovalRequestContentApprovalRequiredAIFunction unusable in handoff workflows #5035

@vasudevanb

Description

@vasudevanb

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

  1. 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>
  2. 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] };
        }
    }
  3. Run dotnet run and observe the output

Expected Behavior

When FunctionInvokingChatClient replaces a FunctionCallContent with ToolApprovalRequestContent (because the tool is an ApprovalRequiredAIFunction), HandoffAgentExecutor should:

  1. Detect the ToolApprovalRequestContent in the agent's streaming output
  2. Emit a RequestInfoEvent containing the ToolApprovalRequestContent
  3. Wait for the consumer to call SendResponseAsync with the approval/rejection
  4. 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

  1. FunctionInvokingChatClient correctly produces ToolApprovalRequestContent
  2. HandoffAgentExecutor.HandleAsync() receives the streaming output containing the approval content
  3. HandleAsync only inspects FunctionCallContent matching known handoff function names — it skips ToolApprovalRequestContent entirely
  4. The approval content flows through as an AgentResponseUpdateEvent to the consumer ❌
  5. The workflow immediately proceeds to HandoffEnd
  6. 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:

  1. Don't use ApprovalRequiredAIFunction in handoff workflows — let tools execute directly and rely on the agent's prompt for conversational confirmation
  2. 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)
  3. 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

In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions