Skip to content

Python: fix: parse Responses function_call_output so hosted tool results reach transports - #8078

Open
Manjunath Janardhan (manjunathshiva) wants to merge 4 commits into
microsoft:mainfrom
manjunathshiva:python-openai-parse-function-call-output-8068
Open

Python: fix: parse Responses function_call_output so hosted tool results reach transports#8078
Manjunath Janardhan (manjunathshiva) wants to merge 4 commits into
microsoft:mainfrom
manjunathshiva:python-openai-parse-function-call-output-8068

Conversation

@manjunathshiva

Copy link
Copy Markdown
Contributor

Motivation & Context

A Foundry hosted agent using a Foundry Toolbox dispatches its knowledge-base tool through the toolbox's generic call_tool wrapper. The Toolbox executes the inner tool server-side and ResponsesHostServer serializes the result as a standard function_call_output Responses item. None of the three parse dispatch sites in agent_framework_openai handled that item type, so it fell into case _: / the end of the elif chain, was logged as Unparsed event of type: ... at debug level, and discarded.

No Content.from_function_result(...) was produced, so agent_framework_ag_ui emitted TOOL_CALL_END with no matching TOOL_CALL_RESULT and its declaration-only fallback took over (_agent_run.py:3100-3108). The model still received the real output — only the client lost the structured result. The same tool called directly is an mcp_call and works, which is what isolates this to the client parser.

Description & Review Guide

  • What are the major changes?

    A function_call_output branch at all three dispatch sites — the non-streaming _parse_response_from_openai and both streaming response.output_item.added / .done handlers — sharing one _parse_function_call_output_content helper and one _function_call_output_has_result gate, so the three item-type lists cannot drift apart on this type again.

    output is str | list[ResponseInputText|Image|File], so it is normalized through the existing _stringify_mcp_output rather than JSON-encoding provider models. That helper's name is MCP-flavoured but its logic is generic; happy to rename it if you would prefer (1 caller, 2 dedicated tests).

    The two streaming handlers emit from whichever event first carries a populated output, recording the item id in a per-request set so the other cannot emit a second result. Keyed on the item id rather than call_id, which the function-calling loop contract says must not be assumed unique forever.

    Two follow-up commits are separated deliberately so they can be read on their own: forwarding the new parameter through the two RawFoundryChatClient / RawFoundryAgentChatClient overrides, and rejecting a blank call_id so no unpairable result is emitted.

  • What is the impact of these changes?

    Hosted-toolbox tool results now reach transports as function_result content, so AG-UI emits the full TOOL_CALL_STARTTOOL_CALL_ENDTOOL_CALL_RESULT lifecycle. No AG-UI change was needed: _emit_tool_result already handles function_result.

    Reviewed against docs/specs/004-python-function-calling-loop.md, which covers provider serialization of function calls and results. No result is orphaned or duplicated, and the streaming and non-streaming paths agree. I have not edited the spec — its checklist asks that the matrix name a regression test for each affected scenario, and I would rather you decide whether this warrants a row than edit a cross-package contract in a bug fix. Glad to add one.

    Verified against a live Foundry Responses endpoint in addition to the unit tests. Two things that measurement settled:

    1. Every output item fires both .added and .done (confirmed for function_call, message, reasoning). Emitting from both handlers without the seen-id set would have produced duplicate results; emitting from only one would have been a guess about which event carries the payload.
    2. A stored function_call_output is re-sent inline on the next turn under previous_response_id, and the service accepts it. Since the outbound serializer emits only call_id / type / output and no item id, a server-generated result is indistinguishable on the wire from a locally-executed one, so this needs no outbound companion change.
  • What do you want reviewers to focus on?

    Three things I could not settle myself, all raised deliberately rather than left for you to find:

    1. The parse signature as an extension point. Adding one per-request state field meant touching a creation site, three call sites, and two subclass overrides — I broke Foundry streaming and caught it on live traffic. I followed the existing mechanism: seen_reasoning_delta_item_ids was added the same way and both Foundry overrides already carry it. If you would rather this state travelled in a per-request context object, that is a refactor of a spec-004 method and I would do it separately rather than inside a bug fix.
    2. Whether _parse_chunk_from_openai counts as public API. RawOpenAIChatClient is exported and its docstring demonstrates subclassing, so an out-of-tree override with the old signature would break the same way Foundry did. I read the leading underscore as private and did not label this a breaking change — tell me if you disagree and I will relabel.
    3. packages/foundry is missing from spec 004's minimum validation commands (docs/specs/004-python-function-calling-loop.md), even though it subclasses the OpenAI Responses client the spec governs. That omission is exactly why my first sweep missed the regression above; uv run poe test -P foundry reproduces it. Worth adding for the next contributor.

    Separately, and not proposed here: the case _: default at all three sites drops any unknown item type at debug severity, which is why the reporter hit three such types in one session (function_call_output plus two SharePoint preview ones). Silently discarding a tool result is a correctness event rather than a diagnostic one. I deliberately kept this PR to the reproduced type — the SharePoint types are preview and I have no repro, so guessing their shape risks a wrong parser. If useful I will open a separate issue proposing either a shared dispatch table across the three sites or a warning for unknown *_output items.

