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

Description

@lordlinus

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

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

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions