Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions agent-samples/foundry/MicrosoftLearnAgent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ name: MicrosoftLearnAgent
description: Microsoft Learn Agent
instructions: You answer questions by searching the Microsoft Learn content only.
model:
id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID
id: =Env.FOUNDRY_MODEL
options:
temperature: 0.9
topP: 0.95
connection:
kind: remote
endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT
endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT
tools:
- kind: mcp
name: microsoft_learn
Expand Down
21 changes: 21 additions & 0 deletions python/packages/anthropic/tests/test_anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,27 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None:
assert response.usage_details["output_token_count"] == 5


def test_process_message_with_dict_response_format(mock_anthropic_client: MagicMock) -> None:
"""_process_message should preserve dict response_format values for response.value parsing."""
client = create_test_anthropic_client(mock_anthropic_client)

mock_message = MagicMock(spec=BetaMessage)
mock_message.id = "msg_123"
mock_message.model = "claude-3-5-sonnet-20241022"
mock_message.content = [BetaTextBlock(type="text", text='{"greeting": "Hello"}')]
mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5)
mock_message.stop_reason = "end_turn"

response = client._process_message(
mock_message,
options={"response_format": {"type": "object", "properties": {"greeting": {"type": "string"}}}},
)

assert response.value is not None
assert isinstance(response.value, dict)
assert response.value["greeting"] == "Hello"


def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None:
"""Test _process_message with tool use."""
client = create_test_anthropic_client(mock_anthropic_client)
Expand Down
12 changes: 2 additions & 10 deletions python/packages/core/agent_framework/_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1026,20 +1026,13 @@ async def _parse_non_streaming_response(
session_context=context["session_context"],
suppress_response_id=context["suppress_response_id"],
)

response_format = context["chat_options"].get("response_format")
if not (
response_format is not None and isinstance(response_format, type) and issubclass(response_format, BaseModel)
):
response_format = None

