Skip to content

Python: [Bug]: Approval-gated tool (confirm_changes flow) re-executes a second time on a later unrelated turn — duplicate side effect, not just a UI display issue #6851

Closed

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.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jul 1, 2026
  2. lordlinus commented on Jul 1, 2026

    @lordlinus
    Author

    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 raw curl straight to the bare hosted-agent /responses endpoint, in a process where agent_framework_ag_ui isn't even installed (only agent_framework_core, agent_framework_foundry, agent_framework_foundry_hosting) — so agent_framework_ag_ui cannot be the cause.

    Precise trigger, isolated via A/B test on the same live agent/scenario (read → propose a gated tool call → approve):

    • Sending the next turn stateless (no previous_response_id at all) → correct behavior, tool does not re-execute.
    • Sending the next turn chained via previous_response_id pointing at the approval-resolution response (the normal/expected client behavior) → the gated tool silently re-executes.

    So the real trigger is: chaining previous_response_id THROUGH a response that itself resolved an mcp_approval_response. This points at agent_framework_foundry_hosting/agent_framework_foundry/agent_framework_core's Responses-conversation-state handling, not agent_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_id forward 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 + the response.completed handling in converse_stream).

    Happy to share a minimal repro script (raw curl / no framework glue) if useful for narrowing this down further.

  3. hummbl-dev commented on Jul 3, 2026

    @hummbl-dev

    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_id chain 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:

    1. create a side-effecting approved tool with an observable counter;
    2. turn 1 reads the counter;
    3. turn 2 requests the gated tool and emits one approval request;
    4. turn 3 sends the matching mcp_approval_response and the counter increments exactly once;
    5. turn 4 chains through previous_response_id=R3 with unrelated text and no approval response;
    6. assert the counter is unchanged and no execution path dispatches the old tool call.

    The terminal record that prevents replay probably needs to bind at least:

    • approval_request_id;
    • tool_call_id;
    • canonical args digest;
    • logical execution attempt id;
    • consumed/resolved marker;
    • terminal result id/status;
    • retry policy state, if a retry is still permitted.

    Then the resolution path can distinguish three cases cleanly:

    • same approval + same execution attempt + non-terminal + retry allowed => retry may run;
    • same approval + terminal execution already recorded => do not dispatch, treat as inert history;
    • same approval + different tool call / args / run context => hard block or typed non-execution result.

    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.

  4. rpelevin commented on Jul 5, 2026

    @rpelevin

    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:

    • same approval/request/tool/args + terminal execution already recorded => inert history, no dispatch
    • same approval/request/tool/args + non-terminal attempt + retry policy allows retry => retry may dispatch
    • same approval/request/tool/args on a later unrelated turn with no new approval response => hard block or typed non-execution result

    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.

  5. deleted a comment from on Jul 7, 2026
  6. deleted a comment from on Jul 7, 2026
  7. deleted a comment from on Jul 7, 2026
  8. deleted a comment from on Jul 7, 2026
  9. added
    ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integration
    and removed
    ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integration
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jul 7, 2026
  10. moonbox3 commented on Aug 18, 2026

    @moonbox3
    Contributor

    Fixed in #7345 and #7271

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

Metadata

Metadata

Labels

ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationpythonUsage: [Issues, PRs], Target: Python

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions