Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions python/packages/foundry/agent_framework_foundry/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ def _parse_chunk_from_openai(
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
seen_function_call_output_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse streaming events while preserving hosted-agent session state."""
update = try_parse_oauth_consent_event(event, self.model)
Expand All @@ -448,6 +449,7 @@ def _parse_chunk_from_openai(
options,
function_call_ids,
seen_reasoning_delta_item_ids,
seen_function_call_output_ids,
)
if agent_session_id := _extract_foundry_hosted_agent_session_id(getattr(event, "response", None)):
if update.additional_properties is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,19 @@ def _parse_chunk_from_openai(
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
seen_function_call_output_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse streaming event, intercepting oauth_consent_request items."""
update = try_parse_oauth_consent_event(event, self.model)
if update is not None:
return update
return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids)
return super()._parse_chunk_from_openai(
event,
options,
function_call_ids,
seen_reasoning_delta_item_ids,
seen_function_call_output_ids,
)

async def configure_azure_monitor(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1846,7 +1846,7 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
return_value=MagicMock(),
) as mock_super:
client._parse_chunk_from_openai(mock_event, {}, {})
mock_super.assert_called_once_with(mock_event, {}, {}, None)
mock_super.assert_called_once_with(mock_event, {}, {}, None, None)


def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1589,7 +1589,7 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
return_value=MagicMock(),
) as mock_super:
client._parse_chunk_from_openai(mock_event, {}, {})
mock_super.assert_called_once_with(mock_event, {}, {}, None)
mock_super.assert_called_once_with(mock_event, {}, {}, None, None)


def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
Expand Down
112 changes: 112 additions & 0 deletions python/packages/openai/agent_framework_openai/_chat_client.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep _parse_chunk_from_openai compatible with existing overrides? Released agent-framework-foundry versions allow any agent-framework-openai<2 but implement the old signature, so upgrading OpenAI alone makes every streaming request fail with TypeError when _chat_client.py:812 passes seen_function_call_output_ids. Could the deduplication state stay behind the existing hook, or could the Foundry dependency floor be coordinated so this stable 1.x package combination cannot resolve?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and the resolution path is exactly as you describe: packages/foundry/pyproject.toml:27
pins agent-framework-openai>=1.10.0,<2, so foundry 1.12.0 + a newer openai 1.x resolves cleanly and
then _chat_client.py:807-813 passes seen_function_call_output_ids= into an override that does not
accept it. Every streaming request raises TypeError. I hit this locally against the in-repo
subclasses and fixed those, which masked the released-wheel case from me.

On your second option, I do not think coordinating the Foundry floor can work here. The constraint
that permits the bad combination lives in wheels that are already published, so no change we make now
can retract it — a user on foundry 1.10/1.11/1.12 who upgrades only agent-framework-openai still
lands on the broken pairing. Raising the floor would protect future foundry releases and nothing
already released. Unless the fix is deferred to agent-framework-openai 2.x, where foundry's <2
bound excludes it by construction, the only shape that actually works is one that does not change the
signature at all.