return AgentResponse(
messages=response.messages,
response_id=None if context["suppress_response_id"] else response.response_id,
created_at=response.created_at,
usage_details=response.usage_details,
value=response.value,
response_format=response_format,
response_format=context["chat_options"].get("response_format"),
continuation_token=response.continuation_token,
raw_representation=response,
additional_properties=response.additional_properties,
Expand Down Expand Up @@ -1125,10 +1118,9 @@ def _finalize_response_updates(
response_format: Any | None = None,
) -> AgentResponse[Any]:
"""Finalize response updates into a single AgentResponse."""
output_format_type = response_format if isinstance(response_format, type) else None
return AgentResponse.from_updates( # pyright: ignore[reportUnknownVariableType]
updates,
output_format_type=output_format_type,
output_format_type=response_format,
)

@staticmethod
Expand Down
3 changes: 1 addition & 2 deletions python/packages/core/agent_framework/_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,9 @@ def _finalize_response_updates(
response_format: Any | None = None,
) -> ChatResponse[Any]:
"""Finalize response updates into a single ChatResponse."""
output_format_type = response_format if isinstance(response_format, type) else None
return ChatResponse.from_updates( # pyright: ignore[reportUnknownVariableType]
updates,
output_format_type=output_format_type,
output_format_type=response_format,
)

def _build_response_stream(
Expand Down
3 changes: 1 addition & 2 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2327,7 +2327,6 @@ async def _get_response() -> ChatResponse[Any]:
return _get_response()

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

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

return ResponseStream(_stream(), finalizer=_finalize)
108 changes: 80 additions & 28 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ def _restore_compaction_annotation_in_additional_properties(
AgentResponseT = TypeVar("AgentResponseT", bound="AgentResponse")
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True)
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
StructuredResponseFormat = type[BaseModel] | Mapping[str, Any] | None

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

Expand Down Expand Up @@ -1949,6 +1950,24 @@ class ContinuationToken(TypedDict):
# endregion


def _parse_structured_response_value(text: str, response_format: Any | None) -> Any | None:
if response_format is None:
return None
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
return response_format.model_validate_json(text)
if isinstance(response_format, Mapping):
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"Response text is not valid JSON: {exc}") from exc
logger.warning(
"Unable to parse structured response value, use either a Pydantic model or a dict defining the schema, "
"received response_format type: %s",
type(response_format), # type: ignore[reportUnknownArgumentType]
)
return None


class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
"""Represents the response to a chat request.

Expand Down Expand Up @@ -2014,7 +2033,7 @@ def __init__(
finish_reason: FinishReasonLiteral | FinishReason | None = None,
usage_details: UsageDetails | None = None,
value: ResponseModelT | None = None,
response_format: type[BaseModel] | None = None,
response_format: StructuredResponseFormat = None,
continuation_token: ContinuationToken | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
Expand Down Expand Up @@ -2058,7 +2077,7 @@ def __init__(
self.finish_reason = finish_reason
self.usage_details = usage_details
self._value: ResponseModelT | None = value
self._response_format: type[BaseModel] | None = response_format
self._response_format: StructuredResponseFormat = response_format
self._value_parsed: bool = value is not None
self.additional_properties = (
_restore_compaction_annotation_in_additional_properties(additional_properties) or {}
Expand Down Expand Up @@ -2087,6 +2106,15 @@ def from_updates(
output_format_type: type[ResponseModelBoundT],
) -> ChatResponse[ResponseModelBoundT]: ...

@overload
@classmethod
def from_updates(
cls: type[ChatResponse[Any]],
updates: Sequence[ChatResponseUpdate],
*,
output_format_type: Mapping[str, Any],
) -> ChatResponse[Any]: ...

@overload
@classmethod
def from_updates(
Expand All @@ -2101,7 +2129,7 @@ def from_updates(
cls: type[ChatResponseT],
updates: Sequence[ChatResponseUpdate],
*,
output_format_type: type[BaseModel] | None = None,
output_format_type: StructuredResponseFormat = None,
) -> ChatResponseT:
"""Joins multiple updates into a single ChatResponse.

Expand All @@ -2124,10 +2152,10 @@ def from_updates(
updates: A sequence of ChatResponseUpdate objects to combine.

Keyword Args:
output_format_type: Optional Pydantic model type to parse the response text into structured data.
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
response text into structured data.
"""
response_format = output_format_type if isinstance(output_format_type, type) else None
msg = cls(messages=[], response_format=response_format)
msg = cls(messages=[], response_format=output_format_type)
for update in updates:
_process_update(msg, update)
_finalize_response(msg)
Expand All @@ -2142,6 +2170,15 @@ async def from_update_generator(
output_format_type: type[ResponseModelBoundT],
) -> ChatResponse[ResponseModelBoundT]: ...

@overload
@classmethod
async def from_update_generator(
cls: type[ChatResponse[Any]],
updates: AsyncIterable[ChatResponseUpdate],
*,
output_format_type: Mapping[str, Any],
) -> ChatResponse[Any]: ...

@overload
@classmethod
async def from_update_generator(
Expand All @@ -2156,7 +2193,7 @@ async def from_update_generator(
cls: type[ChatResponseT],
updates: AsyncIterable[ChatResponseUpdate],
*,
output_format_type: type[BaseModel] | None = None,
output_format_type: StructuredResponseFormat = None,
) -> ChatResponseT:
"""Joins multiple updates into a single ChatResponse.

Expand All @@ -2175,10 +2212,10 @@ async def from_update_generator(
updates: An async iterable of ChatResponseUpdate objects to combine.

Keyword Args:
output_format_type: Optional Pydantic model type to parse the response text into structured data.
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
response text into structured data.
"""
response_format = output_format_type if isinstance(output_format_type, type) else None
msg = cls(messages=[], response_format=response_format)
msg = cls(messages=[], response_format=output_format_type)
async for update in updates:
_process_update(msg, update)
_finalize_response(msg)
Expand All @@ -2198,15 +2235,12 @@ def value(self) -> ResponseModelT | None:

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.
"""
if self._value_parsed:
return self._value
if (
self._response_format is not 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))
if self._response_format is not None:
self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format))
self._value_parsed = True
return self._value

