fix: parse structured response value from final message (#6383)

Signed-off-by: liuzemei <35027683+liuzemei@users.noreply.github.com>
This commit is contained in:
Sheldon
2026-07-09 19:58:39 +08:00
committed by GitHub
parent 13fc425bf5
commit 9f4526a41e
2 changed files with 138 additions and 12 deletions
+34 -12
View File
@@ -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 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
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 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
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 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
"""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 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
"""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 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
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 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
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:
@@ -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 test_chat_response_from_async_generator_mapping_response_format() -> N
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