From b213b586bbe3bb6d2f1d757b855e7c8030d41d17 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 26 Feb 2026 09:50:58 +0900 Subject: [PATCH] Fix AgentResponse.value being None when streaming with response_format (#3970) The streaming path in Agent.run() was reading response_format from the raw options parameter, which doesn't include values from default_options. The non-streaming path correctly read from ctx["chat_options"] which merges default_options with runtime options. Changed the streaming finalizer to lazily read response_format from the prepared run context (ctx_holder), ensuring default_options.response_format is properly propagated when the stream is finalized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_agents.py | 9 +++-- .../packages/core/tests/core/test_agents.py | 33 +++++++++++++++++++ .../core/test_function_invocation_logic.py | 6 ++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index a3f4570b6e..e304fd0fde 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -935,6 +935,11 @@ def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpda session.service_session_id = conv_id return update + def _finalize_with_context(updates: Sequence[AgentResponseUpdate]) -> AgentResponse: + ctx = ctx_holder["ctx"] + response_format = ctx["chat_options"].get("response_format") if ctx else None + return self._finalize_response_updates(updates, response_format=response_format) + return ( ResponseStream .from_awaitable(_get_stream()) @@ -943,9 +948,7 @@ def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpda map_chat_to_agent_update, agent_name=self.name, ), - finalizer=partial( - self._finalize_response_updates, response_format=options.get("response_format") if options else None - ), + finalizer=_finalize_with_context, ) .with_transform_hook(_propagate_conversation_id) .with_result_hook(_post_hook) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 627987a1f2..b396497994 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -97,6 +97,39 @@ async def test_chat_client_agent_run_streaming(client: SupportsChatGetResponse) assert result.text == "test streaming response another update" +async def test_chat_client_agent_run_streaming_response_format_from_default_options( + client: SupportsChatGetResponse, +) -> None: + """Streaming with response_format in default_options should parse value correctly (#3970).""" + from pydantic import BaseModel + + class Greeting(BaseModel): + greeting: str + + json_text = '{"greeting": "Hello!"}' + client.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[Content.from_text(json_text)], + role="assistant", + finish_reason="stop", + ), + ] + ] + + agent = Agent(client=client, default_options={"response_format": Greeting}) + + stream = agent.run("Hello", stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + + assert result.text == json_text + assert result.value is not None + assert isinstance(result.value, Greeting) + assert result.value.greeting == "Hello!" + + async def test_chat_client_agent_create_session(client: SupportsChatGetResponse) -> None: agent = Agent(client=client) session = agent.create_session() diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index f278afaeac..319d35f152 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -937,8 +937,7 @@ def ai_func(arg1: str) -> str: orphaned_calls = all_call_ids - all_result_ids assert not orphaned_calls, ( - f"Response contains orphaned FunctionCallContent without matching " - f"FunctionResultContent: {orphaned_calls}." + f"Response contains orphaned FunctionCallContent without matching FunctionResultContent: {orphaned_calls}." ) @@ -1123,8 +1122,7 @@ def browser_snapshot(url: str) -> str: orphaned_calls = all_call_ids - all_result_ids assert not orphaned_calls, ( - f"Response contains orphaned function calls {orphaned_calls}. " - f"This would cause API errors on the next call." + f"Response contains orphaned function calls {orphaned_calls}. This would cause API errors on the next call." )