Related Issue

Fixes #8068

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…each transports

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` Responses item rather than on the originating
call item. None of the three parse dispatch sites handled that item type, so it
fell through to the `Unparsed ...` debug log and was discarded. No
`Content.from_function_result` was produced, and AG-UI consequently emitted
TOOL_CALL_END with no matching TOOL_CALL_RESULT, falling back to treating the
call as declaration-only. The model still received the real output, so only the
client lost the structured result.

Add a `function_call_output` branch to all three sites -- the non-streaming
`_parse_response_from_openai` and both streaming `response.output_item.added` /
`.done` handlers -- sharing one `_parse_function_call_output_content` helper so
the lists cannot drift again. `output` is a string or a list of input-content
parts, so it is normalized through the existing `_stringify_mcp_output` rather
than JSON-encoding provider models.

The streaming handlers emit from whichever event first carries a populated
`output` and record the item id in a per-request set, so the other event cannot
produce a second result. Keyed on the item id rather than `call_id`, which the
function-calling loop contract says must not be assumed unique forever.

Reviewed against docs/specs/004-python-function-calling-loop.md, which covers
provider serialization of function calls and results: no result is orphaned or
duplicated, and the streaming and non-streaming paths agree.

Fixes microsoft#8068
…parse overrides

`RawFoundryChatClient` and `RawFoundryAgentChatClient` override
`_parse_chunk_from_openai` to intercept oauth_consent items and then delegate to
`RawOpenAIChatClient`. Both had the pre-change signature, so once the base
started passing `seen_function_call_output_ids` every Foundry streaming call
raised `TypeError: _parse_chunk_from_openai() got an unexpected keyword
argument`.

Accept and forward the new parameter in both overrides, and update the two
delegation assertions that pin the forwarded argument list.

Caught against a live Foundry Responses endpoint; `poe test -P foundry` also
reproduces it, but that package is not in the validation command list in
docs/specs/004-python-function-calling-loop.md even though it subclasses the
OpenAI Responses client.
Review follow-up. `Content.from_function_result` does not validate `call_id`, so a
`function_call_output` item carrying a blank one produced an orphaned result:
transports drop it (`_emit_tool_result` returns early on a falsy `call_id`) and
the outbound serializer would re-send it as an unpairable
`function_call_output` input item on the next turn. These items are synthesized
by the hosting layer, so a blank `call_id` is a realistic host-side defect
rather than a theoretical one, and the function-calling loop contract requires
that no result becomes orphaned.

Extract the emission gate into `_function_call_output_has_result` so the
populated-output and pairable-call_id checks are shared by all three dispatch
sites instead of being repeated at each one.

Copilot AI left a comment

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.

🟡 Changes recommended

Supported older SDK versions can crash, and rich output parts are not serialized into usable results.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds parsing for hosted function_call_output items so tool results reach downstream transports.

Changes:

  • Parses streaming and non-streaming function outputs.
  • Deduplicates streaming results by item ID.
  • Propagates parser state through Foundry clients and adds tests.
File summaries
File Description
python/packages/openai/agent_framework_openai/_chat_client.py Implements output parsing and deduplication.
python/packages/openai/tests/openai/test_openai_chat_client.py Tests parsing scenarios.
python/packages/foundry/agent_framework_foundry/_chat_client.py Forwards deduplication state.
python/packages/foundry/agent_framework_foundry/_agent.py Forwards deduplication state.
python/packages/foundry/tests/foundry/test_foundry_chat_client.py Updates delegation assertion.
python/packages/foundry/tests/foundry/test_foundry_agent.py Updates delegation assertion.
Review details

Suppressed comments (1)

python/packages/openai/agent_framework_openai/_chat_client.py:2692

  • For list output, the SDK supplies ResponseInputText/ResponseInputImage/ResponseInputFile model instances, not the dictionaries used in the new test. Text happens to work via .text, but image/file parts fall through to json.dumps(..., default=str), producing quoted Pydantic reprs (and concatenating multiple reprs) rather than preserving usable rich output. Map these provider parts to Content items, or serialize the full list with _serialize_provider_payload while retaining its boundaries before creating the function result.
        return Content.from_function_result(
            call_id=item.call_id,
            result=self._stringify_mcp_output(item.output),
            additional_properties=additional_properties,
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/openai/agent_framework_openai/_chat_client.py Outdated
… SDK floor

Address review on two counts.

`name` is not on `ResponseFunctionToolCallOutputItem` in openai 2.25.0, the
declared floor -- that version ships only call_id/id/output/status/type. Reading
it as an attribute raised `AttributeError` out of the shared parse helper, which
all three dispatch sites call, so on any supported SDK below the release that
added the field the whole response parse failed rather than merely dropping the
result. Read it with `getattr`. The other attributes touched here
(type/status/id/call_id/output) are all present on the floor, and the two module
helpers already used `getattr`.

`output` may also be a list of input-content parts. Passing those provider
models straight to `_stringify_mcp_output` fell through to
`json.dumps(..., default=str)` and embedded a Python repr in the result text sent
back to the model -- e.g. `"ResponseInputImage(detail='auto', ...)"`. Dump each
part first so text extraction still works and non-text parts serialize as
readable JSON.

Both paths are now regression-tested, including a stub item shaped like the
2.25.0 field set.
@manjunathshiva

Copy link
Copy Markdown
Contributor Author

Reproduced end-to-end on real Foundry infrastructure, not just in unit tests, so the before/after is observable rather than inferred.

Setup

  • Azure AI Search (basic tier) with an index of three plain-text docs, one containing a distinctive sentinel string.
  • A project connection (CognitiveSearch / ApiKey) to that Search service.
  • A Foundry toolbox whose default version carries an azure_ai_search tool bound to that connection and index.
  • A Foundry hosted agent built exactly as samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/main.py does — Agent(client=FoundryChatClient(...), tools=FoundryToolbox(credential), default_options={"store": False}) behind ResponsesHostServer.
  • Driven through agent_framework_ag_ui's AgentFrameworkAgent.run(), with an OpenAIChatClient pointed at the local host.

The host emits the item as reported

function_call        name=azure_ai_search  call_id=call_KWrwLEyAGQN83s9XyQCgC9I2
function_call_output call_id=call_KWrwLEyAGQN83s9XyQCgC9I2  output=AG-UI snapshot codename
                     The internal codename for the AG-UI thread snapshot subsystem is BLUEHERON. ...
ANSWER: The internal codename is BLUEHERON.

Matching call_id, populated output, real retrieval from the index.

AG-UI events, same request, same host, only the parser differing

Event main this PR
TOOL_CALL_START 1 1
TOOL_CALL_END 1 1
TOOL_CALL_RESULT absent 1

On main the result never materializes — the reported "TOOL_CALL_END but no matching TOOL_CALL_RESULT". With the change, TOOL_CALL_RESULT carries the retrieved KB text.

Two incidental notes from the exercise, neither part of this change

  1. The host serializes any hosted-agent function_result as a function_call_output output item (foundry_hosting/_responses.py, the content.type == "function_result" branch), so the gap is not specific to a toolbox or to a knowledge-base tool — a toolbox is just one way to get a server-side tool execution. That widens the blast radius of the original report.
  2. The toolbox's azure_ai_search tool defaults to query_type=vector_semantic_hybrid, which fails with "requires a vector field with integrated vectorizer" against a plain-text index. Setting query_type on the index resource fixes it. Unrelated to this PR, noted only in case it saves someone else the detour.

@manjunathshiva

Copy link
Copy Markdown
Contributor Author

Flagging a process point on myself before anyone spends review time here.

docs/specs/004-python-function-calling-loop.md has a Contribution ownership clause I missed on my first read, even though I cited the spec in the PR description:

Issues involving this code must not be picked up by external contributors without first checking with the Agent Framework core team. The core team must confirm the intended behavior, affected scenario-matrix rows, ownership across core/providers/transports, and the required validation scope before implementation starts.

Read together with "Any change to the function-calling loop or its approval/history/serialization paths", this PR is inside that scope — it changes how a provider serializer parses a function-call result — and I did not check first. That's on me.

Rather than just flag it, here is where the change stands against the seven requirements in the same section, so you can judge cheaply whether it is worth continuing or whether you would rather own it:

# Requirement Status
1 Identify every affected scenario-matrix row Not done — see the ask below
2 Add or update the corresponding regression tests 13 tests across the three dispatch sites, including a stub shaped like the openai==2.25.0 field set
3 Validate streaming updates, streaming finalization, and non-streaming All three. .added, .done, and _parse_response_from_openai each covered; confirmed the streaming finalizer aggregates already-yielded updates rather than re-parsing, so there is no double-emit path
4 Validate model-bound history and caller-visible responses Both, on a live Foundry endpoint. Outbound, a re-sent function_call_output carries only call_id/type/output and the service accepts it under previous_response_id; caller-visible, AG-UI emits the paired TOOL_CALL_RESULT
5 Full core tests plus every affected provider/transport package core 4321, openai 488, foundry 388, ag-ui 1149, declarative 989
6 Source typing, test typing, syntax for every affected package openai and foundry (the two changed): pyright strict 0 errors, plus ty/pyrefly/mypy/zuban
7 Extra review on call/result pairing, exactly-once execution, history replay Partly self-addressed — the per-request seen-id set gives exactly-once across .added/.done, and a blank-call_id guard prevents an orphaned result — but the review itself is yours to give

The ask. On requirement 1 I would rather not guess at the matrix rows unilaterally, since that is exactly the judgment the clause reserves for you. My reading is that this touches "History and provider serialization" and nothing else, but I may be wrong about whether it also implicates the streaming/non-streaming agreement rows.

So: would you prefer to

  1. proceed with this PR and tell me which matrix rows to add, or
  2. have me close it and pick the fix up yourselves — the diagnosis in the description and the live-repro comment above should transfer directly, and I would not consider that wasted, or
  3. something else?

Happy with any of those. One note in case it affects the call: the underlying gap is not specific to a toolbox — foundry_hosting serializes any hosted-agent function_result as a function_call_output output item, so every Foundry hosted agent with a tool is affected, not just the reporter's setup.

Comment on lines +2712 to +2715
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,

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.

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.

@moonbox3

Evan Mattson (moonbox3) commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

tagging Eduard van Valkenburg (@eavanvalkenburg) here to look at this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

3 participants