mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
73761aa4a3
commit
83e6229c11
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user