Repository navigation
Description
Description
When a tool is gated by human-in-the-loop approval over AG-UI using the confirm_changes flow (add_agent_framework_fastapi_endpoint, require_confirmation=True, as CopilotKit's useHumanInTheLoop uses), the approved tool call is silently re-executed a second time on a later, unrelated conversation turn — with no new approval anywhere in that turn's input. This is a duplicate real side effect, not merely a UI/display issue.
I believe this shares the exact same root cause as #6828 (_clean_resolved_approvals_from_snapshot not covering the confirm_changes flow), but the consequence is more severe than that issue's reported "tool chip reverts to running" symptom: the gated tool's actual side effect runs again, unprompted, on a subsequent turn. For any real (non-demo) consequential action — sending a payment, approving a request, deleting a resource — this means the action can be silently duplicated without the user ever clicking Approve a second time.
Package Versions
agent-framework-core==1.9.0, agent-framework-ag-ui==1.0.0rc5, agent-framework-foundry==1.8.2 (also reproduced with a locally-served demo agent via ResponsesHostServer/FoundryChatClient; identical package versions to #6828).
Python Version
Python 3.12
Minimal reproduction (isolated to the raw Responses API — no CopilotKit, no custom bridge/proxy code involved)
A minimal agent with a get_value read tool and an apply_delta(delta) tool decorated @tool(approval_mode="always_require"), served via ResponsesHostServer/FoundryChatClient. Four turns chained purely via previous_response_id, sent directly to the raw /responses endpoint with plain curl (no AG-UI/CopilotKit client involved at all):
# Turn 1 (read): input="What is the current value?"
# -> value=100, response_id=R1
# Turn 2 (propose, pause): previous_response_id=R1, input="Apply a delta of 10."
# -> mcp_approval_request emitted, response_id=R2
# Turn 3 (approve): previous_response_id=R2
# input=[{"type": "mcp_approval_response", "approval_request_id": <id>, "approve": true}]
# -> tool executes: "The new value is 110.", response_id=R3
# Turn 4 (unrelated follow-up): previous_response_id=R3, input="Just checking in, what's the value now?"
# -> EXPECTED: reads 110 (no new approval was sent, so nothing should execute).
# -> ACTUAL: value has become 120 - apply_delta ran AGAIN.This reproduces consistently across independent runs (verified multiple times, with values drifting by exactly one extra delta application per additional turn taken after the approval, regardless of what that turn's actual content is).
I separately verified this through an AG-UI bridge (add_agent_framework_fastapi_endpoint + a custom hosted-agent-forwarding SupportsAgentRun) and confirmed the bridge's own approval-detection logic correctly identifies turn 4 as a normal (non-approval) turn and does not re-send an mcp_approval_response — yet the hosted agent still re-executes the tool. This confirms the duplication happens entirely within the core Responses/HITL resolution path (ResponsesHostServer/FoundryChatClient/agent_framework_ag_ui), upstream of any bridge/proxy/AG-UI-client code, and is triggered purely by the stale, never-cleaned-up approval content lingering in the chained conversation history.
Root cause (same as #6828)
_clean_resolved_approvals_from_snapshot (agent_framework_ag_ui/_agent_run.py) only cleans up the stale approval-response content for the direct approval_mode flow (where the approval-response tool message is keyed by the original call_id). It does not cover the confirm_changes flow (where the HITL tool call carries its own separate id) — so for confirm_changes, the original tool call is never durably marked resolved anywhere the framework can see on a later turn, and gets reconsidered for (re-)execution once a new turn arrives in the same chained conversation.
Expected vs actual
- Expected: once approved and executed, a gated tool call must never execute again for any reason short of a fresh, explicit new approval.
- Actual: the SAME approved tool call executes a second time on the next turn in the same conversation, regardless of that turn's content — silently duplicating a real, consequential side effect.
Relationship to #6828
Same underlying defect (#6828 discovered the missing-snapshot-cleanup root cause via a UI symptom — approved tool chip reverting to "Running"). Filing separately because the safety impact here (duplicate execution of a real side effect, not just incorrect UI state) is significantly more severe and may need explicit test coverage/verification beyond what fixes the display symptom — a fix that only makes _build_messages_snapshot show the right state, without also preventing _resolve_approval_responses/the underlying provider from re-resolving the same approval, would leave this issue open even after #6828's fix lands. Happy to consolidate if maintainers prefer, but wanted the duplicate-execution behavior tracked explicitly given the safety implications for HITL-gated consequential actions.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Cross-reference the new upstream issue (#6851) for the duplicate-exec…
lordlinus commentedon Jul 1, 2026
Correction to root-cause attribution + a workaround
Follow-up after further isolation: my original root-cause guess (
agent_framework_ag_ui/_agent_run.py's_clean_resolved_approvals_from_snapshot) was wrong. The bug reproduces via rawcurlstraight to the bare hosted-agent/responsesendpoint, in a process whereagent_framework_ag_uiisn't even installed (onlyagent_framework_core,agent_framework_foundry,agent_framework_foundry_hosting) — soagent_framework_ag_uicannot be the cause.Precise trigger, isolated via A/B test on the same live agent/scenario (read → propose a gated tool call → approve):
previous_response_idat all) → correct behavior, tool does not re-execute.previous_response_idpointing at the approval-resolution response (the normal/expected client behavior) → the gated tool silently re-executes.So the real trigger is: chaining
previous_response_idTHROUGH a response that itself resolved anmcp_approval_response. This points atagent_framework_foundry_hosting/agent_framework_foundry/agent_framework_core's Responses-conversation-state handling, notagent_framework_ag_ui.Workaround we shipped (client-side, in our own bridge, while waiting on a real fix): track whether a turn's own input resolves an approval; if so, don't chain
previous_response_idforward from that turn's response — start the next turn stateless instead. This trades a bit of conversational memory on the single next turn for the guarantee that a gated/consequential tool never silently executes twice. Implementation: https://github.com/lordlinus/forgewright/blob/main/templates/agentic-copilot-foundry/backend/hosted_client.py (_is_approval_turn+ theresponse.completedhandling inconverse_stream).Happy to share a minimal repro script (raw curl / no framework glue) if useful for narrowing this down further.
hummbl-dev commentedon Jul 3, 2026
The client-side workaround makes sense, but I would treat it as a mitigation rather than the invariant. The server/runtime boundary still needs to make the approved execution terminal before the next
previous_response_idchain can make progress.The rule I would test is slightly narrower than "approval artifacts are single-use": an approval artifact authorizes one logical execution transition. A retry of that same non-terminal execution attempt can be valid, but a later conversation turn cannot turn the same approval/tool-call pair back into an executable transition after terminal completion.
A minimal regression fixture could be:
mcp_approval_responseand the counter increments exactly once;previous_response_id=R3with unrelated text and no approval response;The terminal record that prevents replay probably needs to bind at least:
approval_request_id;tool_call_id;Then the resolution path can distinguish three cases cleanly:
That gives the framework a local fix target: resolved approval content may remain in chained history for audit/conversation reconstruction, but it must be rendered non-executable once the approved transition has reached a terminal result.
rpelevin commentedon Jul 5, 2026
Good repro. I would make the regression target the dispatcher boundary, not just snapshot cleanup.
The old approval can stay visible in conversation history for audit/reconstruction, but it cannot remain execution authority after the approved transition has reached a terminal result.
A useful invariant is:
For the fixture here, I would assert two things on the fourth turn: the counter remains 110, and the dispatcher path is not entered for the old tool call at all. That catches fixes that only hide the stale approval from the visible message snapshot while still letting the execution resolver rediscover it.
Boundary: architecture and regression-test feedback only; no claim about running this project, implementation correctness, Microsoft alignment, adoption, integration, customer interest, official alignment, or Neura usage.
moonbox3 commentedon Aug 18, 2026
Fixed in #7345 and #7271