From 23f01772181289fdca67a32cf1f8a3b0c32f1e2b Mon Sep 17 00:00:00 2001 From: Anjan <743179+t-anjan@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:29:49 +0530 Subject: [PATCH] Python: Fix structured output parsing when text contents are not coalesced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `Message.text` joins multiple `TextContent` objects, it uses `" ".join()` which is correct for natural language but corrupts JSON when used for structured output parsing. The `value` property on both `ChatResponse` and `AgentResponse` feeds `self.text` directly into `model_validate_json()`, causing Pydantic validation failures when text chunks happen to not be fully coalesced into a single content. This fix makes the `value` property concatenate text contents directly (without spaces) instead of going through `Message.text`, preserving the integrity of structured JSON output. ## Real-world impact This bug was observed in production with the OpenAI-compatible chat client (OpenRouter → Gemini) where streaming responses intermittently produced multiple text Content objects that survived coalescing. Two distinct failure modes were observed: **Failure 1 — Space injected into JSON key:** The LLM returned valid JSON with `"action": "request_evidence"`, but `Message.text` produced `"action ": "request_evidence"` (trailing space in key). Pydantic rejected this with: `Field required [type=missing, input_value={'action ': ...}]` **Failure 2 — Space injected into JSON value:** The LLM returned `"readiness": "not_started"`, but `Message.text` produced `"readiness": "not_started "` (trailing space in value). Pydantic rejected this with: `Input should be 'not_started', ... [input_value='not_started ']` Both failures were intermittent (retrying the same request succeeded) and the raw LLM response was valid JSON — the corruption was introduced by the `" ".join()` in `Message.text`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../packages/core/agent_framework/_types.py | 22 ++++++++- python/packages/core/tests/core/test_types.py | 45 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index a4e3a57330..21a7ce45d7 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2180,7 +2180,16 @@ def value(self) -> ResponseModelT | None: and isinstance(self._response_format, type) and issubclass(self._response_format, BaseModel) ): - self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) + # Concatenate text contents without spaces to preserve structured + # output (JSON). Message.text uses " ".join which is correct for + # natural language but corrupts JSON keys/values. + raw_text = "".join( + c.text + for msg in self.messages + for c in msg.contents + if c.type == "text" and c.text is not None + ) + self._value = cast(ResponseModelT, self._response_format.model_validate_json(raw_text)) self._value_parsed = True return self._value @@ -2442,7 +2451,16 @@ def value(self) -> ResponseModelT | None: and isinstance(self._response_format, type) and issubclass(self._response_format, BaseModel) ): - self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) + # Concatenate text contents without spaces to preserve structured + # output (JSON). Message.text uses " ".join which is correct for + # natural language but corrupts JSON keys/values. + raw_text = "".join( + c.text + for msg in self.messages + for c in msg.contents + if c.type == "text" and c.text is not None + ) + self._value = cast(ResponseModelT, self._response_format.model_validate_json(raw_text)) self._value_parsed = True return self._value diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 1cde898787..58c2ac26bf 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -834,6 +834,51 @@ class StrictSchema(BaseModel): assert "score" in error_fields, "Expected 'score' gt constraint error" +def test_chat_response_value_multi_chunk_json(): + """Test that ChatResponse.value correctly parses JSON split across multiple text Content objects. + + When streaming responses arrive as multiple text chunks, Message.text joins + them with spaces which corrupts JSON. The value property must concatenate + without spaces so model_validate_json succeeds. + """ + # Simulate a JSON response split across multiple text content chunks + # (as would happen with streaming) + chunks = [ + Content.from_text(text='{"resp'), + Content.from_text(text='onse": "He'), + Content.from_text(text='llo"}'), + ] + message = Message(role="assistant", contents=chunks) + response = ChatResponse(messages=message, response_format=OutputModel) + + # Message.text joins with spaces, which would break JSON parsing + assert " " in message.text # confirms the space-join behavior + + # But value should still parse correctly + assert response.value is not None + assert response.value.response == "Hello" + + +def test_agent_response_value_multi_chunk_json(): + """Test that AgentResponse.value correctly parses JSON split across multiple text Content objects. + + Same as the ChatResponse test but for AgentResponse, which has its own + value property implementation. + """ + chunks = [ + Content.from_text(text='{"resp'), + Content.from_text(text='onse": "He'), + Content.from_text(text='llo"}'), + ] + message = Message(role="assistant", contents=chunks) + response = AgentResponse(messages=message, response_format=OutputModel) + + assert " " in message.text # confirms the space-join behavior + + assert response.value is not None + assert response.value.response == "Hello" + + # region ChatResponseUpdate