From abf8c3c8a363dd8ee91ea92c89e7cb886271e9ff Mon Sep 17 00:00:00 2001 From: Chinedum Echeta <60179183+cecheta@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:31:55 +0000 Subject: [PATCH 1/3] Python: Enhance _OutputItemTracker to prevent duplicate function call streaming --- .../_responses.py | 8 ++++++ .../foundry_hosting/tests/test_responses.py | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 49882462ce..ee0d3bc19a 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -967,6 +967,7 @@ def __init__(self, stream: ResponseEventStream) -> None: self._reasoning_encrypted_content: str | None = None self._fc_builder: OutputItemFunctionCallBuilder | None = None self._mcp_builder: OutputItemMcpCallBuilder | None = None + self._seen_function_call_ids: set[str] = set() self.needs_async = False def handle(self, content: Content) -> Generator[ResponseStreamEvent]: @@ -995,6 +996,12 @@ def handle(self, content: Content) -> Generator[ResponseStreamEvent]: yield self._summary_part.emit_text_delta(content.text) elif content.type == "function_call" and content.call_id is not None: + if ( + content.user_input_request + and content.arguments is None + and content.call_id in self._seen_function_call_ids + ): + return if self._active_type != "function_call" or self._active_id != content.call_id: yield from self._close() yield from self._open_function_call(content) @@ -1080,6 +1087,7 @@ def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent ) self._active_type = "function_call" self._active_id = content.call_id + self._seen_function_call_ids.add(content.call_id or "") yield self._fc_builder.emit_added() def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]: diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index cc1d863304..adaa77c1e1 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -1265,6 +1265,34 @@ async def test_function_call_streaming(self) -> None: assert len(args_done) == 1 assert args_done[0]["data"]["arguments"] == '{"q": "hello"}' + async def test_declaration_only_metadata_does_not_duplicate_streamed_function_call(self) -> None: + metadata = Content.from_function_call("call_1", "search", arguments=None) + metadata.id = "call_1" + metadata.user_input_request = True + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "search", arguments='{"q": "hello"}')], + role="assistant", + ), + AgentResponseUpdate(contents=[Content.from_text("Waiting for the result")], role="assistant"), + AgentResponseUpdate(contents=[metadata], role="assistant"), + ] + ) + server = _make_server(agent) + + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + function_items = [ + event + for event in events + if event["event"] == "response.output_item.added" and event["data"]["item"]["type"] == "function_call" + ] + assert len(function_items) == 1 + assert function_items[0]["data"]["item"]["call_id"] == "call_1" + async def test_function_call_streaming_serializes_dataclass_arguments(self) -> None: @dataclass class HandoffLikeRequest: From a19361bae151ac623a53c7798dd946c5d643d84e Mon Sep 17 00:00:00 2001 From: Chinedum Echeta <60179183+cecheta@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:56:29 +0000 Subject: [PATCH 2/3] Handle empty function call metadata arguments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9b232bff-6a8c-4b02-addd-89de57a82f6a --- .../agent_framework_foundry_hosting/_responses.py | 6 +----- python/packages/foundry_hosting/tests/test_responses.py | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index ee0d3bc19a..cb66ce1fa7 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -996,11 +996,7 @@ def handle(self, content: Content) -> Generator[ResponseStreamEvent]: yield self._summary_part.emit_text_delta(content.text) elif content.type == "function_call" and content.call_id is not None: - if ( - content.user_input_request - and content.arguments is None - and content.call_id in self._seen_function_call_ids - ): + if content.user_input_request and not content.arguments and content.call_id in self._seen_function_call_ids: return if self._active_type != "function_call" or self._active_id != content.call_id: yield from self._close() diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index adaa77c1e1..a4fbc8f5e2 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -1265,8 +1265,11 @@ async def test_function_call_streaming(self) -> None: assert len(args_done) == 1 assert args_done[0]["data"]["arguments"] == '{"q": "hello"}' - async def test_declaration_only_metadata_does_not_duplicate_streamed_function_call(self) -> None: - metadata = Content.from_function_call("call_1", "search", arguments=None) + @pytest.mark.parametrize("arguments", [None, ""]) + async def test_declaration_only_metadata_does_not_duplicate_streamed_function_call( + self, arguments: str | None + ) -> None: + metadata = Content.from_function_call("call_1", "search", arguments=arguments) metadata.id = "call_1" metadata.user_input_request = True agent = _make_agent( From d48c60ecc8ba7a7415322a3abd6903d50b00913c Mon Sep 17 00:00:00 2001 From: Chinedum Echeta <60179183+cecheta@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:10:36 +0000 Subject: [PATCH 3/3] Python: Refactor _OutputItemTracker to manage outstanding function calls and update tests for call ID reuse --- .../_responses.py | 19 +++++++-- .../foundry_hosting/tests/test_responses.py | 39 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index cb66ce1fa7..bd3cadf734 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -967,7 +967,7 @@ def __init__(self, stream: ResponseEventStream) -> None: self._reasoning_encrypted_content: str | None = None self._fc_builder: OutputItemFunctionCallBuilder | None = None self._mcp_builder: OutputItemMcpCallBuilder | None = None - self._seen_function_call_ids: set[str] = set() + self._outstanding_function_calls: dict[str, str | None] = {} self.needs_async = False def handle(self, content: Content) -> Generator[ResponseStreamEvent]: @@ -996,7 +996,14 @@ def handle(self, content: Content) -> Generator[ResponseStreamEvent]: yield self._summary_part.emit_text_delta(content.text) elif content.type == "function_call" and content.call_id is not None: - if content.user_input_request and not content.arguments and content.call_id in self._seen_function_call_ids: + # Declaration-only calls replay request metadata after the streamed call. Scope suppression to the + # outstanding occurrence because a call_id may be reused after its terminal result. + if ( + content.user_input_request + and content.arguments is None + and content.call_id in self._outstanding_function_calls + and self._outstanding_function_calls[content.call_id] == content.name + ): return if self._active_type != "function_call" or self._active_id != content.call_id: yield from self._close() @@ -1006,6 +1013,12 @@ def handle(self, content: Content) -> Generator[ResponseStreamEvent]: if self._fc_builder is not None: yield self._fc_builder.emit_arguments_delta(args_str) + elif content.type == "function_result": + yield from self._close() + if content.call_id is not None: + self._outstanding_function_calls.pop(content.call_id, None) + self.needs_async = True + elif content.type == "mcp_server_tool_call" and content.tool_name: key = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}" 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 ) self._active_type = "function_call" self._active_id = content.call_id - self._seen_function_call_ids.add(content.call_id or "") + self._outstanding_function_calls[content.call_id or ""] = content.name yield self._fc_builder.emit_added() def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]: diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index a4fbc8f5e2..587ba737fc 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -1265,9 +1265,9 @@ async def test_function_call_streaming(self) -> None: assert len(args_done) == 1 assert args_done[0]["data"]["arguments"] == '{"q": "hello"}' - @pytest.mark.parametrize("arguments", [None, ""]) - async def test_declaration_only_metadata_does_not_duplicate_streamed_function_call( - self, arguments: str | None + @pytest.mark.parametrize(("arguments", "expected_count"), [(None, 1), ("", 2)]) + async def test_declaration_only_metadata_replay_requires_none_arguments( + self, arguments: str | None, expected_count: int ) -> None: metadata = Content.from_function_call("call_1", "search", arguments=arguments) metadata.id = "call_1" @@ -1293,8 +1293,37 @@ async def test_declaration_only_metadata_does_not_duplicate_streamed_function_ca for event in events if event["event"] == "response.output_item.added" and event["data"]["item"]["type"] == "function_call" ] - assert len(function_items) == 1 - assert function_items[0]["data"]["item"]["call_id"] == "call_1" + assert len(function_items) == expected_count + + async def test_function_call_id_can_be_reused_after_terminal_result(self) -> None: + reused_call = Content.from_function_call("call_1", "search", arguments=None) + reused_call.id = "call_1" + reused_call.user_input_request = True + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "search", arguments='{"q": "first"}')], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_function_result("call_1", result="first result")], + role="tool", + ), + AgentResponseUpdate(contents=[reused_call], role="assistant"), + ] + ) + server = _make_server(agent) + + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + function_items = [ + event + for event in events + if event["event"] == "response.output_item.added" and event["data"]["item"]["type"] == "function_call" + ] + assert [event["data"]["item"]["call_id"] for event in function_items] == ["call_1", "call_1"] async def test_function_call_streaming_serializes_dataclass_arguments(self) -> None: @dataclass