Worth flagging against my own PR description: I justified this design by pointing at
seen_reasoning_delta_item_ids as precedent. That precedent has the same latent break. It was added
in 5e8fe0b (#5162), while foundry's floor was last touched in 5e056b6 (#4818), so it was never
coordinated either. So this is a pre-existing pattern rather than something my change introduces, and
my change would be the second instance. I should not have leaned on it as evidence the shape was safe.

Taking your first option, the shape I would propose is to keep the per-request dedup state off the
signature entirely by carrying it in the options dict that is already threaded through every call
site, under a private key. That preserves the order-insensitive behaviour the current tests pin while
leaving the signature byte-identical, so existing overrides keep working.

There is a simpler variant worth considering: emit only on response.output_item.done and drop the
dedup state altogether. I measured on live Foundry that both .added and .done fire for each
function_call_output item, so .done alone is sufficient for the reproduced case, and it is the
authoritative terminal event. I have not verified that .done always carries a populated output,
though — only that it fires — so I would want to confirm that on real traffic before betting the fix
on it rather than assume it.

Happy to implement either. Say which you prefer, or whether you would rather this waited for the
2.x boundary.

Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
from openai.types.responses import (
FunctionShellToolParam,
ResponseCustomToolCall,
ResponseFunctionToolCallOutputItem,
ResponseToolSearchCall,
response_create_params,
)
Expand Down Expand Up @@ -344,6 +345,63 @@ async def _open_event_stream(raw_response: Any) -> AsyncGenerator[Any]:
yield raw_response


def _plain_function_call_output(output: Any) -> Any:
"""Render provider content parts as plain data before stringifying a tool result.

``function_call_output.output`` is either a string or a list of input-content parts
(text/image/file). Passing those provider models straight to
:meth:`_stringify_mcp_output` would fall through to ``json.dumps(..., default=str)`` and embed
a Python repr in the tool result. Dumping each part first keeps text extraction working and
turns non-text parts into readable JSON.
"""
if output is None or isinstance(output, str):
return output
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
entries = cast(Sequence[Any], output)
plain: list[Any] = []
for entry in entries:
model_dump = getattr(entry, "model_dump", None)
plain.append(model_dump(exclude_none=True) if callable(model_dump) else entry)
return plain
return output


def _function_call_output_has_result(item: Any) -> bool:
"""True when a ``function_call_output`` item carries a result that can be paired.

``.added`` can precede a populated ``output``, so an in-progress skeleton must not synthesize
an empty result. A blank ``call_id`` is rejected as well: these items are synthesized by the
hosting layer, and a result that cannot be paired to its call is dropped by transports and,
worse, re-sent as an unpairable ``function_call_output`` input item on the next turn.
"""
if getattr(item, "output", None) is None:
return False
if not getattr(item, "call_id", None):
logger.debug("Skipping function_call_output with no call_id: item_id=%s", getattr(item, "id", None))
return False
return True


def _claim_function_call_output(seen_item_ids: set[str] | None, item: Any) -> bool:
"""Claim a ``function_call_output`` item for emission; return ``False`` if already claimed.

The Responses stream can surface the same output item on both ``response.output_item.added``
and ``response.output_item.done``. Whichever event first carries a populated ``output`` emits
the result, and this test-and-set keeps the other from producing a duplicate one. Keyed on the
item id rather than ``call_id``, which is not guaranteed to be unique forever. When no set is
supplied the item is always claimable, so a single event parsed on its own still yields output.
"""
if seen_item_ids is None:
return True
item_id = getattr(item, "id", None)
if not isinstance(item_id, str) or not item_id:
return True
if item_id in seen_item_ids:
return False
seen_item_ids.add(item_id)
return True


def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]:
"""Convert framework `Annotation` objects to Responses API `output_text` annotation dicts.

Expand Down Expand Up @@ -712,6 +770,7 @@ def _inner_get_response(
if stream:
function_call_ids: dict[int, tuple[str, str]] = {}
seen_reasoning_delta_item_ids: set[str] = set()
seen_function_call_output_ids: set[str] = set()
validated_options: dict[str, Any] | None = None
# Captured once request options are validated/prepared so the streaming finalizer can
# still parse the aggregated response into structured output after the stream completes.
Expand Down Expand Up @@ -750,6 +809,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
options=validated_options,
function_call_ids=function_call_ids,
seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
seen_function_call_output_ids=seen_function_call_output_ids,
)
if served_model is not None:
update.model = served_model
Expand Down Expand Up @@ -780,6 +840,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
options=validated_options,
function_call_ids=function_call_ids,
seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
seen_function_call_output_ids=seen_function_call_output_ids,
)
else:
raw_create_response = await client.responses.with_raw_response.create(
Expand All @@ -794,6 +855,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
options=validated_options,
function_call_ids=function_call_ids,
seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids,
seen_function_call_output_ids=seen_function_call_output_ids,
)
if served_model is not None:
update.model = served_model
Expand Down Expand Up @@ -2628,6 +2690,32 @@ def _parse_hosted_function_call_content(
raw_representation=item,
)

def _parse_function_call_output_content(self, item: ResponseFunctionToolCallOutputItem) -> Content:
"""Create function result content for a Responses ``function_call_output`` item.

