Skip to content

Commit d48c60e

Browse files
committed
Python: Refactor _OutputItemTracker to manage outstanding function calls and update tests for call ID reuse
1 parent a19361b commit d48c60e

2 files changed

Lines changed: 50 additions & 8 deletions

File tree

python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@ def __init__(self, stream: ResponseEventStream) -> None:
967967
self._reasoning_encrypted_content: str | None = None
968968
self._fc_builder: OutputItemFunctionCallBuilder | None = None
969969
self._mcp_builder: OutputItemMcpCallBuilder | None = None
970-
self._seen_function_call_ids: set[str] = set()
970+
self._outstanding_function_calls: dict[str, str | None] = {}
971971
self.needs_async = False
972972

973973
def handle(self, content: Content) -> Generator[ResponseStreamEvent]:
@@ -996,7 +996,14 @@ def handle(self, content: Content) -> Generator[ResponseStreamEvent]:
996996
yield self._summary_part.emit_text_delta(content.text)
997997

998998
elif content.type == "function_call" and content.call_id is not None:
999-
if content.user_input_request and not content.arguments and content.call_id in self._seen_function_call_ids:
999+
# Declaration-only calls replay request metadata after the streamed call. Scope suppression to the
1000+
# outstanding occurrence because a call_id may be reused after its terminal result.
1001+
if (
1002+
content.user_input_request
1003+
and content.arguments is None
1004+
and content.call_id in self._outstanding_function_calls
1005+
and self._outstanding_function_calls[content.call_id] == content.name
1006+
):
10001007
return
10011008
if self._active_type != "function_call" or self._active_id != content.call_id:
10021009
yield from self._close()
@@ -1006,6 +1013,12 @@ def handle(self, content: Content) -> Generator[ResponseStreamEvent]:
10061013
if self._fc_builder is not None:
10071014
yield self._fc_builder.emit_arguments_delta(args_str)
10081015

1016+
elif content.type == "function_result":
1017+
yield from self._close()
1018+
if content.call_id is not None:
1019+
self._outstanding_function_calls.pop(content.call_id, None)
1020+
self.needs_async = True
1021+
10091022
elif content.type == "mcp_server_tool_call" and content.tool_name:
10101023
key = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}"
10111024
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
@@ -1083,7 +1096,7 @@ def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent
10831096
)
10841097
self._active_type = "function_call"
10851098
self._active_id = content.call_id
1086-
self._seen_function_call_ids.add(content.call_id or "")
1099+
self._outstanding_function_calls[content.call_id or ""] = content.name
10871100
yield self._fc_builder.emit_added()
10881101

10891102
def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]:

python/packages/foundry_hosting/tests/test_responses.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,9 +1265,9 @@ async def test_function_call_streaming(self) -> None:
12651265
assert len(args_done) == 1
12661266
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
12671267

1268-
@pytest.mark.parametrize("arguments", [None, ""])
1269-
async def test_declaration_only_metadata_does_not_duplicate_streamed_function_call(
1270-
self, arguments: str | None
1268+
@pytest.mark.parametrize(("arguments", "expected_count"), [(None, 1), ("", 2)])
1269+
async def test_declaration_only_metadata_replay_requires_none_arguments(
1270+
self, arguments: str | None, expected_count: int
12711271
) -> None:
12721272
metadata = Content.from_function_call("call_1", "search", arguments=arguments)
12731273
metadata.id = "call_1"
@@ -1293,8 +1293,37 @@ async def test_declaration_only_metadata_does_not_duplicate_streamed_function_ca
12931293
for event in events
12941294
if event["event"] == "response.output_item.added" and event["data"]["item"]["type"] == "function_call"
12951295
]
1296-
assert len(function_items) == 1
1297-
assert function_items[0]["data"]["item"]["call_id"] == "call_1"
1296+
assert len(function_items) == expected_count
1297+
1298+
async def test_function_call_id_can_be_reused_after_terminal_result(self) -> None:
1299+
reused_call = Content.from_function_call("call_1", "search", arguments=None)
1300+
reused_call.id = "call_1"
1301+
reused_call.user_input_request = True
1302+
agent = _make_agent(
1303+
stream_updates=[
1304+
AgentResponseUpdate(
1305+
contents=[Content.from_function_call("call_1", "search", arguments='{"q": "first"}')],
1306+
role="assistant",
1307+
),
1308+
AgentResponseUpdate(
1309+
contents=[Content.from_function_result("call_1", result="first result")],
1310+
role="tool",
1311+
),
1312+
AgentResponseUpdate(contents=[reused_call], role="assistant"),
1313+
]
1314+
)
1315+
server = _make_server(agent)
1316+
1317+
resp = await _post(server, stream=True)
1318+
1319+
assert resp.status_code == 200
1320+
events = _parse_sse_events(resp.text)
1321+
function_items = [
1322+
event
1323+
for event in events
1324+
if event["event"] == "response.output_item.added" and event["data"]["item"]["type"] == "function_call"
1325+
]
1326+
assert [event["data"]["item"]["call_id"] for event in function_items] == ["call_1", "call_1"]
12981327

12991328
async def test_function_call_streaming_serializes_dataclass_arguments(self) -> None:
13001329
@dataclass

0 commit comments

Comments
 (0)