diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 9fb7670c06..52df892130 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2187,6 +2187,16 @@ def _parse_structured_response_value(text: str, response_format: Any | None) -> return None +def _last_non_empty_assistant_message_text(messages: Sequence[Message]) -> str: + for message in reversed(messages): + if message.role != "assistant": + continue + text = message.text + if text.strip(): + return text + return "" + + class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to a chat request. @@ -2372,7 +2382,7 @@ def from_updates( Keyword Args: output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the - response text into structured data. + final non-empty assistant message text into structured data. """ msg = cls(messages=[], response_format=output_format_type) for update in updates: @@ -2432,7 +2442,7 @@ async def from_update_generator( Keyword Args: output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the - response text into structured data. + final non-empty assistant message text into structured data. """ msg = cls(messages=[], response_format=output_format_type) async for update in updates: @@ -2450,16 +2460,22 @@ def value(self) -> ResponseModelT | None: """Get the parsed structured output value. If a response_format was provided and parsing hasn't been attempted yet, - this will attempt to parse the text into the specified type. + this will attempt to parse the last non-empty assistant message text into the specified type. Raises: - ValidationError: If the response text doesn't match the expected schema. - ValueError: If the response text is not valid JSON for a non-Pydantic structured format. + ValidationError: If the assistant message text doesn't match the expected schema. + ValueError: If the assistant message text is not valid JSON for a non-Pydantic structured format. """ if self._value_parsed: return self._value if self._response_format is not None: - self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format)) + self._value = cast( + ResponseModelT, + _parse_structured_response_value( + _last_non_empty_assistant_message_text(self.messages), + self._response_format, + ), + ) self._value_parsed = True return self._value @@ -2714,16 +2730,22 @@ def value(self) -> ResponseModelT | None: """Get the parsed structured output value. If a response_format was provided and parsing hasn't been attempted yet, - this will attempt to parse the text into the specified type. + this will attempt to parse the last non-empty assistant message text into the specified type. Raises: - ValidationError: If the response text doesn't match the expected schema. - ValueError: If the response text is not valid JSON for a non-Pydantic structured format. + ValidationError: If the assistant message text doesn't match the expected schema. + ValueError: If the assistant message text is not valid JSON for a non-Pydantic structured format. """ if self._value_parsed: return self._value if self._response_format is not None: - self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format)) + self._value = cast( + ResponseModelT, + _parse_structured_response_value( + _last_non_empty_assistant_message_text(self.messages), + self._response_format, + ), + ) self._value_parsed = True return self._value @@ -2782,7 +2804,7 @@ def from_updates( Keyword Args: output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the - response text into structured data. + final non-empty assistant message text into structured data. value: Optional pre-parsed structured output value to set directly on the response. """ msg = cls(messages=[], response_format=output_format_type, value=value) @@ -2832,7 +2854,7 @@ async def from_update_generator( Keyword Args: output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the - response text into structured data. + final non-empty assistant message text into structured data. """ msg = cls(messages=[], response_format=output_format_type) async for update in updates: diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 8e83454685..40c7081e09 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -869,6 +869,79 @@ def test_chat_response_with_mapping_response_format() -> None: assert response.value["response"] == "Hello" +def test_chat_response_value_parses_final_message_with_response_format() -> None: + """ChatResponse.value should ignore intermediate messages when parsing structured output.""" + response = ChatResponse( + messages=[ + Message(role="assistant", contents=['{"skill_name": "building-permit-compliance"}']), + Message(role="assistant", contents=['{"response": "Hello"}']), + ], + response_format=OutputModel, + ) + + assert response.text == '{"skill_name": "building-permit-compliance"}\n{"response": "Hello"}' + assert response.value is not None + assert response.value.response == "Hello" + + +def test_agent_response_value_parses_final_message_with_response_format() -> None: + """AgentResponse.value should ignore intermediate messages when parsing structured output.""" + response = AgentResponse( + messages=[ + Message(role="assistant", contents=['{"skill_name": "building-permit-compliance"}']), + Message(role="assistant", contents=['{"response": "Hello"}']), + ], + response_format=OutputModel, + ) + + assert response.text == '{"skill_name": "building-permit-compliance"}{"response": "Hello"}' + assert response.value is not None + assert response.value.response == "Hello" + + +def test_agent_response_mapping_value_parses_final_message() -> None: + """AgentResponse.value should parse the final message for JSON schema mappings.""" + response = AgentResponse( + messages=[ + Message(role="assistant", contents=['{"skill_name": "building-permit-compliance"}']), + Message(role="assistant", contents=['{"response": "Hello"}']), + ], + response_format={"type": "object", "properties": {"response": {"type": "string"}}}, + ) + + assert response.value is not None + assert isinstance(response.value, dict) + assert response.value["response"] == "Hello" + + +def test_chat_response_value_ignores_trailing_non_assistant_message() -> None: + """ChatResponse.value should parse the final assistant message when later tool output exists.""" + response = ChatResponse( + messages=[ + Message(role="assistant", contents=['{"response": "Hello"}']), + Message(role="tool", contents=["tool output is not structured JSON"]), + ], + response_format=OutputModel, + ) + + assert response.value is not None + assert response.value.response == "Hello" + + +def test_agent_response_value_ignores_trailing_non_assistant_message() -> None: + """AgentResponse.value should parse the final assistant message when later tool output exists.""" + response = AgentResponse( + messages=[ + Message(role="assistant", contents=['{"response": "Hello"}']), + Message(role="tool", contents=["tool output is not structured JSON"]), + ], + response_format=OutputModel, + ) + + assert response.value is not None + assert response.value.response == "Hello" + + def test_parse_structured_response_value_empty_text_with_pydantic_model() -> None: """Empty text should return None instead of raising when response_format is a Pydantic model.""" result = _parse_structured_response_value("", OutputModel) @@ -1115,6 +1188,37 @@ async def gen() -> AsyncIterable[ChatResponseUpdate]: assert resp.value["response"] == "Hello" +def test_chat_response_from_streaming_updates_parses_final_assistant_message() -> None: + """Combined streaming updates should parse the final assistant message, not trailing tool output.""" + updates = [ + ChatResponseUpdate( + role="assistant", + message_id="skill-message", + contents=[Content.from_text('{"skill_name": "building-permit-compliance"}')], + ), + ChatResponseUpdate( + role="assistant", + message_id="final-message", + contents=[Content.from_text('{"respon')], + ), + ChatResponseUpdate( + message_id="final-message", + contents=[Content.from_text('se": "Hello"}')], + ), + ChatResponseUpdate( + role="tool", + message_id="tool-message", + contents=[Content.from_text("tool output is not structured JSON")], + ), + ] + + response = ChatResponse.from_updates(updates, output_format_type=OutputModel) + + assert [message.role for message in response.messages] == ["assistant", "assistant", "tool"] + assert response.value is not None + assert response.value.response == "Hello" + + # region ToolMode