A hosted tool that executes server-side -- for example a Foundry Toolbox dispatching
through its generic ``call_tool`` wrapper -- returns its result as a standalone
``function_call_output`` item rather than on the originating call item. Parsing it keeps
the call/result pair intact for transports such as AG-UI, which otherwise sees a tool call
with no result and falls back to treating it as declaration-only (issue #8068).

``output`` is either a string or a list of input-content parts, so it is normalized through
:meth:`_stringify_mcp_output` rather than JSON-encoding provider models.
"""
additional_properties: dict[str, Any] = {"item_type": item.type, "status": item.status}
if item.id:
additional_properties["item_id"] = item.id
# `name` (and the other caller-attribution fields) only exist on newer openai SDKs; the
# declared floor of 2.25.0 ships only call_id/id/output/status/type.
if tool_name := getattr(item, "name", None):
additional_properties["name"] = tool_name
return Content.from_function_result(
call_id=item.call_id,
result=self._stringify_mcp_output(_plain_function_call_output(item.output)),
additional_properties=additional_properties,
Comment on lines +2712 to +2715

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we preserve list-shaped function_call_output.output as canonical Content.items instead of flattening it to text here? A valid input_image or input_file becomes JSON inside a text item, so AG-UI cannot expose the rich result and history replay sends a string instead of the supported content-part list. Could _parse_function_call_output_content map these parts to framework content before calling Content.from_function_result?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and from_function_result is explicit that the list is the intended shape — _types.py:899-909
documents list[Content] as "the canonical form" with result derived from the text items for
backwards compatibility. My current _plain_function_call_output -> _stringify_mcp_output chain
collapses every part into one text item, so an input_image arrives as JSON inside text and neither
AG-UI nor history replay can see it as a content part. An earlier review pass moved this from a Python
repr to JSON; you are pointing out that JSON-in-text is still the wrong representation, and that is
right.

The mapping I would write, mirroring the outbound direction that already exists at :1996-2041 and
:2164-2181:

  • input_text -> Content.from_text(text)
  • input_image with image_url -> Content.from_uri(uri, media_type=...), keeping detail in
    additional_properties
  • input_file with file_url -> Content.from_uri; with base64 file_data -> Content.from_data

Then pass the resulting list[Content] to from_function_result and let it populate result from the
text items, so string-shaped output and existing consumers are unaffected.

One case I would rather not guess at: input_image/input_file carrying a file_id instead of inline
data. Content.from_hosted_file(file_id, media_type=..., name=...) looks like the right target, but a
hosted reference is only resolvable by the provider that issued it, so I do not know whether you want
it surfaced as hosted-file content or left as text. Which would you prefer?

I will also keep an unrecognised part type falling back to its dumped JSON as a text item rather than
dropping it, so a future part type degrades instead of disappearing.

Holding implementation on both of these until the contribution-ownership question you tagged
Eduard van Valkenburg (@eavanvalkenburg) on is settled, so nothing lands here unsanctioned. Both changes are small and I can
turn them around quickly once there is a direction.

raw_representation=item,
)

# region Parse methods
def _get_finish_reason_from_openai_response(self, response: Any) -> FinishReason | None:
"""Get the framework finish reason from a terminal Responses API response."""
Expand Down Expand Up @@ -2855,6 +2943,9 @@ def _parse_response_from_openai(
raw_representation=item,
)
)
case "function_call_output": # ResponseFunctionToolCallOutputItem
if _function_call_output_has_result(item):
contents.append(self._parse_function_call_output_content(item))
case "custom_tool_call":
contents.append(
self._parse_hosted_function_call_content(item, name=item.name, arguments=item.input)
Expand Down Expand Up @@ -2966,6 +3057,7 @@ def _parse_chunk_from_openai(
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
seen_function_call_output_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
metadata: dict[str, Any] = {}
Expand Down Expand Up @@ -3330,6 +3422,14 @@ def output_text_properties(output: Any) -> dict[str, Any] | None:
)
case "web_search_call" | "file_search_call":
contents.append(self._parse_search_tool_call_content(event_item))
case "function_call_output": # ResponseFunctionToolCallOutputItem
# Emitted from whichever of `.added` / `.done` first carries a populated
# `output`; the item id is recorded so the other event cannot emit a second
# result for the same item (issue #8068).
if _function_call_output_has_result(event_item) and _claim_function_call_output(
seen_function_call_output_ids, event_item
):
contents.append(self._parse_function_call_output_content(event_item))
case _:
if getattr(event_item, "type", None) != _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE:
logger.debug("Unparsed event of type: %s: %s", event.type, event)
Expand Down Expand Up @@ -3534,6 +3634,18 @@ def _get_ann_value(key: str) -> Any:
arguments=tool_search_call.arguments,
)
)
elif getattr(done_item, "type", None) == "function_call_output":
# Counterpart to the `response.output_item.added` branch: whichever event first
# carries a populated `output` emits the result, and the shared seen-id set
# keeps the other from duplicating it (issue #8068).
if _function_call_output_has_result(done_item) and _claim_function_call_output(
seen_function_call_output_ids, done_item
):
contents.append(
self._parse_function_call_output_content(
cast(ResponseFunctionToolCallOutputItem, done_item)
)
)
elif getattr(done_item, "type", None) == _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE:
pass
case _:
Expand Down
Loading
Loading