.NET: surface a workflow tool approval request once - #7945
Open
Yashvant Mahadev Hange (YashvantHange) wants to merge 2 commits into
Open
.NET: surface a workflow tool approval request once#7945Yashvant Mahadev Hange (YashvantHange) wants to merge 2 commits into
Yashvant Mahadev Hange (YashvantHange) wants to merge 2 commits into
Conversation
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.
Yashvant Mahadev Hange (YashvantHange)
deployed
to
github-app-auth
August 29, 2026 08:24 — with
GitHub Actions
Active
Yashvant Mahadev Hange (YashvantHange)
deployed
to
github-app-auth
August 29, 2026 08:24 — with
GitHub Actions
Active
Yashvant Mahadev Hange (YashvantHange)
deployed
to
github-app-auth
August 29, 2026 08:24 — with
GitHub Actions
Active
Yashvant Mahadev Hange (YashvantHange)
deployed
to
github-app-auth
August 29, 2026 08:24 — with
GitHub Actions
Active
Copilot started reviewing on behalf of
Yashvant Mahadev Hange (YashvantHange)
August 29, 2026 08:24
View session
Contributor
There was a problem hiding this comment.
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.
Yashvant Mahadev Hange (YashvantHange)
deployed
to
github-app-auth
August 29, 2026 08:37 — with
GitHub Actions
Active
Yashvant Mahadev Hange (YashvantHange)
requested a balanced review from Copilot
August 29, 2026 08:40
Copilot started reviewing on behalf of
Yashvant Mahadev Hange (YashvantHange)
August 29, 2026 08:41
View session
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ToolApprovalRequestContentthe agent produced is emitted with the agent-localRequestId. It then hands that same approval to its request port, andWorkflowSessionturns the resultingRequestInfoEventinto a secondToolApprovalRequestContent, cloned with the workflow-facing request ID (<len>:<portId>:<requestId>). A caller consumingworkflow.AsAIAgent().RunStreamingAsyncsees both.Only the second is answerable.
WorkflowSessionmatches an incomingToolApprovalResponseContentagainst_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
HandoffAgentExecutorandAIAgentHostExecutorhave the same pair of emissions, so a plain agent binding withEmitAgentUpdateEvents = trueandInterceptUserInputRequests = falseshows it too.Description & Review Guide
An agent host executor no longer emits, as its own output, an approval it hands to its request port.
AIContentExternalHandlergainsRaisesExternalRequestsso the executor can tell a port-backed handler from an intercepted one, andAIAgentUnservicedRequestsCollectorgains the two filters that apply it: one for the streamedAgentResponseUpdate, one for theAgentResponseemitted underEmitAgentResponseEvents. 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
SubmitAsyncemits 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.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. TheAgentResponsereturned 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
AgentResponseEventwith no messages, so underincludeWorkflowOutputsInResponsethat 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/ExecutorAgentHarnessrepeats 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.Whether withholding the streamed copy and repairing it in
SubmitAsyncis 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
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.