Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Python: Refactor _OutputItemTracker to manage outstanding function ca…
…lls and update tests for call ID reuse
  • Loading branch information
cecheta committed Aug 10, 2026
commit 833f2ddf5a00673913a9b74600edf5cfe0e6ad3b
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,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]:
Expand Down Expand Up @@ -702,7 +702,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
Comment thread
cecheta marked this conversation as resolved.
Resolved
if self._active_type != "function_call" or self._active_id != content.call_id:
yield from self._close()
Expand All @@ -712,6 +719,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:
Expand Down Expand Up @@ -789,7 +802,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]:
Expand Down
39 changes: 34 additions & 5 deletions python/packages/foundry_hosting/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -1068,9 +1068,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"
Expand All @@ -1096,8 +1096,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
Expand Down
Loading