Skip to content

.NET: externalLoop + ApprovalRequiredAIFunction leaves dangling function_call in Azure Conversations API, breaks resume #5662

@alliscode

Description

@alliscode

Issue: externalLoop + ApprovalRequiredAIFunction leaves dangling function_call in Azure Conversations API, breaks resume

Summary

When a declarative workflow invokes an Azure agent with externalLoop: true and that agent has a tool wrapped in ApprovalRequiredAIFunction, the first turn correctly surfaces an mcp_approval_request to the client. On the second turn (after the client posts the mcp_approval_response), the request fails with:

HTTP 400: No tool output found for function call call_J9Y...

This blocks the canonical pattern of "agent with a tool that requires approval" inside a declarative workflow on Foundry hosting. The HITL bridge added in #5589 works correctly for the InvokeFunctionTool + requireApproval shape (workflow-level tool); only the agent-level / externalLoop shape is affected.

Repro

Repo: alliscode/foundry-samples-pr (or any equivalent declarative-workflow hosted-agent setup using 1.4.x packages).

  1. In workflow.yaml, define a single agent step using InvokeAzureAgent with externalLoop: true (mirroring microsoft/agent-framework/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.yaml).

  2. In Program.cs, register the target Azure agent with a tool wrapped in ApprovalRequiredAIFunction, e.g.:

    var issueRefund = AIFunctionFactory.Create(IssueRefund);
    var approvalRequired = new ApprovalRequiredAIFunction(issueRefund);
    // register approvalRequired as a tool on the agent definition
  3. Deploy to a Foundry hosted-agent project (azd up or equivalent).

  4. Invoke the agent with a prompt that triggers the tool (e.g. "Issue a refund for order 123").

Observed

  • Turn 1: agent emits function_call for IssueRefund. ApprovalRequiredAIFunction halts; bridge surfaces mcp_approval_request{name=IssueRefund, arguments=...} to the client. ✅

  • Turn 2: client POSTs mcp_approval_response{approve=true}. Server returns:

    HTTP 400 BadRequest
    No tool output found for function call call_J9Y...
    

Expected

  • Turn 2 resumes the agent loop with the approval result, the tool actually executes, and the assistant returns a final reply that incorporates the tool output.

Root cause (working hypothesis)

The Azure Conversations API persists the function_call item as soon as the model emits it (during turn 1, before the local tool would normally execute). ApprovalRequiredAIFunction halts the agent execution flow before any function_call_output is written back. From the Conversations API's perspective there is now a dangling function_call with no matching output. On the next request against that conversation, the API's invariant check fails and rejects the call — no matter what the new request actually contains.

This is independent of the declarative-workflow HITL bridge (IExternalRequestEnvelope / WorkflowSession bridge) introduced in #5589: that bridge correctly emits the mcp_approval_request and would correctly consume an mcp_approval_response, but never gets a chance because the conversation is already wedged at the storage layer.

The same ApprovalRequiredAIFunction shape works fine when the agent is not running under a hosted Conversations-backed thread (e.g. ChatClientAgent with in-memory thread), which is consistent with this hypothesis.

Possible fixes (need design input)

  1. Synthesize a placeholder function_call_output on halt. When ApprovalRequiredAIFunction halts, write a sentinel function_call_output (e.g. {"status":"pending_approval"}) so the conversation stays consistent. On resume, emit a new function_call with the same name + arguments, then write the real output. Pro: keeps the storage invariant intact. Con: surfaces a synthetic event in conversation history.
  2. Defer function_call persistence until after the function executes (or is approved + executes). Buffer the function_call item locally until either (a) the function returns and we can write the pair atomically, or (b) approval is denied and we write a function_call_output with a refusal payload. Pro: cleaner conversation history. Con: requires plumbing through the Conversations write path.
  3. Special-case ApprovalRequiredAIFunction in the agent loop. Don't write the function_call to Conversations at all when the function is ApprovalRequiredAIFunction and is halting; instead translate the halt into an mcp_approval_request envelope and write only that. On resume, write the matched function_call/function_call_output pair atomically once the function actually runs. Probably the cleanest semantically.

Option 3 most closely matches what the workflow-level InvokeFunctionTool + requireApproval path already does (no function_call persisted; only the bridge envelope is exchanged).

Workaround

Use InvokeFunctionTool at the workflow level with requireApproval: true instead of ApprovalRequiredAIFunction at the agent-tool level. This works end-to-end today (see samples/csharp/hosted-agents/agent-framework/declarative-workflow-approval).

Repro environment

  • Microsoft.Agents.AI.Workflows.Declarative 1.4.0-rc1
  • Microsoft.Agents.AI.Workflows.Declarative.Foundry 1.4.0-rc1
  • Microsoft.Agents.AI.Foundry.Hosting 1.4.0-preview.260505.1
  • HITL bridge from .NET: Hosting updates to declarative workflows #5589 applied
  • Azure AI Foundry hosted-agent (Responses protocol), gpt-4.1-mini deployment

References

  • HITL bridge PR: .NET: Hosting updates to declarative workflows #5589
  • Working InvokeFunctionTool + requireApproval sample (workaround): alliscode/foundry-samples-pr, branch sample/declarative-workflow-dotnet, path samples/csharp/hosted-agents/agent-framework/declarative-workflow-approval/
  • Upstream externalLoop reference YAML: microsoft/agent-framework/dotnet/samples/03-workflows/Declarative/FunctionTools/FunctionTools.yaml
  • Test bench used for repro: C:\Users\bentho\src\ha-dec-5\src\declarative-customer-support (with .hitl-bak files restored).

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions