fix: parse structured response value from final message (#6383)
Signed-off-by: liuzemei <35027683+liuzemei@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user