Skip to content

Commit 519bb0c

Browse files
Python: updated declarative samples and handling of non-pydantic response formats (#5022)
* updated declarative samples and handling of non-pydantic response formats * fixed from comments * update docstring
1 parent 6acab3d commit 519bb0c

21 files changed

Lines changed: 370 additions & 90 deletions

File tree

agent-samples/foundry/MicrosoftLearnAgent.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ name: MicrosoftLearnAgent
33
description: Microsoft Learn Agent
44
instructions: You answer questions by searching the Microsoft Learn content only.
55
model:
6-
id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID
6+
id: =Env.FOUNDRY_MODEL
77
options:
88
temperature: 0.9
99
topP: 0.95
1010
connection:
1111
kind: remote
12-
endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT
12+
endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT
1313
tools:
1414
- kind: mcp
1515
name: microsoft_learn

python/packages/anthropic/tests/test_anthropic_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,27 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None:
992992
assert response.usage_details["output_token_count"] == 5
993993

994994

995+
def test_process_message_with_dict_response_format(mock_anthropic_client: MagicMock) -> None:
996+
"""_process_message should preserve dict response_format values for response.value parsing."""
997+
client = create_test_anthropic_client(mock_anthropic_client)
998+
999+
mock_message = MagicMock(spec=BetaMessage)
1000+
mock_message.id = "msg_123"
1001+
mock_message.model = "claude-3-5-sonnet-20241022"
1002+
mock_message.content = [BetaTextBlock(type="text", text='{"greeting": "Hello"}')]
1003+
mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5)
1004+
mock_message.stop_reason = "end_turn"
1005+
1006+
response = client._process_message(
1007+
mock_message,
1008+
options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}},
1009+
)
1010+
1011+
assert response.value is not None
1012+
assert isinstance(response.value, dict)
1013+
assert response.value["greeting"] == "Hello"
1014+
1015+
9951016
def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None:
9961017
"""Test _process_message with tool use."""
9971018
client = create_test_anthropic_client(mock_anthropic_client)

python/packages/core/agent_framework/_agents.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,20 +1026,13 @@ async def _parse_non_streaming_response(
10261026
session_context=context["session_context"],
10271027
suppress_response_id=context["suppress_response_id"],
10281028
)
1029-
1030-
response_format = context["chat_options"].get("response_format")
1031-
if not (
1032-
response_format is not None and isinstance(response_format, type) and issubclass(response_format, BaseModel)
1033-
):
1034-
response_format = None
1035-
10361029
return AgentResponse(
10371030
messages=response.messages,
10381031
response_id=None if context["suppress_response_id"] else response.response_id,
10391032
created_at=response.created_at,
10401033
usage_details=response.usage_details,
10411034
value=response.value,
1042-
response_format=response_format,
1035+
response_format=context["chat_options"].get("response_format"),
10431036
continuation_token=response.continuation_token,
10441037
raw_representation=response,
10451038
additional_properties=response.additional_properties,
@@ -1125,10 +1118,9 @@ def _finalize_response_updates(
11251118
response_format: Any | None = None,
11261119
) -> AgentResponse[Any]:
11271120
"""Finalize response updates into a single AgentResponse."""
1128-
output_format_type = response_format if isinstance(response_format, type) else None
11291121
return AgentResponse.from_updates( # pyright: ignore[reportUnknownVariableType]
11301122
updates,
1131-
output_format_type=output_format_type,
1123+
output_format_type=response_format,
11321124
)
11331125

11341126
@staticmethod

python/packages/core/agent_framework/_clients.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,9 @@ def _finalize_response_updates(
345345
response_format: Any | None = None,
346346
) -> ChatResponse[Any]:
347347
"""Finalize response updates into a single ChatResponse."""
348-
output_format_type = response_format if isinstance(response_format, type) else None
349348
return ChatResponse.from_updates( # pyright: ignore[reportUnknownVariableType]
350349
updates,
351-
output_format_type=output_format_type,
350+
output_format_type=response_format,
352351
)
353352

354353
def _build_response_stream(

python/packages/core/agent_framework/_tools.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2327,7 +2327,6 @@ async def _get_response() -> ChatResponse[Any]:
23272327
return _get_response()
23282328

23292329
response_format = mutable_options.get("response_format") if mutable_options else None
2330-
output_format_type: type[BaseModel] | None = response_format if isinstance(response_format, type) else None
23312330
stream_result_hooks: list[Callable[[ChatResponse], Any]] = []
23322331

23332332
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
@@ -2485,6 +2484,6 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
24852484
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]:
24862485
# Note: stream_result_hooks are already run via inner stream's get_final_response()
24872486
# We don't need to run them again here
2488-
return ChatResponse.from_updates(updates, output_format_type=output_format_type)
2487+
return ChatResponse.from_updates(updates, output_format_type=response_format)
24892488

24902489
return ResponseStream(_stream(), finalizer=_finalize)

python/packages/core/agent_framework/_types.py

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ def _restore_compaction_annotation_in_additional_properties(
299299
AgentResponseT = TypeVar("AgentResponseT", bound="AgentResponse")
300300
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True)
301301
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
302+
StructuredResponseFormat = type[BaseModel] | Mapping[str, Any] | None
302303

303304
CreatedAtT = str # Use a datetimeoffset type? Or a more specific type like datetime.datetime?
304305

@@ -1949,6 +1950,24 @@ class ContinuationToken(TypedDict):
19491950
# endregion
19501951

19511952

1953+
def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None:
1954+
if response_format is None:
1955+
return None
1956+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
1957+
return response_format.model_validate_json(text)
1958+
if isinstance(response_format, Mapping):
1959+
try:
1960+
return json.loads(text)
1961+
except json.JSONDecodeError as exc:
1962+
raise ValueError(f"Response text is not valid JSON: {exc}") from exc
1963+
logger.warning(
1964+
"Unable to parse structured response value, use either a Pydantic model or a dict defining the schema, "
1965+
"received response_format type: %s",
1966+
type(response_format), # type: ignore[reportUnknownArgumentType]
1967+
)
1968+
return None
1969+
1970+
19521971
class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
19531972
"""Represents the response to a chat request.
19541973
@@ -2014,7 +2033,7 @@ def __init__(
20142033
finish_reason: FinishReasonLiteral | FinishReason | None = None,
20152034
usage_details: UsageDetails | None = None,
20162035
value: ResponseModelT | None = None,
2017-
response_format: type[BaseModel] | None = None,
2036+
response_format: StructuredResponseFormat = None,
20182037
continuation_token: ContinuationToken | None = None,
20192038
additional_properties: dict[str, Any] | None = None,
20202039
raw_representation: Any | None = None,
@@ -2058,7 +2077,7 @@ def __init__(
20582077
self.finish_reason = finish_reason
20592078
self.usage_details = usage_details
20602079
self._value: ResponseModelT | None = value
2061-
self._response_format: type[BaseModel] | None = response_format
2080+
self._response_format: StructuredResponseFormat = response_format
20622081
self._value_parsed: bool = value is not None
20632082
self.additional_properties = (
20642083
_restore_compaction_annotation_in_additional_properties(additional_properties) or {}
@@ -2087,6 +2106,15 @@ def from_updates(
20872106
output_format_type: type[ResponseModelBoundT],
20882107
) -> ChatResponse[ResponseModelBoundT]: ...
20892108

2109+
@overload
2110+
@classmethod
2111+
def from_updates(
2112+
cls: type[ChatResponse[Any]],
2113+
updates: Sequence[ChatResponseUpdate],
2114+
*,
2115+
output_format_type: Mapping[str, Any],
2116+
) -> ChatResponse[Any]: ...
2117+
20902118
@overload
20912119
@classmethod
20922120
def from_updates(
@@ -2101,7 +2129,7 @@ def from_updates(
21012129
cls: type[ChatResponseT],
21022130
updates: Sequence[ChatResponseUpdate],
21032131
*,
2104-
output_format_type: type[BaseModel] | None = None,
2132+
output_format_type: StructuredResponseFormat = None,
21052133
) -> ChatResponseT:
21062134
"""Joins multiple updates into a single ChatResponse.
21072135
@@ -2124,10 +2152,10 @@ def from_updates(
21242152
updates: A sequence of ChatResponseUpdate objects to combine.
21252153
21262154
Keyword Args:
2127-
output_format_type: Optional Pydantic model type to parse the response text into structured data.
2155+
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
2156+
response text into structured data.
21282157
"""
2129-
response_format = output_format_type if isinstance(output_format_type, type) else None
2130-
msg = cls(messages=[], response_format=response_format)
2158+
msg = cls(messages=[], response_format=output_format_type)
21312159
for update in updates:
21322160
_process_update(msg, update)
21332161
_finalize_response(msg)
@@ -2142,6 +2170,15 @@ async def from_update_generator(
21422170
output_format_type: type[ResponseModelBoundT],
21432171
) -> ChatResponse[ResponseModelBoundT]: ...
21442172

2173+
@overload
2174+
@classmethod
2175+
async def from_update_generator(
2176+
cls: type[ChatResponse[Any]],
2177+
updates: AsyncIterable[ChatResponseUpdate],
2178+
*,
2179+
output_format_type: Mapping[str, Any],
2180+
) -> ChatResponse[Any]: ...
2181+
21452182
@overload
21462183
@classmethod
21472184
async def from_update_generator(
@@ -2156,7 +2193,7 @@ async def from_update_generator(
21562193
cls: type[ChatResponseT],
21572194
updates: AsyncIterable[ChatResponseUpdate],
21582195
*,
2159-
output_format_type: type[BaseModel] | None = None,
2196+
output_format_type: StructuredResponseFormat = None,
21602197
) -> ChatResponseT:
21612198
"""Joins multiple updates into a single ChatResponse.
21622199
@@ -2175,10 +2212,10 @@ async def from_update_generator(
21752212
updates: An async iterable of ChatResponseUpdate objects to combine.
21762213
21772214
Keyword Args:
2178-
output_format_type: Optional Pydantic model type to parse the response text into structured data.
2215+
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
2216+
response text into structured data.
21792217
"""
2180-
response_format = output_format_type if isinstance(output_format_type, type) else None
2181-
msg = cls(messages=[], response_format=response_format)
2218+
msg = cls(messages=[], response_format=output_format_type)
21822219
async for update in updates:
21832220
_process_update(msg, update)
21842221
_finalize_response(msg)
@@ -2198,15 +2235,12 @@ def value(self) -> ResponseModelT | None:
21982235
21992236
Raises:
22002237
ValidationError: If the response text doesn't match the expected schema.
2238+
ValueError: If the response text is not valid JSON for a non-Pydantic structured format.
22012239
"""
22022240
if self._value_parsed:
22032241
return self._value
2204-
if (
2205-
self._response_format is not None
2206-
and isinstance(self._response_format, type)
2207-
and issubclass(self._response_format, BaseModel)
2208-
):
2209-
self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text))
2242+
if self._response_format is not None:
2243+
self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format))
22102244
self._value_parsed = True
22112245
return self._value
22122246

@@ -2397,7 +2431,7 @@ def __init__(
23972431
created_at: CreatedAtT | None = None,
23982432
usage_details: UsageDetails | None = None,
23992433
value: ResponseModelT | None = None,
2400-
response_format: type[BaseModel] | None = None,
2434+
response_format: StructuredResponseFormat = None,
24012435
continuation_token: ContinuationToken | None = None,
24022436
raw_representation: Any | None = None,
24032437
additional_properties: dict[str, Any] | None = None,
@@ -2438,7 +2472,7 @@ def __init__(
24382472
self.created_at = created_at
24392473
self.usage_details = usage_details
24402474
self._value: ResponseModelT | None = value
2441-
self._response_format: type[BaseModel] | None = response_format
2475+
self._response_format: type[BaseModel] | Mapping[str, Any] | None = response_format
24422476
self._value_parsed: bool = value is not None
24432477
self.additional_properties = (
24442478
_restore_compaction_annotation_in_additional_properties(additional_properties) or {}
@@ -2460,15 +2494,12 @@ def value(self) -> ResponseModelT | None:
24602494
24612495
Raises:
24622496
ValidationError: If the response text doesn't match the expected schema.
2497+
ValueError: If the response text is not valid JSON for a non-Pydantic structured format.
24632498
"""
24642499
if self._value_parsed:
24652500
return self._value
2466-
if (
2467-
self._response_format is not None
2468-
and isinstance(self._response_format, type)
2469-
and issubclass(self._response_format, BaseModel)
2470-
):
2471-
self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text))
2501+
if self._response_format is not None:
2502+
self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format))
24722503
self._value_parsed = True
24732504
return self._value
24742505

@@ -2492,6 +2523,16 @@ def from_updates(
24922523
value: Any | None = None,
24932524
) -> AgentResponse[ResponseModelBoundT]: ...
24942525

2526+
@overload
2527+
@classmethod
2528+
def from_updates(
2529+
cls: type[AgentResponse[Any]],
2530+
updates: Sequence[AgentResponseUpdate],
2531+
*,
2532+
output_format_type: Mapping[str, Any],
2533+
value: Any | None = None,
2534+
) -> AgentResponse[Any]: ...
2535+
24952536
@overload
24962537
@classmethod
24972538
def from_updates(
@@ -2507,7 +2548,7 @@ def from_updates(
25072548
cls: type[AgentResponseT],
25082549
updates: Sequence[AgentResponseUpdate],
25092550
*,
2510-
output_format_type: type[BaseModel] | None = None,
2551+
output_format_type: StructuredResponseFormat = None,
25112552
value: Any | None = None,
25122553
) -> AgentResponseT:
25132554
"""Joins multiple updates into a single AgentResponse.
@@ -2516,7 +2557,8 @@ def from_updates(
25162557
updates: A sequence of AgentResponseUpdate objects to combine.
25172558
25182559
Keyword Args:
2519-
output_format_type: Optional Pydantic model type to parse the response text into structured data.
2560+
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
2561+
response text into structured data.
25202562
value: Optional pre-parsed structured output value to set directly on the response.
25212563
"""
25222564
msg = cls(messages=[], response_format=output_format_type, value=value)
@@ -2534,6 +2576,15 @@ async def from_update_generator(
25342576
output_format_type: type[ResponseModelBoundT],
25352577
) -> AgentResponse[ResponseModelBoundT]: ...
25362578

2579+
@overload
2580+
@classmethod
2581+
async def from_update_generator(
2582+
cls: type[AgentResponse[Any]],
2583+
updates: AsyncIterable[AgentResponseUpdate],
2584+
*,
2585+
output_format_type: Mapping[str, Any],
2586+
) -> AgentResponse[Any]: ...
2587+
25372588
@overload
25382589
@classmethod
25392590
async def from_update_generator(
@@ -2548,15 +2599,16 @@ async def from_update_generator(
25482599
cls: type[AgentResponseT],
25492600
updates: AsyncIterable[AgentResponseUpdate],
25502601
*,
2551-
output_format_type: type[BaseModel] | None = None,
2602+
output_format_type: StructuredResponseFormat = None,
25522603
) -> AgentResponseT:
25532604
"""Joins multiple updates into a single AgentResponse.
25542605
25552606
Args:
25562607
updates: An async iterable of AgentResponseUpdate objects to combine.
25572608
25582609
Keyword Args:
2559-
output_format_type: Optional Pydantic model type to parse the response text into structured data
2610+
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
2611+
response text into structured data.
25602612
"""
25612613
msg = cls(messages=[], response_format=output_format_type)
25622614
async for update in updates:

python/packages/core/tests/core/conftest.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
127127
yield ChatResponseUpdate(contents=[Content.from_text("another update")], role="assistant")
128128

129129
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
130-
response_format = options.get("response_format")
131-
output_format_type = response_format if isinstance(response_format, type) else None
132-
return ChatResponse.from_updates(updates, output_format_type=output_format_type)
130+
return ChatResponse.from_updates(updates, output_format_type=options.get("response_format"))
133131

134132
return ResponseStream(_stream(), finalizer=_finalize)
135133

@@ -233,9 +231,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
233231
await asyncio.sleep(0)
234232

235233
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
236-
response_format = options.get("response_format")
237-
output_format_type = response_format if isinstance(response_format, type) else None
238-
return ChatResponse.from_updates(updates, output_format_type=output_format_type)
234+
return ChatResponse.from_updates(updates, output_format_type=options.get("response_format"))
239235

240236
return ResponseStream(_stream(), finalizer=_finalize)
241237

0 commit comments

Comments
 (0)