Skip to content

Python: [Bug]: Approval request restores a third function_call when a completed call and a replayed approval pair share one call_id #7304

Description

@atty57

Description

Python: [Bug]: Approval request restores a third function_call when a completed call and a replayed approval pair share one call_id

Description

What happened?

_replace_approval_contents_with_results scopes its duplicate-restore check to call ids that are still awaiting a result (added in #7271, fixing #7267). A call id is treated as answered if any non-placeholder function_result for it exists anywhere in the history.

That works for the two cases it was designed for, but the two can overlap. If an earlier invocation of call_id has already completed and a hosting layer then replays a fresh function_call + function_approval_request pair reusing that same id, the id is in answered_call_ids because of the old result. The replayed open call is therefore excluded from the pending set, the approval request expands anyway, and the transcript ends up with three function_call entries and two function_result entries.

The orphaned third call has no matching output, which is the same shape the Responses API rejects with No tool output found for function call <id> — the failure originally reported in #7267.

Reusing a call_id for a later invocation is supported behaviour (test_same_call_id_and_function_can_be_reapproved in python/packages/core/tests/test_security.py), so this is not a misuse of the API.

Related but distinct: #5546 is the mirror case an orphaned function_call_output with no matching function_call in the Foundry hosted-toolbox persistence path rather than in the approval-replay path.

What did you expect to happen?

Every function_call in the resulting transcript should be paired with exactly one function_result. In this scenario that means two calls and two results, not three and two.

Steps to reproduce the issue

Unit-level reproduction against main:

from agent_framework import Content, Message
from agent_framework._tools import (
    _collect_approval_responses,
    _replace_approval_contents_with_results,
)

# An earlier invocation of call_x that already completed.
old_call = Content.from_function_call(call_id="call_x", name="t", arguments="{}")
old_result = Content.from_function_result(call_id="call_x", result="first output")

# A replayed pair reusing the same call id: the stored function_call and its
# approval request arrive as separate assistant messages.
new_call = Content.from_function_call(call_id="call_x", name="t", arguments="{}")
new_request = Content.from_function_approval_request(id="approval_3", function_call=new_call)
new_response = new_request.to_function_approval_response(approved=True)

messages = [
    Message(role="assistant", contents=[old_call]),
    Message(role="tool", contents=[old_result]),
    Message(role="assistant", contents=[new_call]),
    Message(role="assistant", contents=[new_request]),
    Message(role="user", contents=[new_response]),
]

_replace_approval_contents_with_results(
    messages,
    _collect_approval_responses(messages),
    [Content.from_function_result(call_id="call_x", result="second output")],
)

calls = [c.call_id for m in messages for c in m.contents if c.type == "function_call"]
results = [c.call_id for m in messages for c in m.contents if c.type == "function_result"]
print(calls)    # ['call_x', 'call_x', 'call_x']
print(results)  # ['call_x', 'call_x']

Resulting transcript:

assistant  function_call    call_x
tool       function_result  call_x  "first output"
assistant  function_call    call_x   <- replayed
assistant  function_call    call_x   <- restored from the approval request (orphan)
tool       function_result  call_x  "second output"

Caveat: this is a unit-level reproduction constructed directly against
_replace_approval_contents_with_results. I have not reproduced it end to end through a
live provider, so I can't confirm how often a hosting layer emits this exact shape. Filing
it because the code path admits it and it produces the same invalid transcript that #7267
was about.

Possible fix

An id-keyed set can't distinguish a closed call from a replayed open call sharing the same
id, because both collapse to one key. Tracking open calls positionally during the existing
ordered pass would disambiguate them increment a per-id counter on each function_call,
decrement on each non-placeholder function_result, and suppress the restore only when the
counter is non-zero at the point the approval request is reached.

That assumes the hosting layer always replays the function_call before its approval
request. If the request can arrive first, the counter needs a lookahead or a different
pairing signal, so it's worth confirming the ordering guarantee before settling on an
approach.

Happy to open a PR if the direction looks right.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: reproduced against main @ d98ac29 (pyproject declares 1.12.1)

Python Version

Python 3.12

Additional Context

No response

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions