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

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

Activity

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

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

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions