Skip to content

.NET: surface a workflow tool approval request once - #7945

Open
Yashvant Mahadev Hange (YashvantHange) wants to merge 2 commits into
microsoft:mainfrom
YashvantHange:dotnet-single-approval-request
Open

.NET: surface a workflow tool approval request once#7945
Yashvant Mahadev Hange (YashvantHange) wants to merge 2 commits into
microsoft:mainfrom
YashvantHange:dotnet-single-approval-request

Conversation

@YashvantHange

Copy link
Copy Markdown
Contributor

Motivation & Context

A tool approval raised inside a workflow reaches the caller twice, with two different IDs, and only one of them is answerable.

An agent host executor streams the agent's updates straight out as workflow output, so the ToolApprovalRequestContent the agent produced is emitted with the agent-local RequestId. It then hands that same approval to its request port, and WorkflowSession turns the resulting RequestInfoEvent into a second ToolApprovalRequestContent, cloned with the workflow-facing request ID (<len>:<portId>:<requestId>). A caller consuming workflow.AsAIAgent().RunStreamingAsync sees both.

Only the second is answerable. WorkflowSession matches an incoming ToolApprovalResponseContent against _pendingRequests, which is keyed by the workflow-facing ID. A response addressed with the agent-local ID misses that lookup and is forwarded into the workflow as an ordinary message, so it silently does nothing.

Reported against a handoff workflow, but HandoffAgentExecutor and AIAgentHostExecutor have the same pair of emissions, so a plain agent binding with EmitAgentUpdateEvents = true and InterceptUserInputRequests = false shows it too.

Description & Review Guide

  • What are the major changes?

An agent host executor no longer emits, as its own output, an approval it hands to its request port. AIContentExternalHandler gains RaisesExternalRequests so the executor can tell a port-backed handler from an intercepted one, and AIAgentUnservicedRequestsCollector gains the two filters that apply it: one for the streamed AgentResponseUpdate, one for the AgentResponse emitted under EmitAgentResponseEvents. Both executors call them; nothing else changes about how a request is raised or answered.

The response filter runs after the whole response has been collected, so it removes exactly the approvals whose requests are about to be raised. The streaming filter cannot know that yet, so it keeps each withheld copy and SubmitAsync emits it after all if no request was raised for that ID, which is what happens when an agent answers its own approval within one run.

  • What is the impact of these changes?

An approval reaches the caller once, carrying the ID the caller has to answer with. Intercepted user-input handling (InterceptUserInputRequests = true) is untouched, because no workflow-facing request exists for it. The AgentResponse returned by the executor is not modified, so message forwarding, the handoff conversation and chat history are unaffected; only the copy emitted as output is filtered. No public API changed: both new members are on internal types.

One consequence worth naming: a turn whose only agent output was the approval now emits an AgentResponseEvent with no messages, so under includeWorkflowOutputsInResponse that turn contributes nothing to the response instead of contributing the duplicate approval. That is the intended trade, since the approval itself is already on the wire as the workflow-facing request.

Function call content is deliberately left alone. Unterminated function calls have the equivalent double-surfacing, but a function call in an agent stream is normally terminated inline in the same run and there is no way to tell mid-stream, so filtering them would hide ordinary tool calls. Magentic/ExecutorAgentHarness repeats the same emit-then-collect shape and is left as it is because it is currently never constructed; it would need the same filter on the day it is wired up.

  • What do you want reviewers to focus on?

Whether withholding the streamed copy and repairing it in SubmitAsync is the right split, or whether the executor should instead defer the whole approval-carrying update until the run ends.

Related Issue

Fixes #7857

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

An agent host executor streamed the agent's ToolApprovalRequestContent out as
workflow output and then handed the same approval to its request port, which
WorkflowSession surfaced again cloned with the workflow-facing request ID. A
caller of workflow.AsAIAgent() saw two approval requests for one tool call.

Only the workflow-facing one is answerable: WorkflowSession matches an incoming
ToolApprovalResponseContent against its pending requests by that ID, so a
response addressed with the agent-local ID is forwarded into the workflow as an
ordinary message and does nothing.

AIAgentHostExecutor and HandoffAgentExecutor now withhold an approval they raise
through a port from their own output. AIContentExternalHandler reports whether it
is port-backed, so intercepted user-input handling keeps streaming as before, and
the collector re-emits a withheld approval when no request was raised for it, as
happens when an agent answers its own approval within one run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents duplicate workflow tool-approval requests by exposing only the answerable workflow-facing request.

Changes:

  • Filters externally raised approvals from agent updates and responses.
  • Restores self-answered approvals that never become external requests.
  • Adds host and handoff workflow coverage.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
WorkflowHostSmokeTests.cs Tests approval filtering scenarios.
HandoffOrchestrationTests.cs Tests single approval surfacing in handoffs.
HandoffAgentExecutor.cs Filters emitted approval copies.
AIContentExternalHandler.cs Identifies externally raised requests.
AIAgentUnservicedRequestsCollector.cs Withholds, filters, and restores approvals.
AIAgentHostExecutor.cs Applies filtering to host outputs.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

A withheld approval that the agent answers within the same run was put back on
the wire by SubmitAsync, after every later update had already been emitted, so
the caller saw the ToolApprovalResponseContent before the request it answers.

The collector now hands back a withheld approval as soon as the update that
answers it arrives, so it goes out first, and leaves an approval answered inside
the same update where it is. SubmitAsync still covers the case where no answer
and no request ever appear, and no approval is emitted twice.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

.NET Usage: [Issues, PRs], Target: .Net workflows Usage: [Issues, PRs], Target: Workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Bug]: ToolApprovalRequestContent triggers 2 times in handoff workflow with different Id's

2 participants