Skip to content

Commit 11628c3

Browse files
amitmukhAmit Mukherjeedmytrostruk
authored
Python: Fix structured_output propagation in ClaudeAgent (microsoft#4137)
* Fix structured_output propagation in ClaudeAgent Capture structured_output from ResultMessage in _get_stream() and propagate it to AgentResponse.value via a custom finalizer. Previously structured_output was silently discarded, making output_format unusable. Fixes microsoft#4095 * Address review feedback: use value parameter instead of private properties - Extend AgentResponse.from_updates() to accept optional value parameter - Remove structured_output yield from _get_stream() - Update _finalize_response() to pass value via public API - Update streaming test to use get_final_response() * Fix mypy errors: add value parameter to from_updates overloads Add value parameter to both @overload signatures of AgentResponse.from_updates() so mypy recognizes the argument. --------- Co-authored-by: Amit Mukherjee <amimukherjee@microsoft.com> Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
1 parent 69eabcd commit 11628c3

3 files changed

Lines changed: 183 additions & 2 deletions

File tree

python/packages/claude/agent_framework_claude/_agent.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,12 +618,24 @@ def run(
618618
"""
619619
response = ResponseStream(
620620
self._get_stream(messages, session=session, options=options, **kwargs),
621-
finalizer=AgentResponse.from_updates,
621+
finalizer=self._finalize_response,
622622
)
623623
if stream:
624624
return response
625625
return response.get_final_response()
626626

627+
def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]:
628+
"""Build AgentResponse and propagate structured_output as value.
629+
630+
Args:
631+
updates: The collected stream updates.
632+
633+
Returns:
634+
An AgentResponse with structured_output set as value if present.
635+
"""
636+
structured_output = getattr(self, "_structured_output", None)
637+
return AgentResponse.from_updates(updates, value=structured_output)
638+
627639
async def _get_stream(
628640
self,
629641
messages: AgentRunInputs | None = None,
@@ -647,6 +659,7 @@ async def _get_stream(
647659
await self._apply_runtime_options(dict(options) if options else None)
648660

649661
session_id: str | None = None
662+
structured_output: Any = None
650663

651664
await self._client.query(prompt)
652665
async for message in self._client.receive_response():
@@ -700,7 +713,11 @@ async def _get_stream(
700713
error_msg = message.result or "Unknown error from Claude API"
701714
raise AgentException(f"Claude API error: {error_msg}")
702715
session_id = message.session_id
716+
structured_output = message.structured_output
703717

704718
# Update session with session ID
705719
if session_id:
706720
session.service_session_id = session_id
721+
722+
# Store structured output for the finalizer
723+
self._structured_output = structured_output

python/packages/claude/tests/test_claude_agent.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,163 @@ async def test_apply_runtime_options_none(self) -> None:
785785
await agent._apply_runtime_options(None) # type: ignore[reportPrivateUsage]
786786
mock_client.set_model.assert_not_called()
787787
mock_client.set_permission_mode.assert_not_called()
788+
789+
790+
# region Test ClaudeAgent Structured Output
791+
792+
793+
class TestClaudeAgentStructuredOutput:
794+
"""Tests for ClaudeAgent structured output propagation."""
795+
796+
@staticmethod
797+
async def _create_async_generator(items: list[Any]) -> Any:
798+
"""Helper to create async generator from list."""
799+
for item in items:
800+
yield item
801+
802+
def _create_mock_client(self, messages: list[Any]) -> MagicMock:
803+
"""Create a mock ClaudeSDKClient that yields given messages."""
804+
mock_client = MagicMock()
805+
mock_client.connect = AsyncMock()
806+
mock_client.disconnect = AsyncMock()
807+
mock_client.query = AsyncMock()
808+
mock_client.set_model = AsyncMock()
809+
mock_client.set_permission_mode = AsyncMock()
810+
mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages))
811+
return mock_client
812+
813+
async def test_structured_output_propagated_to_response(self) -> None:
814+
"""Test that structured_output from ResultMessage is propagated to response.value."""
815+
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
816+
from claude_agent_sdk.types import StreamEvent
817+
818+
structured_data = {"name": "Alice", "age": 30}
819+
messages = [
820+
StreamEvent(
821+
event={
822+
"type": "content_block_delta",
823+
"delta": {"type": "text_delta", "text": '{"name": "Alice", "age": 30}'},
824+
},
825+
uuid="event-1",
826+
session_id="session-123",
827+
),
828+
AssistantMessage(
829+
content=[TextBlock(text='{"name": "Alice", "age": 30}')],
830+
model="claude-sonnet",
831+
),
832+
ResultMessage(
833+
subtype="success",
834+
duration_ms=100,
835+
duration_api_ms=50,
836+
is_error=False,
837+
num_turns=1,
838+
session_id="session-123",
839+
structured_output=structured_data,
840+
),
841+
]
842+
mock_client = self._create_mock_client(messages)
843+
844+
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
845+
agent = ClaudeAgent()
846+
response = await agent.run("Return structured data")
847+
assert response.value == structured_data
848+
849+
async def test_structured_output_none_when_not_present(self) -> None:
850+
"""Test that response.value is None when structured_output is not present."""
851+
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
852+
from claude_agent_sdk.types import StreamEvent
853+
854+
messages = [
855+
StreamEvent(
856+
event={
857+
"type": "content_block_delta",
858+
"delta": {"type": "text_delta", "text": "Hello!"},
859+
},
860+
uuid="event-1",
861+
session_id="session-123",
862+
),
863+
AssistantMessage(
864+
content=[TextBlock(text="Hello!")],
865+
model="claude-sonnet",
866+
),
867+
ResultMessage(
868+
subtype="success",
869+
duration_ms=100,
870+
duration_api_ms=50,
871+
is_error=False,
872+
num_turns=1,
873+
session_id="session-123",
874+
),
875+
]
876+
mock_client = self._create_mock_client(messages)
877+
878+
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
879+
agent = ClaudeAgent()
880+
response = await agent.run("Hello")
881+
assert response.value is None
882+
883+
async def test_structured_output_with_streaming(self) -> None:
884+
"""Test that structured_output is available via get_final_response after streaming."""
885+
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
886+
from claude_agent_sdk.types import StreamEvent
887+
888+
structured_data = {"key": "value"}
889+
messages = [
890+
StreamEvent(
891+
event={
892+
"type": "content_block_delta",
893+
"delta": {"type": "text_delta", "text": '{"key": "value"}'},
894+
},
895+
uuid="event-1",
896+
session_id="session-123",
897+
),
898+
AssistantMessage(
899+
content=[TextBlock(text='{"key": "value"}')],
900+
model="claude-sonnet",
901+
),
902+
ResultMessage(
903+
subtype="success",
904+
duration_ms=100,
905+
duration_api_ms=50,
906+
is_error=False,
907+
num_turns=1,
908+
session_id="session-123",
909+
structured_output=structured_data,
910+
),
911+
]
912+
mock_client = self._create_mock_client(messages)
913+
914+
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
915+
agent = ClaudeAgent()
916+
stream = agent.run("Return structured data", stream=True)
917+
# Consume the stream
918+
async for _ in stream:
919+
pass
920+
# Structured output should be available via get_final_response
921+
response = await stream.get_final_response()
922+
assert response.value == structured_data
923+
924+
async def test_structured_output_with_error_does_not_propagate(self) -> None:
925+
"""Test that structured_output is not propagated when ResultMessage is an error."""
926+
from agent_framework.exceptions import AgentException
927+
from claude_agent_sdk import ResultMessage
928+
929+
messages = [
930+
ResultMessage(
931+
subtype="error",
932+
duration_ms=100,
933+
duration_api_ms=50,
934+
is_error=True,
935+
num_turns=0,
936+
session_id="error-session",
937+
result="Something went wrong",
938+
structured_output={"some": "data"},
939+
),
940+
]
941+
mock_client = self._create_mock_client(messages)
942+
943+
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
944+
agent = ClaudeAgent()
945+
with pytest.raises(AgentException) as exc_info:
946+
await agent.run("Hello")
947+
assert "Something went wrong" in str(exc_info.value)

python/packages/core/agent_framework/_types.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,7 @@ def from_updates(
22562256
updates: Sequence[AgentResponseUpdate],
22572257
*,
22582258
output_format_type: type[ResponseModelBoundT],
2259+
value: Any | None = None,
22592260
) -> AgentResponse[ResponseModelBoundT]: ...
22602261

22612262
@overload
@@ -2265,6 +2266,7 @@ def from_updates(
22652266
updates: Sequence[AgentResponseUpdate],
22662267
*,
22672268
output_format_type: None = None,
2269+
value: Any | None = None,
22682270
) -> AgentResponse[Any]: ...
22692271

22702272
@classmethod
@@ -2273,6 +2275,7 @@ def from_updates(
22732275
updates: Sequence[AgentResponseUpdate],
22742276
*,
22752277
output_format_type: type[BaseModel] | None = None,
2278+
value: Any | None = None,
22762279
) -> AgentResponseT:
22772280
"""Joins multiple updates into a single AgentResponse.
22782281
@@ -2281,8 +2284,9 @@ def from_updates(
22812284
22822285
Keyword Args:
22832286
output_format_type: Optional Pydantic model type to parse the response text into structured data.
2287+
value: Optional pre-parsed structured output value to set directly on the response.
22842288
"""
2285-
msg = cls(messages=[], response_format=output_format_type)
2289+
msg = cls(messages=[], response_format=output_format_type, value=value)
22862290
for update in updates:
22872291
_process_update(msg, update)
22882292
_finalize_response(msg)

0 commit comments

Comments
 (0)