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
Description
Python: [Bug]: Approval request restores a third
function_callwhen a completed call and a replayed approval pair share onecall_idDescription
What happened?
_replace_approval_contents_with_resultsscopes 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-placeholderfunction_resultfor 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_idhas already completed and a hosting layer then replays a freshfunction_call+function_approval_requestpair reusing that same id, the id is inanswered_call_idsbecause 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 threefunction_callentries and twofunction_resultentries.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_idfor a later invocation is supported behaviour (test_same_call_id_and_function_can_be_reapprovedinpython/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_outputwith no matchingfunction_callin the Foundry hosted-toolbox persistence path rather than in the approval-replay path.What did you expect to happen?
Every
function_callin the resulting transcript should be paired with exactly onefunction_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:Resulting transcript:
Caveat: this is a unit-level reproduction constructed directly against
_replace_approval_contents_with_results. I have not reproduced it end to end through alive 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 thecounter is non-zero at the point the approval request is reached.
That assumes the hosting layer always replays the
function_callbefore its approvalrequest. 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