Expand Down Expand Up @@ -2397,7 +2431,7 @@ def __init__(
created_at: CreatedAtT | None = None,
usage_details: UsageDetails | None = None,
value: ResponseModelT | None = None,
response_format: type[BaseModel] | None = None,
response_format: StructuredResponseFormat = None,
continuation_token: ContinuationToken | None = None,
raw_representation: Any | None = None,
additional_properties: dict[str, Any] | None = None,
Expand Down Expand Up @@ -2438,7 +2472,7 @@ def __init__(
self.created_at = created_at
self.usage_details = usage_details
self._value: ResponseModelT | None = value
self._response_format: type[BaseModel] | None = response_format
self._response_format: type[BaseModel] | Mapping[str, Any] | None = response_format
self._value_parsed: bool = value is not None
self.additional_properties = (
_restore_compaction_annotation_in_additional_properties(additional_properties) or {}
Expand All @@ -2460,15 +2494,12 @@ def value(self) -> ResponseModelT | None:

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.
"""
if self._value_parsed:
return self._value
if (
self._response_format is not 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))
if self._response_format is not None:
self._value = cast(ResponseModelT, _parse_structured_response_value(self.text, self._response_format))
self._value_parsed = True
return self._value

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

@overload
@classmethod
def from_updates(
cls: type[AgentResponse[Any]],
updates: Sequence[AgentResponseUpdate],
*,
output_format_type: Mapping[str, Any],
value: Any | None = None,
) -> AgentResponse[Any]: ...

@overload
@classmethod
def from_updates(
Expand All @@ -2507,7 +2548,7 @@ def from_updates(
cls: type[AgentResponseT],
updates: Sequence[AgentResponseUpdate],
*,
output_format_type: type[BaseModel] | None = None,
output_format_type: StructuredResponseFormat = None,
value: Any | None = None,
) -> AgentResponseT:
"""Joins multiple updates into a single AgentResponse.
Expand All @@ -2516,7 +2557,8 @@ def from_updates(
updates: A sequence of AgentResponseUpdate objects to combine.

Keyword Args:
output_format_type: Optional Pydantic model type to parse the response text into structured data.
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
response 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)
Expand All @@ -2534,6 +2576,15 @@ async def from_update_generator(
output_format_type: type[ResponseModelBoundT],
) -> AgentResponse[ResponseModelBoundT]: ...

@overload
@classmethod
async def from_update_generator(
cls: type[AgentResponse[Any]],
updates: AsyncIterable[AgentResponseUpdate],
*,
output_format_type: Mapping[str, Any],
) -> AgentResponse[Any]: ...

@overload
@classmethod
async def from_update_generator(
Expand All @@ -2548,15 +2599,16 @@ async def from_update_generator(
cls: type[AgentResponseT],
updates: AsyncIterable[AgentResponseUpdate],
*,
output_format_type: type[BaseModel] | None = None,
output_format_type: StructuredResponseFormat = None,
) -> AgentResponseT:
"""Joins multiple updates into a single AgentResponse.

Args:
updates: An async iterable of AgentResponseUpdate objects to combine.

Keyword Args:
output_format_type: Optional Pydantic model type to parse the response text into structured data
output_format_type: Optional Pydantic model type or JSON schema mapping used to parse the
response text into structured data.
"""
msg = cls(messages=[], response_format=output_format_type)
async for update in updates:
Expand Down
8 changes: 2 additions & 6 deletions python/packages/core/tests/core/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[Content.from_text("another update")], role="assistant")

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

return ResponseStream(_stream(), finalizer=_finalize)

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

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

return ResponseStream(_stream(), finalizer=_finalize)

Expand Down
Loading
Loading