Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)

* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test
This commit is contained in:
Eduard van Valkenburg
2026-01-20 23:09:39 +01:00
committed by GitHub
Unverified
parent 73761aa4a3
commit 83e6229c11
132 changed files with 3949 additions and 4741 deletions
@@ -1140,9 +1140,9 @@ class ChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc]
# Convert result to MCP content
if isinstance(result, str):
return [types.TextContent(type="text", text=result)]
return [types.TextContent(type="text", text=result)] # type: ignore[attr-defined]
return [types.TextContent(type="text", text=str(result))]
return [types.TextContent(type="text", text=str(result))] # type: ignore[attr-defined]
@server.set_logging_level() # type: ignore
async def _set_logging_level(level: types.LoggingLevel) -> None: # type: ignore
+49 -55
View File
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import logging
import re
import sys
@@ -29,13 +30,8 @@ from ._tools import (
)
from ._types import (
ChatMessage,
Contents,
DataContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
UriContent,
)
from .exceptions import ToolException, ToolExecutionException
@@ -82,7 +78,7 @@ def _parse_message_from_mcp(
def _parse_contents_from_mcp_tool_result(
mcp_type: types.CallToolResult,
) -> list[Contents]:
) -> list[Content]:
"""Parse an MCP CallToolResult into Agent Framework content types.
This function extracts the complete _meta field from CallToolResult objects
@@ -147,25 +143,27 @@ def _parse_content_from_mcp(
| types.ToolUseContent
| types.ToolResultContent
],
) -> list[Contents]:
) -> list[Content]:
"""Parse an MCP type into an Agent Framework type."""
mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type]
return_types: list[Contents] = []
return_types: list[Content] = []
for mcp_type in mcp_types:
match mcp_type:
case types.TextContent():
return_types.append(TextContent(text=mcp_type.text, raw_representation=mcp_type))
return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type))
case types.ImageContent() | types.AudioContent():
# MCP protocol uses base64-encoded strings, convert to bytes
data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data
return_types.append(
DataContent(
data=mcp_type.data,
Content.from_data(
data=data_bytes,
media_type=mcp_type.mimeType,
raw_representation=mcp_type,
)
)
case types.ResourceLink():
return_types.append(
UriContent(
Content.from_uri(
uri=str(mcp_type.uri),
media_type=mcp_type.mimeType or "application/json",
raw_representation=mcp_type,
@@ -173,7 +171,7 @@ def _parse_content_from_mcp(
)
case types.ToolUseContent():
return_types.append(
FunctionCallContent(
Content.from_function_call(
call_id=mcp_type.id,
name=mcp_type.name,
arguments=mcp_type.input,
@@ -182,12 +180,12 @@ def _parse_content_from_mcp(
)
case types.ToolResultContent():
return_types.append(
FunctionResultContent(
Content.from_function_result(
call_id=mcp_type.toolUseId,
result=_parse_content_from_mcp(mcp_type.content)
if mcp_type.content
else mcp_type.structuredContent,
exception=Exception() if mcp_type.isError else None,
exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type]
raw_representation=mcp_type,
)
)
@@ -195,7 +193,7 @@ def _parse_content_from_mcp(
match mcp_type.resource:
case types.TextResourceContents():
return_types.append(
TextContent(
Content.from_text(
text=mcp_type.resource.text,
raw_representation=mcp_type,
additional_properties=(
@@ -205,7 +203,7 @@ def _parse_content_from_mcp(
)
case types.BlobResourceContents():
return_types.append(
DataContent(
Content.from_uri(
uri=mcp_type.resource.blob,
media_type=mcp_type.resource.mimeType,
raw_representation=mcp_type,
@@ -218,45 +216,41 @@ def _parse_content_from_mcp(
def _prepare_content_for_mcp(
content: Contents,
content: Content,
) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None:
"""Prepare an Agent Framework content type for MCP."""
match content:
case TextContent():
return types.TextContent(type="text", text=content.text)
case DataContent():
if content.media_type and content.media_type.startswith("image/"):
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type)
if content.media_type and content.media_type.startswith("audio/"):
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type)
if content.media_type and content.media_type.startswith("application/"):
return types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
blob=content.uri,
mimeType=content.media_type,
# uri's are not limited in MCP but they have to be set.
# the uri of data content, contains the data uri, which
# is not the uri meant here, UriContent would match this.
uri=(
content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary"
), # type: ignore[reportArgumentType]
),
)
return None
case UriContent():
return types.ResourceLink(
type="resource_link",
uri=content.uri, # type: ignore[reportArgumentType]
mimeType=content.media_type,
name=(
content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"
if content.type == "text":
return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined]
if content.type == "data":
if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined]
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined]
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined]
return types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
blob=content.uri, # type: ignore[attr-defined]
mimeType=content.media_type, # type: ignore[attr-defined]
# uri's are not limited in MCP but they have to be set.
# the uri of data content, contains the data uri, which
# is not the uri meant here, UriContent would match this.
uri=(
content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary"
), # type: ignore[reportArgumentType]
),
)
case _:
return None
return None
if content.type == "uri":
return types.ResourceLink(
type="resource_link",
uri=content.uri, # type: ignore[reportArgumentType,attr-defined]
mimeType=content.media_type, # type: ignore[attr-defined]
name=(content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"),
)
return None
def _prepare_message_for_mcp(
@@ -650,7 +644,7 @@ class MCPTool:
input_model = _get_input_model_from_mcp_tool(tool)
approval_mode = self._determine_approval_mode(local_name)
# Create AIFunctions out of each tool
func: AIFunction[BaseModel, list[Contents] | Any | types.CallToolResult] = AIFunction(
func: AIFunction[BaseModel, list[Content] | Any | types.CallToolResult] = AIFunction(
func=partial(self.call_tool, tool.name),
name=local_name,
description=tool.description or "",
@@ -704,7 +698,7 @@ class MCPTool:
inner_exception=ex,
) from ex
async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Contents] | Any | types.CallToolResult:
async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any | types.CallToolResult:
"""Call a tool with the given arguments.
Args:
+104 -116
View File
@@ -54,9 +54,7 @@ if TYPE_CHECKING:
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Contents,
FunctionApprovalResponseContent,
FunctionCallContent,
Content,
)
from typing import overload
@@ -104,15 +102,15 @@ _NOOP_HISTOGRAM = _NoOpHistogram()
def _parse_inputs(
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None",
) -> list["Contents"]:
"""Parse the inputs for a tool, ensuring they are of type Contents.
inputs: "Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None",
) -> list["Content"]:
"""Parse the inputs for a tool, ensuring they are of type Content.
Args:
inputs: The inputs to parse. Can be a single item or list of Contents, dicts, or strings.
inputs: The inputs to parse. Can be a single item or list of Content, dicts, or strings.
Returns:
A list of Contents objects.
A list of Content objects.
Raises:
ValueError: If an unsupported input type is encountered.
@@ -122,43 +120,39 @@ def _parse_inputs(
return []
from ._types import (
BaseContent,
DataContent,
HostedFileContent,
HostedVectorStoreContent,
UriContent,
Content,
)
parsed_inputs: list["Contents"] = []
parsed_inputs: list["Content"] = []
if not isinstance(inputs, list):
inputs = [inputs]
for input_item in inputs:
if isinstance(input_item, str):
# If it's a string, we assume it's a URI or similar identifier.
# Convert it to a UriContent or similar type as needed.
parsed_inputs.append(UriContent(uri=input_item, media_type="text/plain"))
parsed_inputs.append(Content.from_uri(uri=input_item, media_type="text/plain"))
elif isinstance(input_item, dict):
# If it's a dict, we assume it contains properties for a specific content type.
# we check if the required keys are present to determine the type.
# for instance, if it has "uri" and "media_type", we treat it as UriContent.
# if is only has uri, then we treat it as DataContent.
# if it only has uri and media_type without a specific type indicator, we treat it as DataContent.
# etc.
if "uri" in input_item:
parsed_inputs.append(
UriContent(**input_item) if "media_type" in input_item else DataContent(**input_item)
)
# Use Content.from_uri for proper URI content, DataContent for backwards compatibility
parsed_inputs.append(Content.from_uri(**input_item))
elif "file_id" in input_item:
parsed_inputs.append(HostedFileContent(**input_item))
parsed_inputs.append(Content.from_hosted_file(**input_item))
elif "vector_store_id" in input_item:
parsed_inputs.append(HostedVectorStoreContent(**input_item))
parsed_inputs.append(Content.from_hosted_vector_store(**input_item))
elif "data" in input_item:
parsed_inputs.append(DataContent(**input_item))
# DataContent helper handles both uri and data parameters
parsed_inputs.append(Content.from_data(**input_item))
else:
raise ValueError(f"Unsupported input type: {input_item}")
elif isinstance(input_item, BaseContent):
elif isinstance(input_item, Content):
parsed_inputs.append(input_item)
else:
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected Contents or dict.")
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected Content or dict.")
return parsed_inputs
@@ -254,7 +248,7 @@ class HostedCodeInterpreterTool(BaseTool):
def __init__(
self,
*,
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
inputs: "Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None" = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
@@ -266,8 +260,8 @@ class HostedCodeInterpreterTool(BaseTool):
This should mostly be HostedFileContent or HostedVectorStoreContent.
Can also be DataContent, depending on the service used.
When supplying a list, it can contain:
- Contents instances
- dicts with properties for Contents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- Content instances
- dicts with properties for Content (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- strings (which will be converted to UriContent with media_type "text/plain").
If None, defaults to an empty list.
description: A description of the tool.
@@ -503,7 +497,7 @@ class HostedFileSearchTool(BaseTool):
def __init__(
self,
*,
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
inputs: "Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None" = None,
max_results: int | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -515,8 +509,8 @@ class HostedFileSearchTool(BaseTool):
inputs: A list of contents that the tool can accept as input. Defaults to None.
This should be one or more HostedVectorStoreContents.
When supplying a list, it can contain:
- Contents instances
- dicts with properties for Contents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- Content instances
- dicts with properties for Content (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- strings (which will be converted to UriContent with media_type "text/plain").
If None, defaults to an empty list.
max_results: The maximum number of results to return from the file search.
@@ -1480,7 +1474,7 @@ class FunctionExecutionResult:
__slots__ = ("content", "terminate")
def __init__(self, content: "Contents", terminate: bool = False) -> None:
def __init__(self, content: "Content", terminate: bool = False) -> None:
"""Initialize FunctionExecutionResult.
Args:
@@ -1492,7 +1486,7 @@ class FunctionExecutionResult:
async def _auto_invoke_function(
function_call_content: "FunctionCallContent | FunctionApprovalResponseContent",
function_call_content: "Content",
custom_args: dict[str, Any] | None = None,
*,
config: FunctionInvocationConfiguration,
@@ -1500,7 +1494,7 @@ async def _auto_invoke_function(
sequence_index: int | None = None,
request_index: int | None = None,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline
) -> "FunctionExecutionResult | Contents":
) -> "FunctionExecutionResult | Content":
"""Invoke a function call requested by the agent, applying middleware that is defined.
Args:
@@ -1516,41 +1510,42 @@ async def _auto_invoke_function(
Returns:
A FunctionExecutionResult wrapping the content and terminate signal,
or a Contents object for approval/hosted tool scenarios.
or a Content object for approval/hosted tool scenarios.
Raises:
KeyError: If the requested function is not found in the tool map.
"""
from ._types import Content
# Note: The scenarios for approval_mode="always_require", declaration_only, and
# terminate_on_unknown_calls are all handled in _try_execute_function_calls before
# this function is called. This function only handles the actual execution of approved,
# non-declaration-only functions.
from ._types import FunctionCallContent, FunctionResultContent
tool: AIFunction[BaseModel, Any] | None = None
if function_call_content.type == "function_call":
tool = tool_map.get(function_call_content.name)
tool = tool_map.get(function_call_content.name) # type: ignore[arg-type]
# Tool should exist because _try_execute_function_calls validates this
if tool is None:
exc = KeyError(f'Function "{function_call_content.name}" not found.')
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=f'Error: Requested function "{function_call_content.name}" not found.',
exception=exc,
exception=str(exc), # type: ignore[arg-type]
)
)
else:
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
# and never reach this function, so we only handle approved=True cases here.
inner_call = function_call_content.function_call
if not isinstance(inner_call, FunctionCallContent):
inner_call = function_call_content.function_call # type: ignore[attr-defined]
if inner_call.type != "function_call": # type: ignore[union-attr]
return function_call_content
tool = tool_map.get(inner_call.name)
tool = tool_map.get(inner_call.name) # type: ignore[attr-defined, union-attr, arg-type]
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = inner_call
function_call_content = inner_call # type: ignore[assignment]
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
@@ -1567,7 +1562,11 @@ async def _auto_invoke_function(
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=message,
exception=str(exc), # type: ignore[arg-type]
)
)
if not middleware_pipeline or (
@@ -1581,8 +1580,8 @@ async def _auto_invoke_function(
**runtime_kwargs if getattr(tool, "_forward_runtime_kwargs", False) else {},
)
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=function_result,
)
)
@@ -1591,7 +1590,11 @@ async def _auto_invoke_function(
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=message,
exception=str(exc),
)
)
# Execute through middleware pipeline if available
from ._middleware import FunctionInvocationContext
@@ -1617,8 +1620,8 @@ async def _auto_invoke_function(
final_handler=final_function_handler,
)
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=function_result,
),
terminate=middleware_context.terminate,
@@ -1628,7 +1631,11 @@ async def _auto_invoke_function(
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=message,
exception=str(exc), # type: ignore[arg-type]
)
)
@@ -1653,14 +1660,14 @@ def _get_tool_map(
async def _try_execute_function_calls(
custom_args: dict[str, Any],
attempt_idx: int,
function_calls: Sequence["FunctionCallContent"] | Sequence["FunctionApprovalResponseContent"],
function_calls: Sequence["Content"],
tools: "ToolProtocol \
| Callable[..., Any] \
| MutableMapping[str, Any] \
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
config: FunctionInvocationConfiguration,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> tuple[Sequence["Contents"], bool]:
) -> tuple[Sequence["Content"], bool]:
"""Execute multiple function calls concurrently.
Args:
@@ -1673,12 +1680,12 @@ async def _try_execute_function_calls(
Returns:
A tuple of:
- A list of Contents containing the results of each function call,
- A list of Content containing the results of each function call,
or the approval requests if any function requires approval,
or the original function calls if any are declaration only.
- A boolean indicating whether to terminate the function calling loop.
"""
from ._types import FunctionApprovalRequestContent, FunctionCallContent
from ._types import Content
tool_map = _get_tool_map(tools)
approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"]
@@ -1689,27 +1696,27 @@ async def _try_execute_function_calls(
approval_needed = False
declaration_only_flag = False
for fcc in function_calls:
if isinstance(fcc, FunctionCallContent) and fcc.name in approval_tools:
if fcc.type == "function_call" and fcc.name in approval_tools: # type: ignore[attr-defined]
approval_needed = True
break
if isinstance(fcc, FunctionCallContent) and (fcc.name in declaration_only or fcc.name in additional_tool_names):
if fcc.type == "function_call" and (fcc.name in declaration_only or fcc.name in additional_tool_names): # type: ignore[attr-defined]
declaration_only_flag = True
break
if config.terminate_on_unknown_calls and isinstance(fcc, FunctionCallContent) and fcc.name not in tool_map:
raise KeyError(f'Error: Requested function "{fcc.name}" not found.')
if config.terminate_on_unknown_calls and fcc.type == "function_call" and fcc.name not in tool_map: # type: ignore[attr-defined]
raise KeyError(f'Error: Requested function "{fcc.name}" not found.') # type: ignore[attr-defined]
if approval_needed:
# approval can only be needed for Function Call Contents, not Approval Responses.
# approval can only be needed for Function Call Content, not Approval Responses.
return (
[
FunctionApprovalRequestContent(id=fcc.call_id, function_call=fcc)
Content.from_function_approval_request(id=fcc.call_id, function_call=fcc) # type: ignore[attr-defined, arg-type]
for fcc in function_calls
if isinstance(fcc, FunctionCallContent)
if fcc.type == "function_call"
],
False,
)
if declaration_only_flag:
# return the declaration only tools to the user, since we cannot execute them.
return ([fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)], False)
return ([fcc for fcc in function_calls if fcc.type == "function_call"], False)
# Run all function calls concurrently
execution_results = await asyncio.gather(*[
@@ -1726,7 +1733,7 @@ async def _try_execute_function_calls(
])
# Unpack FunctionExecutionResult wrappers and check for terminate signal
contents: list[Contents] = []
contents: list[Content] = []
should_terminate = False
for result in execution_results:
if isinstance(result, FunctionExecutionResult):
@@ -1734,7 +1741,7 @@ async def _try_execute_function_calls(
if result.terminate:
should_terminate = True
else:
# Direct Contents (e.g., from hosted tools)
# Direct Content (e.g., from hosted tools)
contents.append(result)
return (contents, should_terminate)
@@ -1772,30 +1779,27 @@ def _extract_tools(options: dict[str, Any] | None) -> Any:
def _collect_approval_responses(
messages: "list[ChatMessage]",
) -> dict[str, "FunctionApprovalResponseContent"]:
) -> dict[str, "Content"]:
"""Collect approval responses (both approved and rejected) from messages."""
from ._types import ChatMessage, FunctionApprovalResponseContent
from ._types import ChatMessage, Content
fcc_todo: dict[str, FunctionApprovalResponseContent] = {}
fcc_todo: dict[str, Content] = {}
for msg in messages:
for content in msg.contents if isinstance(msg, ChatMessage) else []:
# Collect BOTH approved and rejected responses
if isinstance(content, FunctionApprovalResponseContent):
fcc_todo[content.id] = content
if content.type == "function_approval_response":
fcc_todo[content.id] = content # type: ignore[attr-defined, index]
return fcc_todo
def _replace_approval_contents_with_results(
messages: "list[ChatMessage]",
fcc_todo: dict[str, "FunctionApprovalResponseContent"],
approved_function_results: "list[Contents]",
fcc_todo: dict[str, "Content"],
approved_function_results: "list[Content]",
) -> None:
"""Replace approval request/response contents with function call/result contents in-place."""
from ._types import (
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
)
@@ -1803,23 +1807,25 @@ def _replace_approval_contents_with_results(
for msg in messages:
# First pass - collect existing function call IDs to avoid duplicates
existing_call_ids = {
content.call_id for content in msg.contents if isinstance(content, FunctionCallContent) and content.call_id
content.call_id # type: ignore[union-attr, operator]
for content in msg.contents
if content.type == "function_call" and content.call_id # type: ignore[attr-defined]
}
# Track approval requests that should be removed (duplicates)
contents_to_remove = []
for content_idx, content in enumerate(msg.contents):
if isinstance(content, FunctionApprovalRequestContent):
if content.type == "function_approval_request":
# Don't add the function call if it already exists (would create duplicate)
if content.function_call.call_id in existing_call_ids:
if content.function_call.call_id in existing_call_ids: # type: ignore[attr-defined, union-attr, operator]
# Just mark for removal - the function call already exists
contents_to_remove.append(content_idx)
else:
# Put back the function call content only if it doesn't exist
msg.contents[content_idx] = content.function_call
elif isinstance(content, FunctionApprovalResponseContent):
if content.approved and content.id in fcc_todo:
msg.contents[content_idx] = content.function_call # type: ignore[attr-defined, assignment]
elif content.type == "function_approval_response":
if content.approved and content.id in fcc_todo: # type: ignore[attr-defined]
# Replace with the corresponding result
if result_idx < len(approved_function_results):
msg.contents[content_idx] = approved_function_results[result_idx]
@@ -1828,8 +1834,8 @@ def _replace_approval_contents_with_results(
else:
# Create a "not approved" result for rejected calls
# Use function_call.call_id (the function's ID), not content.id (approval's ID)
msg.contents[content_idx] = FunctionResultContent(
call_id=content.function_call.call_id,
msg.contents[content_idx] = Content.from_function_result(
call_id=content.function_call.call_id, # type: ignore[union-attr, arg-type]
result="Error: Tool call invocation was rejected by user.",
)
msg.role = Role.TOOL
@@ -1867,9 +1873,6 @@ def _handle_function_calls_response(
from ._middleware import extract_and_merge_function_middleware
from ._types import (
ChatMessage,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
prepare_messages,
)
@@ -1893,7 +1896,7 @@ def _handle_function_calls_response(
tools = _extract_tools(options)
# Only execute APPROVED function calls, not rejected ones
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
approved_function_results: list[Content] = []
if approved_responses:
results, _ = await _try_execute_function_calls(
custom_args=kwargs,
@@ -1907,7 +1910,7 @@ def _handle_function_calls_response(
if any(
fcr.exception is not None
for fcr in approved_function_results
if isinstance(fcr, FunctionResultContent)
if fcr.type == "function_result"
):
errors_in_a_row += 1
# no need to reset the counter here, since this is the start of a new attempt.
@@ -1926,13 +1929,11 @@ def _handle_function_calls_response(
filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ("thread", "tools", "tool_choice")}
response = await func(self, messages=prepped_messages, options=options, **filtered_kwargs)
# if there are function calls, we will handle them first
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
}
function_results = {it.call_id for it in response.messages[0].contents if it.type == "function_result"}
function_calls = [
it
for it in response.messages[0].contents
if isinstance(it, FunctionCallContent) and it.call_id not in function_results
if it.type == "function_call" and it.call_id not in function_results
]
if response.conversation_id is not None:
@@ -1953,7 +1954,7 @@ def _handle_function_calls_response(
config=config,
)
# Check if we have approval requests or function calls (not results) in the results
if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results):
if any(fccr.type == "function_approval_request" for fccr in function_call_results):
# Add approval requests to the existing assistant message (with tool_calls)
# instead of creating a separate tool message
from ._types import Role
@@ -1965,7 +1966,7 @@ def _handle_function_calls_response(
result_message = ChatMessage(role="assistant", contents=function_call_results)
response.messages.append(result_message)
return response
if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results):
if any(fccr.type == "function_call" for fccr in function_call_results):
# the function calls are already in the response, so we just continue
return response
@@ -1980,11 +1981,7 @@ def _handle_function_calls_response(
response.messages.insert(0, msg)
return response
if any(
fcr.exception is not None
for fcr in function_call_results
if isinstance(fcr, FunctionResultContent)
):
if any(fcr.exception is not None for fcr in function_call_results if fcr.type == "function_result"):
errors_in_a_row += 1
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
@@ -2071,8 +2068,6 @@ def _handle_function_calls_streaming_response(
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
prepare_messages,
)
@@ -2094,7 +2089,7 @@ def _handle_function_calls_streaming_response(
tools = _extract_tools(options)
# Only execute APPROVED function calls, not rejected ones
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
approved_function_results: list[Content] = []
if approved_responses:
results, _ = await _try_execute_function_calls(
custom_args=kwargs,
@@ -2108,7 +2103,7 @@ def _handle_function_calls_streaming_response(
if any(
fcr.exception is not None
for fcr in approved_function_results
if isinstance(fcr, FunctionResultContent)
if fcr.type == "function_result"
):
errors_in_a_row += 1
# no need to reset the counter here, since this is the start of a new attempt.
@@ -2124,10 +2119,9 @@ def _handle_function_calls_streaming_response(
# efficient check for FunctionCallContent in the updates
# if there is at least one, this stops and continuous
# if there are no FCC's then it returns
from ._types import FunctionApprovalRequestContent
if not any(
isinstance(item, (FunctionCallContent, FunctionApprovalRequestContent))
item.type in ("function_call", "function_approval_request")
for upd in all_updates
for item in upd.contents
):
@@ -2139,13 +2133,11 @@ def _handle_function_calls_streaming_response(
response: "ChatResponse" = ChatResponse.from_chat_response_updates(all_updates)
# get the function calls (excluding ones that already have results)
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
}
function_results = {it.call_id for it in response.messages[0].contents if it.type == "function_result"}
function_calls = [
it
for it in response.messages[0].contents
if isinstance(it, FunctionCallContent) and it.call_id not in function_results
if it.type == "function_call" and it.call_id not in function_results
]
# When conversation id is present, it means that messages are hosted on the server.
@@ -2169,7 +2161,7 @@ def _handle_function_calls_streaming_response(
)
# Check if we have approval requests or function calls (not results) in the results
if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results):
if any(fccr.type == "function_approval_request" for fccr in function_call_results):
# Add approval requests to the existing assistant message (with tool_calls)
# instead of creating a separate tool message
from ._types import Role
@@ -2184,7 +2176,7 @@ def _handle_function_calls_streaming_response(
yield ChatResponseUpdate(contents=function_call_results, role="assistant")
response.messages.append(result_message)
return
if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results):
if any(fccr.type == "function_call" for fccr in function_call_results):
# the function calls were already yielded.
return
@@ -2195,11 +2187,7 @@ def _handle_function_calls_streaming_response(
yield ChatResponseUpdate(contents=function_call_results, role="tool")
return
if any(
fcr.exception is not None
for fcr in function_call_results
if isinstance(fcr, FunctionResultContent)
):
if any(fcr.exception is not None for fcr in function_call_results if fcr.type == "function_result"):
errors_in_a_row += 1
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
File diff suppressed because it is too large Load Diff
@@ -13,18 +13,13 @@ from agent_framework import (
AgentResponseUpdate,
AgentThread,
BaseAgent,
BaseContent,
ChatMessage,
Contents,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
UsageDetails,
)
from .._types import add_usage_details
from ..exceptions import AgentExecutionException
from ._agent_executor import AgentExecutor
from ._checkpoint import CheckpointStorage
@@ -357,12 +352,12 @@ class WorkflowAgent(BaseAgent):
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
function_call = FunctionCallContent(
function_call = Content.from_function_call(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id=request_id,
function_call=function_call,
additional_properties={"request_id": request_id},
@@ -385,9 +380,9 @@ class WorkflowAgent(BaseAgent):
function_responses: dict[str, Any] = {}
for message in input_messages:
for content in message.contents:
if isinstance(content, FunctionApprovalResponseContent):
if content.type == "function_approval_response":
# Parse the function arguments to recover request payload
arguments_payload = content.function_call.arguments
arguments_payload = content.function_call.arguments # type: ignore[attr-defined, union-attr]
if isinstance(arguments_payload, str):
try:
parsed_args = self.RequestInfoFunctionArgs.from_json(arguments_payload)
@@ -402,8 +397,8 @@ class WorkflowAgent(BaseAgent):
"FunctionApprovalResponseContent arguments must be a mapping or JSON string."
)
request_id = parsed_args.request_id or content.id
if not content.approved:
request_id = parsed_args.request_id or content.id # type: ignore[attr-defined]
if not content.approved: # type: ignore[attr-defined]
raise AgentExecutionException(f"Request '{request_id}' was not approved by the caller.")
if request_id in self.pending_requests:
@@ -412,10 +407,10 @@ class WorkflowAgent(BaseAgent):
raise AgentExecutionException(
"Only responses for pending requests are allowed when there are outstanding approvals."
)
elif isinstance(content, FunctionResultContent):
request_id = content.call_id
elif content.type == "function_result":
request_id = content.call_id # type: ignore[attr-defined]
if request_id in self.pending_requests:
response_data = content.result if hasattr(content, "result") else str(content)
response_data = content.result if hasattr(content, "result") else str(content) # type: ignore[attr-defined]
function_responses[request_id] = response_data
elif bool(self.pending_requests):
raise AgentExecutionException(
@@ -426,17 +421,17 @@ class WorkflowAgent(BaseAgent):
raise AgentExecutionException("Unexpected content type while awaiting request info responses.")
return function_responses
def _extract_contents(self, data: Any) -> list[Contents]:
"""Recursively extract Contents from workflow output data."""
def _extract_contents(self, data: Any) -> list[Content]:
"""Recursively extract Content from workflow output data."""
if isinstance(data, ChatMessage):
return list(data.contents)
if isinstance(data, list):
return [c for item in data for c in self._extract_contents(item)]
if isinstance(data, BaseContent):
return [cast(Contents, data)]
if isinstance(data, Content):
return [data] # type: ignore[redundant-cast]
if isinstance(data, str):
return [TextContent(text=data)]
return [TextContent(text=str(data))]
return [Content.from_text(text=data)]
return [Content.from_text(text=str(data))]
class _ResponseState(TypedDict):
"""State for grouping response updates by message_id."""
@@ -468,7 +463,7 @@ class WorkflowAgent(BaseAgent):
for u in updates:
if u.response_id:
for content in u.contents:
if isinstance(content, FunctionCallContent) and content.call_id:
if content.type == "function_call" and content.call_id:
call_id_to_response_id[content.call_id] = u.response_id
# Second pass: group updates, associating FunctionResultContent with their calls
@@ -480,7 +475,7 @@ class WorkflowAgent(BaseAgent):
# If no response_id, check if this is a FunctionResultContent that matches a call
if not effective_response_id:
for content in u.contents:
if isinstance(content, FunctionResultContent) and content.call_id:
if content.type == "function_result" and content.call_id:
effective_response_id = call_id_to_response_id.get(content.call_id)
if effective_response_id:
break
@@ -508,13 +503,6 @@ class WorkflowAgent(BaseAgent):
except Exception:
return (0, v)
def _sum_usage(a: UsageDetails | None, b: UsageDetails | None) -> UsageDetails | None:
if a is None:
return b
if b is None:
return a
return a + b
def _merge_responses(current: AgentResponse | None, incoming: AgentResponse) -> AgentResponse:
if current is None:
return incoming
@@ -534,7 +522,7 @@ class WorkflowAgent(BaseAgent):
messages=(current.messages or []) + (incoming.messages or []),
response_id=current.response_id or incoming.response_id,
created_at=incoming.created_at or current.created_at,
usage_details=_sum_usage(current.usage_details, incoming.usage_details),
usage_details=add_usage_details(current.usage_details, incoming.usage_details), # type: ignore[arg-type]
raw_representation=raw_list if raw_list else None,
additional_properties=incoming.additional_properties or current.additional_properties,
)
@@ -569,7 +557,7 @@ class WorkflowAgent(BaseAgent):
if aggregated:
final_messages.extend(aggregated.messages)
if aggregated.usage_details:
merged_usage = _sum_usage(merged_usage, aggregated.usage_details)
merged_usage = add_usage_details(merged_usage, aggregated.usage_details) # type: ignore[arg-type]
if aggregated.created_at and (
not latest_created_at or _parse_dt(aggregated.created_at) > _parse_dt(latest_created_at)
):
@@ -593,7 +581,7 @@ class WorkflowAgent(BaseAgent):
flattened = AgentResponse.from_agent_run_response_updates(global_dangling)
final_messages.extend(flattened.messages)
if flattened.usage_details:
merged_usage = _sum_usage(merged_usage, flattened.usage_details)
merged_usage = add_usage_details(merged_usage, flattened.usage_details) # type: ignore[arg-type]
if flattened.created_at and (
not latest_created_at or _parse_dt(flattened.created_at) > _parse_dt(latest_created_at)
):
@@ -5,7 +5,7 @@ import sys
from dataclasses import dataclass
from typing import Any, cast
from agent_framework import FunctionApprovalRequestContent, FunctionApprovalResponseContent
from agent_framework import Content
from .._agents import AgentProtocol, ChatAgent
from .._threads import AgentThread
@@ -95,8 +95,8 @@ class AgentExecutor(Executor):
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._pending_agent_requests: dict[str, FunctionApprovalRequestContent] = {}
self._pending_responses_to_agent: list[FunctionApprovalResponseContent] = []
self._pending_agent_requests: dict[str, Content] = {}
self._pending_responses_to_agent: list[Content] = []
self._output_response = output_response
# AgentExecutor maintains an internal cache of messages in between runs
@@ -179,8 +179,8 @@ class AgentExecutor(Executor):
@response_handler
async def handle_user_input_response(
self,
original_request: FunctionApprovalRequestContent,
response: FunctionApprovalResponseContent,
original_request: Content,
response: Content,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse],
) -> None:
"""Handle user input responses for function approvals during agent execution.
@@ -193,7 +193,7 @@ class AgentExecutor(Executor):
ctx: The workflow context for emitting events and outputs.
"""
self._pending_responses_to_agent.append(response)
self._pending_agent_requests.pop(original_request.id, None)
self._pending_agent_requests.pop(original_request.id, None) # type: ignore[arg-type]
if not self._pending_agent_requests:
# All pending requests have been resolved; resume agent execution
@@ -344,8 +344,8 @@ class AgentExecutor(Executor):
# Handle any user input requests
if response.user_input_requests:
for user_input_request in response.user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request
await ctx.request_info(user_input_request, FunctionApprovalResponseContent)
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
await ctx.request_info(user_input_request, Content)
return None
return response
@@ -362,7 +362,7 @@ class AgentExecutor(Executor):
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
updates: list[AgentResponseUpdate] = []
user_input_requests: list[FunctionApprovalRequestContent] = []
user_input_requests: list[Content] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
@@ -387,8 +387,8 @@ class AgentExecutor(Executor):
# Handle any user input requests after the streaming completes
if user_input_requests:
for user_input_request in user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request
await ctx.request_info(user_input_request, FunctionApprovalResponseContent)
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
await ctx.request_info(user_input_request, Content)
return None
return response
@@ -37,8 +37,6 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
Returns:
Cleaned conversation safe for handoff routing
"""
from agent_framework import FunctionApprovalRequestContent, FunctionCallContent
cleaned: list[ChatMessage] = []
for msg in conversation:
# Skip tool response messages entirely
@@ -49,7 +47,7 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
has_tool_content = False
if msg.contents:
has_tool_content = any(
isinstance(content, (FunctionApprovalRequestContent, FunctionCallContent)) for content in msg.contents
content.type in ("function_approval_request", "function_call") for content in msg.contents
)
# If no tool content, keep original
@@ -13,10 +13,10 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from pydantic import ValidationError
from agent_framework import (
Annotation,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
TextContent,
Content,
use_chat_middleware,
use_function_invocation,
)
@@ -267,8 +267,8 @@ class AzureOpenAIChatClient(
)
@override
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object.
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
"""Parse the choice into a Content object with type='text'.
Overwritten from OpenAIBaseChatClient to deal with Azure On Your Data function.
For docs see:
@@ -279,10 +279,10 @@ class AzureOpenAIChatClient(
if message is None: # type: ignore
return None
if hasattr(message, "refusal") and message.refusal:
return TextContent(text=message.refusal, raw_representation=choice)
return Content.from_text(text=message.refusal, raw_representation=choice)
if not message.content:
return None
text_content = TextContent(text=message.content, raw_representation=choice)
text_content = Content.from_text(text=message.content, raw_representation=choice)
if not message.model_extra or "context" not in message.model_extra:
return text_content
@@ -304,7 +304,8 @@ class AzureOpenAIChatClient(
text_content.annotations = []
for citation in citations:
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
title=citation.get("title", ""),
url=citation.get("url", ""),
snippet=citation.get("content", ""),
@@ -40,7 +40,7 @@ if TYPE_CHECKING: # pragma: no cover
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Contents,
Content,
FinishReason,
)
@@ -1750,8 +1750,10 @@ def _to_otel_message(message: "ChatMessage") -> dict[str, Any]:
return {"role": message.role.value, "parts": [_to_otel_part(content) for content in message.contents]}
def _to_otel_part(content: "Contents") -> dict[str, Any] | None:
def _to_otel_part(content: "Content") -> dict[str, Any] | None:
"""Create a otel representation of a Content."""
from ._types import _get_data_bytes_as_str
match content.type:
case "text":
return {"type": "text", "content": content.text}
@@ -1767,7 +1769,7 @@ def _to_otel_part(content: "Contents") -> dict[str, Any] | None:
case "data":
return {
"type": "blob",
"content": content.get_data_bytes_as_str(),
"content": _get_data_bytes_as_str(content),
"mime_type": content.media_type,
"modality": content.media_type.split("/")[0] if content.media_type else None,
}
@@ -1808,10 +1810,10 @@ def _get_response_attributes(
if model_id := getattr(response, "model_id", None):
attributes[SpanAttributes.LLM_RESPONSE_MODEL] = model_id
if capture_usage and (usage := response.usage_details):
if usage.input_token_count:
attributes[OtelAttr.INPUT_TOKENS] = usage.input_token_count
if usage.output_token_count:
attributes[OtelAttr.OUTPUT_TOKENS] = usage.output_token_count
if usage.get("input_token_count"):
attributes[OtelAttr.INPUT_TOKENS] = usage["input_token_count"]
if usage.get("output_token_count"):
attributes[OtelAttr.OUTPUT_TOKENS] = usage["output_token_count"]
if duration:
attributes[Meters.LLM_OPERATION_DURATION] = duration
return attributes
@@ -47,15 +47,8 @@ from .._types import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CodeInterpreterToolCallContent,
Contents,
FunctionCallContent,
FunctionResultContent,
MCPServerToolCallContent,
Content,
Role,
TextContent,
UriContent,
UsageContent,
UsageDetails,
prepare_function_call_results,
)
@@ -416,7 +409,7 @@ class OpenAIAssistantsClient(
thread_id: str | None,
assistant_id: str,
run_options: dict[str, Any],
tool_results: list[FunctionResultContent] | None,
tool_results: list[Content] | None,
) -> tuple[Any, str]:
"""Create the assistant stream for processing.
@@ -526,7 +519,7 @@ class OpenAIAssistantsClient(
and response.data.usage is not None
):
usage = response.data.usage
usage_content = UsageContent(
usage_content = Content.from_usage(
UsageDetails(
input_token_count=usage.prompt_tokens,
output_token_count=usage.completion_tokens,
@@ -551,9 +544,9 @@ class OpenAIAssistantsClient(
role=Role.ASSISTANT,
)
def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Contents]:
def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Content]:
"""Parse function call contents from an assistants tool action event."""
contents: list[Contents] = []
contents: list[Content] = []
if event_data.required_action is not None:
for tool_call in event_data.required_action.submit_tool_outputs.tool_calls:
@@ -563,10 +556,12 @@ class OpenAIAssistantsClient(
if tool_type == "code_interpreter" and getattr(tool_call_any, "code_interpreter", None):
code_input = getattr(tool_call_any.code_interpreter, "input", None)
inputs = (
[TextContent(text=code_input, raw_representation=tool_call)] if code_input is not None else None
[Content.from_text(text=code_input, raw_representation=tool_call)]
if code_input is not None
else None
)
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=call_id,
inputs=inputs,
raw_representation=tool_call,
@@ -574,7 +569,7 @@ class OpenAIAssistantsClient(
)
elif tool_type == "mcp":
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=call_id,
tool_name=getattr(tool_call, "name", "") or "",
server_name=getattr(tool_call, "server_label", None),
@@ -586,7 +581,7 @@ class OpenAIAssistantsClient(
function_name = tool_call.function.name
function_arguments = json.loads(tool_call.function.arguments)
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name=function_name,
arguments=function_arguments,
@@ -600,7 +595,7 @@ class OpenAIAssistantsClient(
messages: MutableSequence[ChatMessage],
options: dict[str, Any],
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent] | None]:
) -> tuple[dict[str, Any], list[Content] | None]:
from .._types import validate_tool_mode
run_options: dict[str, Any] = {**kwargs}
@@ -672,7 +667,7 @@ class OpenAIAssistantsClient(
}
instructions: list[str] = []
tool_results: list[FunctionResultContent] | None = None
tool_results: list[Content] | None = None
additional_messages: list[AdditionalMessage] | None = None
@@ -681,21 +676,23 @@ class OpenAIAssistantsClient(
# All other messages are added 1:1.
for chat_message in messages:
if chat_message.role.value in ["system", "developer"]:
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
instructions.append(text_content.text)
for text_content in [content for content in chat_message.contents if content.type == "text"]:
text = getattr(text_content, "text", None)
if text:
instructions.append(text)
continue
message_contents: list[MessageContentPartParam] = []
for content in chat_message.contents:
if isinstance(content, TextContent):
message_contents.append(TextContentBlockParam(type="text", text=content.text))
elif isinstance(content, UriContent) and content.has_top_level_media_type("image"):
if content.type == "text":
message_contents.append(TextContentBlockParam(type="text", text=content.text)) # type: ignore[attr-defined, typeddict-item]
elif content.type == "uri" and content.has_top_level_media_type("image"):
message_contents.append(
ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri))
ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri)) # type: ignore[attr-defined, typeddict-item]
)
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
if tool_results is None:
tool_results = []
tool_results.append(content)
@@ -720,7 +717,7 @@ class OpenAIAssistantsClient(
def _prepare_tool_outputs_for_assistants(
self,
tool_results: list[FunctionResultContent] | None,
tool_results: list[Content] | None,
) -> tuple[str | None, list[ToolOutput] | None]:
"""Prepare function results for submission to the assistants API."""
run_id: str | None = None
@@ -731,7 +728,7 @@ class OpenAIAssistantsClient(
# When creating the FunctionCallContent, we created it with a CallId == [runId, callId].
# We need to extract the run ID and ensure that the ToolOutput we send back to Azure
# is only the call ID.
run_and_call_ids: list[str] = json.loads(function_result_content.call_id)
run_and_call_ids: list[str] = json.loads(function_result_content.call_id) # type: ignore[arg-type]
if (
not run_and_call_ids
@@ -25,18 +25,9 @@ from .._types import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Contents,
DataContent,
Content,
FinishReason,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
TextReasoningContent,
UriContent,
UsageContent,
UsageDetails,
prepare_function_call_results,
)
@@ -294,13 +285,13 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
response_metadata.update(self._get_metadata_from_chat_choice(choice))
if choice.finish_reason:
finish_reason = FinishReason(value=choice.finish_reason)
contents: list[Contents] = []
contents: list[Content] = []
if text_content := self._parse_text_from_openai(choice):
contents.append(text_content)
if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]:
contents.extend(parsed_tool_calls)
if reasoning_details := getattr(choice.message, "reasoning_details", None):
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details)))
messages.append(ChatMessage(role="assistant", contents=contents))
return ChatResponse(
response_id=response.id,
@@ -322,13 +313,17 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
if chunk.usage:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[UsageContent(details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk)],
contents=[
Content.from_usage(
usage_details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk
)
],
model_id=chunk.model,
additional_properties=chunk_metadata,
response_id=chunk.id,
message_id=chunk.id,
)
contents: list[Contents] = []
contents: list[Content] = []
finish_reason: FinishReason | None = None
for choice in chunk.choices:
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
@@ -339,7 +334,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
if text_content := self._parse_text_from_openai(choice):
contents.append(text_content)
if reasoning_details := getattr(choice.delta, "reasoning_details", None):
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details)))
return ChatResponseUpdate(
created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
contents=contents,
@@ -360,27 +355,27 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
)
if usage.completion_tokens_details:
if tokens := usage.completion_tokens_details.accepted_prediction_tokens:
details["completion/accepted_prediction_tokens"] = tokens
details["completion/accepted_prediction_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.completion_tokens_details.audio_tokens:
details["completion/audio_tokens"] = tokens
details["completion/audio_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.completion_tokens_details.reasoning_tokens:
details["completion/reasoning_tokens"] = tokens
details["completion/reasoning_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.completion_tokens_details.rejected_prediction_tokens:
details["completion/rejected_prediction_tokens"] = tokens
details["completion/rejected_prediction_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if usage.prompt_tokens_details:
if tokens := usage.prompt_tokens_details.audio_tokens:
details["prompt/audio_tokens"] = tokens
details["prompt/audio_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.prompt_tokens_details.cached_tokens:
details["prompt/cached_tokens"] = tokens
details["prompt/cached_tokens"] = tokens # type: ignore[typeddict-unknown-key]
return details
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object."""
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
"""Parse the choice into a Content object with type='text'."""
message = choice.message if isinstance(choice, Choice) else choice.delta
if message.content:
return TextContent(text=message.content, raw_representation=choice)
return Content.from_text(text=message.content, raw_representation=choice)
if hasattr(message, "refusal") and message.refusal:
return TextContent(text=message.refusal, raw_representation=choice)
return Content.from_text(text=message.refusal, raw_representation=choice)
return None
def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]:
@@ -401,15 +396,15 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
"logprobs": getattr(choice, "logprobs", None),
}
def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Contents]:
def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Content]:
"""Parse tool calls from an OpenAI response choice."""
resp: list[Contents] = []
resp: list[Content] = []
content = choice.message if isinstance(choice, Choice) else choice.delta
if content and content.tool_calls:
for tool in content.tool_calls:
if not isinstance(tool, ChatCompletionMessageCustomToolCall) and tool.function:
# ignoring tool.custom
fcc = FunctionCallContent(
fcc = Content.from_function_call(
call_id=tool.id if tool.id else "",
name=tool.function.name if tool.function.name else "",
arguments=tool.function.arguments if tool.function.arguments else "",
@@ -455,7 +450,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
all_messages: list[dict[str, Any]] = []
for content in message.contents:
# Skip approval content - it's internal framework state, not for the LLM
if isinstance(content, (FunctionApprovalRequestContent, FunctionApprovalResponseContent)):
if content.type in ("function_approval_request", "function_approval_response"):
continue
args: dict[str, Any] = {
@@ -467,21 +462,21 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
details := message.additional_properties["reasoning_details"]
):
args["reasoning_details"] = details
match content:
case FunctionCallContent():
match content.type:
case "function_call":
if all_messages and "tool_calls" in all_messages[-1]:
# If the last message already has tool calls, append to it
all_messages[-1]["tool_calls"].append(self._prepare_content_for_openai(content))
else:
args["tool_calls"] = [self._prepare_content_for_openai(content)] # type: ignore
case FunctionResultContent():
case "function_result":
args["tool_call_id"] = content.call_id
# Always include content for tool results - API requires it even if empty
# Functions returning None should still have a tool result message
args["content"] = (
prepare_function_call_results(content.result) if content.result is not None else ""
)
case TextReasoningContent(protected_data=protected_data) if protected_data is not None:
case "text_reasoning" if (protected_data := content.protected_data) is not None:
all_messages[-1]["reasoning_details"] = json.loads(protected_data)
case _:
if "content" not in args:
@@ -492,27 +487,27 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
all_messages.append(args)
return all_messages
def _prepare_content_for_openai(self, content: Contents) -> dict[str, Any]:
def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]:
"""Prepare content for OpenAI."""
match content:
case FunctionCallContent():
match content.type:
case "function_call":
args = json.dumps(content.arguments) if isinstance(content.arguments, Mapping) else content.arguments
return {
"id": content.call_id,
"type": "function",
"function": {"name": content.name, "arguments": args},
}
case FunctionResultContent():
case "function_result":
return {
"tool_call_id": content.call_id,
"content": content.result,
}
case DataContent() | UriContent() if content.has_top_level_media_type("image"):
case "data" | "uri" if content.has_top_level_media_type("image"):
return {
"type": "image_url",
"image_url": {"url": content.uri},
}
case DataContent() | UriContent() if content.has_top_level_media_type("audio"):
case "data" | "uri" if content.has_top_level_media_type("audio"):
if content.media_type and "wav" in content.media_type:
audio_format = "wav"
elif content.media_type and "mp3" in content.media_type:
@@ -523,9 +518,9 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
# Extract base64 data from data URI
audio_data = content.uri
if audio_data.startswith("data:"):
if audio_data.startswith("data:"): # type: ignore[union-attr]
# Extract just the base64 part after "data:audio/format;base64,"
audio_data = audio_data.split(",", 1)[-1]
audio_data = audio_data.split(",", 1)[-1] # type: ignore[union-attr]
return {
"type": "input_audio",
@@ -534,9 +529,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
"format": audio_format,
},
}
case DataContent() | UriContent() if content.has_top_level_media_type(
"application"
) and content.uri.startswith("data:"):
case "data" | "uri" if content.has_top_level_media_type("application") and content.uri.startswith("data:"): # type: ignore[union-attr]
# All application/* media types should be treated as files for OpenAI
filename = getattr(content, "filename", None) or (
content.additional_properties.get("filename")
@@ -48,33 +48,16 @@ from .._tools import (
use_function_invocation,
)
from .._types import (
Annotation,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
Contents,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
HostedFileContent,
HostedVectorStoreContent,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
MCPServerToolCallContent,
MCPServerToolResultContent,
Content,
Role,
TextContent,
TextReasoningContent,
TextSpanRegion,
UriContent,
UsageContent,
UsageDetails,
_parse_content,
detect_media_type_from_base64,
prepare_function_call_results,
prepend_instructions_to_messages,
validate_tool_mode,
@@ -231,7 +214,6 @@ class OpenAIBaseResponsesClient(
response = await client.responses.parse(stream=False, **run_options)
else:
response = await client.responses.create(stream=False, **run_options)
return self._parse_response_from_openai(response, options=options)
except BadRequestError as ex:
if ex.code == "content_filter":
raise OpenAIContentFilterException(
@@ -247,6 +229,7 @@ class OpenAIBaseResponsesClient(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
return self._parse_response_from_openai(response, options=options)
@override
async def _inner_get_streaming_response(
@@ -391,7 +374,7 @@ class OpenAIBaseResponsesClient(
if tool.inputs:
tool_args["file_ids"] = []
for tool_input in tool.inputs:
if isinstance(tool_input, HostedFileContent):
if tool_input.type == "hosted_file":
tool_args["file_ids"].append(tool_input.file_id) # type: ignore[attr-defined]
if not tool_args["file_ids"]:
tool_args.pop("file_ids")
@@ -417,7 +400,9 @@ class OpenAIBaseResponsesClient(
if not tool.inputs:
raise ValueError("HostedFileSearchTool requires inputs to be specified.")
inputs: list[str] = [
inp.vector_store_id for inp in tool.inputs if isinstance(inp, HostedVectorStoreContent)
inp.vector_store_id # type: ignore[misc]
for inp in tool.inputs
if inp.type == "hosted_vector_store" # type: ignore[attr-defined]
]
if not inputs:
raise ValueError(
@@ -629,11 +614,11 @@ class OpenAIBaseResponsesClient(
for message in chat_messages:
for content in message.contents:
if (
isinstance(content, FunctionCallContent)
content.type == "function_call"
and content.additional_properties
and "fc_id" in content.additional_properties
):
call_id_to_id[content.call_id] = content.additional_properties["fc_id"]
call_id_to_id[content.call_id] = content.additional_properties["fc_id"] # type: ignore[attr-defined, index]
list_of_list = [self._prepare_message_for_openai(message, call_id_to_id) for message in chat_messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
@@ -649,18 +634,18 @@ class OpenAIBaseResponsesClient(
"role": message.role.value if isinstance(message.role, Role) else message.role,
}
for content in message.contents:
match content:
case TextReasoningContent():
match content.type:
case "text_reasoning":
# Don't send reasoning content back to model
continue
case FunctionResultContent():
case "function_result":
new_args: dict[str, Any] = {}
new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id))
all_messages.append(new_args)
case FunctionCallContent():
case "function_call":
function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id)
all_messages.append(function_call) # type: ignore
case FunctionApprovalResponseContent() | FunctionApprovalRequestContent():
case "function_approval_response" | "function_approval_request":
all_messages.append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore
case _:
if "content" not in args:
@@ -673,17 +658,17 @@ class OpenAIBaseResponsesClient(
def _prepare_content_for_openai(
self,
role: Role,
content: Contents,
content: Content,
call_id_to_id: dict[str, str],
) -> dict[str, Any]:
"""Prepare content for the OpenAI Responses API format."""
match content:
case TextContent():
match content.type:
case "text":
return {
"type": "output_text" if role == Role.ASSISTANT else "input_text",
"text": content.text,
}
case TextReasoningContent():
case "text_reasoning":
ret: dict[str, Any] = {
"type": "reasoning",
"summary": {
@@ -703,7 +688,7 @@ class OpenAIBaseResponsesClient(
if encrypted_content := props.get("encrypted_content"):
ret["encrypted_content"] = encrypted_content
return ret
case DataContent() | UriContent():
case "data" | "uri":
if content.has_top_level_media_type("image"):
return {
"type": "input_image",
@@ -744,7 +729,7 @@ class OpenAIBaseResponsesClient(
file_obj["filename"] = filename
return file_obj
return {}
case FunctionCallContent():
case "function_call":
if not content.call_id:
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
return {}
@@ -761,7 +746,7 @@ class OpenAIBaseResponsesClient(
"arguments": content.arguments,
"status": None,
}
case FunctionResultContent():
case "function_result":
# call_id for the result needs to be the same as the call_id for the function call
args: dict[str, Any] = {
"call_id": content.call_id,
@@ -769,29 +754,29 @@ class OpenAIBaseResponsesClient(
"output": prepare_function_call_results(content.result),
}
return args
case FunctionApprovalRequestContent():
case "function_approval_request":
return {
"type": "mcp_approval_request",
"id": content.id,
"arguments": content.function_call.arguments,
"name": content.function_call.name,
"server_label": content.function_call.additional_properties.get("server_label")
if content.function_call.additional_properties
"id": content.id, # type: ignore[union-attr]
"arguments": content.function_call.arguments, # type: ignore[union-attr]
"name": content.function_call.name, # type: ignore[union-attr]
"server_label": content.function_call.additional_properties.get("server_label") # type: ignore[union-attr]
if content.function_call.additional_properties # type: ignore[union-attr]
else None,
}
case FunctionApprovalResponseContent():
case "function_approval_response":
return {
"type": "mcp_approval_response",
"approval_request_id": content.id,
"approve": content.approved,
}
case HostedFileContent():
case "hosted_file":
return {
"type": "input_file",
"file_id": content.file_id,
}
case _: # should catch UsageDetails and ErrorContent and HostedVectorStoreContent
logger.debug("Unsupported content type passed (type: %s)", type(content))
logger.debug("Unsupported content type passed (type: %s)", content.type)
return {}
# region Parse methods
@@ -804,7 +789,7 @@ class OpenAIBaseResponsesClient(
structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None # type: ignore[reportUnknownMemberType]
metadata: dict[str, Any] = response.metadata or {}
contents: list[Contents] = []
contents: list[Content] = []
for item in response.output: # type: ignore[reportUnknownMemberType]
match item.type:
# types:
@@ -829,7 +814,7 @@ class OpenAIBaseResponsesClient(
for message_content in item.content: # type: ignore[reportMissingTypeArgument]
match message_content.type:
case "output_text":
text_content = TextContent(
text_content = Content.from_text(
text=message_content.text,
raw_representation=message_content, # type: ignore[reportUnknownArgumentType]
)
@@ -840,7 +825,8 @@ class OpenAIBaseResponsesClient(
match annotation.type:
case "file_path":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
file_id=annotation.file_id,
additional_properties={
"index": annotation.index,
@@ -850,7 +836,8 @@ class OpenAIBaseResponsesClient(
)
case "file_citation":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
url=annotation.filename,
file_id=annotation.file_id,
raw_representation=annotation,
@@ -861,11 +848,13 @@ class OpenAIBaseResponsesClient(
)
case "url_citation":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
title=annotation.title,
url=annotation.url,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@@ -875,7 +864,8 @@ class OpenAIBaseResponsesClient(
)
case "container_file_citation":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
file_id=annotation.file_id,
url=annotation.filename,
additional_properties={
@@ -883,6 +873,7 @@ class OpenAIBaseResponsesClient(
},
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@@ -898,7 +889,7 @@ class OpenAIBaseResponsesClient(
contents.append(text_content)
case "refusal":
contents.append(
TextContent(
Content.from_text(
text=message_content.refusal,
raw_representation=message_content,
)
@@ -910,7 +901,7 @@ class OpenAIBaseResponsesClient(
if hasattr(item, "summary") and item.summary and index < len(item.summary):
additional_properties = {"summary": item.summary[index]}
contents.append(
TextReasoningContent(
Content.from_text_reasoning(
text=reasoning_content.text,
raw_representation=reasoning_content,
additional_properties=additional_properties,
@@ -919,23 +910,23 @@ class OpenAIBaseResponsesClient(
if hasattr(item, "summary") and item.summary:
for summary in item.summary:
contents.append(
TextReasoningContent(text=summary.text, raw_representation=summary) # type: ignore[arg-type]
Content.from_text_reasoning(text=summary.text, raw_representation=summary) # type: ignore[arg-type]
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(item, "call_id", None) or getattr(item, "id", None)
outputs: list["Contents"] = []
outputs: list["Content"] = []
if item_outputs := getattr(item, "outputs", None):
for code_output in item_outputs:
if getattr(code_output, "type", None) == "logs":
outputs.append(
TextContent(
Content.from_text(
text=code_output.logs,
raw_representation=code_output,
)
)
elif getattr(code_output, "type", None) == "image":
outputs.append(
UriContent(
Content.from_uri(
uri=code_output.url,
raw_representation=code_output,
media_type="image",
@@ -943,14 +934,14 @@ class OpenAIBaseResponsesClient(
)
if code := getattr(item, "code", None):
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=call_id,
inputs=[TextContent(text=code, raw_representation=item)],
inputs=[Content.from_text(text=code, raw_representation=item)],
raw_representation=item,
)
)
contents.append(
CodeInterpreterToolResultContent(
Content.from_code_interpreter_tool_result(
call_id=call_id,
outputs=outputs,
raw_representation=item,
@@ -958,7 +949,7 @@ class OpenAIBaseResponsesClient(
)
case "function_call": # ResponseOutputFunctionCall
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=item.call_id if hasattr(item, "call_id") and item.call_id else "",
name=item.name if hasattr(item, "name") else "",
arguments=item.arguments if hasattr(item, "arguments") else "",
@@ -968,9 +959,9 @@ class OpenAIBaseResponsesClient(
)
case "mcp_approval_request": # ResponseOutputMcpApprovalRequest
contents.append(
FunctionApprovalRequestContent(
Content.from_function_approval_request(
id=item.id,
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=item.id,
name=item.name,
arguments=item.arguments,
@@ -982,7 +973,7 @@ class OpenAIBaseResponsesClient(
case "mcp_call":
call_id = item.id
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=call_id,
tool_name=item.name,
server_name=item.server_label,
@@ -992,31 +983,31 @@ class OpenAIBaseResponsesClient(
)
if item.output is not None:
contents.append(
MCPServerToolResultContent(
Content.from_mcp_server_tool_result(
call_id=call_id,
output=[TextContent(text=item.output)],
output=[Content.from_text(text=item.output)],
raw_representation=item,
)
)
case "image_generation_call": # ResponseOutputImageGenerationCall
image_output: DataContent | None = None
if item.result:
base64_data = item.result
image_format = DataContent.detect_image_format_from_base64(base64_data)
image_output = DataContent(
data=base64_data,
media_type=f"image/{image_format}" if image_format else "image/png",
image_output: Content | None = None
if item.result is not None:
# item.result contains raw base64 string
# so we call detect_media_type_from_base64 to get the media type and fallback to image/png
image_output = Content.from_uri(
uri=f"data:{detect_media_type_from_base64(data_str=item.result) or 'image/png'}"
f";base64,{item.result}",
raw_representation=item.result,
)
image_id = item.id
contents.append(
ImageGenerationToolCallContent(
Content.from_image_generation_tool_call(
image_id=image_id,
raw_representation=item,
)
)
contents.append(
ImageGenerationToolResultContent(
Content.from_image_generation_tool_result(
image_id=image_id,
outputs=image_output,
raw_representation=item,
@@ -1056,11 +1047,10 @@ class OpenAIBaseResponsesClient(
) -> ChatResponseUpdate:
"""Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
metadata: dict[str, Any] = {}
contents: list[Contents] = []
contents: list[Content] = []
conversation_id: str | None = None
response_id: str | None = None
model = self.model_id
# TODO(peterychang): Add support for other content types
match event.type:
# types:
# ResponseAudioDeltaEvent,
@@ -1120,26 +1110,26 @@ class OpenAIBaseResponsesClient(
event_part = event.part
match event_part.type:
case "output_text":
contents.append(TextContent(text=event_part.text, raw_representation=event))
contents.append(Content.from_text(text=event_part.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event_part))
case "refusal":
contents.append(TextContent(text=event_part.refusal, raw_representation=event))
contents.append(Content.from_text(text=event_part.refusal, raw_representation=event))
case _:
pass
case "response.output_text.delta":
contents.append(TextContent(text=event.delta, raw_representation=event))
contents.append(Content.from_text(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_text.delta":
contents.append(TextReasoningContent(text=event.delta, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_text.done":
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_summary_text.delta":
contents.append(TextReasoningContent(text=event.delta, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_summary_text.done":
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.created":
response_id = event.response.id
@@ -1154,7 +1144,7 @@ class OpenAIBaseResponsesClient(
if event.response.usage:
usage = self._parse_usage_from_openai(event.response.usage)
if usage:
contents.append(UsageContent(details=usage, raw_representation=event))
contents.append(Content.from_usage(usage_details=usage, raw_representation=event))
case "response.output_item.added":
event_item = event.item
match event_item.type:
@@ -1179,9 +1169,9 @@ class OpenAIBaseResponsesClient(
)
case "mcp_approval_request":
contents.append(
FunctionApprovalRequestContent(
Content.from_function_approval_request(
id=event_item.id,
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=event_item.id,
name=event_item.name,
arguments=event_item.arguments,
@@ -1193,7 +1183,7 @@ class OpenAIBaseResponsesClient(
case "mcp_call":
call_id = getattr(event_item, "id", None) or getattr(event_item, "call_id", None) or ""
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=call_id,
tool_name=getattr(event_item, "name", "") or "",
server_name=getattr(event_item, "server_label", None),
@@ -1206,7 +1196,7 @@ class OpenAIBaseResponsesClient(
or getattr(event_item, "output", None)
or getattr(event_item, "outputs", None)
)
parsed_output: list[Contents] | None = None
parsed_output: list[Content] | None = None
if result_output:
normalized = (
result_output
@@ -1214,9 +1204,9 @@ class OpenAIBaseResponsesClient(
and not isinstance(result_output, (str, bytes, MutableMapping))
else [result_output]
)
parsed_output = [_parse_content(output_item) for output_item in normalized]
parsed_output = [Content.from_dict(output_item) for output_item in normalized]
contents.append(
MCPServerToolResultContent(
Content.from_mcp_server_tool_result(
call_id=call_id,
output=parsed_output,
raw_representation=event_item,
@@ -1224,19 +1214,19 @@ class OpenAIBaseResponsesClient(
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None)
outputs: list[Contents] = []
outputs: list[Content] = []
if hasattr(event_item, "outputs") and event_item.outputs:
for code_output in event_item.outputs:
if getattr(code_output, "type", None) == "logs":
outputs.append(
TextContent(
Content.from_text(
text=cast(Any, code_output).logs,
raw_representation=code_output,
)
)
elif getattr(code_output, "type", None) == "image":
outputs.append(
UriContent(
Content.from_uri(
uri=cast(Any, code_output).url,
raw_representation=code_output,
media_type="image",
@@ -1244,10 +1234,10 @@ class OpenAIBaseResponsesClient(
)
if hasattr(event_item, "code") and event_item.code:
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=call_id,
inputs=[
TextContent(
Content.from_text(
text=event_item.code,
raw_representation=event_item,
)
@@ -1256,7 +1246,7 @@ class OpenAIBaseResponsesClient(
)
)
contents.append(
CodeInterpreterToolResultContent(
Content.from_code_interpreter_tool_result(
call_id=call_id,
outputs=outputs,
raw_representation=event_item,
@@ -1273,7 +1263,7 @@ class OpenAIBaseResponsesClient(
):
additional_properties = {"summary": event_item.summary[index]}
contents.append(
TextReasoningContent(
Content.from_text_reasoning(
text=reasoning_content.text,
raw_representation=reasoning_content,
additional_properties=additional_properties,
@@ -1285,7 +1275,7 @@ class OpenAIBaseResponsesClient(
call_id, name = function_call_ids.get(event.output_index, (None, None))
if call_id and name:
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name=name,
arguments=event.delta,
@@ -1300,13 +1290,9 @@ class OpenAIBaseResponsesClient(
# Handle streaming partial image generation
image_base64 = event.partial_image_b64
partial_index = event.partial_image_index
# Use helper function to create data URI from base64
uri, media_type = DataContent.create_data_uri_from_base64(image_base64)
image_output = DataContent(
uri=uri,
media_type=media_type,
image_output = Content.from_uri(
uri=f"data:{detect_media_type_from_base64(data_str=image_base64) or 'image/png'}"
f";base64,{image_base64}",
additional_properties={
"partial_image_index": partial_index,
"is_partial_image": True,
@@ -1316,13 +1302,13 @@ class OpenAIBaseResponsesClient(
image_id = getattr(event, "item_id", None)
contents.append(
ImageGenerationToolCallContent(
Content.from_image_generation_tool_call(
image_id=image_id,
raw_representation=event,
)
)
contents.append(
ImageGenerationToolResultContent(
Content.from_image_generation_tool_result(
image_id=image_id,
outputs=image_output,
raw_representation=event,
@@ -1343,7 +1329,7 @@ class OpenAIBaseResponsesClient(
if ann_type == "file_path":
if ann_file_id:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
@@ -1355,7 +1341,7 @@ class OpenAIBaseResponsesClient(
elif ann_type == "file_citation":
if ann_file_id:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
@@ -1368,7 +1354,7 @@ class OpenAIBaseResponsesClient(
elif ann_type == "container_file_citation":
if ann_file_id:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
@@ -1402,9 +1388,9 @@ class OpenAIBaseResponsesClient(
total_token_count=usage.total_tokens,
)
if usage.input_tokens_details and usage.input_tokens_details.cached_tokens:
details["openai.cached_input_tokens"] = usage.input_tokens_details.cached_tokens
details["openai.cached_input_tokens"] = usage.input_tokens_details.cached_tokens # type: ignore[typeddict-unknown-key]
if usage.output_tokens_details and usage.output_tokens_details.reasoning_tokens:
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens # type: ignore[typeddict-unknown-key]
return details
def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
@@ -18,7 +18,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
TextContent,
)
from agent_framework.azure import AzureOpenAIAssistantsClient
from agent_framework.exceptions import ServiceInitializationError
@@ -332,7 +331,7 @@ async def test_azure_assistants_client_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
@@ -358,7 +357,7 @@ async def test_azure_assistants_client_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
@@ -25,7 +25,6 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
TextContent,
ai_function,
)
from agent_framework._telemetry import USER_AGENT_KEY
@@ -304,9 +303,9 @@ async def test_azure_on_your_data(
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].type == "text"
assert len(content.messages[0].contents[0].annotations) == 1
assert content.messages[0].contents[0].annotations[0].title == "test title"
assert content.messages[0].contents[0].annotations[0]["title"] == "test title"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
@@ -374,9 +373,9 @@ async def test_azure_on_your_data_string(
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].type == "text"
assert len(content.messages[0].contents[0].annotations) == 1
assert content.messages[0].contents[0].annotations[0].title == "test title"
assert content.messages[0].contents[0].annotations[0]["title"] == "test title"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
@@ -433,7 +432,7 @@ async def test_azure_on_your_data_fail(
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].type == "text"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
@@ -628,9 +627,7 @@ async def test_streaming_with_none_delta(
results.append(msg)
assert len(results) > 0
assert any(
isinstance(content, TextContent) and content.text == "test" for msg in results for content in msg.contents
)
assert any(content.type == "text" and content.text == "test" for msg in results for content in msg.contents)
assert any(msg.contents for msg in results)
@@ -731,7 +728,7 @@ async def test_azure_openai_chat_client_streaming() -> None:
assert chunk.message_id is not None
assert chunk.response_id is not None
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert "Emily" in full_message or "David" in full_message
@@ -757,7 +754,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert "Emily" in full_message or "David" in full_message
@@ -15,10 +15,10 @@ from agent_framework import (
ChatClientProtocol,
ChatMessage,
ChatResponse,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ai_function,
)
@@ -48,7 +48,7 @@ async def get_weather(location: Annotated[str, "The location as a city name"]) -
return f"The weather in {location} is sunny and 72°F."
async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, HostedVectorStoreContent]:
async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="assistants"
@@ -61,7 +61,7 @@ async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str,
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
+5 -5
View File
@@ -20,8 +20,8 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Content,
Role,
TextContent,
ToolProtocol,
ai_function,
use_chat_middleware,
@@ -108,8 +108,8 @@ class MockChatClient:
for update in self.streaming_responses.pop(0):
yield update
else:
yield ChatResponseUpdate(text=TextContent(text="test streaming response "), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant")
yield ChatResponseUpdate(text=Content.from_text(text="test streaming response "), role="assistant")
yield ChatResponseUpdate(contents=[Content.from_text(text="another update")], role="assistant")
@use_chat_middleware
@@ -233,7 +233,7 @@ class MockAgent(AgentProtocol):
**kwargs: Any,
) -> AgentResponse:
logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}")
return AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Response")])])
return AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("Response")])])
async def run_stream(
self,
@@ -243,7 +243,7 @@ class MockAgent(AgentProtocol):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}")
yield AgentResponseUpdate(contents=[TextContent("Response")])
yield AgentResponseUpdate(contents=[Content.from_text("Response")])
def get_new_thread(self) -> AgentThread:
return MockAgentThread()
+10 -7
View File
@@ -18,12 +18,11 @@ from agent_framework import (
ChatMessage,
ChatMessageStore,
ChatResponse,
Content,
Context,
ContextProvider,
FunctionCallContent,
HostedCodeInterpreterTool,
Role,
TextContent,
ai_function,
)
from agent_framework._mcp import MCPTool
@@ -136,7 +135,7 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(chat_client: Ch
async def test_chat_client_agent_update_thread_id(chat_client_base: ChatClientProtocol) -> None:
mock_response = ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("test response")])],
conversation_id="123",
)
chat_client_base.run_responses = [mock_response]
@@ -200,7 +199,9 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
chat_client_base.run_responses = [
ChatResponse(
messages=[
ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")], author_name="TestAuthor")
ChatMessage(
role=Role.ASSISTANT, contents=[Content.from_text("test response")], author_name="TestAuthor"
)
]
)
]
@@ -264,7 +265,7 @@ async def test_chat_agent_context_providers_thread_created(chat_client_base: Cha
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("test response")])],
conversation_id="test-thread-id",
)
]
@@ -345,7 +346,7 @@ async def test_chat_agent_context_providers_with_thread_service_id(chat_client_b
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("test response")])],
conversation_id="service-thread-123",
)
]
@@ -575,7 +576,9 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')],
contents=[
Content.from_function_call(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')
],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
@@ -5,7 +5,7 @@
from collections.abc import Awaitable, Callable
from typing import Any
from agent_framework import ChatAgent, ChatMessage, ChatResponse, FunctionCallContent, agent_middleware
from agent_framework import ChatAgent, ChatMessage, ChatResponse, Content, agent_middleware
from agent_framework._middleware import AgentRunContext
from .conftest import MockChatClient
@@ -113,7 +113,7 @@ class TestAsToolKwargsPropagation:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_c_1",
name="call_c",
arguments='{"task": "Please execute agent_c"}',
@@ -170,10 +170,10 @@ class TestAsToolKwargsPropagation:
await next(context)
# Setup mock streaming responses
from agent_framework import ChatResponseUpdate, TextContent
from agent_framework import ChatResponseUpdate
chat_client.streaming_responses = [
[ChatResponseUpdate(text=TextContent(text="Streaming response"), role="assistant")],
[ChatResponseUpdate(text=Content.from_text(text="Streaming response"), role="assistant")],
]
sub_agent = ChatAgent(
File diff suppressed because it is too large Load Diff
@@ -8,8 +8,7 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
TextContent,
Content,
ai_function,
)
from agent_framework._tools import _handle_function_calls_response, _handle_function_calls_streaming_response
@@ -42,7 +41,9 @@ class TestKwargsPropagationToAIFunction:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}')
Content.from_function_call(
call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}'
)
],
)
]
@@ -94,7 +95,9 @@ class TestKwargsPropagationToAIFunction:
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="simple_tool", arguments='{"x": 99}')],
contents=[
Content.from_function_call(call_id="call_1", name="simple_tool", arguments='{"x": 99}')
],
)
]
)
@@ -136,10 +139,10 @@ class TestKwargsPropagationToAIFunction:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_1", name="tracking_tool", arguments='{"name": "first"}'
),
FunctionCallContent(
Content.from_function_call(
call_id="call_2", name="tracking_tool", arguments='{"name": "second"}'
),
],
@@ -187,7 +190,7 @@ class TestKwargsPropagationToAIFunction:
yield ChatResponseUpdate(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="stream_call_1",
name="streaming_capture_tool",
arguments='{"value": "streaming-test"}',
@@ -197,7 +200,9 @@ class TestKwargsPropagationToAIFunction:
)
else:
# Second call: return final response
yield ChatResponseUpdate(text=TextContent(text="Stream complete!"), role="assistant", is_finished=True)
yield ChatResponseUpdate(
text=Content.from_text(text="Stream complete!"), role="assistant", is_finished=True
)
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
+40 -41
View File
@@ -13,14 +13,12 @@ from pydantic import AnyUrl, BaseModel, ValidationError
from agent_framework import (
ChatMessage,
DataContent,
Content,
MCPStdioTool,
MCPStreamableHTTPTool,
MCPWebsocketTool,
Role,
TextContent,
ToolProtocol,
UriContent,
)
from agent_framework._mcp import (
MCPTool,
@@ -65,7 +63,7 @@ def test_mcp_prompt_message_to_ai_content():
assert isinstance(ai_content, ChatMessage)
assert ai_content.role.value == "user"
assert len(ai_content.contents) == 1
assert isinstance(ai_content.contents[0], TextContent)
assert ai_content.contents[0].type == "text"
assert ai_content.contents[0].text == "Hello, world!"
assert ai_content.raw_representation == mcp_message
@@ -75,20 +73,20 @@ def test_parse_contents_from_mcp_tool_result():
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Result text"),
types.ImageContent(type="image", data="xyz", mimeType="image/png"),
types.ImageContent(type="image", data=b"abc", mimeType="image/webp"),
types.ImageContent(type="image", data="eHl6", mimeType="image/png"), # base64 for "xyz"
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), # base64 for "abc"
]
)
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 3
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Result text"
assert isinstance(ai_contents[1], DataContent)
assert ai_contents[1].uri == "data:image/png;base64,xyz"
assert ai_contents[1].type == "data"
assert ai_contents[1].uri == "data:image/png;base64,eHl6"
assert ai_contents[1].media_type == "image/png"
assert isinstance(ai_contents[2], DataContent)
assert ai_contents[2].uri == "data:image/webp;base64,abc"
assert ai_contents[2].type == "data"
assert ai_contents[2].uri == "data:image/webp;base64,YWJj"
assert ai_contents[2].media_type == "image/webp"
@@ -103,7 +101,7 @@ def test_mcp_call_tool_result_with_meta_error():
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Error occurred"
# Check that _meta data is merged into additional_properties
@@ -134,7 +132,7 @@ def test_mcp_call_tool_result_with_meta_arbitrary_data():
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Success result"
# Check that _meta data is preserved in additional_properties
@@ -172,7 +170,7 @@ def test_mcp_call_tool_result_with_meta_none():
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "No meta test"
# Should handle gracefully when no _meta field exists
@@ -187,7 +185,7 @@ def test_mcp_call_tool_result_regression_successful_workflow():
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Success message"),
types.ImageContent(type="image", data="abc123", mimeType="image/jpeg"),
types.ImageContent(type="image", data="YWJjMTIz", mimeType="image/jpeg"), # base64 for "abc123"
]
)
@@ -197,12 +195,12 @@ def test_mcp_call_tool_result_regression_successful_workflow():
assert len(ai_contents) == 2
text_content = ai_contents[0]
assert isinstance(text_content, TextContent)
assert text_content.type == "text"
assert text_content.text == "Success message"
image_content = ai_contents[1]
assert isinstance(image_content, DataContent)
assert image_content.uri == "data:image/jpeg;base64,abc123"
assert image_content.type == "data"
assert image_content.uri == "data:image/jpeg;base64,YWJjMTIz"
assert image_content.media_type == "image/jpeg"
# Should have no additional_properties when no _meta field
@@ -215,30 +213,31 @@ def test_mcp_content_types_to_ai_content_text():
mcp_content = types.TextContent(type="text", text="Sample text")
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, TextContent)
assert ai_content.type == "text"
assert ai_content.text == "Sample text"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_image():
"""Test conversion of MCP image content to AI content."""
mcp_content = types.ImageContent(type="image", data="abc", mimeType="image/jpeg")
mcp_content = types.ImageContent(type="image", data=b"abc", mimeType="image/jpeg")
# MCP can send data as base64 string or as bytes
mcp_content = types.ImageContent(type="image", data="YWJj", mimeType="image/jpeg") # base64 for b"abc"
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:image/jpeg;base64,abc"
assert ai_content.type == "data"
assert ai_content.uri == "data:image/jpeg;base64,YWJj"
assert ai_content.media_type == "image/jpeg"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_audio():
"""Test conversion of MCP audio content to AI content."""
mcp_content = types.AudioContent(type="audio", data="def", mimeType="audio/wav")
# Use properly padded base64
mcp_content = types.AudioContent(type="audio", data="ZGVm", mimeType="audio/wav") # base64 for b"def"
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:audio/wav;base64,def"
assert ai_content.type == "data"
assert ai_content.uri == "data:audio/wav;base64,ZGVm"
assert ai_content.media_type == "audio/wav"
assert ai_content.raw_representation == mcp_content
@@ -253,7 +252,7 @@ def test_mcp_content_types_to_ai_content_resource_link():
)
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, UriContent)
assert ai_content.type == "uri"
assert ai_content.uri == "https://example.com/resource"
assert ai_content.media_type == "application/json"
assert ai_content.raw_representation == mcp_content
@@ -269,7 +268,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text():
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, TextContent)
assert ai_content.type == "text"
assert ai_content.text == "Embedded text content"
assert ai_content.raw_representation == mcp_content
@@ -285,7 +284,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, DataContent)
assert ai_content.type == "data"
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert ai_content.media_type == "application/octet-stream"
assert ai_content.raw_representation == mcp_content
@@ -293,7 +292,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
def test_ai_content_to_mcp_content_types_text():
"""Test conversion of AI text content to MCP content."""
ai_content = TextContent(text="Sample text")
ai_content = Content.from_text(text="Sample text")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.TextContent)
@@ -303,7 +302,7 @@ def test_ai_content_to_mcp_content_types_text():
def test_ai_content_to_mcp_content_types_data_image():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:image/png;base64,xyz", media_type="image/png")
ai_content = Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.ImageContent)
@@ -314,7 +313,7 @@ def test_ai_content_to_mcp_content_types_data_image():
def test_ai_content_to_mcp_content_types_data_audio():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
ai_content = Content.from_uri(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.AudioContent)
@@ -325,7 +324,7 @@ def test_ai_content_to_mcp_content_types_data_audio():
def test_ai_content_to_mcp_content_types_data_binary():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(
ai_content = Content.from_uri(
uri="data:application/octet-stream;base64,xyz",
media_type="application/octet-stream",
)
@@ -339,7 +338,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
def test_ai_content_to_mcp_content_types_uri():
"""Test conversion of AI URI content to MCP content."""
ai_content = UriContent(uri="https://example.com/resource", media_type="application/json")
ai_content = Content.from_uri(uri="https://example.com/resource", media_type="application/json")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.ResourceLink)
@@ -352,8 +351,8 @@ def test_prepare_message_for_mcp():
message = ChatMessage(
role="user",
contents=[
TextContent(text="test"),
DataContent(uri="data:image/png;base64,xyz", media_type="image/png"),
Content.from_text(text="test"),
Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png"),
],
)
mcp_contents = _prepare_message_for_mcp(message)
@@ -871,7 +870,7 @@ async def test_mcp_tool_call_tool_with_meta_integration():
result = await func.invoke(param="test_value")
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Tool executed with metadata"
# Verify that _meta data is present in additional_properties
@@ -920,7 +919,7 @@ async def test_local_mcp_server_function_execution():
result = await func.invoke(param="test_value")
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Tool executed successfully"
@@ -969,7 +968,7 @@ async def test_local_mcp_server_function_execution_with_nested_object():
result = await func.invoke(params={"customer_id": 251})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
# Verify the session.call_tool was called with the correct nested structure
server.session.call_tool.assert_called_once()
@@ -1413,7 +1412,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception():
async def test_mcp_tool_sampling_callback_no_valid_content():
"""Test sampling callback when response has no valid content types."""
from agent_framework import ChatMessage, DataContent, Role
from agent_framework import ChatMessage, Role
tool = MCPStdioTool(name="test_tool", command="python")
@@ -1424,7 +1423,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
ChatMessage(
role=Role.ASSISTANT,
contents=[
DataContent(
Content.from_uri(
uri="data:application/json;base64,e30K",
media_type="application/json",
)
@@ -14,8 +14,8 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Content,
Role,
TextContent,
)
from agent_framework._middleware import (
AgentMiddleware,
@@ -217,8 +217,8 @@ class TestAgentMiddlewarePipeline:
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
updates: list[AgentResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context, final_handler):
@@ -250,8 +250,8 @@ class TestAgentMiddlewarePipeline:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
execution_order.append("handler_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[AgentResponseUpdate] = []
@@ -313,8 +313,8 @@ class TestAgentMiddlewarePipeline:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
# Handler should not be executed when terminated before next()
execution_order.append("handler_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[AgentResponseUpdate] = []
@@ -336,8 +336,8 @@ class TestAgentMiddlewarePipeline:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
execution_order.append("handler_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[AgentResponseUpdate] = []
@@ -609,8 +609,8 @@ class TestChatMiddlewarePipeline:
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
updates: list[ChatResponseUpdate] = []
async for update in pipeline.execute_stream(mock_chat_client, messages, chat_options, context, final_handler):
@@ -641,8 +641,8 @@ class TestChatMiddlewarePipeline:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
execution_order.append("handler_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[ChatResponseUpdate] = []
@@ -706,8 +706,8 @@ class TestChatMiddlewarePipeline:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
# Handler should not be executed when terminated before next()
execution_order.append("handler_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[ChatResponseUpdate] = []
@@ -730,8 +730,8 @@ class TestChatMiddlewarePipeline:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
execution_order.append("handler_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[ChatResponseUpdate] = []
@@ -1264,7 +1264,7 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
streaming_flags.append(ctx.is_streaming)
yield AgentResponseUpdate(contents=[TextContent(text="chunk")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk")])
updates: list[AgentResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context_stream, final_stream_handler):
@@ -1292,9 +1292,9 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
chunks_processed.append("stream_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
chunks_processed.append("chunk1_yielded")
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
chunks_processed.append("chunk2_yielded")
chunks_processed.append("stream_end")
@@ -1342,7 +1342,7 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
streaming_flags.append(ctx.is_streaming)
yield ChatResponseUpdate(contents=[TextContent(text="chunk")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk")])
updates: list[ChatResponseUpdate] = []
async for update in pipeline.execute_stream(
@@ -1371,9 +1371,9 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
chunks_processed.append("stream_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
chunks_processed.append("chunk1_yielded")
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
chunks_processed.append("chunk2_yielded")
chunks_processed.append("stream_end")
@@ -1486,7 +1486,7 @@ class TestMiddlewareExecutionControl:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
nonlocal handler_called
handler_called = True
yield AgentResponseUpdate(contents=[TextContent(text="should not execute")])
yield AgentResponseUpdate(contents=[Content.from_text(text="should not execute")])
# When middleware doesn't call next(), streaming should yield no updates
updates: list[AgentResponseUpdate] = []
@@ -1617,7 +1617,7 @@ class TestMiddlewareExecutionControl:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
nonlocal handler_called
handler_called = True
yield ChatResponseUpdate(contents=[TextContent(text="should not execute")])
yield ChatResponseUpdate(contents=[Content.from_text(text="should not execute")])
# When middleware doesn't call next(), streaming should yield no updates
updates: list[ChatResponseUpdate] = []
@@ -13,8 +13,8 @@ from agent_framework import (
AgentResponseUpdate,
ChatAgent,
ChatMessage,
Content,
Role,
TextContent,
)
from agent_framework._middleware import (
AgentMiddleware,
@@ -75,8 +75,8 @@ class TestResultOverrideMiddleware:
"""Test that agent middleware can override response for streaming execution."""
async def override_stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="overridden")])
yield AgentResponseUpdate(contents=[TextContent(text=" stream")])
yield AgentResponseUpdate(contents=[Content.from_text(text="overridden")])
yield AgentResponseUpdate(contents=[Content.from_text(text=" stream")])
class StreamResponseOverrideMiddleware(AgentMiddleware):
async def process(
@@ -92,7 +92,7 @@ class TestResultOverrideMiddleware:
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="original")])
yield AgentResponseUpdate(contents=[Content.from_text(text="original")])
updates: list[AgentResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context, final_handler):
@@ -175,9 +175,9 @@ class TestResultOverrideMiddleware:
mock_chat_client = MockChatClient()
async def custom_stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="Custom")])
yield AgentResponseUpdate(contents=[TextContent(text=" streaming")])
yield AgentResponseUpdate(contents=[TextContent(text=" response!")])
yield AgentResponseUpdate(contents=[Content.from_text(text="Custom")])
yield AgentResponseUpdate(contents=[Content.from_text(text=" streaming")])
yield AgentResponseUpdate(contents=[Content.from_text(text=" response!")])
class ChatAgentStreamOverrideMiddleware(AgentMiddleware):
async def process(
@@ -13,10 +13,8 @@ from agent_framework import (
ChatMiddleware,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
agent_middleware,
chat_middleware,
function_middleware,
@@ -201,7 +199,9 @@ class TestChatAgentFunctionBasedMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(call_id="test_call", name="test_function", arguments={"text": "test"})
Content.from_function_call(
call_id="test_call", name="test_function", arguments={"text": "test"}
)
],
)
]
@@ -256,7 +256,9 @@ class TestChatAgentFunctionBasedMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(call_id="test_call", name="test_function", arguments={"text": "test"})
Content.from_function_call(
call_id="test_call", name="test_function", arguments={"text": "test"}
)
],
)
]
@@ -365,8 +367,8 @@ class TestChatAgentStreamingMiddleware:
# Set up mock streaming responses
chat_client.streaming_responses = [
[
ChatResponseUpdate(contents=[TextContent(text="Streaming")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[TextContent(text=" response")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text="Streaming")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text=" response")], role=Role.ASSISTANT),
]
]
@@ -550,7 +552,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_123",
name="sample_tool_function",
arguments='{"location": "Seattle"}',
@@ -585,8 +587,8 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -610,7 +612,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_456",
name="sample_tool_function",
arguments='{"location": "San Francisco"}',
@@ -644,8 +646,8 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -682,7 +684,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_789",
name="sample_tool_function",
arguments='{"location": "New York"}',
@@ -723,8 +725,8 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -769,14 +771,16 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call", name="sample_tool_function", arguments={"location": "Seattle"}
)
],
)
]
),
ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Function completed")])]),
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("Function completed")])]
),
]
# Create ChatAgent with function middleware
@@ -1076,8 +1080,8 @@ class TestRunLevelMiddleware:
# Set up mock streaming responses
chat_client.streaming_responses = [
[
ChatResponseUpdate(contents=[TextContent(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[TextContent(text=" response")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text=" response")], role=Role.ASSISTANT),
]
]
@@ -1159,7 +1163,7 @@ class TestRunLevelMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1204,8 +1208,8 @@ class TestRunLevelMiddleware:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -1248,7 +1252,7 @@ class TestMiddlewareDecoratorLogic:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1315,7 +1319,7 @@ class TestMiddlewareDecoratorLogic:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1365,7 +1369,7 @@ class TestMiddlewareDecoratorLogic:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1704,8 +1708,8 @@ class TestChatAgentChatMiddleware:
# Set up mock streaming responses
chat_client.streaming_responses = [
[
ChatResponseUpdate(contents=[TextContent(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[TextContent(text=" response")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text=" response")], role=Role.ASSISTANT),
]
]
@@ -1806,7 +1810,7 @@ class TestChatAgentChatMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_456",
name="sample_tool_function",
arguments='{"location": "San Francisco"}',
@@ -1850,8 +1854,8 @@ class TestChatAgentChatMiddleware:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -9,7 +9,7 @@ from agent_framework import (
ChatMessage,
ChatMiddleware,
ChatResponse,
FunctionCallContent,
Content,
FunctionInvocationContext,
Role,
chat_middleware,
@@ -349,7 +349,7 @@ class TestChatMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_1",
name="sample_tool",
arguments={"location": "San Francisco"},
@@ -405,7 +405,7 @@ class TestChatMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_2",
name="sample_tool",
arguments={"location": "New York"},
+100 -105
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ValidationError
from agent_framework import (
AIFunction,
Content,
HostedCodeInterpreterTool,
HostedImageGenerationTool,
HostedMCPTool,
@@ -639,24 +640,22 @@ def test_parse_inputs_none():
def test_parse_inputs_string():
"""Test _parse_inputs with string input."""
from agent_framework import UriContent
result = _parse_inputs("http://example.com")
assert len(result) == 1
assert isinstance(result[0], UriContent)
assert result[0].type == "uri"
assert result[0].uri == "http://example.com"
assert result[0].media_type == "text/plain"
def test_parse_inputs_list_of_strings():
"""Test _parse_inputs with list of strings."""
from agent_framework import UriContent
inputs = ["http://example.com", "https://test.org"]
result = _parse_inputs(inputs)
assert len(result) == 2
assert all(isinstance(item, UriContent) for item in result)
assert all(item.type == "uri" for item in result)
assert result[0].uri == "http://example.com"
assert result[1].uri == "https://test.org"
assert all(item.media_type == "text/plain" for item in result)
@@ -664,88 +663,84 @@ def test_parse_inputs_list_of_strings():
def test_parse_inputs_uri_dict():
"""Test _parse_inputs with URI dictionary."""
from agent_framework import UriContent
input_dict = {"uri": "http://example.com", "media_type": "application/json"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], UriContent)
assert result[0].type == "uri"
assert result[0].uri == "http://example.com"
assert result[0].media_type == "application/json"
def test_parse_inputs_hosted_file_dict():
"""Test _parse_inputs with hosted file dictionary."""
from agent_framework import HostedFileContent
input_dict = {"file_id": "file-123"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], HostedFileContent)
assert result[0].type == "hosted_file"
assert result[0].file_id == "file-123"
def test_parse_inputs_hosted_vector_store_dict():
"""Test _parse_inputs with hosted vector store dictionary."""
from agent_framework import HostedVectorStoreContent
from agent_framework import Content
input_dict = {"vector_store_id": "vs-789"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], HostedVectorStoreContent)
assert isinstance(result[0], Content)
assert result[0].type == "hosted_vector_store"
assert result[0].vector_store_id == "vs-789"
def test_parse_inputs_data_dict():
"""Test _parse_inputs with data dictionary."""
from agent_framework import DataContent
input_dict = {"data": b"test data", "media_type": "application/octet-stream"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], DataContent)
assert result[0].type == "data"
assert result[0].uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert result[0].media_type == "application/octet-stream"
def test_parse_inputs_ai_contents_instance():
"""Test _parse_inputs with Contents instance."""
from agent_framework import TextContent
"""Test _parse_inputs with Content instance."""
text_content = TextContent(text="Hello, world!")
text_content = Content.from_text(text="Hello, world!")
result = _parse_inputs(text_content)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Hello, world!"
def test_parse_inputs_mixed_list():
"""Test _parse_inputs with mixed input types."""
from agent_framework import HostedFileContent, TextContent, UriContent
inputs = [
"http://example.com", # string
{"uri": "https://test.org", "media_type": "text/html"}, # URI dict
{"file_id": "file-456"}, # hosted file dict
TextContent(text="Hello"), # Contents instance
Content.from_text(text="Hello"), # Content instance
]
result = _parse_inputs(inputs)
assert len(result) == 4
assert isinstance(result[0], UriContent)
assert result[0].type == "uri"
assert result[0].uri == "http://example.com"
assert isinstance(result[1], UriContent)
assert result[1].type == "uri"
assert result[1].uri == "https://test.org"
assert result[1].media_type == "text/html"
assert isinstance(result[2], HostedFileContent)
assert result[2].type == "hosted_file"
assert result[2].file_id == "file-456"
assert isinstance(result[3], TextContent)
assert result[3].type == "text"
assert result[3].text == "Hello"
@@ -765,55 +760,51 @@ def test_parse_inputs_unsupported_type():
def test_hosted_code_interpreter_tool_with_string_input():
"""Test HostedCodeInterpreterTool with string input."""
from agent_framework import UriContent
tool = HostedCodeInterpreterTool(inputs="http://example.com")
assert len(tool.inputs) == 1
assert isinstance(tool.inputs[0], UriContent)
assert tool.inputs[0].type == "uri"
assert tool.inputs[0].uri == "http://example.com"
def test_hosted_code_interpreter_tool_with_dict_inputs():
"""Test HostedCodeInterpreterTool with dictionary inputs."""
from agent_framework import HostedFileContent, UriContent
inputs = [{"uri": "http://example.com", "media_type": "text/html"}, {"file_id": "file-123"}]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert isinstance(tool.inputs[0], UriContent)
assert tool.inputs[0].type == "uri"
assert tool.inputs[0].uri == "http://example.com"
assert tool.inputs[0].media_type == "text/html"
assert isinstance(tool.inputs[1], HostedFileContent)
assert tool.inputs[1].type == "hosted_file"
assert tool.inputs[1].file_id == "file-123"
def test_hosted_code_interpreter_tool_with_ai_contents():
"""Test HostedCodeInterpreterTool with Contents instances."""
from agent_framework import DataContent, TextContent
"""Test HostedCodeInterpreterTool with Content instances."""
inputs = [TextContent(text="Hello, world!"), DataContent(data=b"test", media_type="text/plain")]
inputs = [Content.from_text(text="Hello, world!"), Content.from_data(data=b"test", media_type="text/plain")]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert isinstance(tool.inputs[0], TextContent)
assert tool.inputs[0].type == "text"
assert tool.inputs[0].text == "Hello, world!"
assert isinstance(tool.inputs[1], DataContent)
assert tool.inputs[1].type == "data"
assert tool.inputs[1].media_type == "text/plain"
def test_hosted_code_interpreter_tool_with_single_input():
"""Test HostedCodeInterpreterTool with single input (not in list)."""
from agent_framework import HostedFileContent
input_dict = {"file_id": "file-single"}
tool = HostedCodeInterpreterTool(inputs=input_dict)
assert len(tool.inputs) == 1
assert isinstance(tool.inputs[0], HostedFileContent)
assert tool.inputs[0].type == "hosted_file"
assert tool.inputs[0].file_id == "file-single"
@@ -983,7 +974,7 @@ def mock_chat_client():
yield ChatResponseUpdate(contents=[content], role=msg.role)
else:
# Default response
yield ChatResponseUpdate(contents=["Default response"], role="assistant")
yield ChatResponseUpdate(text="Default response", role="assistant")
return MockChatClient()
@@ -1006,7 +997,7 @@ def requires_approval_tool(x: int) -> int:
async def test_non_streaming_single_function_no_approval():
"""Test non-streaming handler with single function call that doesn't require approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
# Create mock client
@@ -1017,11 +1008,11 @@ async def test_non_streaming_single_function_no_approval():
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
contents=[Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
)
]
)
final_response = ChatResponse(messages=[ChatMessage(role="assistant", contents=["The result is 10"])])
final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="The result is 10")])
call_count = [0]
responses = [initial_response, final_response]
@@ -1039,17 +1030,16 @@ async def test_non_streaming_single_function_no_approval():
# Verify: should have 3 messages: function call, function result, final answer
assert len(result.messages) == 3
assert isinstance(result.messages[0].contents[0], FunctionCallContent)
from agent_framework import FunctionResultContent
assert result.messages[0].contents[0].type == "function_call"
assert isinstance(result.messages[1].contents[0], FunctionResultContent)
assert result.messages[1].contents[0].type == "function_result"
assert result.messages[1].contents[0].result == 10 # 5 * 2
assert result.messages[2].contents[0] == "The result is 10"
assert result.messages[2].text == "The result is 10"
async def test_non_streaming_single_function_requires_approval():
"""Test non-streaming handler with single function call that requires approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1059,7 +1049,9 @@ async def test_non_streaming_single_function_requires_approval():
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
contents=[
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')
],
)
]
)
@@ -1078,18 +1070,17 @@ async def test_non_streaming_single_function_requires_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]})
# Verify: should return 1 message with function call and approval request
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 1
assert len(result.messages[0].contents) == 2
assert isinstance(result.messages[0].contents[0], FunctionCallContent)
assert isinstance(result.messages[0].contents[1], FunctionApprovalRequestContent)
assert result.messages[0].contents[0].type == "function_call"
assert result.messages[0].contents[1].type == "function_approval_request"
assert result.messages[0].contents[1].function_call.name == "requires_approval_tool"
async def test_non_streaming_two_functions_both_no_approval():
"""Test non-streaming handler with two function calls, neither requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1100,15 +1091,13 @@ async def test_non_streaming_two_functions_both_no_approval():
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
],
)
]
)
final_response = ChatResponse(
messages=[ChatMessage(role="assistant", contents=["Both tools executed successfully"])]
)
final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Both tools executed successfully")])
call_count = [0]
responses = [initial_response, final_response]
@@ -1124,21 +1113,20 @@ async def test_non_streaming_two_functions_both_no_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool]})
# Verify: should have function calls, results, and final answer
from agent_framework import FunctionResultContent
assert len(result.messages) == 3
# First message has both function calls
assert len(result.messages[0].contents) == 2
# Second message has both results
assert len(result.messages[1].contents) == 2
assert all(isinstance(c, FunctionResultContent) for c in result.messages[1].contents)
assert all(c.type == "function_result" for c in result.messages[1].contents)
assert result.messages[1].contents[0].result == 10 # 5 * 2
assert result.messages[1].contents[1].result == 6 # 3 * 2
async def test_non_streaming_two_functions_both_require_approval():
"""Test non-streaming handler with two function calls, both requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1149,8 +1137,8 @@ async def test_non_streaming_two_functions_both_require_approval():
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
],
)
]
@@ -1170,12 +1158,11 @@ async def test_non_streaming_two_functions_both_require_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]})
# Verify: should return 1 message with function calls and approval requests
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 1
assert len(result.messages[0].contents) == 4 # 2 function calls + 2 approval requests
function_calls = [c for c in result.messages[0].contents if isinstance(c, FunctionCallContent)]
approval_requests = [c for c in result.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)]
function_calls = [c for c in result.messages[0].contents if c.type == "function_call"]
approval_requests = [c for c in result.messages[0].contents if c.type == "function_approval_request"]
assert len(function_calls) == 2
assert len(approval_requests) == 2
assert approval_requests[0].function_call.name == "requires_approval_tool"
@@ -1184,7 +1171,7 @@ async def test_non_streaming_two_functions_both_require_approval():
async def test_non_streaming_two_functions_mixed_approval():
"""Test non-streaming handler with two function calls, one requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1195,8 +1182,8 @@ async def test_non_streaming_two_functions_mixed_approval():
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
],
)
]
@@ -1216,17 +1203,16 @@ async def test_non_streaming_two_functions_mixed_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool, requires_approval_tool]})
# Verify: should return approval requests for both (when one needs approval, all are sent for approval)
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 1
assert len(result.messages[0].contents) == 4 # 2 function calls + 2 approval requests
approval_requests = [c for c in result.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)]
approval_requests = [c for c in result.messages[0].contents if c.type == "function_approval_request"]
assert len(approval_requests) == 2
async def test_streaming_single_function_no_approval():
"""Test streaming handler with single function call that doesn't require approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1234,11 +1220,11 @@ async def test_streaming_single_function_no_approval():
# Initial response with function call, then final response after function execution
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
contents=[Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
)
]
final_updates = [ChatResponseUpdate(contents=["The result is 10"], role="assistant")]
final_updates = [ChatResponseUpdate(text="The result is 10", role="assistant")]
call_count = [0]
updates_list = [initial_updates, final_updates]
@@ -1257,22 +1243,23 @@ async def test_streaming_single_function_no_approval():
updates.append(update)
# Verify: should have function call update, tool result update (injected), and final update
from agent_framework import FunctionResultContent, Role
from agent_framework import Role
assert len(updates) >= 3
# First update is the function call
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
# Second update should be the tool result (injected by the wrapper)
assert updates[1].role == Role.TOOL
assert isinstance(updates[1].contents[0], FunctionResultContent)
assert updates[1].contents[0].type == "function_result"
assert updates[1].contents[0].result == 10 # 5 * 2
# Last update is the final message
assert updates[-1].contents[0] == "The result is 10"
assert updates[-1].contents[0].type == "text"
assert updates[-1].contents[0].text == "The result is 10"
async def test_streaming_single_function_requires_approval():
"""Test streaming handler with single function call that requires approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1280,7 +1267,9 @@ async def test_streaming_single_function_requires_approval():
# Initial response with function call
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
contents=[
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')
],
role="assistant",
)
]
@@ -1302,17 +1291,17 @@ async def test_streaming_single_function_requires_approval():
updates.append(update)
# Verify: should yield function call and then approval request
from agent_framework import FunctionApprovalRequestContent, Role
from agent_framework import Role
assert len(updates) == 2
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
assert updates[1].role == Role.ASSISTANT
assert isinstance(updates[1].contents[0], FunctionApprovalRequestContent)
assert updates[1].contents[0].type == "function_approval_request"
async def test_streaming_two_functions_both_no_approval():
"""Test streaming handler with two function calls, neither requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1320,15 +1309,14 @@ async def test_streaming_two_functions_both_no_approval():
# Initial response with two function calls to the same tool
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}')],
contents=[
Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
],
role="assistant",
),
]
final_updates = [ChatResponseUpdate(contents=["Both tools executed successfully"], role="assistant")]
final_updates = [ChatResponseUpdate(text="Both tools executed successfully", role="assistant")]
call_count = [0]
updates_list = [initial_updates, final_updates]
@@ -1347,22 +1335,23 @@ async def test_streaming_two_functions_both_no_approval():
updates.append(update)
# Verify: should have both function calls, one tool result update with both results, and final message
from agent_framework import FunctionResultContent, Role
from agent_framework import Role
assert len(updates) >= 3
# First two updates are function calls
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
assert len(updates) >= 2
# First update has both function calls
assert len(updates[0].contents) == 2
assert updates[0].contents[0].type == "function_call"
assert updates[0].contents[1].type == "function_call"
# Should have a tool result update with both results
tool_updates = [u for u in updates if u.role == Role.TOOL]
assert len(tool_updates) == 1
assert len(tool_updates[0].contents) == 2
assert all(isinstance(c, FunctionResultContent) for c in tool_updates[0].contents)
assert all(c.type == "function_result" for c in tool_updates[0].contents)
async def test_streaming_two_functions_both_require_approval():
"""Test streaming handler with two function calls, both requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1370,11 +1359,15 @@ async def test_streaming_two_functions_both_require_approval():
# Initial response with two function calls to the same tool
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
contents=[
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')
],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')],
contents=[
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')
],
role="assistant",
),
]
@@ -1396,20 +1389,20 @@ async def test_streaming_two_functions_both_require_approval():
updates.append(update)
# Verify: should yield both function calls and then approval requests
from agent_framework import FunctionApprovalRequestContent, Role
from agent_framework import Role
assert len(updates) == 3
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
assert updates[1].contents[0].type == "function_call"
# Assistant update with both approval requests
assert updates[2].role == Role.ASSISTANT
assert len(updates[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents)
assert all(c.type == "function_approval_request" for c in updates[2].contents)
async def test_streaming_two_functions_mixed_approval():
"""Test streaming handler with two function calls, one requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1417,11 +1410,13 @@ async def test_streaming_two_functions_mixed_approval():
# Initial response with two function calls
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
contents=[Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')],
contents=[
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')
],
role="assistant",
),
]
@@ -1445,15 +1440,15 @@ async def test_streaming_two_functions_mixed_approval():
updates.append(update)
# Verify: should yield both function calls and then approval requests (when one needs approval, all wait)
from agent_framework import FunctionApprovalRequestContent, Role
from agent_framework import Role
assert len(updates) == 3
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
assert updates[1].contents[0].type == "function_call"
# Assistant update with both approval requests
assert updates[2].role == Role.ASSISTANT
assert len(updates[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents)
assert all(c.type == "function_approval_request" for c in updates[2].contents)
async def test_ai_function_with_kwargs_injection():
File diff suppressed because it is too large Load Diff
@@ -19,15 +19,10 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedVectorStoreContent,
Role,
TextContent,
UriContent,
UsageContent,
ai_function,
)
from agent_framework.exceptions import ServiceInitializationError
@@ -68,7 +63,7 @@ def create_test_openai_assistants_client(
return client
async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, HostedVectorStoreContent]:
async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 25C."), purpose="user_data"
@@ -81,7 +76,7 @@ async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, Host
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: OpenAIAssistantsClient, file_id: str, vector_store_id: str) -> None:
@@ -464,7 +459,7 @@ async def test_process_stream_events_requires_action(mock_async_openai: MagicMoc
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Mock the _parse_function_calls_from_assistants method to return test content
test_function_content = FunctionCallContent(call_id="call-123", name="test_func", arguments={"arg": "value"})
test_function_content = Content.from_function_call(call_id="call-123", name="test_func", arguments={"arg": "value"})
chat_client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore
# Create a mock Run object
@@ -578,10 +573,10 @@ async def test_process_stream_events_run_completed_with_usage(
# Check the usage content
usage_content = update.contents[0]
assert isinstance(usage_content, UsageContent)
assert usage_content.details.input_token_count == 100
assert usage_content.details.output_token_count == 50
assert usage_content.details.total_token_count == 150
assert usage_content.type == "usage"
assert usage_content.usage_details["input_token_count"] == 100
assert usage_content.usage_details["output_token_count"] == 50
assert usage_content.usage_details["total_token_count"] == 150
assert update.raw_representation == mock_run
@@ -609,7 +604,7 @@ def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock
# Test that one function call content was created
assert len(contents) == 1
assert isinstance(contents[0], FunctionCallContent)
assert contents[0].type == "function_call"
assert contents[0].name == "get_weather"
assert contents[0].arguments == {"location": "Seattle"}
@@ -830,7 +825,7 @@ def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> Non
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Create message with image content
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg")
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
# Call the method
@@ -861,7 +856,7 @@ def test_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock)
chat_client = create_test_openai_assistants_client(mock_async_openai)
call_id = json.dumps(["run-123", "call-456"])
function_result = FunctionResultContent(call_id=call_id, result="Function executed successfully")
function_result = Content.from_function_result(call_id=call_id, result="Function executed successfully")
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore
@@ -881,8 +876,8 @@ def test_prepare_tool_outputs_for_assistants_mismatched_run_ids(
# Create function results with different run IDs
call_id1 = json.dumps(["run-123", "call-456"])
call_id2 = json.dumps(["run-789", "call-xyz"]) # Different run ID
function_result1 = FunctionResultContent(call_id=call_id1, result="Result 1")
function_result2 = FunctionResultContent(call_id=call_id2, result="Result 2")
function_result1 = Content.from_function_result(call_id=call_id1, result="Result 1")
function_result2 = Content.from_function_result(call_id=call_id2, result="Result 2")
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore
@@ -1006,7 +1001,7 @@ async def test_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
@@ -1035,7 +1030,7 @@ async def test_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
@@ -1121,7 +1116,7 @@ async def test_file_search_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
await delete_vector_store(openai_assistants_client, file_id, vector_store.vector_store_id)
@@ -14,8 +14,7 @@ from agent_framework import (
ChatClientProtocol,
ChatMessage,
ChatResponse,
DataContent,
FunctionResultContent,
Content,
HostedWebSearchTool,
ToolProtocol,
ai_function,
@@ -282,7 +281,9 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
client = OpenAIChatClient()
# Test with empty list (falsy but not None)
message_with_empty_list = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-123", result=[])])
message_with_empty_list = ChatMessage(
role="tool", contents=[Content.from_function_result(call_id="call-123", result=[])]
)
openai_messages = client._prepare_message_for_openai(message_with_empty_list)
assert len(openai_messages) == 1
@@ -290,7 +291,7 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
# Test with empty string (falsy but not None)
message_with_empty_string = ChatMessage(
role="tool", contents=[FunctionResultContent(call_id="call-456", result="")]
role="tool", contents=[Content.from_function_result(call_id="call-456", result="")]
)
openai_messages = client._prepare_message_for_openai(message_with_empty_string)
@@ -298,7 +299,9 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
assert openai_messages[0]["content"] == "" # Empty string should be preserved
# Test with False (falsy but not None)
message_with_false = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-789", result=False)])
message_with_false = ChatMessage(
role="tool", contents=[Content.from_function_result(call_id="call-789", result=False)]
)
openai_messages = client._prepare_message_for_openai(message_with_false)
assert len(openai_messages) == 1
@@ -317,7 +320,7 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
message_with_exception = ChatMessage(
role="tool",
contents=[
FunctionResultContent(call_id="call-123", result="Error: Function failed.", exception=test_exception)
Content.from_function_result(call_id="call-123", result="Error: Function failed.", exception=test_exception)
],
)
@@ -339,7 +342,7 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
client = OpenAIChatClient()
# Test DataContent with image media type
image_data_content = DataContent(
image_data_content = Content.from_uri(
uri="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
media_type="image/png",
)
@@ -351,7 +354,7 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
assert result["image_url"]["url"] == image_data_content.uri
# Test DataContent with non-image media type should use default model_dump
text_data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
text_data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
result = client._prepare_content_for_openai(text_data_content) # type: ignore
@@ -361,7 +364,7 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
assert result["media_type"] == "text/plain"
# Test DataContent with audio media type
audio_data_content = DataContent(
audio_data_content = Content.from_uri(
uri="data:audio/wav;base64,UklGRjBEAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQwEAAAAAAAAAAAA",
media_type="audio/wav",
)
@@ -375,7 +378,9 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
assert result["input_audio"]["format"] == "wav"
# Test DataContent with MP3 audio
mp3_data_content = DataContent(uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3")
mp3_data_content = Content.from_uri(
uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3"
)
result = client._prepare_content_for_openai(mp3_data_content) # type: ignore
@@ -391,7 +396,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
client = OpenAIChatClient()
# Test PDF without filename - should omit filename in OpenAI payload
pdf_data_content = DataContent(
pdf_data_content = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXS9QYXJlbnQgMiAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDQgMCBSPj4+Pi9Db250ZW50cyA1IDAgUj4+CmVuZG9iago0IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1R5cGUxL0Jhc2VGb250L0hlbHZldGljYT4+CmVuZG9iago1IDAgb2JqCjw8L0xlbmd0aCA0ND4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSA4IFRmCihIZWxsbyBXb3JsZCEpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQ1IDAwMDAwIG4gCjAwMDAwMDAzMDcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDYvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgo0MDUKJSVFT0Y=",
media_type="application/pdf",
)
@@ -407,7 +412,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["file"]["file_data"] == pdf_data_content.uri
# Test PDF with custom filename via additional_properties
pdf_with_filename = DataContent(
pdf_with_filename = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={"filename": "report.pdf"},
@@ -441,7 +446,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
for case in test_cases:
# Test without filename
doc_content = DataContent(
doc_content = Content.from_uri(
uri=f"data:{case['media_type']};base64,{case['base64']}",
media_type=case["media_type"],
)
@@ -454,7 +459,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["file"]["file_data"] == doc_content.uri
# Test with filename - should now use file format with filename
doc_with_filename = DataContent(
doc_with_filename = Content.from_uri(
uri=f"data:{case['media_type']};base64,{case['base64']}",
media_type=case["media_type"],
additional_properties={"filename": case["filename"]},
@@ -468,7 +473,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["file"]["file_data"] == doc_with_filename.uri
# Test edge case: empty additional_properties dict
pdf_empty_props = DataContent(
pdf_empty_props = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={},
@@ -480,7 +485,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert "filename" not in result["file"]
# Test edge case: None filename in additional_properties
pdf_none_filename = DataContent(
pdf_none_filename = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={"filename": None},
@@ -33,26 +33,13 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
Role,
TextContent,
TextReasoningContent,
UriContent,
ai_function,
)
from agent_framework.exceptions import (
@@ -81,7 +68,7 @@ class OutputStruct(BaseModel):
async def create_vector_store(
client: OpenAIResponsesClient,
) -> tuple[str, HostedVectorStoreContent]:
) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."),
@@ -99,7 +86,7 @@ async def create_vector_store(
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
@@ -285,7 +272,7 @@ def test_file_search_tool_with_invalid_inputs() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test with invalid inputs type (should trigger ValueError)
file_search_tool = HostedFileSearchTool(inputs=[HostedFileContent(file_id="invalid")])
file_search_tool = HostedFileSearchTool(inputs=[Content.from_hosted_file(file_id="invalid")])
# Should raise an error due to invalid inputs
with pytest.raises(ValueError, match="HostedFileSearchTool requires inputs to be of type"):
@@ -314,7 +301,7 @@ def test_code_interpreter_tool_variations() -> None:
# Test code interpreter with files
code_tool_with_files = HostedCodeInterpreterTool(
inputs=[HostedFileContent(file_id="file1"), HostedFileContent(file_id="file2")]
inputs=[Content.from_hosted_file(file_id="file1"), Content.from_hosted_file(file_id="file2")]
)
with pytest.raises(ServiceResponseException):
@@ -367,14 +354,14 @@ def test_chat_message_parsing_with_function_calls() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Create messages with function call and result content
function_call = FunctionCallContent(
function_call = Content.from_function_call(
call_id="test-call-id",
name="test_function",
arguments='{"param": "value"}',
additional_properties={"fc_id": "test-fc-id"},
)
function_result = FunctionResultContent(call_id="test-call-id", result="Function executed successfully")
function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully")
messages = [
ChatMessage(role="user", text="Call a function"),
@@ -516,7 +503,7 @@ def test_response_content_creation_with_annotations() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) >= 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "Text with annotations."
assert response.messages[0].contents[0].annotations is not None
@@ -547,7 +534,7 @@ def test_response_content_creation_with_refusal() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "I cannot provide that information."
@@ -577,7 +564,7 @@ def test_response_content_creation_with_reasoning() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) == 2
assert isinstance(response.messages[0].contents[0], TextReasoningContent)
assert response.messages[0].contents[0].type == "text_reasoning"
assert response.messages[0].contents[0].text == "Reasoning step"
@@ -614,13 +601,13 @@ def test_response_content_creation_with_code_interpreter() -> None:
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, CodeInterpreterToolCallContent)
assert call_content.type == "code_interpreter_tool_call"
assert call_content.inputs is not None
assert isinstance(call_content.inputs[0], TextContent)
assert isinstance(result_content, CodeInterpreterToolResultContent)
assert call_content.inputs[0].type == "text"
assert result_content.type == "code_interpreter_tool_result"
assert result_content.outputs is not None
assert any(isinstance(out, TextContent) for out in result_content.outputs)
assert any(isinstance(out, UriContent) for out in result_content.outputs)
assert any(out.type == "text" for out in result_content.outputs)
assert any(out.type == "uri" for out in result_content.outputs)
def test_response_content_creation_with_function_call() -> None:
@@ -648,7 +635,7 @@ def test_response_content_creation_with_function_call() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].type == "function_call"
function_call = response.messages[0].contents[0]
assert function_call.call_id == "call_123"
assert function_call.name == "get_weather"
@@ -708,7 +695,7 @@ def test_parse_response_from_openai_with_mcp_approval_request() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert isinstance(response.messages[0].contents[0], FunctionApprovalRequestContent)
assert response.messages[0].contents[0].type == "function_approval_request"
req = response.messages[0].contents[0]
assert req.id == "approval-1"
assert req.function_call.name == "do_sensitive_action"
@@ -874,8 +861,8 @@ def test_parse_chunk_from_openai_with_mcp_approval_request() -> None:
mock_event.item = mock_item
update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
assert any(isinstance(c, FunctionApprovalRequestContent) for c in update.contents)
fa = next(c for c in update.contents if isinstance(c, FunctionApprovalRequestContent))
assert any(c.type == "function_approval_request" for c in update.contents)
fa = next(c for c in update.contents if c.type == "function_approval_request")
assert fa.id == "approval-stream-1"
assert fa.function_call.name == "do_stream_action"
@@ -925,12 +912,12 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create:
# First call: get the approval request
response = await client.get_response(messages=[ChatMessage(role="user", text="Trigger approval")])
assert isinstance(response.messages[0].contents[0], FunctionApprovalRequestContent)
assert response.messages[0].contents[0].type == "function_approval_request"
req = response.messages[0].contents[0]
assert req.id == "approval-1"
# Build a user approval and send it (include required function_call)
approval = FunctionApprovalResponseContent(approved=True, id=req.id, function_call=req.function_call)
approval = Content.from_function_approval_response(approved=True, id=req.id, function_call=req.function_call)
approval_message = ChatMessage(role="user", contents=[approval])
_ = await client.get_response(messages=[approval_message])
@@ -961,9 +948,9 @@ def test_usage_details_basic() -> None:
details = client._parse_usage_from_openai(mock_usage) # type: ignore
assert details is not None
assert details.input_token_count == 100
assert details.output_token_count == 50
assert details.total_token_count == 150
assert details["input_token_count"] == 100
assert details["output_token_count"] == 50
assert details["total_token_count"] == 150
def test_usage_details_with_cached_tokens() -> None:
@@ -980,8 +967,8 @@ def test_usage_details_with_cached_tokens() -> None:
details = client._parse_usage_from_openai(mock_usage) # type: ignore
assert details is not None
assert details.input_token_count == 200
assert details.additional_counts["openai.cached_input_tokens"] == 25
assert details["input_token_count"] == 200
assert details["openai.cached_input_tokens"] == 25
def test_usage_details_with_reasoning_tokens() -> None:
@@ -998,8 +985,8 @@ def test_usage_details_with_reasoning_tokens() -> None:
details = client._parse_usage_from_openai(mock_usage) # type: ignore
assert details is not None
assert details.output_token_count == 80
assert details.additional_counts["openai.reasoning_tokens"] == 30
assert details["output_token_count"] == 80
assert details["openai.reasoning_tokens"] == 30
def test_get_metadata_from_response() -> None:
@@ -1098,7 +1085,7 @@ def test_streaming_annotation_added_with_file_path() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert isinstance(content, HostedFileContent)
assert content.type == "hosted_file"
assert content.file_id == "file-abc123"
assert content.additional_properties is not None
assert content.additional_properties.get("annotation_index") == 0
@@ -1125,7 +1112,7 @@ def test_streaming_annotation_added_with_file_citation() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert isinstance(content, HostedFileContent)
assert content.type == "hosted_file"
assert content.file_id == "file-xyz789"
assert content.additional_properties is not None
assert content.additional_properties.get("filename") == "sample.txt"
@@ -1154,7 +1141,7 @@ def test_streaming_annotation_added_with_container_file_citation() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert isinstance(content, HostedFileContent)
assert content.type == "hosted_file"
assert content.file_id == "file-container123"
assert content.additional_properties is not None
assert content.additional_properties.get("container_id") == "container-456"
@@ -1228,7 +1215,7 @@ def test_prepare_content_for_openai_image_content() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test image content with detail parameter and file_id
image_content_with_detail = UriContent(
image_content_with_detail = Content.from_uri(
uri="https://example.com/image.jpg",
media_type="image/jpeg",
additional_properties={"detail": "high", "file_id": "file_123"},
@@ -1240,7 +1227,7 @@ def test_prepare_content_for_openai_image_content() -> None:
assert result["file_id"] == "file_123"
# Test image content without additional properties (defaults)
image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png")
image_content_basic = Content.from_uri(uri="https://example.com/basic.png", media_type="image/png")
result = client._prepare_content_for_openai(Role.USER, image_content_basic, {}) # type: ignore
assert result["type"] == "input_image"
assert result["detail"] == "auto"
@@ -1252,14 +1239,14 @@ def test_prepare_content_for_openai_audio_content() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test WAV audio content
wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
wav_content = Content.from_uri(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
result = client._prepare_content_for_openai(Role.USER, wav_content, {}) # type: ignore
assert result["type"] == "input_audio"
assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123"
assert result["input_audio"]["format"] == "wav"
# Test MP3 audio content
mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3")
mp3_content = Content.from_uri(uri="data:audio/mp3;base64,def456", media_type="audio/mp3")
result = client._prepare_content_for_openai(Role.USER, mp3_content, {}) # type: ignore
assert result["type"] == "input_audio"
assert result["input_audio"]["format"] == "mp3"
@@ -1270,12 +1257,12 @@ def test_prepare_content_for_openai_unsupported_content() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test unsupported audio format
unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
unsupported_audio = Content.from_uri(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
result = client._prepare_content_for_openai(Role.USER, unsupported_audio, {}) # type: ignore
assert result == {}
# Test non-media content
text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain")
text_uri_content = Content.from_uri(uri="https://example.com/document.txt", media_type="text/plain")
result = client._prepare_content_for_openai(Role.USER, text_uri_content, {}) # type: ignore
assert result == {}
@@ -1299,11 +1286,9 @@ def test_parse_chunk_from_openai_code_interpreter() -> None:
result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) # type: ignore
assert len(result.contents) == 1
assert isinstance(result.contents[0], CodeInterpreterToolResultContent)
assert result.contents[0].type == "code_interpreter_tool_result"
assert result.contents[0].outputs
assert any(
isinstance(out, UriContent) and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs
)
assert any(out.type == "uri" and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs)
def test_parse_chunk_from_openai_reasoning() -> None:
@@ -1324,7 +1309,7 @@ def test_parse_chunk_from_openai_reasoning() -> None:
result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids) # type: ignore
assert len(result.contents) == 1
assert isinstance(result.contents[0], TextReasoningContent)
assert result.contents[0].type == "text_reasoning"
assert result.contents[0].text == "Analyzing the problem step by step..."
if result.contents[0].additional_properties:
assert result.contents[0].additional_properties["summary"] == "Problem analysis summary"
@@ -1335,7 +1320,7 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test TextReasoningContent with all additional properties
comprehensive_reasoning = TextReasoningContent(
comprehensive_reasoning = Content.from_text_reasoning(
text="Comprehensive reasoning summary",
additional_properties={
"status": "in_progress",
@@ -1371,7 +1356,7 @@ def test_streaming_reasoning_text_delta_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "reasoning delta"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1396,7 +1381,7 @@ def test_streaming_reasoning_text_done_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "complete reasoning"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1422,7 +1407,7 @@ def test_streaming_reasoning_summary_text_delta_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "summary delta"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1447,7 +1432,7 @@ def test_streaming_reasoning_summary_text_done_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "complete summary"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1488,8 +1473,8 @@ def test_streaming_reasoning_events_preserve_metadata() -> None:
assert reasoning_response.additional_properties == {"test": "metadata"}
# Content types should be different
assert isinstance(text_response.contents[0], TextContent)
assert isinstance(reasoning_response.contents[0], TextReasoningContent)
assert text_response.contents[0].type == "text"
assert reasoning_response.contents[0].type == "text_reasoning"
def test_parse_response_from_openai_image_generation_raw_base64():
@@ -1521,11 +1506,11 @@ def test_parse_response_from_openai_image_generation_raw_base64():
# Verify the response contains call + result with DataContent output
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, ImageGenerationToolCallContent)
assert isinstance(result_content, ImageGenerationToolResultContent)
assert call_content.type == "image_generation_tool_call"
assert result_content.type == "image_generation_tool_result"
assert result_content.outputs
data_out = result_content.outputs
assert isinstance(data_out, DataContent)
assert data_out.type == "data"
assert data_out.uri.startswith("data:image/png;base64,")
assert data_out.media_type == "image/png"
@@ -1558,11 +1543,11 @@ def test_parse_response_from_openai_image_generation_existing_data_uri():
# Verify the response contains call + result with DataContent output
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, ImageGenerationToolCallContent)
assert isinstance(result_content, ImageGenerationToolResultContent)
assert call_content.type == "image_generation_tool_call"
assert result_content.type == "image_generation_tool_result"
assert result_content.outputs
data_out = result_content.outputs
assert isinstance(data_out, DataContent)
assert data_out.type == "data"
assert data_out.uri == f"data:image/webp;base64,{valid_webp_base64}"
assert data_out.media_type == "image/webp"
@@ -1591,9 +1576,9 @@ def test_parse_response_from_openai_image_generation_format_detection():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response_jpeg = client._parse_response_from_openai(mock_response_jpeg, options={}) # type: ignore
result_contents = response_jpeg.messages[0].contents
assert isinstance(result_contents[1], ImageGenerationToolResultContent)
assert result_contents[1].type == "image_generation_tool_result"
outputs = result_contents[1].outputs
assert outputs and isinstance(outputs, DataContent)
assert outputs and outputs.type == "data"
assert outputs.media_type == "image/jpeg"
assert "data:image/jpeg;base64," in outputs.uri
@@ -1617,7 +1602,7 @@ def test_parse_response_from_openai_image_generation_format_detection():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response_webp = client._parse_response_from_openai(mock_response_webp, options={}) # type: ignore
outputs_webp = response_webp.messages[0].contents[1].outputs
assert outputs_webp and isinstance(outputs_webp, DataContent)
assert outputs_webp and outputs_webp.type == "data"
assert outputs_webp.media_type == "image/webp"
assert "data:image/webp;base64," in outputs_webp.uri
@@ -1650,7 +1635,7 @@ def test_parse_response_from_openai_image_generation_fallback():
# Verify it falls back to PNG format for unrecognized binary data
assert len(response.messages[0].contents) == 2
result_content = response.messages[0].contents[1]
assert isinstance(result_content, ImageGenerationToolResultContent)
assert result_content.type == "image_generation_tool_result"
assert result_content.outputs
content = result_content.outputs
assert content.media_type == "image/png"
@@ -1944,7 +1929,7 @@ async def test_integration_streaming_file_search() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
@@ -5,7 +5,7 @@
import json
from datetime import datetime
from agent_framework._types import FunctionResultContent
from agent_framework import Content
from agent_framework.observability import _to_otel_part
@@ -14,7 +14,7 @@ def test_datetime_in_tool_results() -> None:
Reproduces issue #2219 where datetime objects caused TypeError.
"""
content = FunctionResultContent(
content = Content.from_function_result(
call_id="test-call",
result={"timestamp": datetime(2025, 11, 16, 10, 30, 0)},
)
@@ -11,9 +11,9 @@ from agent_framework import (
BaseAgent,
ChatMessage,
ChatMessageStore,
Content,
Role,
SequentialBuilder,
TextContent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
@@ -49,7 +49,7 @@ class _CountingAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
self.call_count += 1
yield AgentResponseUpdate(contents=[TextContent(text=f"Response #{self.call_count}: {self.name}")])
yield AgentResponseUpdate(contents=[Content.from_text(text=f"Response #{self.call_count}: {self.name}")])
async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
@@ -19,12 +19,9 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
Content,
RequestInfoEvent,
Role,
TextContent,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
@@ -60,14 +57,14 @@ class _ToolCallingAgent(BaseAgent):
"""Simulate streaming with tool calls and results."""
# First update: some text
yield AgentResponseUpdate(
contents=[TextContent(text="Let me search for that...")],
contents=[Content.from_text(text="Let me search for that...")],
role=Role.ASSISTANT,
)
# Second update: tool call (no text!)
yield AgentResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_123",
name="search",
arguments={"query": "weather"},
@@ -79,7 +76,7 @@ class _ToolCallingAgent(BaseAgent):
# Third update: tool result (no text!)
yield AgentResponseUpdate(
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call_123",
result={"temperature": 72, "condition": "sunny"},
)
@@ -89,7 +86,7 @@ class _ToolCallingAgent(BaseAgent):
# Fourth update: final text response
yield AgentResponseUpdate(
contents=[TextContent(text="The weather is sunny, 72°F.")],
contents=[Content.from_text(text="The weather is sunny, 72°F.")],
role=Role.ASSISTANT,
)
@@ -113,25 +110,25 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
# First event: text update
assert events[0].data is not None
assert isinstance(events[0].data.contents[0], TextContent)
assert events[0].data.contents[0].type == "text"
assert "Let me search" in events[0].data.contents[0].text
# Second event: function call
assert events[1].data is not None
assert isinstance(events[1].data.contents[0], FunctionCallContent)
assert events[1].data.contents[0].type == "function_call"
func_call = events[1].data.contents[0]
assert func_call.call_id == "call_123"
assert func_call.name == "search"
# Third event: function result
assert events[2].data is not None
assert isinstance(events[2].data.contents[0], FunctionResultContent)
assert events[2].data.contents[0].type == "function_result"
func_result = events[2].data.contents[0]
assert func_result.call_id == "call_123"
# Fourth event: final text
assert events[3].data is not None
assert isinstance(events[3].data.contents[0], TextContent)
assert events[3].data.contents[0].type == "text"
assert "sunny" in events[3].data.contents[0].text
@@ -161,10 +158,10 @@ class MockChatClient:
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
FunctionCallContent(
Content.from_function_call(
call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
],
@@ -175,7 +172,7 @@ class MockChatClient:
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
)
],
@@ -196,10 +193,10 @@ class MockChatClient:
if self._parallel_request:
yield ChatResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
FunctionCallContent(
Content.from_function_call(
call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
],
@@ -208,15 +205,15 @@ class MockChatClient:
else:
yield ChatResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
)
],
role="assistant",
)
else:
yield ChatResponseUpdate(text=TextContent(text="Tool executed "), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="successfully.")], role="assistant")
yield ChatResponseUpdate(text=Content.from_text(text="Tool executed "), role="assistant")
yield ChatResponseUpdate(contents=[Content.from_text(text="successfully.")], role="assistant")
self._iteration += 1
@@ -243,12 +240,14 @@ async def test_agent_executor_tool_call_with_approval() -> None:
# Assert
assert len(events.get_request_info_events()) == 1
approval_request = events.get_request_info_events()[0]
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
events = await workflow.send_responses({approval_request.request_id: approval_request.data.create_response(True)})
events = await workflow.send_responses({
approval_request.request_id: approval_request.data.to_function_approval_response(True)
})
# Assert
final_response = events.get_outputs()
@@ -276,14 +275,14 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
# Assert
assert len(request_info_events) == 1
approval_request = request_info_events[0]
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
output: str | None = None
async for event in workflow.send_responses_streaming({
approval_request.request_id: approval_request.data.create_response(True)
approval_request.request_id: approval_request.data.to_function_approval_response(True)
}):
if isinstance(event, WorkflowOutputEvent):
output = event.data
@@ -310,13 +309,13 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None:
# Assert
assert len(events.get_request_info_events()) == 2
for approval_request in events.get_request_info_events():
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
responses = {
approval_request.request_id: approval_request.data.create_response(True) # type: ignore
approval_request.request_id: approval_request.data.to_function_approval_response(True) # type: ignore
for approval_request in events.get_request_info_events()
}
events = await workflow.send_responses(responses)
@@ -347,13 +346,13 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
# Assert
assert len(request_info_events) == 2
for approval_request in request_info_events:
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
responses = {
approval_request.request_id: approval_request.data.create_response(True) # type: ignore
approval_request.request_id: approval_request.data.to_function_approval_response(True) # type: ignore
for approval_request in request_info_events
}
@@ -14,10 +14,10 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
Role,
SequentialBuilder,
TextContent,
WorkflowBuilder,
WorkflowContext,
WorkflowRunState,
@@ -50,7 +50,7 @@ class _SimpleAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
# This agent does not support streaming; yield a single complete response
yield AgentResponseUpdate(contents=[TextContent(text=self._reply_text)])
yield AgentResponseUpdate(contents=[Content.from_text(text=self._reply_text)])
class _CaptureFullConversation(Executor):
@@ -136,7 +136,7 @@ class _CaptureAgent(BaseAgent):
elif isinstance(m, str):
norm.append(ChatMessage(role=Role.USER, text=m))
self._last_messages = norm
yield AgentResponseUpdate(contents=[TextContent(text=self._reply_text)])
yield AgentResponseUpdate(contents=[Content.from_text(text=self._reply_text)])
async def test_sequential_adapter_uses_full_conversation() -> None:
@@ -17,6 +17,7 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Content,
GroupChatBuilder,
GroupChatState,
MagenticContext,
@@ -25,7 +26,6 @@ from agent_framework import (
MagenticProgressLedgerItem,
RequestInfoEvent,
Role,
TextContent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
@@ -57,7 +57,7 @@ class StubAgent(BaseAgent):
) -> AsyncIterable[AgentResponseUpdate]:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[TextContent(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
contents=[Content.from_text(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
)
return _stream()
@@ -141,7 +141,7 @@ class StubManagerAgent(ChatAgent):
async def _stream_initial() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[
TextContent(
Content.from_text(
text=(
'{"terminate": false, "reason": "Selecting agent", '
'"next_speaker": "agent", "final_message": null}'
@@ -157,7 +157,7 @@ class StubManagerAgent(ChatAgent):
async def _stream_final() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[
TextContent(
Content.from_text(
text=(
'{"terminate": true, "reason": "Task complete", '
'"next_speaker": null, "final_message": "agent manager final"}'
@@ -11,12 +11,11 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
Content,
HandoffAgentUserRequest,
HandoffBuilder,
RequestInfoEvent,
Role,
TextContent,
WorkflowEvent,
WorkflowOutputEvent,
resolve_agent_id,
@@ -74,14 +73,16 @@ def _build_reply_contents(
agent_name: str,
handoff_to: str | None,
call_id: str | None,
) -> list[TextContent | FunctionCallContent]:
contents: list[TextContent | FunctionCallContent] = []
) -> list[Content]:
contents: list[Content] = []
if handoff_to and call_id:
contents.append(
FunctionCallContent(call_id=call_id, name=f"handoff_to_{handoff_to}", arguments={"handoff_to": handoff_to})
Content.from_function_call(
call_id=call_id, name=f"handoff_to_{handoff_to}", arguments={"handoff_to": handoff_to}
)
)
text = f"{agent_name} reply"
contents.append(TextContent(text=text))
contents.append(Content.from_text(text=text))
return contents
@@ -15,6 +15,7 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
GroupChatRequestMessage,
MagenticBuilder,
@@ -28,7 +29,6 @@ from agent_framework import (
RequestInfoEvent,
Role,
StandardMagenticManager,
TextContent,
Workflow,
WorkflowCheckpoint,
WorkflowCheckpointException,
@@ -172,7 +172,7 @@ class StubAgent(BaseAgent):
) -> AsyncIterable[AgentResponseUpdate]:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[TextContent(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
contents=[Content.from_text(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
)
return _stream()
@@ -541,7 +541,7 @@ class StubThreadAgent(BaseAgent):
async def run_stream(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
yield AgentResponseUpdate(
contents=[TextContent(text="thread-ok")],
contents=[Content.from_text(text="thread-ok")],
author_name=self.name,
role=Role.ASSISTANT,
)
@@ -563,7 +563,7 @@ class StubAssistantsAgent(BaseAgent):
async def run_stream(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
yield AgentResponseUpdate(
contents=[TextContent(text="assistants-ok")],
contents=[Content.from_text(text="assistants-ok")],
author_name=self.name,
role=Role.ASSISTANT,
)
@@ -12,10 +12,10 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
Role,
SequentialBuilder,
TextContent,
TypeCompatibilityError,
WorkflowContext,
WorkflowOutputEvent,
@@ -46,7 +46,7 @@ class _EchoAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
# Minimal async generator with one assistant update
yield AgentResponseUpdate(contents=[TextContent(text=f"{self.name} reply")])
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
class _SummarizerExec(Executor):
@@ -18,12 +18,12 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
FileCheckpointStorage,
Message,
RequestInfoEvent,
Role,
TextContent,
WorkflowBuilder,
WorkflowCheckpointException,
WorkflowContext,
@@ -881,7 +881,7 @@ class _StreamingTestAgent(BaseAgent):
"""Streaming run - yields incremental updates."""
# Simulate streaming by yielding character by character
for char in self._reply_text:
yield AgentResponseUpdate(contents=[TextContent(text=char)])
yield AgentResponseUpdate(contents=[Content.from_text(text=char)])
async def test_agent_streaming_vs_non_streaming() -> None:
@@ -14,16 +14,9 @@ from agent_framework import (
AgentThread,
ChatMessage,
ChatMessageStore,
DataContent,
Content,
Executor,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UriContent,
UsageContent,
UsageDetails,
WorkflowAgent,
WorkflowBuilder,
@@ -44,17 +37,15 @@ class SimpleExecutor(Executor):
@handler
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
input_text = (
message[0].contents[0].text if message and isinstance(message[0].contents[0], TextContent) else "no input"
)
input_text = message[0].contents[0].text if message and message[0].contents[0].type == "text" else "no input"
response_text = f"{self.response_text}: {input_text}"
# Create response message for both streaming and non-streaming cases
response_message = ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text=response_text)])
response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)])
# Emit update event.
streaming_update = AgentResponseUpdate(
contents=[TextContent(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
contents=[Content.from_text(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=streaming_update))
@@ -76,7 +67,7 @@ class RequestingExecutor(Executor):
) -> None:
# Handle the response and emit completion response
update = AgentResponseUpdate(
contents=[TextContent(text="Request completed successfully")],
contents=[Content.from_text(text="Request completed successfully")],
role=Role.ASSISTANT,
message_id=str(uuid.uuid4()),
)
@@ -99,10 +90,10 @@ class ConversationHistoryCapturingExecutor(Executor):
message_count = len(messages)
response_text = f"Received {message_count} messages"
response_message = ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text=response_text)])
response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)])
streaming_update = AgentResponseUpdate(
contents=[TextContent(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
contents=[Content.from_text(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=streaming_update))
await ctx.send_message([response_message])
@@ -134,7 +125,7 @@ class TestWorkflowAgent:
for message in result.messages:
first_content = message.contents[0]
if isinstance(first_content, TextContent):
if first_content.type == "text":
text = first_content.text
if text.startswith("Step1:"):
step1_messages.append(message)
@@ -172,11 +163,11 @@ class TestWorkflowAgent:
# Verify we got a streaming update
assert updates[0].contents is not None
first_content: TextContent = updates[0].contents[0] # type: ignore[assignment]
second_content: TextContent = updates[1].contents[0] # type: ignore[assignment]
assert isinstance(first_content, TextContent)
first_content: Content = updates[0].contents[0] # type: ignore[assignment]
second_content: Content = updates[1].contents[0] # type: ignore[assignment]
assert first_content.type == "text"
assert "Streaming1: Test input" in first_content.text
assert isinstance(second_content, TextContent)
assert second_content.type == "text"
assert "Streaming2: Streaming1: Test input" in second_content.text
async def test_end_to_end_request_info_handling(self):
@@ -200,17 +191,15 @@ class TestWorkflowAgent:
approval_update: AgentResponseUpdate | None = None
for update in updates:
if any(isinstance(content, FunctionApprovalRequestContent) for content in update.contents):
if any(content.type == "function_approval_request" for content in update.contents):
approval_update = update
break
assert approval_update is not None, "Should have received a request_info approval request"
function_call = next(
content for content in approval_update.contents if isinstance(content, FunctionCallContent)
)
function_call = next(content for content in approval_update.contents if content.type == "function_call")
approval_request = next(
content for content in approval_update.contents if isinstance(content, FunctionApprovalRequestContent)
content for content in approval_update.contents if content.type == "function_approval_request"
)
# Verify the function call has expected structure
@@ -233,10 +222,10 @@ class TestWorkflowAgent:
data="User provided answer",
).to_dict()
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved=True,
id=approval_request.id,
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=function_call.call_id,
name=function_call.name,
arguments=response_args,
@@ -306,7 +295,7 @@ class TestWorkflowAgent:
workflow = WorkflowBuilder().set_start_executor(yielding_executor).build()
# Run directly - should return WorkflowOutputEvent in result
direct_result = await workflow.run([ChatMessage(role=Role.USER, contents=[TextContent(text="hello")])])
direct_result = await workflow.run([ChatMessage(role=Role.USER, contents=[Content.from_text(text="hello")])])
direct_outputs = direct_result.get_outputs()
assert len(direct_outputs) == 1
assert direct_outputs[0] == "processed: hello"
@@ -340,14 +329,14 @@ class TestWorkflowAgent:
assert "second output" in texts
async def test_workflow_as_agent_yield_output_with_content_types(self) -> None:
"""Test that yield_output preserves different content types (TextContent, DataContent, etc.)."""
"""Test that yield_output preserves different content types (Content, Content, etc.)."""
@executor
async def content_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
# Yield different content types
await ctx.yield_output(TextContent(text="text content"))
await ctx.yield_output(DataContent(data=b"binary data", media_type="application/octet-stream"))
await ctx.yield_output(UriContent(uri="https://example.com/image.png", media_type="image/png"))
await ctx.yield_output(Content.from_text(text="text content"))
await ctx.yield_output(Content.from_data(data=b"binary data", media_type="application/octet-stream"))
await ctx.yield_output(Content.from_uri(uri="https://example.com/image.png", media_type="image/png"))
workflow = WorkflowBuilder().set_start_executor(content_yielding_executor).build()
agent = workflow.as_agent("content-test-agent")
@@ -358,13 +347,13 @@ class TestWorkflowAgent:
assert len(result.messages) == 3
# Verify each content type is preserved
assert isinstance(result.messages[0].contents[0], TextContent)
assert result.messages[0].contents[0].type == "text"
assert result.messages[0].contents[0].text == "text content"
assert isinstance(result.messages[1].contents[0], DataContent)
assert result.messages[1].contents[0].type == "data"
assert result.messages[1].contents[0].media_type == "application/octet-stream"
assert isinstance(result.messages[2].contents[0], UriContent)
assert result.messages[2].contents[0].type == "uri"
assert result.messages[2].contents[0].uri == "https://example.com/image.png"
async def test_workflow_as_agent_yield_output_with_chat_message(self) -> None:
@@ -374,7 +363,7 @@ class TestWorkflowAgent:
async def chat_message_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="response text")],
contents=[Content.from_text(text="response text")],
author_name="custom-author",
)
await ctx.yield_output(msg)
@@ -404,7 +393,7 @@ class TestWorkflowAgent:
async def raw_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
# Yield different types of data
await ctx.yield_output("simple string")
await ctx.yield_output(TextContent(text="text content"))
await ctx.yield_output(Content.from_text(text="text content"))
custom = CustomData(42)
await ctx.yield_output(custom)
@@ -420,7 +409,7 @@ class TestWorkflowAgent:
# Verify raw_representation is set for each update
assert updates[0].raw_representation == "simple string"
assert isinstance(updates[1].raw_representation, TextContent)
assert updates[1].raw_representation.type == "text"
assert updates[1].raw_representation.text == "text content"
assert isinstance(updates[2].raw_representation, CustomData)
assert updates[2].raw_representation.value == 42
@@ -428,19 +417,19 @@ class TestWorkflowAgent:
async def test_workflow_as_agent_yield_output_with_list_of_chat_messages(self) -> None:
"""Test that yield_output with list[ChatMessage] extracts contents from all messages.
Note: TextContent items are coalesced by _finalize_response, so multiple text contents
become a single merged TextContent in the final response.
Note: Content items are coalesced by _finalize_response, so multiple text contents
become a single merged Content in the final response.
"""
@executor
async def list_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
# Yield a list of ChatMessages (as SequentialBuilder does)
msg_list = [
ChatMessage(role=Role.USER, contents=[TextContent(text="first message")]),
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="second message")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="first message")]),
ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="second message")]),
ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="third"), TextContent(text="fourth")],
contents=[Content.from_text(text="third"), Content.from_text(text="fourth")],
),
]
await ctx.yield_output(msg_list)
@@ -455,7 +444,7 @@ class TestWorkflowAgent:
assert len(updates) == 1
assert len(updates[0].contents) == 4
texts = [c.text for c in updates[0].contents if isinstance(c, TextContent)]
texts = [c.text for c in updates[0].contents if c.type == "text"]
assert texts == ["first message", "second message", "third", "fourth"]
# Verify run() coalesces text contents (expected behavior)
@@ -463,7 +452,7 @@ class TestWorkflowAgent:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
# TextContent items are coalesced into one
# Content items are coalesced into one
assert len(result.messages[0].contents) == 1
assert result.messages[0].text == "first messagesecond messagethirdfourth"
@@ -599,7 +588,7 @@ class TestWorkflowAgent:
) -> AsyncIterable[AgentResponseUpdate]:
for word in self._response_text.split():
yield AgentResponseUpdate(
contents=[TextContent(text=word + " ")],
contents=[Content.from_text(text=word + " ")],
role=Role.ASSISTANT,
author_name=self._name,
)
@@ -672,7 +661,7 @@ class TestWorkflowAgent:
self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any
) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[TextContent(text=self._response_text)],
contents=[Content.from_text(text=self._response_text)],
role=Role.ASSISTANT,
author_name=self._name,
)
@@ -738,7 +727,7 @@ class TestWorkflowAgentAuthorName:
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
# Emit update with explicit author_name
update = AgentResponseUpdate(
contents=[TextContent(text="Response with author")],
contents=[Content.from_text(text="Response with author")],
role=Role.ASSISTANT,
author_name="custom_author_name", # Explicitly set
message_id=str(uuid.uuid4()),
@@ -790,7 +779,7 @@ class TestWorkflowAgentMergeUpdates:
updates = [
# Response B, Message 2 (latest in resp B)
AgentResponseUpdate(
contents=[TextContent(text="RespB-Msg2")],
contents=[Content.from_text(text="RespB-Msg2")],
role=Role.ASSISTANT,
response_id="resp-b",
message_id="msg-2",
@@ -798,7 +787,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Response A, Message 1 (earliest overall)
AgentResponseUpdate(
contents=[TextContent(text="RespA-Msg1")],
contents=[Content.from_text(text="RespA-Msg1")],
role=Role.ASSISTANT,
response_id="resp-a",
message_id="msg-1",
@@ -806,7 +795,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Response B, Message 1 (earlier in resp B)
AgentResponseUpdate(
contents=[TextContent(text="RespB-Msg1")],
contents=[Content.from_text(text="RespB-Msg1")],
role=Role.ASSISTANT,
response_id="resp-b",
message_id="msg-1",
@@ -814,7 +803,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Response A, Message 2 (later in resp A)
AgentResponseUpdate(
contents=[TextContent(text="RespA-Msg2")],
contents=[Content.from_text(text="RespA-Msg2")],
role=Role.ASSISTANT,
response_id="resp-a",
message_id="msg-2",
@@ -822,7 +811,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Global dangling update (no response_id) - should go at end
AgentResponseUpdate(
contents=[TextContent(text="Global-Dangling")],
contents=[Content.from_text(text="Global-Dangling")],
role=Role.ASSISTANT,
response_id=None,
message_id="msg-global",
@@ -841,9 +830,7 @@ class TestWorkflowAgentMergeUpdates:
# Verify ordering: responses are processed by response_id groups,
# within each group messages are chronologically ordered,
# global dangling goes at the end
message_texts = [
msg.contents[0].text if isinstance(msg.contents[0], TextContent) else "" for msg in result.messages
]
message_texts = [msg.contents[0].text if msg.contents[0].type == "text" else "" for msg in result.messages]
# The exact order depends on dict iteration order for response_ids,
# but within each response group, chronological order should be maintained
@@ -894,9 +881,9 @@ class TestWorkflowAgentMergeUpdates:
updates = [
AgentResponseUpdate(
contents=[
TextContent(text="First"),
UsageContent(
details=UsageDetails(input_token_count=10, output_token_count=5, total_token_count=15)
Content.from_text(text="First"),
Content.from_usage(
usage_details={"input_token_count": 10, "output_token_count": 5, "total_token_count": 15}
),
],
role=Role.ASSISTANT,
@@ -907,9 +894,9 @@ class TestWorkflowAgentMergeUpdates:
),
AgentResponseUpdate(
contents=[
TextContent(text="Second"),
UsageContent(
details=UsageDetails(input_token_count=20, output_token_count=8, total_token_count=28)
Content.from_text(text="Second"),
Content.from_usage(
usage_details={"input_token_count": 20, "output_token_count": 8, "total_token_count": 28}
),
],
role=Role.ASSISTANT,
@@ -920,8 +907,10 @@ class TestWorkflowAgentMergeUpdates:
),
AgentResponseUpdate(
contents=[
TextContent(text="Third"),
UsageContent(details=UsageDetails(input_token_count=5, output_token_count=3, total_token_count=8)),
Content.from_text(text="Third"),
Content.from_usage(
usage_details={"input_token_count": 5, "output_token_count": 3, "total_token_count": 8}
),
],
role=Role.ASSISTANT,
response_id="resp-1", # Same response_id as first
@@ -985,7 +974,7 @@ class TestWorkflowAgentMergeUpdates:
updates = [
# User question
AgentResponseUpdate(
contents=[TextContent(text="What is the weather?")],
contents=[Content.from_text(text="What is the weather?")],
role=Role.USER,
response_id="resp-1",
message_id="msg-1",
@@ -993,7 +982,9 @@ class TestWorkflowAgentMergeUpdates:
),
# Assistant with function call
AgentResponseUpdate(
contents=[FunctionCallContent(call_id=call_id, name="get_weather", arguments='{"location": "NYC"}')],
contents=[
Content.from_function_call(call_id=call_id, name="get_weather", arguments='{"location": "NYC"}')
],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-2",
@@ -1002,7 +993,7 @@ class TestWorkflowAgentMergeUpdates:
# Function result: no response_id previously caused this to go to global_dangling
# and be placed at the end (the bug); fix now correctly associates via call_id
AgentResponseUpdate(
contents=[FunctionResultContent(call_id=call_id, result="Sunny, 72F")],
contents=[Content.from_function_result(call_id=call_id, result="Sunny, 72F")],
role=Role.TOOL,
response_id=None,
message_id="msg-3",
@@ -1010,7 +1001,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Final assistant answer
AgentResponseUpdate(
contents=[TextContent(text="The weather in NYC is sunny and 72F.")],
contents=[Content.from_text(text="The weather in NYC is sunny and 72F.")],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-4",
@@ -1026,11 +1017,11 @@ class TestWorkflowAgentMergeUpdates:
content_sequence = []
for msg in result.messages:
for content in msg.contents:
if isinstance(content, TextContent):
if content.type == "text":
content_sequence.append(("text", msg.role))
elif isinstance(content, FunctionCallContent):
elif content.type == "function_call":
content_sequence.append(("function_call", msg.role))
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
content_sequence.append(("function_result", msg.role))
# Verify correct ordering: user -> function_call -> function_result -> assistant_answer
@@ -1051,10 +1042,10 @@ class TestWorkflowAgentMergeUpdates:
function_result_idx = None
for i, msg in enumerate(result.messages):
for content in msg.contents:
if isinstance(content, FunctionCallContent):
if content.type == "function_call":
function_call_idx = i
assert content.call_id == call_id
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
function_result_idx = i
assert content.call_id == call_id
@@ -1081,7 +1072,7 @@ class TestWorkflowAgentMergeUpdates:
updates = [
# User question
AgentResponseUpdate(
contents=[TextContent(text="What's the weather and time?")],
contents=[Content.from_text(text="What's the weather and time?")],
role=Role.USER,
response_id="resp-1",
message_id="msg-1",
@@ -1089,7 +1080,9 @@ class TestWorkflowAgentMergeUpdates:
),
# Assistant with first function call
AgentResponseUpdate(
contents=[FunctionCallContent(call_id=call_id_1, name="get_weather", arguments='{"location": "NYC"}')],
contents=[
Content.from_function_call(call_id=call_id_1, name="get_weather", arguments='{"location": "NYC"}')
],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-2",
@@ -1097,7 +1090,9 @@ class TestWorkflowAgentMergeUpdates:
),
# Assistant with second function call
AgentResponseUpdate(
contents=[FunctionCallContent(call_id=call_id_2, name="get_time", arguments='{"timezone": "EST"}')],
contents=[
Content.from_function_call(call_id=call_id_2, name="get_time", arguments='{"timezone": "EST"}')
],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-3",
@@ -1105,7 +1100,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Second function result arrives first (no response_id)
AgentResponseUpdate(
contents=[FunctionResultContent(call_id=call_id_2, result="3:00 PM EST")],
contents=[Content.from_function_result(call_id=call_id_2, result="3:00 PM EST")],
role=Role.TOOL,
response_id=None,
message_id="msg-4",
@@ -1113,7 +1108,7 @@ class TestWorkflowAgentMergeUpdates:
),
# First function result arrives second (no response_id)
AgentResponseUpdate(
contents=[FunctionResultContent(call_id=call_id_1, result="Sunny, 72F")],
contents=[Content.from_function_result(call_id=call_id_1, result="Sunny, 72F")],
role=Role.TOOL,
response_id=None,
message_id="msg-5",
@@ -1121,7 +1116,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Final assistant answer
AgentResponseUpdate(
contents=[TextContent(text="It's sunny (72F) and 3 PM in NYC.")],
contents=[Content.from_text(text="It's sunny (72F) and 3 PM in NYC.")],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-6",
@@ -1137,11 +1132,11 @@ class TestWorkflowAgentMergeUpdates:
content_sequence = []
for msg in result.messages:
for content in msg.contents:
if isinstance(content, TextContent):
if content.type == "text":
content_sequence.append(("text", None))
elif isinstance(content, FunctionCallContent):
elif content.type == "function_call":
content_sequence.append(("function_call", content.call_id))
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
content_sequence.append(("function_result", content.call_id))
# Verify all function results appear before the final assistant text
@@ -1172,7 +1167,7 @@ class TestWorkflowAgentMergeUpdates:
"""
updates = [
AgentResponseUpdate(
contents=[TextContent(text="Hello")],
contents=[Content.from_text(text="Hello")],
role=Role.USER,
response_id="resp-1",
message_id="msg-1",
@@ -1180,14 +1175,14 @@ class TestWorkflowAgentMergeUpdates:
),
# Function result with no matching call
AgentResponseUpdate(
contents=[FunctionResultContent(call_id="orphan_call_id", result="orphan result")],
contents=[Content.from_function_result(call_id="orphan_call_id", result="orphan result")],
role=Role.TOOL,
response_id=None,
message_id="msg-2",
created_at="2024-01-01T12:00:01Z",
),
AgentResponseUpdate(
contents=[TextContent(text="Goodbye")],
contents=[Content.from_text(text="Goodbye")],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-3",
@@ -1203,9 +1198,9 @@ class TestWorkflowAgentMergeUpdates:
content_types = []
for msg in result.messages:
for content in msg.contents:
if isinstance(content, TextContent):
if content.type == "text":
content_types.append("text")
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
content_types.append("function_result")
# Order: text (user), text (assistant), function_result (orphan at end)
@@ -12,12 +12,12 @@ from agent_framework import (
BaseAgent,
ChatMessage,
ConcurrentBuilder,
Content,
GroupChatBuilder,
GroupChatState,
HandoffBuilder,
Role,
SequentialBuilder,
TextContent,
WorkflowRunState,
WorkflowStatusEvent,
ai_function,
@@ -67,7 +67,7 @@ class _KwargsCapturingAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
self.captured_kwargs.append(dict(kwargs))
yield AgentResponseUpdate(contents=[TextContent(text=f"{self.name} response")])
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} response")])
# region Sequential Builder Tests