Skip to content

Commit 83e8965

Browse files
authored
Python: [BREAKING] Make response_format validation errors visible to users (#3274)
* Make response_format validation errors visible to users * Small fix * Addressed comments
1 parent 3c1be2a commit 83e8965

File tree

12 files changed

+298
-62
lines changed

12 files changed

+298
-62
lines changed

python/packages/core/agent_framework/_types.py

Lines changed: 124 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,14 +2861,13 @@ def __init__(
28612861
self.created_at = created_at
28622862
self.finish_reason = finish_reason
28632863
self.usage_details = usage_details
2864-
self.value = value
2864+
self._value: Any | None = value
2865+
self._response_format: type[BaseModel] | None = response_format
2866+
self._value_parsed: bool = value is not None
28652867
self.additional_properties = additional_properties or {}
28662868
self.additional_properties.update(kwargs or {})
28672869
self.raw_representation: Any | list[Any] | None = raw_representation
28682870

2869-
if response_format:
2870-
self.try_parse_value(output_format_type=response_format)
2871-
28722871
@classmethod
28732872
def from_chat_response_updates(
28742873
cls: type[TChatResponse],
@@ -2933,29 +2932,78 @@ async def from_chat_response_generator(
29332932
Keyword Args:
29342933
output_format_type: Optional Pydantic model type to parse the response text into structured data.
29352934
"""
2936-
msg = cls(messages=[])
2935+
response_format = output_format_type if isinstance(output_format_type, type) else None
2936+
msg = cls(messages=[], response_format=response_format)
29372937
async for update in updates:
29382938
_process_update(msg, update)
29392939
_finalize_response(msg)
2940-
if output_format_type and isinstance(output_format_type, type) and issubclass(output_format_type, BaseModel):
2941-
msg.try_parse_value(output_format_type)
2940+
if response_format and issubclass(response_format, BaseModel):
2941+
msg.try_parse_value(response_format)
29422942
return msg
29432943

29442944
@property
29452945
def text(self) -> str:
29462946
"""Returns the concatenated text of all messages in the response."""
29472947
return ("\n".join(message.text for message in self.messages if isinstance(message, ChatMessage))).strip()
29482948

2949+
@property
2950+
def value(self) -> Any | None:
2951+
"""Get the parsed structured output value.
2952+
2953+
If a response_format was provided and parsing hasn't been attempted yet,
2954+
this will attempt to parse the text into the specified type.
2955+
2956+
Raises:
2957+
ValidationError: If the response text doesn't match the expected schema.
2958+
"""
2959+
if self._value_parsed:
2960+
return self._value
2961+
if (
2962+
self._response_format is not None
2963+
and isinstance(self._response_format, type)
2964+
and issubclass(self._response_format, BaseModel)
2965+
):
2966+
self._value = self._response_format.model_validate_json(self.text)
2967+
self._value_parsed = True
2968+
return self._value
2969+
29492970
def __str__(self) -> str:
29502971
return self.text
29512972

2952-
def try_parse_value(self, output_format_type: type[BaseModel]) -> None:
2953-
"""If there is a value, does nothing, otherwise tries to parse the text into the value."""
2954-
if self.value is None and isinstance(output_format_type, type) and issubclass(output_format_type, BaseModel):
2955-
try:
2956-
self.value = output_format_type.model_validate_json(self.text) # type: ignore[reportUnknownMemberType]
2957-
except ValidationError as ex:
2958-
logger.debug("Failed to parse value from chat response text: %s", ex)
2973+
def try_parse_value(self, output_format_type: type[_T] | None = None) -> _T | None:
2974+
"""Try to parse the text into a typed value.
2975+
2976+
This is the safe alternative to accessing the value property directly.
2977+
Returns the parsed value on success, or None on failure.
2978+
2979+
Args:
2980+
output_format_type: The Pydantic model type to parse into.
2981+
If None, uses the response_format from initialization.
2982+
2983+
Returns:
2984+
The parsed value as the specified type, or None if parsing fails.
2985+
"""
2986+
format_type = output_format_type or self._response_format
2987+
if format_type is None or not (isinstance(format_type, type) and issubclass(format_type, BaseModel)):
2988+
return None
2989+
2990+
# Cache the result unless a different schema than the configured response_format is requested.
2991+
# This prevents calls with a different schema from polluting the cached value.
2992+
use_cache = (
2993+
self._response_format is None or output_format_type is None or output_format_type is self._response_format
2994+
)
2995+
2996+
if use_cache and self._value_parsed and self._value is not None:
2997+
return self._value # type: ignore[return-value, no-any-return]
2998+
try:
2999+
parsed_value = format_type.model_validate_json(self.text) # type: ignore[reportUnknownMemberType]
3000+
if use_cache:
3001+
self._value = parsed_value
3002+
self._value_parsed = True
3003+
return parsed_value # type: ignore[return-value]
3004+
except ValidationError as ex:
3005+
logger.warning("Failed to parse value from chat response text: %s", ex)
3006+
return None
29593007

29603008

29613009
# region ChatResponseUpdate
@@ -3141,6 +3189,7 @@ def __init__(
31413189
created_at: CreatedAtT | None = None,
31423190
usage_details: UsageDetails | MutableMapping[str, Any] | None = None,
31433191
value: Any | None = None,
3192+
response_format: type[BaseModel] | None = None,
31443193
raw_representation: Any | None = None,
31453194
additional_properties: dict[str, Any] | None = None,
31463195
**kwargs: Any,
@@ -3153,6 +3202,7 @@ def __init__(
31533202
created_at: A timestamp for the chat response.
31543203
usage_details: The usage details for the chat response.
31553204
value: The structured output of the agent run response, if applicable.
3205+
response_format: Optional response format for the agent response.
31563206
additional_properties: Any additional properties associated with the chat response.
31573207
raw_representation: The raw representation of the chat response from an underlying implementation.
31583208
**kwargs: Additional properties to set on the response.
@@ -3180,7 +3230,9 @@ def __init__(
31803230
self.response_id = response_id
31813231
self.created_at = created_at
31823232
self.usage_details = usage_details
3183-
self.value = value
3233+
self._value: Any | None = value
3234+
self._response_format: type[BaseModel] | None = response_format
3235+
self._value_parsed: bool = value is not None
31843236
self.additional_properties = additional_properties or {}
31853237
self.additional_properties.update(kwargs or {})
31863238
self.raw_representation = raw_representation
@@ -3190,6 +3242,27 @@ def text(self) -> str:
31903242
"""Get the concatenated text of all messages."""
31913243
return "".join(msg.text for msg in self.messages) if self.messages else ""
31923244

3245+
@property
3246+
def value(self) -> Any | None:
3247+
"""Get the parsed structured output value.
3248+
3249+
If a response_format was provided and parsing hasn't been attempted yet,
3250+
this will attempt to parse the text into the specified type.
3251+
3252+
Raises:
3253+
ValidationError: If the response text doesn't match the expected schema.
3254+
"""
3255+
if self._value_parsed:
3256+
return self._value
3257+
if (
3258+
self._response_format is not None
3259+
and isinstance(self._response_format, type)
3260+
and issubclass(self._response_format, BaseModel)
3261+
):
3262+
self._value = self._response_format.model_validate_json(self.text)
3263+
self._value_parsed = True
3264+
return self._value
3265+
31933266
@property
31943267
def user_input_requests(self) -> list[UserInputRequestContents]:
31953268
"""Get all BaseUserInputRequest messages from the response."""
@@ -3215,7 +3288,7 @@ def from_agent_run_response_updates(
32153288
Keyword Args:
32163289
output_format_type: Optional Pydantic model type to parse the response text into structured data.
32173290
"""
3218-
msg = cls(messages=[])
3291+
msg = cls(messages=[], response_format=output_format_type)
32193292
for update in updates:
32203293
_process_update(msg, update)
32213294
_finalize_response(msg)
@@ -3238,7 +3311,7 @@ async def from_agent_response_generator(
32383311
Keyword Args:
32393312
output_format_type: Optional Pydantic model type to parse the response text into structured data
32403313
"""
3241-
msg = cls(messages=[])
3314+
msg = cls(messages=[], response_format=output_format_type)
32423315
async for update in updates:
32433316
_process_update(msg, update)
32443317
_finalize_response(msg)
@@ -3249,13 +3322,40 @@ async def from_agent_response_generator(
32493322
def __str__(self) -> str:
32503323
return self.text
32513324

3252-
def try_parse_value(self, output_format_type: type[BaseModel]) -> None:
3253-
"""If there is a value, does nothing, otherwise tries to parse the text into the value."""
3254-
if self.value is None:
3255-
try:
3256-
self.value = output_format_type.model_validate_json(self.text) # type: ignore[reportUnknownMemberType]
3257-
except ValidationError as ex:
3258-
logger.debug("Failed to parse value from agent run response text: %s", ex)
3325+
def try_parse_value(self, output_format_type: type[_T] | None = None) -> _T | None:
3326+
"""Try to parse the text into a typed value.
3327+
3328+
This is the safe alternative when you need to parse the response text into a typed value.
3329+
Returns the parsed value on success, or None on failure.
3330+
3331+
Args:
3332+
output_format_type: The Pydantic model type to parse into.
3333+
If None, uses the response_format from initialization.
3334+
3335+
Returns:
3336+
The parsed value as the specified type, or None if parsing fails.
3337+
"""
3338+
format_type = output_format_type or self._response_format
3339+
if format_type is None or not (isinstance(format_type, type) and issubclass(format_type, BaseModel)):
3340+
return None
3341+
3342+
# Cache the result unless a different schema than the configured response_format is requested.
3343+
# This prevents calls with a different schema from polluting the cached value.
3344+
use_cache = (
3345+
self._response_format is None or output_format_type is None or output_format_type is self._response_format
3346+
)
3347+
3348+
if use_cache and self._value_parsed and self._value is not None:
3349+
return self._value # type: ignore[return-value, no-any-return]
3350+
try:
3351+
parsed_value = format_type.model_validate_json(self.text) # type: ignore[reportUnknownMemberType]
3352+
if use_cache:
3353+
self._value = parsed_value
3354+
self._value_parsed = True
3355+
return parsed_value # type: ignore[return-value]
3356+
except ValidationError as ex:
3357+
logger.warning("Failed to parse value from agent run response text: %s", ex)
3358+
return None
32593359

32603360

32613361
# region AgentResponseUpdate

python/packages/core/tests/core/test_types.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,124 @@ def test_chat_response_with_format_init():
705705
assert response.value.response == "Hello"
706706

707707

708+
def test_chat_response_value_raises_on_invalid_schema():
709+
"""Test that value property raises ValidationError with field constraint details."""
710+
from typing import Literal
711+
712+
from pydantic import Field, ValidationError
713+
714+
class StrictSchema(BaseModel):
715+
id: Literal[5]
716+
name: str = Field(min_length=10)
717+
score: int = Field(gt=0, le=100)
718+
719+
message = ChatMessage(role="assistant", text='{"id": 1, "name": "test", "score": -5}')
720+
response = ChatResponse(messages=message, response_format=StrictSchema)
721+
722+
with raises(ValidationError) as exc_info:
723+
_ = response.value
724+
725+
errors = exc_info.value.errors()
726+
error_fields = {e["loc"][0] for e in errors}
727+
assert "id" in error_fields, "Expected 'id' Literal constraint error"
728+
assert "name" in error_fields, "Expected 'name' min_length constraint error"
729+
assert "score" in error_fields, "Expected 'score' gt constraint error"
730+
731+
732+
def test_chat_response_try_parse_value_returns_none_on_invalid():
733+
"""Test that try_parse_value returns None on validation failure with Field constraints."""
734+
from typing import Literal
735+
736+
from pydantic import Field
737+
738+
class StrictSchema(BaseModel):
739+
id: Literal[5]
740+
name: str = Field(min_length=10)
741+
score: int = Field(gt=0, le=100)
742+
743+
message = ChatMessage(role="assistant", text='{"id": 1, "name": "test", "score": -5}')
744+
response = ChatResponse(messages=message)
745+
746+
result = response.try_parse_value(StrictSchema)
747+
assert result is None
748+
749+
750+
def test_chat_response_try_parse_value_returns_value_on_success():
751+
"""Test that try_parse_value returns parsed value when all constraints pass."""
752+
from pydantic import Field
753+
754+
class MySchema(BaseModel):
755+
name: str = Field(min_length=3)
756+
score: int = Field(ge=0, le=100)
757+
758+
message = ChatMessage(role="assistant", text='{"name": "test", "score": 85}')
759+
response = ChatResponse(messages=message)
760+
761+
result = response.try_parse_value(MySchema)
762+
assert result is not None
763+
assert result.name == "test"
764+
assert result.score == 85
765+
766+
767+
def test_agent_response_value_raises_on_invalid_schema():
768+
"""Test that AgentResponse.value property raises ValidationError with field constraint details."""
769+
from typing import Literal
770+
771+
from pydantic import Field, ValidationError
772+
773+
class StrictSchema(BaseModel):
774+
id: Literal[5]
775+
name: str = Field(min_length=10)
776+
score: int = Field(gt=0, le=100)
777+
778+
message = ChatMessage(role="assistant", text='{"id": 1, "name": "test", "score": -5}')
779+
response = AgentResponse(messages=message, response_format=StrictSchema)
780+
781+
with raises(ValidationError) as exc_info:
782+
_ = response.value
783+
784+
errors = exc_info.value.errors()
785+
error_fields = {e["loc"][0] for e in errors}
786+
assert "id" in error_fields, "Expected 'id' Literal constraint error"
787+
assert "name" in error_fields, "Expected 'name' min_length constraint error"
788+
assert "score" in error_fields, "Expected 'score' gt constraint error"
789+
790+
791+
def test_agent_response_try_parse_value_returns_none_on_invalid():
792+
"""Test that AgentResponse.try_parse_value returns None on Field constraint failure."""
793+
from typing import Literal
794+
795+
from pydantic import Field
796+
797+
class StrictSchema(BaseModel):
798+
id: Literal[5]
799+
name: str = Field(min_length=10)
800+
score: int = Field(gt=0, le=100)
801+
802+
message = ChatMessage(role="assistant", text='{"id": 1, "name": "test", "score": -5}')
803+
response = AgentResponse(messages=message)
804+
805+
result = response.try_parse_value(StrictSchema)
806+
assert result is None
807+
808+
809+
def test_agent_response_try_parse_value_returns_value_on_success():
810+
"""Test that AgentResponse.try_parse_value returns parsed value when all constraints pass."""
811+
from pydantic import Field
812+
813+
class MySchema(BaseModel):
814+
name: str = Field(min_length=3)
815+
score: int = Field(ge=0, le=100)
816+
817+
message = ChatMessage(role="assistant", text='{"name": "test", "score": 85}')
818+
response = AgentResponse(messages=message)
819+
820+
result = response.try_parse_value(MySchema)
821+
assert result is not None
822+
assert result.name == "test"
823+
assert result.score == 85
824+
825+
708826
# region ChatResponseUpdate
709827

710828

python/samples/getting_started/agents/azure_ai/azure_ai_with_response_format.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ async def main() -> None:
4141
print(f"User: {query}")
4242
result = await agent.run(query)
4343

44-
if isinstance(result.value, ReleaseBrief):
45-
release_brief = result.value
44+
if release_brief := result.try_parse_value(ReleaseBrief):
4645
print("Agent:")
4746
print(f"Feature: {release_brief.feature}")
4847
print(f"Benefit: {release_brief.benefit}")
4948
print(f"Launch date: {release_brief.launch_date}")
49+
else:
50+
print(f"Failed to parse response: {result.text}")
5051

5152

5253
if __name__ == "__main__":

0 commit comments

Comments
 (0)