Python: [BREAKING] Replace Hosted*Tool classes with tool methods (#3634)

* Replace Hosted*Tool classes with client static factory methods

* fixed failing test

* mypy fix

* mypy fix 2

* declarative mypy fix

* addressed comments

* ToolProtocol removal

* fixed test

* agents mypy fix

* fix failing tests

* mypy fix

* addressed comments

* fixed tests

* addressed comments + added factory method overrides for azureai v2 client

* mypy fix

* added kwargs to azureai tool methods

* fixed in test

* _sessions fix

* test fix
This commit is contained in:
Giles Odigwe
2026-02-10 16:04:27 -08:00
committed by GitHub
Unverified
parent d249473a6d
commit 7a88af0aef
133 changed files with 3018 additions and 2650 deletions
+27 -21
View File
@@ -38,7 +38,6 @@ from ._threads import AgentThread, ChatMessageStoreProtocol
from ._tools import (
FunctionInvocationLayer,
FunctionTool,
ToolProtocol,
)
from ._types import (
AgentResponse,
@@ -615,10 +614,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
id: str | None = None,
name: str | None = None,
description: str | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
default_options: OptionsCoT | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
@@ -681,10 +681,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
# Get tools from options or named parameter (named param takes precedence)
tools_ = tools if tools is not None else opts.pop("tools", None)
tools_ = cast(
ToolProtocol
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None,
tools_,
)
@@ -694,10 +694,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
# We ignore the MCP Servers here and store them separately,
# we add their functions to the tools list at runtime
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
normalized_tools: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
[] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_] # type: ignore[list-item]
)
self.mcp_tools: list[MCPTool] = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
self.mcp_tools: list[MCPTool] = [tool for tool in normalized_tools if isinstance(tool, MCPTool)] # type: ignore[misc]
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
# Build chat options dict
@@ -780,10 +780,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
options: ChatOptions[ResponseModelBoundT],
**kwargs: Any,
@@ -796,10 +797,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
options: OptionsCoT | ChatOptions[None] | None = None,
**kwargs: Any,
@@ -812,10 +814,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
*,
stream: Literal[True],
thread: AgentThread | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
options: OptionsCoT | ChatOptions[Any] | None = None,
**kwargs: Any,
@@ -827,10 +830,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
*,
stream: bool = False,
thread: AgentThread | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
options: OptionsCoT | ChatOptions[Any] | None = None,
**kwargs: Any,
@@ -981,10 +985,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
*,
messages: str | Message | Sequence[str | Message] | None,
thread: AgentThread | None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None,
options: Mapping[str, Any] | None,
kwargs: dict[str, Any],
@@ -1000,13 +1005,13 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
)
# Normalize tools
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = (
normalized_tools: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] = (
[] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_]
)
agent_name = self._get_agent_name()
# Resolve final tool list (runtime provided tools + local MCP server tools)
final_tools: list[ToolProtocol | Callable[..., Any] | dict[str, Any]] = []
final_tools: list[FunctionTool | Callable[..., Any] | dict[str, Any] | Any] = []
for tool in normalized_tools:
if isinstance(tool, MCPTool):
if not tool.is_connected:
@@ -1392,10 +1397,11 @@ class Agent(
id: str | None = None,
name: str | None = None,
description: str | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Any
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
default_options: OptionsCoT | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
@@ -33,7 +33,7 @@ from ._serialization import SerializationMixin
from ._threads import ChatMessageStoreProtocol
from ._tools import (
FunctionInvocationConfiguration,
ToolProtocol,
FunctionTool,
)
from ._types import (
ChatResponse,
@@ -68,6 +68,11 @@ logger = get_logger()
__all__ = [
"BaseChatClient",
"SupportsChatGetResponse",
"SupportsCodeInterpreterTool",
"SupportsFileSearchTool",
"SupportsImageGenerationTool",
"SupportsMCPTool",
"SupportsWebSearchTool",
]
@@ -437,10 +442,10 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
name: str | None = None,
description: str | None = None,
instructions: str | None = None,
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
default_options: OptionsCoT | Mapping[str, Any] | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
@@ -510,3 +515,163 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
# endregion
# region Tool Support Protocols
@runtime_checkable
class SupportsCodeInterpreterTool(Protocol):
"""Protocol for clients that support code interpreter tools.
This protocol enables runtime checking to determine if a client
supports code interpreter functionality.
Examples:
.. code-block:: python
from agent_framework import SupportsCodeInterpreterTool
if isinstance(client, SupportsCodeInterpreterTool):
tool = client.get_code_interpreter_tool()
agent = ChatAgent(client, tools=[tool])
"""
@staticmethod
def get_code_interpreter_tool(**kwargs: Any) -> Any:
"""Create a code interpreter tool configuration.
Keyword Args:
**kwargs: Provider-specific configuration options.
Returns:
A tool configuration ready to pass to ChatAgent.
"""
...
@runtime_checkable
class SupportsWebSearchTool(Protocol):
"""Protocol for clients that support web search tools.
This protocol enables runtime checking to determine if a client
supports web search functionality.
Examples:
.. code-block:: python
from agent_framework import SupportsWebSearchTool
if isinstance(client, SupportsWebSearchTool):
tool = client.get_web_search_tool()
agent = ChatAgent(client, tools=[tool])
"""
@staticmethod
def get_web_search_tool(**kwargs: Any) -> Any:
"""Create a web search tool configuration.
Keyword Args:
**kwargs: Provider-specific configuration options.
Returns:
A tool configuration ready to pass to ChatAgent.
"""
...
@runtime_checkable
class SupportsImageGenerationTool(Protocol):
"""Protocol for clients that support image generation tools.
This protocol enables runtime checking to determine if a client
supports image generation functionality.
Examples:
.. code-block:: python
from agent_framework import SupportsImageGenerationTool
if isinstance(client, SupportsImageGenerationTool):
tool = client.get_image_generation_tool()
agent = ChatAgent(client, tools=[tool])
"""
@staticmethod
def get_image_generation_tool(**kwargs: Any) -> Any:
"""Create an image generation tool configuration.
Keyword Args:
**kwargs: Provider-specific configuration options.
Returns:
A tool configuration ready to pass to ChatAgent.
"""
...
@runtime_checkable
class SupportsMCPTool(Protocol):
"""Protocol for clients that support MCP (Model Context Protocol) tools.
This protocol enables runtime checking to determine if a client
supports MCP server connections.
Examples:
.. code-block:: python
from agent_framework import SupportsMCPTool
if isinstance(client, SupportsMCPTool):
tool = client.get_mcp_tool(name="my_mcp", url="https://...")
agent = ChatAgent(client, tools=[tool])
"""
@staticmethod
def get_mcp_tool(**kwargs: Any) -> Any:
"""Create an MCP tool configuration.
Keyword Args:
**kwargs: Provider-specific configuration options including
name and url for the MCP server.
Returns:
A tool configuration ready to pass to ChatAgent.
"""
...
@runtime_checkable
class SupportsFileSearchTool(Protocol):
"""Protocol for clients that support file search tools.
This protocol enables runtime checking to determine if a client
supports file search functionality with vector stores.
Examples:
.. code-block:: python
from agent_framework import SupportsFileSearchTool
if isinstance(client, SupportsFileSearchTool):
tool = client.get_file_search_tool(vector_store_ids=["vs_123"])
agent = ChatAgent(client, tools=[tool])
"""
@staticmethod
def get_file_search_tool(**kwargs: Any) -> Any:
"""Create a file search tool configuration.
Keyword Args:
**kwargs: Provider-specific configuration options.
Returns:
A tool configuration ready to pass to ChatAgent.
"""
...
# endregion
+20 -6
View File
@@ -12,7 +12,7 @@ from collections.abc import Callable, Collection, Sequence
from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore
from datetime import timedelta
from functools import partial
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Literal, TypedDict
import httpx
from anyio import ClosedResourceError
@@ -28,7 +28,6 @@ from pydantic import BaseModel, create_model
from ._tools import (
FunctionTool,
HostedMCPSpecificApproval,
_build_pydantic_model_from_json_schema,
)
from ._types import (
@@ -45,6 +44,21 @@ else:
if TYPE_CHECKING:
from ._clients import SupportsChatGetResponse
class MCPSpecificApproval(TypedDict, total=False):
"""Represents the specific approval mode for an MCP tool.
When using this mode, the user must specify which tools always or never require approval.
Attributes:
always_require_approval: A sequence of tool names that always require approval.
never_require_approval: A sequence of tool names that never require approval.
"""
always_require_approval: Collection[str] | None
never_require_approval: Collection[str] | None
logger = logging.getLogger(__name__)
# region: Helpers
@@ -327,7 +341,7 @@ class MCPTool:
self,
name: str,
description: str | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
load_tools: bool = True,
parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True,
@@ -937,7 +951,7 @@ class MCPStdioTool(MCPTool):
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
args: list[str] | None = None,
env: dict[str, str] | None = None,
@@ -1058,7 +1072,7 @@ class MCPStreamableHTTPTool(MCPTool):
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
terminate_on_close: bool | None = None,
client: SupportsChatGetResponse | None = None,
@@ -1173,7 +1187,7 @@ class MCPWebsocketTool(MCPTool):
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None,
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
client: SupportsChatGetResponse | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Final
from ._types import Message
if TYPE_CHECKING:
from ._tools import ToolProtocol
from ._tools import FunctionTool
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
@@ -52,7 +52,7 @@ class Context:
self,
instructions: str | None = None,
messages: Sequence[Message] | None = None,
tools: Sequence[ToolProtocol] | None = None,
tools: Sequence[FunctionTool] | None = None,
):
"""Create a new Context object.
@@ -63,7 +63,7 @@ class Context:
"""
self.instructions = instructions
self.messages: Sequence[Message] = messages or []
self.tools: Sequence[ToolProtocol] = tools or []
self.tools: Sequence[FunctionTool] = tools or []
# region ContextProvider
@@ -18,7 +18,6 @@ from abc import abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from ._tools import ToolProtocol
from ._types import AgentResponse, Message
if TYPE_CHECKING:
@@ -110,7 +109,7 @@ class SessionContext:
input_messages: list[Message],
context_messages: dict[str, list[Message]] | None = None,
instructions: list[str] | None = None,
tools: list[ToolProtocol] | None = None,
tools: list[Any] | None = None,
options: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
):
@@ -131,7 +130,7 @@ class SessionContext:
self.input_messages = input_messages
self.context_messages: dict[str, list[Message]] = context_messages or {}
self.instructions: list[str] = instructions or []
self.tools: list[ToolProtocol] = tools or []
self.tools: list[Any] = tools or []
self._response: AgentResponse | None = None
self.options: dict[str, Any] = options or {}
self.metadata: dict[str, Any] = metadata or {}
@@ -185,7 +184,7 @@ class SessionContext:
instructions = [instructions]
self.instructions.extend(instructions)
def extend_tools(self, source_id: str, tools: Sequence[ToolProtocol]) -> None:
def extend_tools(self, source_id: str, tools: Sequence[Any]) -> None:
"""Add tools to be available for this invocation.
Tools are added with source attribution in their metadata.
+36 -406
View File
@@ -10,7 +10,6 @@ from collections.abc import (
AsyncIterable,
Awaitable,
Callable,
Collection,
Mapping,
MutableMapping,
Sequence,
@@ -25,18 +24,16 @@ from typing import (
Final,
Generic,
Literal,
Protocol,
TypedDict,
Union,
cast,
get_args,
get_origin,
overload,
runtime_checkable,
)
from opentelemetry.metrics import Histogram, NoOpHistogram
from pydantic import AnyUrl, BaseModel, Field, ValidationError, create_model
from pydantic import BaseModel, Field, ValidationError, create_model
from ._logging import get_logger
from ._serialization import SerializationMixin
@@ -58,10 +55,6 @@ if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
if TYPE_CHECKING:
@@ -85,13 +78,6 @@ __all__ = [
"FunctionInvocationConfiguration",
"FunctionInvocationLayer",
"FunctionTool",
"HostedCodeInterpreterTool",
"HostedFileSearchTool",
"HostedImageGenerationTool",
"HostedMCPSpecificApproval",
"HostedMCPTool",
"HostedWebSearchTool",
"ToolProtocol",
"normalize_function_invocation_configuration",
"tool",
]
@@ -163,380 +149,6 @@ def _parse_inputs(
# region Tools
@runtime_checkable
class ToolProtocol(Protocol):
"""Represents a generic tool.
This protocol defines the interface that all tools must implement to be compatible
with the agent framework. It is implemented by various tool classes such as HostedMCPTool,
HostedWebSearchTool, and FunctionTool's. A FunctionTool is usually created by the `tool` decorator.
Since each connector needs to parse tools differently, users can pass a dict to
specify a service-specific tool when no abstraction is available.
Attributes:
name: The name of the tool.
description: A description of the tool, suitable for use in describing the purpose to a model.
additional_properties: Additional properties associated with the tool.
"""
name: str
"""The name of the tool."""
description: str
"""A description of the tool, suitable for use in describing the purpose to a model."""
additional_properties: dict[str, Any] | None
"""Additional properties associated with the tool."""
def __str__(self) -> str:
"""Return a string representation of the tool."""
...
class BaseTool(SerializationMixin):
"""Base class for AI tools, providing common attributes and methods.
Used as the base class for the various tools in the agent framework, such as HostedMCPTool,
HostedWebSearchTool, and FunctionTool.
Since each connector needs to parse tools differently, this class is not exposed directly to end users.
In most cases, users can pass a dict to specify a service-specific tool when no abstraction is available.
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
def __init__(
self,
*,
name: str,
description: str = "",
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the BaseTool.
Keyword Args:
name: The name of the tool.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
**kwargs: Additional keyword arguments.
"""
self.name = name
self.description = description
self.additional_properties = additional_properties
for key, value in kwargs.items():
setattr(self, key, value)
def __str__(self) -> str:
"""Return a string representation of the tool."""
if self.description:
return f"{self.__class__.__name__}(name={self.name}, description={self.description})"
return f"{self.__class__.__name__}(name={self.name})"
class HostedCodeInterpreterTool(BaseTool):
"""Represents a hosted tool that can be specified to an AI service to enable it to execute generated code.
This tool does not implement code interpretation itself. It serves as a marker to inform a service
that it is allowed to execute generated code if the service is capable of doing so.
Examples:
.. code-block:: python
from agent_framework import HostedCodeInterpreterTool
# Create a code interpreter tool
code_tool = HostedCodeInterpreterTool()
# With file inputs
code_tool_with_files = HostedCodeInterpreterTool(inputs=[{"file_id": "file-123"}, {"file_id": "file-456"}])
"""
def __init__(
self,
*,
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,
) -> None:
"""Initialize the HostedCodeInterpreterTool.
Keyword Args:
inputs: A list of contents that the tool can accept as input. Defaults to None.
This should mostly be HostedFileContent or HostedVectorStoreContent.
Can also be DataContent, depending on the service used.
When supplying a list, it can contain:
- 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.
additional_properties: Additional properties associated with the tool.
**kwargs: Additional keyword arguments to pass to the base class.
"""
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedCodeInterpreterTool and cannot be set.")
self.inputs = _parse_inputs(inputs) if inputs else []
super().__init__(
name="code_interpreter",
description=description or "",
additional_properties=additional_properties,
**kwargs,
)
class HostedWebSearchTool(BaseTool):
"""Represents a web search tool that can be specified to an AI service to enable it to perform web searches.
Examples:
.. code-block:: python
from agent_framework import HostedWebSearchTool
# Create a basic web search tool
search_tool = HostedWebSearchTool()
# With location context
search_tool_with_location = HostedWebSearchTool(
description="Search the web for information",
additional_properties={"user_location": {"city": "Seattle", "country": "US"}},
)
"""
def __init__(
self,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize a HostedWebSearchTool.
Keyword Args:
description: A description of the tool.
additional_properties: Additional properties associated with the tool
(e.g., {"user_location": {"city": "Seattle", "country": "US"}}).
**kwargs: Additional keyword arguments to pass to the base class.
if additional_properties is not provided, any kwargs will be added to additional_properties.
"""
args: dict[str, Any] = {
"name": "web_search",
}
if additional_properties is not None:
args["additional_properties"] = additional_properties
elif kwargs:
args["additional_properties"] = kwargs
if description is not None:
args["description"] = description
super().__init__(**args)
class HostedImageGenerationToolOptions(TypedDict, total=False):
"""Options for HostedImageGenerationTool."""
count: int
image_size: str
media_type: str
model_id: str
response_format: Literal["uri", "data", "hosted"]
streaming_count: int
class HostedImageGenerationTool(BaseTool):
"""Represents a hosted tool that can be specified to an AI service to enable it to perform image generation."""
def __init__(
self,
*,
options: HostedImageGenerationToolOptions | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize a HostedImageGenerationTool."""
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedImageGenerationTool and cannot be set.")
self.options = options
super().__init__(
name="image_generation",
description=description or "",
additional_properties=additional_properties,
**kwargs,
)
class HostedMCPSpecificApproval(TypedDict, total=False):
"""Represents the specific mode for a hosted tool.
When using this mode, the user must specify which tools always or never require approval.
This is represented as a dictionary with two optional keys:
Attributes:
always_require_approval: A sequence of tool names that always require approval.
never_require_approval: A sequence of tool names that never require approval.
"""
always_require_approval: Collection[str] | None
never_require_approval: Collection[str] | None
class HostedMCPTool(BaseTool):
"""Represents a MCP tool that is managed and executed by the service.
Examples:
.. code-block:: python
from agent_framework import HostedMCPTool
# Create a basic MCP tool
mcp_tool = HostedMCPTool(
name="my_mcp_tool",
url="https://example.com/mcp",
)
# With approval mode and allowed tools
mcp_tool_with_approval = HostedMCPTool(
name="my_mcp_tool",
description="My MCP tool",
url="https://example.com/mcp",
approval_mode="always_require",
allowed_tools=["tool1", "tool2"],
headers={"Authorization": "Bearer token"},
)
# With specific approval mode
mcp_tool_specific = HostedMCPTool(
name="my_mcp_tool",
url="https://example.com/mcp",
approval_mode={
"always_require_approval": ["dangerous_tool"],
"never_require_approval": ["safe_tool"],
},
)
"""
def __init__(
self,
*,
name: str,
description: str | None = None,
url: AnyUrl | str,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
allowed_tools: Collection[str] | None = None,
headers: dict[str, str] | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Create a hosted MCP tool.
Keyword Args:
name: The name of the tool.
description: A description of the tool.
url: The URL of the tool.
approval_mode: The approval mode for the tool. This can be:
- "always_require": The tool always requires approval before use.
- "never_require": The tool never requires approval before use.
- A dict with keys `always_require_approval` or `never_require_approval`,
followed by a sequence of strings with the names of the relevant tools.
allowed_tools: A list of tools that are allowed to use this tool.
headers: Headers to include in requests to the tool.
additional_properties: Additional properties to include in the tool definition.
**kwargs: Additional keyword arguments to pass to the base class.
"""
try:
# Validate approval_mode
if approval_mode is not None:
if isinstance(approval_mode, str):
if approval_mode not in ("always_require", "never_require"):
raise ValueError(
f"Invalid approval_mode: {approval_mode}. "
"Must be 'always_require', 'never_require', or a dict with 'always_require_approval' "
"or 'never_require_approval' keys."
)
elif isinstance(approval_mode, dict):
# Validate that the dict has sets
for key, value in approval_mode.items():
if not isinstance(value, set):
approval_mode[key] = set(value) # type: ignore
# Validate allowed_tools
if allowed_tools is not None and isinstance(allowed_tools, dict):
raise TypeError(
f"allowed_tools must be a sequence of strings, not a dict. Got: {type(allowed_tools).__name__}"
)
super().__init__(
name=name,
description=description or "",
additional_properties=additional_properties,
**kwargs,
)
self.url = url if isinstance(url, AnyUrl) else AnyUrl(url)
self.approval_mode = approval_mode
self.allowed_tools = set(allowed_tools) if allowed_tools else None
self.headers = headers
except (ValidationError, ValueError, TypeError) as err:
raise ToolException(f"Error initializing HostedMCPTool: {err}", inner_exception=err) from err
class HostedFileSearchTool(BaseTool):
"""Represents a file search tool that can be specified to an AI service to enable it to perform file searches.
Examples:
.. code-block:: python
from agent_framework import HostedFileSearchTool
# Create a basic file search tool
file_search = HostedFileSearchTool()
# With vector store inputs and max results
file_search_with_inputs = HostedFileSearchTool(
inputs=[{"vector_store_id": "vs_123"}],
max_results=10,
description="Search files in vector store",
)
"""
def __init__(
self,
*,
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,
**kwargs: Any,
):
"""Initialize a FileSearchTool.
Keyword Args:
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:
- 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.
If None, max limit is applied.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
**kwargs: Additional keyword arguments to pass to the base class.
"""
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedFileSearchTool and cannot be set.")
self.inputs = _parse_inputs(inputs) if inputs else None
self.max_results = max_results
super().__init__(
name="file_search",
description=description or "",
additional_properties=additional_properties,
**kwargs,
)
def _default_histogram() -> Histogram:
@@ -576,12 +188,17 @@ class EmptyInputModel(BaseModel):
"""An empty input model for functions with no parameters."""
class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]):
class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
"""A tool that wraps a Python function to make it callable by AI models.
This class wraps a Python function to make it callable by AI models with automatic
parameter validation and JSON schema generation.
Attributes:
name: The name of the tool.
description: A description of the tool, suitable for use in describing the purpose to a model.
additional_properties: Additional properties associated with the tool.
Examples:
.. code-block:: python
@@ -619,7 +236,12 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]):
"""
INJECTABLE: ClassVar[set[str]] = {"func"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"input_model", "_invocation_duration_histogram", "_cached_parameters"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {
"additional_properties",
"input_model",
"_invocation_duration_histogram",
"_cached_parameters",
}
def __init__(
self,
@@ -661,12 +283,14 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]):
the expected arguments.
**kwargs: Additional keyword arguments.
"""
super().__init__(
name=name,
description=description,
additional_properties=additional_properties,
**kwargs,
)
# Core attributes (formerly from BaseTool)
self.name = name
self.description = description
self.additional_properties = additional_properties
for key, value in kwargs.items():
setattr(self, key, value)
# FunctionTool-specific attributes
self.func = func
self._instance = None # Store the instance for bound methods
self.input_model = self._resolve_input_model(input_model)
@@ -690,6 +314,12 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]):
self._forward_runtime_kwargs = True
break
def __str__(self) -> str:
"""Return a string representation of the tool."""
if self.description:
return f"{self.__class__.__name__}(name={self.name}, description={self.description})"
return f"{self.__class__.__name__}(name={self.name})"
@property
def declaration_only(self) -> bool:
"""Indicate whether the function is declaration only (i.e., has no implementation)."""
@@ -907,10 +537,10 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]):
def _tools_to_dict(
tools: (
ToolProtocol
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[str | dict[str, Any]] | None:
@@ -1464,7 +1094,7 @@ class FunctionInvocationConfiguration(TypedDict, total=False):
max_iterations: int
max_consecutive_errors_per_request: int
terminate_on_unknown_calls: bool
additional_tools: Sequence[ToolProtocol]
additional_tools: Sequence[FunctionTool]
include_detailed_errors: bool
@@ -1638,10 +1268,10 @@ async def _auto_invoke_function(
def _get_tool_map(
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]],
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]],
) -> dict[str, FunctionTool[Any, Any]]:
tool_list: dict[str, FunctionTool[Any, Any]] = {}
for tool_item in tools if isinstance(tools, list) else [tools]:
@@ -1659,10 +1289,10 @@ async def _try_execute_function_calls(
custom_args: dict[str, Any],
attempt_idx: int,
function_calls: Sequence[Content],
tools: ToolProtocol
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]],
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]],
config: FunctionInvocationConfiguration,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> tuple[Sequence[Content], bool]:
@@ -1848,8 +1478,8 @@ def _extract_tools(options: dict[str, Any] | None) -> Any:
options: The options dict containing chat options.
Returns:
ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] |
Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None
FunctionTool | Callable[..., Any] | MutableMapping[str, Any] |
Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None
"""
if options and isinstance(options, dict):
return options.get("tools")
+15 -15
View File
@@ -15,7 +15,7 @@ from pydantic import BaseModel
from ._logging import get_logger
from ._serialization import SerializationMixin
from ._tools import ToolProtocol, tool
from ._tools import FunctionTool, tool
from .exceptions import AdditionItemMismatch, ContentError
if sys.version_info >= (3, 13):
@@ -2972,10 +2972,10 @@ class _ChatOptionsBase(TypedDict, total=False):
# Tool configuration (forward reference to avoid circular import)
tools: (
ToolProtocol
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None
)
tool_choice: ToolMode | Literal["auto", "required", "none"]
@@ -3065,17 +3065,17 @@ async def validate_chat_options(options: dict[str, Any]) -> dict[str, Any]:
def normalize_tools(
tools: (
ToolProtocol
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[ToolProtocol | MutableMapping[str, Any]]:
) -> list[FunctionTool | MutableMapping[str, Any]]:
"""Normalize tools into a list.
Converts callables to FunctionTool objects and ensures all tools are either
ToolProtocol instances or MutableMappings.
FunctionTool instances or MutableMappings.
Args:
tools: Tools to normalize - can be a single tool, callable, or sequence.
@@ -3100,16 +3100,16 @@ def normalize_tools(
# List of tools
tools = normalize_tools([my_tool, another_tool])
"""
final_tools: list[ToolProtocol | MutableMapping[str, Any]] = []
final_tools: list[FunctionTool | MutableMapping[str, Any]] = []
if not tools:
return final_tools
if not isinstance(tools, Sequence) or isinstance(tools, (str, MutableMapping)):
# Single tool (not a sequence, or is a mapping which shouldn't be treated as sequence)
if not isinstance(tools, (ToolProtocol, MutableMapping)):
if not isinstance(tools, (FunctionTool, MutableMapping)):
return [tool(tools)]
return [tools]
for tool_item in tools:
if isinstance(tool_item, (ToolProtocol, MutableMapping)):
if isinstance(tool_item, (FunctionTool, MutableMapping)):
final_tools.append(tool_item)
else:
# Convert callable to FunctionTool
@@ -3119,17 +3119,17 @@ def normalize_tools(
async def validate_tools(
tools: (
ToolProtocol
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[ToolProtocol | MutableMapping[str, Any]]:
) -> list[FunctionTool | MutableMapping[str, Any]]:
"""Validate and normalize tools into a list.
Converts callables to FunctionTool objects, expands MCP tools to their constituent
functions (connecting them if needed), and ensures all tools are either ToolProtocol
functions (connecting them if needed), and ensures all tools are either FunctionTool
instances or MutableMappings.
Args:
@@ -3159,7 +3159,7 @@ async def validate_tools(
normalized = normalize_tools(tools)
# Handle MCP tool expansion (async-only)
final_tools: list[ToolProtocol | MutableMapping[str, Any]] = []
final_tools: list[FunctionTool | MutableMapping[str, Any]] = []
for tool_ in normalized:
# Import MCPTool here to avoid circular imports
from ._mcp import MCPTool
@@ -13,7 +13,7 @@ from pydantic import BaseModel, SecretStr, ValidationError
from .._agents import Agent
from .._memory import ContextProvider
from .._middleware import MiddlewareTypes
from .._tools import FunctionTool, ToolProtocol
from .._tools import FunctionTool
from .._types import normalize_tools
from ..exceptions import ServiceInitializationError
from ._assistants_client import OpenAIAssistantsClient
@@ -43,10 +43,10 @@ OptionsCoT = TypeVar(
)
_ToolsType = (
ToolProtocol
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
)
@@ -221,8 +221,8 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
description: A description of the assistant.
tools: Tools available to the assistant. Can include:
- FunctionTool instances or callables decorated with @tool
- HostedCodeInterpreterTool for code execution
- HostedFileSearchTool for vector store search
- Dict-based tools from OpenAIAssistantsClient.get_code_interpreter_tool()
- Dict-based tools from OpenAIAssistantsClient.get_file_search_tool()
- Raw tool dictionaries
metadata: Metadata to attach to the assistant (max 16 key-value pairs).
default_options: A TypedDict containing default chat options for the agent.
@@ -494,7 +494,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
self,
assistant_tools: list[Any],
user_tools: _ToolsType | None,
) -> list[ToolProtocol | MutableMapping[str, Any]]:
) -> list[FunctionTool | MutableMapping[str, Any]]:
"""Merge hosted tools from assistant with user-provided function tools.
Args:
@@ -504,7 +504,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
Returns:
A list of all tools (hosted tools + user function implementations).
"""
merged: list[ToolProtocol | MutableMapping[str, Any]] = []
merged: list[FunctionTool | MutableMapping[str, Any]] = []
# Add hosted tools from assistant using shared conversion
hosted_tools = from_assistant_tools(assistant_tools)
@@ -520,7 +520,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
def _create_chat_agent_from_assistant(
self,
assistant: Assistant,
tools: list[ToolProtocol | MutableMapping[str, Any]] | None,
tools: list[FunctionTool | MutableMapping[str, Any]] | None,
instructions: str | None,
middleware: Sequence[MiddlewareTypes] | None,
context_provider: ContextProvider | None,
@@ -35,8 +35,6 @@ from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
HostedCodeInterpreterTool,
HostedFileSearchTool,
)
from .._types import (
ChatOptions,
@@ -214,6 +212,62 @@ class OpenAIAssistantsClient( # type: ignore[misc]
):
"""OpenAI Assistants client with middleware, telemetry, and function invocation support."""
# region Hosted Tool Factory Methods
@staticmethod
def get_code_interpreter_tool() -> dict[str, Any]:
"""Create a code interpreter tool configuration for the Assistants API.
Returns:
A dict tool configuration ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIAssistantsClient
# Enable code interpreter
tool = OpenAIAssistantsClient.get_code_interpreter_tool()
agent = ChatAgent(client, tools=[tool])
"""
return {"type": "code_interpreter"}
@staticmethod
def get_file_search_tool(
*,
max_num_results: int | None = None,
) -> dict[str, Any]:
"""Create a file search tool configuration for the Assistants API.
Keyword Args:
max_num_results: Maximum number of results to return from file search.
Returns:
A dict tool configuration ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIAssistantsClient
# Basic file search
tool = OpenAIAssistantsClient.get_file_search_tool()
# With result limit
tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)
agent = ChatAgent(client, tools=[tool])
"""
tool: dict[str, Any] = {"type": "file_search"}
if max_num_results is not None:
tool["file_search"] = {"max_num_results": max_num_results}
return tool
# endregion
def __init__(
self,
*,
@@ -643,16 +697,8 @@ class OpenAIAssistantsClient( # type: ignore[misc]
for tool in tools:
if isinstance(tool, FunctionTool):
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
elif isinstance(tool, HostedCodeInterpreterTool):
tool_definitions.append({"type": "code_interpreter"})
elif isinstance(tool, HostedFileSearchTool):
params: dict[str, Any] = {
"type": "file_search",
}
if tool.max_results is not None:
params["max_num_results"] = tool.max_results
tool_definitions.append(params)
elif isinstance(tool, MutableMapping):
# Pass through dict-based tools directly (from static factory methods)
tool_definitions.append(tool)
if len(tool_definitions) > 0:
@@ -16,6 +16,7 @@ from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
from openai.types.chat.completion_create_params import WebSearchOptions
from pydantic import BaseModel, ValidationError
from .._clients import BaseChatClient
@@ -25,8 +26,6 @@ from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
HostedWebSearchTool,
ToolProtocol,
)
from .._types import (
ChatOptions,
@@ -154,6 +153,58 @@ class RawOpenAIChatClient( # type: ignore[misc]
Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied.
"""
# region Hosted Tool Factory Methods
@staticmethod
def get_web_search_tool(
*,
web_search_options: WebSearchOptions | None = None,
) -> dict[str, Any]:
"""Create a web search tool configuration for the Chat Completions API.
Note: For the Chat Completions API, web search is passed via the `web_search_options`
parameter rather than in the `tools` array. This method returns a dict that can be
passed as a tool to ChatAgent, which will handle it appropriately.
Keyword Args:
web_search_options: The full WebSearchOptions configuration. This TypedDict includes:
- user_location: Location context with "type" and "approximate" containing
"city", "country", "region", "timezone".
- search_context_size: One of "low", "medium", "high".
Returns:
A dict configuration that enables web search when passed to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
# Basic web search
tool = OpenAIChatClient.get_web_search_tool()
# With location context
tool = OpenAIChatClient.get_web_search_tool(
web_search_options={
"user_location": {
"type": "approximate",
"approximate": {"city": "Seattle", "country": "US"},
},
"search_context_size": "medium",
}
)
agent = ChatAgent(client, tools=[tool])
"""
tool: dict[str, Any] = {"type": "web_search"}
if web_search_options:
tool.update(web_search_options)
return tool
# endregion
@override
def _inner_get_response(
self,
@@ -222,35 +273,35 @@ class RawOpenAIChatClient( # type: ignore[misc]
# region content creation
def _prepare_tools_for_openai(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> dict[str, Any]:
chat_tools: list[dict[str, Any]] = []
def _prepare_tools_for_openai(self, tools: Sequence[Any]) -> dict[str, Any]:
"""Prepare tools for the OpenAI Chat Completions API.
Converts FunctionTool to JSON schema format. Web search tools are routed
to web_search_options parameter. All other tools pass through unchanged.
Args:
tools: Sequence of tools to prepare.
Returns:
Dict containing tools and optionally web_search_options.
"""
chat_tools: list[Any] = []
web_search_options: dict[str, Any] | None = None
for tool in tools:
if isinstance(tool, ToolProtocol):
match tool:
case FunctionTool():
chat_tools.append(tool.to_json_schema_spec())
case HostedWebSearchTool():
web_search_options = (
{
"user_location": {
"approximate": tool.additional_properties.get("user_location", None),
"type": "approximate",
}
}
if tool.additional_properties and "user_location" in tool.additional_properties
else {}
)
case _:
logger.debug("Unsupported tool passed (type: %s), ignoring", type(tool))
if isinstance(tool, FunctionTool):
chat_tools.append(tool.to_json_schema_spec())
elif isinstance(tool, MutableMapping) and tool.get("type") == "web_search":
# Web search is handled via web_search_options, not tools array
web_search_options = {k: v for k, v in tool.items() if k != "type"}
else:
chat_tools.append(tool) # type: ignore[arg-type]
ret_dict: dict[str, Any] = {}
# Pass through all other tools (dicts, SDK types) unchanged
chat_tools.append(tool)
result: dict[str, Any] = {}
if chat_tools:
ret_dict["tools"] = chat_tools
result["tools"] = chat_tools
if web_search_options is not None:
ret_dict["web_search_options"] = web_search_options
return ret_dict
result["web_search_options"] = web_search_options
return result
def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]:
# Prepend instructions from options if they exist
@@ -29,8 +29,8 @@ from openai.types.responses.response_usage import ResponseUsage
from openai.types.responses.tool_param import (
CodeInterpreter,
CodeInterpreterContainerCodeInterpreterToolAuto,
ImageGeneration,
Mcp,
ToolParam,
)
from openai.types.responses.web_search_tool_param import WebSearchToolParam
from pydantic import BaseModel, ValidationError
@@ -42,12 +42,6 @@ from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedWebSearchTool,
ToolProtocol,
)
from .._types import (
Annotation,
@@ -433,138 +427,334 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
# region Prep methods
def _prepare_tools_for_openai(
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None
) -> list[ToolParam | dict[str, Any]]:
response_tools: list[ToolParam | dict[str, Any]] = []
if not tools:
return response_tools
for tool in tools:
if isinstance(tool, ToolProtocol):
match tool:
case HostedMCPTool():
response_tools.append(self._prepare_mcp_tool(tool))
case HostedCodeInterpreterTool():
tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"}
if tool.inputs:
tool_args["file_ids"] = []
for tool_input in tool.inputs:
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")
response_tools.append(
CodeInterpreter(
type="code_interpreter",
container=tool_args,
)
)
case FunctionTool():
params = tool.parameters()
params["additionalProperties"] = False
response_tools.append(
FunctionToolParam(
name=tool.name,
parameters=params,
strict=False,
type="function",
description=tool.description,
)
)
case HostedFileSearchTool():
if not tool.inputs:
raise ValueError("HostedFileSearchTool requires inputs to be specified.")
inputs: list[str] = [
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(
"HostedFileSearchTool requires inputs to be of type `HostedVectorStoreContent`."
)
def _prepare_tools_for_openai(self, tools: Sequence[Any] | None) -> list[Any]:
"""Prepare tools for the OpenAI Responses API.
response_tools.append(
FileSearchToolParam(
type="file_search",
vector_store_ids=inputs,
max_num_results=tool.max_results
or self.FILE_SEARCH_MAX_RESULTS, # default to max results if not specified
)
)
case HostedWebSearchTool():
web_search_tool = WebSearchToolParam(type="web_search")
if location := (
tool.additional_properties.get("user_location", None)
if tool.additional_properties
else None
):
web_search_tool["user_location"] = {
"type": "approximate",
"city": location.get("city", None),
"country": location.get("country", None),
"region": location.get("region", None),
"timezone": location.get("timezone", None),
}
if filters := (
tool.additional_properties.get("filters", None) if tool.additional_properties else None
):
web_search_tool["filters"] = filters
if search_context_size := (
tool.additional_properties.get("search_context_size", None)
if tool.additional_properties
else None
):
web_search_tool["search_context_size"] = search_context_size
response_tools.append(web_search_tool)
case HostedImageGenerationTool():
mapped_tool: dict[str, Any] = {"type": "image_generation"}
if tool.options:
option_mapping = {
"image_size": "size",
"media_type": "output_format",
"model_id": "model",
"streaming_count": "partial_images",
}
# count and response_format are not supported by Responses API
for key, value in tool.options.items():
mapped_key = option_mapping.get(key, key)
mapped_tool[mapped_key] = value
if tool.additional_properties:
mapped_tool.update(tool.additional_properties)
response_tools.append(mapped_tool)
case _:
logger.debug("Unsupported tool passed (type: %s)", type(tool))
Converts FunctionTool to Responses API format. All other tools pass through unchanged.
Args:
tools: Sequence of tools to prepare.
Returns:
List of tool parameters ready for the OpenAI API.
"""
if not tools:
return []
response_tools: list[Any] = []
for tool in tools:
if isinstance(tool, FunctionTool):
params = tool.parameters()
params["additionalProperties"] = False
response_tools.append(
FunctionToolParam(
name=tool.name,
parameters=params,
strict=False,
type="function",
description=tool.description,
)
)
else:
# Handle raw dictionary tools
tool_dict = tool if isinstance(tool, dict) else dict(tool)
response_tools.append(tool_dict)
# Pass through all other tools (dicts, SDK types) unchanged
response_tools.append(tool)
return response_tools
# region Hosted Tool Factory Methods
@staticmethod
def _prepare_mcp_tool(tool: HostedMCPTool) -> Mcp:
"""Get MCP tool from HostedMCPTool."""
def get_code_interpreter_tool(
*,
file_ids: list[str] | None = None,
container: Literal["auto"] | CodeInterpreterContainerCodeInterpreterToolAuto = "auto",
) -> Any:
"""Create a code interpreter tool configuration for the Responses API.
Keyword Args:
file_ids: List of file IDs to make available to the code interpreter.
container: Container configuration. Use "auto" for automatic container management,
or provide a TypedDict with custom container settings.
Returns:
A CodeInterpreter tool parameter ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIResponsesClient
# Basic code interpreter
tool = OpenAIResponsesClient.get_code_interpreter_tool()
# With file access
tool = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file-abc123"])
# Use with agent
agent = ChatAgent(client, tools=[tool])
"""
container_config: CodeInterpreterContainerCodeInterpreterToolAuto = (
container if isinstance(container, dict) else {"type": "auto"}
)
if file_ids:
container_config["file_ids"] = file_ids
return CodeInterpreter(type="code_interpreter", container=container_config)
@staticmethod
def get_web_search_tool(
*,
user_location: dict[str, str] | None = None,
search_context_size: Literal["low", "medium", "high"] | None = None,
filters: dict[str, Any] | None = None,
) -> Any:
"""Create a web search tool configuration for the Responses API.
Keyword Args:
user_location: Location context for search results. Dict with keys like
"city", "country", "region", "timezone".
search_context_size: Amount of context to include from search results.
One of "low", "medium", or "high".
filters: Additional search filters.
Returns:
A WebSearchToolParam dict ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIResponsesClient
# Basic web search
tool = OpenAIResponsesClient.get_web_search_tool()
# With location context
tool = OpenAIResponsesClient.get_web_search_tool(
user_location={"city": "Seattle", "country": "US"},
search_context_size="medium",
)
agent = ChatAgent(client, tools=[tool])
"""
web_search_tool = WebSearchToolParam(type="web_search")
if user_location:
web_search_tool["user_location"] = {
"type": "approximate",
"city": user_location.get("city"),
"country": user_location.get("country"),
"region": user_location.get("region"),
"timezone": user_location.get("timezone"),
}
if search_context_size:
web_search_tool["search_context_size"] = search_context_size
if filters:
web_search_tool["filters"] = filters # type: ignore[typeddict-item]
return web_search_tool
@staticmethod
def get_image_generation_tool(
*,
size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None,
output_format: Literal["png", "jpeg", "webp"] | None = None,
model: Literal["gpt-image-1", "gpt-image-1-mini"] | str | None = None,
quality: Literal["low", "medium", "high", "auto"] | None = None,
partial_images: int | None = None,
background: Literal["transparent", "opaque", "auto"] | None = None,
moderation: Literal["auto", "low"] | None = None,
output_compression: int | None = None,
) -> Any:
"""Create an image generation tool configuration for the Responses API.
Keyword Args:
size: Image dimensions. One of "1024x1024", "1024x1536", "1536x1024", or "auto".
output_format: Output image format. One of "png", "jpeg", or "webp".
model: Model to use for image generation. One of "gpt-image-1" or "gpt-image-1-mini".
quality: Image quality level. One of "low", "medium", "high", or "auto".
partial_images: Number of partial images to stream during generation.
background: Background type. One of "transparent", "opaque", or "auto".
moderation: Moderation level. One of "auto" or "low".
output_compression: Compression level for output (0-100).
Returns:
An ImageGeneration tool parameter dict ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIResponsesClient
# Basic image generation
tool = OpenAIResponsesClient.get_image_generation_tool()
# High quality large image
tool = OpenAIResponsesClient.get_image_generation_tool(
size="1536x1024",
quality="high",
output_format="png",
)
agent = ChatAgent(client, tools=[tool])
"""
tool: ImageGeneration = {"type": "image_generation"}
if size:
tool["size"] = size
if output_format:
tool["output_format"] = output_format
if model:
tool["model"] = model
if quality:
tool["quality"] = quality
if partial_images is not None:
tool["partial_images"] = partial_images
if background:
tool["background"] = background
if moderation:
tool["moderation"] = moderation
if output_compression is not None:
tool["output_compression"] = output_compression
return tool
@staticmethod
def get_mcp_tool(
*,
name: str,
url: str,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None,
allowed_tools: list[str] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
"""Create a hosted MCP (Model Context Protocol) tool configuration for the Responses API.
This configures an MCP server that will be called by OpenAI's service.
The tools from this MCP server are executed remotely by OpenAI,
not locally by your application.
Note:
For local MCP execution where your application calls the MCP server
directly, use the MCP client tools instead of this method.
Keyword Args:
name: A label/name for the MCP server.
url: The URL of the MCP server.
description: A description of what the MCP server provides.
approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools,
or provide a dict with "always_require_approval" and/or "never_require_approval"
keys mapping to lists of tool names.
allowed_tools: List of tool names that are allowed to be used from this MCP server.
headers: HTTP headers to include in requests to the MCP server.
Returns:
An Mcp tool parameter dict ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIResponsesClient
# Basic MCP tool
tool = OpenAIResponsesClient.get_mcp_tool(
name="my_mcp",
url="https://mcp.example.com",
)
# With approval settings
tool = OpenAIResponsesClient.get_mcp_tool(
name="github_mcp",
url="https://mcp.github.com",
description="GitHub MCP server",
approval_mode="always_require",
headers={"Authorization": "Bearer token"},
)
# With specific tool approvals
tool = OpenAIResponsesClient.get_mcp_tool(
name="tools_mcp",
url="https://tools.example.com",
approval_mode={
"always_require_approval": ["dangerous_tool"],
"never_require_approval": ["safe_tool"],
},
)
agent = ChatAgent(client, tools=[tool])
"""
mcp: Mcp = {
"type": "mcp",
"server_label": tool.name.replace(" ", "_"),
"server_url": str(tool.url),
"server_description": tool.description,
"headers": tool.headers,
"server_label": name.replace(" ", "_"),
"server_url": url,
}
if tool.allowed_tools:
mcp["allowed_tools"] = list(tool.allowed_tools)
if tool.approval_mode:
match tool.approval_mode:
case str():
mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never"
case _:
if always_require_approvals := tool.approval_mode.get("always_require_approval"):
mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}}
if never_require_approvals := tool.approval_mode.get("never_require_approval"):
mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}}
if description:
mcp["server_description"] = description
if headers:
mcp["headers"] = headers
if allowed_tools:
mcp["allowed_tools"] = allowed_tools
if approval_mode:
if isinstance(approval_mode, str):
mcp["require_approval"] = "always" if approval_mode == "always_require" else "never"
else:
if always_require := approval_mode.get("always_require_approval"):
mcp["require_approval"] = {"always": {"tool_names": always_require}}
if never_require := approval_mode.get("never_require_approval"):
mcp["require_approval"] = {"never": {"tool_names": never_require}}
return mcp
@staticmethod
def get_file_search_tool(
*,
vector_store_ids: list[str],
max_num_results: int | None = None,
) -> Any:
"""Create a file search tool configuration for the Responses API.
Keyword Args:
vector_store_ids: List of vector store IDs to search within.
max_num_results: Maximum number of results to return. Defaults to 50 if not specified.
Returns:
A FileSearchToolParam dict ready to pass to ChatAgent.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIResponsesClient
# Basic file search
tool = OpenAIResponsesClient.get_file_search_tool(
vector_store_ids=["vs_abc123"],
)
# With result limit
tool = OpenAIResponsesClient.get_file_search_tool(
vector_store_ids=["vs_abc123", "vs_def456"],
max_num_results=10,
)
agent = ChatAgent(client, tools=[tool])
"""
tool = FileSearchToolParam(
type="file_search",
vector_store_ids=vector_store_ids,
)
if max_num_results is not None:
tool["max_num_results"] = max_num_results
return tool
# endregion
async def _prepare_options(
self,
messages: Sequence[Message],
@@ -904,7 +1094,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
for annotation in message_content.annotations:
match annotation.type:
case "file_path":
text_content.annotations.append(
text_content.annotations.append( # pyright: ignore[reportUnknownMemberType]
Annotation(
type="citation",
file_id=annotation.file_id,
@@ -915,7 +1105,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
)
)
case "file_citation":
text_content.annotations.append(
text_content.annotations.append( # pyright: ignore[reportUnknownMemberType]
Annotation(
type="citation",
url=annotation.filename,
@@ -927,7 +1117,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
)
)
case "url_citation":
text_content.annotations.append(
text_content.annotations.append( # pyright: ignore[reportUnknownMemberType]
Annotation(
type="citation",
title=annotation.title,
@@ -943,7 +1133,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
)
)
case "container_file_citation":
text_content.annotations.append(
text_content.annotations.append( # pyright: ignore[reportUnknownMemberType]
Annotation(
type="citation",
file_id=annotation.file_id,
@@ -1107,7 +1297,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
"raw_representation": response,
}
if conversation_id := self._get_conversation_id(response, options.get("store")):
if conversation_id := self._get_conversation_id(response, options.get("store")): # pyright: ignore[reportUnknownArgumentType]
args["conversation_id"] = conversation_id
if response.usage and (usage_details := self._parse_usage_from_openai(response.usage)):
args["usage_details"] = usage_details
@@ -1329,13 +1519,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
)
parsed_output: list[Content] | None = None
if result_output:
normalized = (
normalized = ( # pyright: ignore[reportUnknownVariableType]
result_output
if isinstance(result_output, Sequence)
and not isinstance(result_output, (str, bytes, MutableMapping))
else [result_output]
)
parsed_output = [Content.from_dict(output_item) for output_item in normalized]
parsed_output = [Content.from_dict(output_item) for output_item in normalized] # pyright: ignore[reportArgumentType,reportUnknownVariableType]
contents.append(
Content.from_mcp_server_tool_result(
call_id=call_id,
@@ -26,7 +26,7 @@ from .._logging import get_logger
from .._pydantic import AFBaseSettings
from .._serialization import SerializationMixin
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
from .._tools import FunctionTool, HostedCodeInterpreterTool, HostedFileSearchTool, ToolProtocol
from .._tools import FunctionTool
from ..exceptions import ServiceInitializationError
logger: logging.Logger = get_logger("agent_framework.openai")
@@ -284,12 +284,14 @@ class OpenAIConfigMixin(OpenAIBase):
def to_assistant_tools(
tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None,
tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Convert Agent Framework tools to OpenAI Assistants API format.
Handles FunctionTool instances and dict-based tools from static factory methods.
Args:
tools: Normalized tools (from ChatOptions.tools).
tools: Sequence of Agent Framework tools.
Returns:
List of tool definitions for OpenAI Assistants API.
@@ -302,15 +304,8 @@ def to_assistant_tools(
for tool in tools:
if isinstance(tool, FunctionTool):
tool_definitions.append(tool.to_json_schema_spec())
elif isinstance(tool, HostedCodeInterpreterTool):
tool_definitions.append({"type": "code_interpreter"})
elif isinstance(tool, HostedFileSearchTool):
params: dict[str, Any] = {"type": "file_search"}
if tool.max_results is not None:
params["file_search"] = {"max_num_results": tool.max_results}
tool_definitions.append(params)
elif isinstance(tool, MutableMapping):
# Pass through raw dict definitions
# Pass through dict-based tools directly (from static factory methods)
tool_definitions.append(dict(tool))
return tool_definitions
@@ -318,11 +313,11 @@ def to_assistant_tools(
def from_assistant_tools(
assistant_tools: list[Any] | None,
) -> list[ToolProtocol]:
"""Convert OpenAI Assistant tools to Agent Framework format.
) -> list[dict[str, Any]]:
"""Convert OpenAI Assistant tools to dict-based format.
This converts hosted tools (code_interpreter, file_search) from an OpenAI
Assistant definition back to Agent Framework tool instances.
Assistant definition back to dict-based tool definitions.
Note: Function tools are skipped - user must provide implementations separately.
@@ -330,12 +325,12 @@ def from_assistant_tools(
assistant_tools: Tools from OpenAI Assistant object (assistant.tools).
Returns:
List of Agent Framework tool instances for hosted tools.
List of dict-based tool definitions for hosted tools.
"""
if not assistant_tools:
return []
tools: list[ToolProtocol] = []
tools: list[dict[str, Any]] = []
for tool in assistant_tools:
if hasattr(tool, "type"):
@@ -346,9 +341,9 @@ def from_assistant_tools(
tool_type = None
if tool_type == "code_interpreter":
tools.append(HostedCodeInterpreterTool())
tools.append({"type": "code_interpreter"})
elif tool_type == "file_search":
tools.append(HostedFileSearchTool())
tools.append({"type": "file_search"})
# Skip function tools - user must provide implementations
return tools
@@ -15,7 +15,6 @@ from agent_framework import (
AgentThread,
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
Message,
SupportsChatGetResponse,
tool,
@@ -513,7 +512,7 @@ async def test_azure_assistants_agent_code_interpreter():
async with Agent(
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can write and execute Python code.",
tools=[HostedCodeInterpreterTool()],
tools=[AzureOpenAIAssistantsClient.get_code_interpreter_tool()],
) as agent:
# Request code execution
response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.")
@@ -14,10 +14,6 @@ from agent_framework import (
AgentResponse,
ChatResponse,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedWebSearchTool,
Message,
SupportsChatGetResponse,
tool,
@@ -289,7 +285,7 @@ async def test_integration_web_search() -> None:
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
"tools": [AzureOpenAIResponsesClient.get_web_search_tool()],
},
"stream": streaming,
}
@@ -305,17 +301,13 @@ async def test_integration_web_search() -> None:
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
"tools": [
AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})
],
},
"stream": streaming,
}
@@ -341,7 +333,12 @@ async def test_integration_client_file_search() -> None:
text="What is the weather today? Do a file search to find the answer.",
)
],
options={"tools": [HostedFileSearchTool(inputs=vector_store)], "tool_choice": "auto"},
options={
"tools": [
AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
],
"tool_choice": "auto",
},
)
assert "sunny" in response.text.lower()
@@ -366,7 +363,12 @@ async def test_integration_client_file_search_streaming() -> None:
)
],
stream=True,
options={"tools": [HostedFileSearchTool(inputs=vector_store)], "tool_choice": "auto"},
options={
"tools": [
AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
],
"tool_choice": "auto",
},
)
full_response = await response_stream.get_final_response()
@@ -379,23 +381,23 @@ async def test_integration_client_file_search_streaming() -> None:
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
"""Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP."""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
"How to create an Azure storage account using az cli?",
options={
# this needs to be high enough to handle the full MCP tool response.
"max_tokens": 5000,
"tools": HostedMCPTool(
"tools": AzureOpenAIResponsesClient.get_mcp_tool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
},
)
assert isinstance(response, ChatResponse)
assert response.text
# MCP server may return empty response intermittently - skip test rather than fail
if not response.text:
pytest.skip("MCP server returned empty response - service-side issue")
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@@ -403,13 +405,13 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None:
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_hosted_code_interpreter_tool():
"""Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureOpenAIResponsesClient."""
"""Test Azure Responses Client agent with code interpreter tool."""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
"Calculate the sum of numbers from 1 to 10 using Python code.",
options={
"tools": [HostedCodeInterpreterTool()],
"tools": [AzureOpenAIResponsesClient.get_code_interpreter_tool()],
},
)
# Should contain calculation result (sum of 1-10 = 55) or code execution content
+10 -17
View File
@@ -8,7 +8,6 @@ from typing import Any, Generic
from unittest.mock import patch
from uuid import uuid4
from pydantic import BaseModel
from pytest import fixture
from agent_framework import (
@@ -21,10 +20,10 @@ from agent_framework import (
ChatResponseUpdate,
Content,
FunctionInvocationLayer,
FunctionTool,
Message,
ResponseStream,
SupportsAgentRun,
ToolProtocol,
tool,
)
from agent_framework._clients import OptionsCoT
@@ -48,26 +47,20 @@ def chat_history() -> list[Message]:
@fixture
def ai_tool() -> ToolProtocol:
"""Returns a generic ToolProtocol."""
def ai_tool() -> FunctionTool:
"""Returns a generic FunctionTool."""
class GenericTool(BaseModel):
name: str
description: str
additional_properties: dict[str, Any] | None = None
@tool
def generic_tool(name: str) -> str:
"""A generic tool that echoes the name."""
return f"Hello, {name}"
def parameters(self) -> dict[str, Any]:
"""Return the parameters of the tool as a JSON schema."""
return {
"name": {"type": "string"},
}
return GenericTool(name="generic_tool", description="A generic tool")
return generic_tool
@fixture
def tool_tool() -> ToolProtocol:
"""Returns a executable ToolProtocol."""
def tool_tool() -> FunctionTool:
"""Returns a executable FunctionTool."""
@tool(approval_mode="never_require")
def simple_function(x: int, y: int) -> int:
+9 -10
View File
@@ -20,11 +20,10 @@ from agent_framework import (
Content,
Context,
ContextProvider,
HostedCodeInterpreterTool,
FunctionTool,
Message,
SupportsAgentRun,
SupportsChatGetResponse,
ToolProtocol,
tool,
)
from agent_framework._agents import _merge_options, _sanitize_agent_name
@@ -117,7 +116,7 @@ async def test_chat_client_agent_prepare_thread_and_messages(client: SupportsCha
async def test_prepare_thread_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
tool = HostedCodeInterpreterTool()
tool = {"type": "code_interpreter"}
agent = Agent(client=client, tools=[tool])
assert agent.default_options.get("tools") is not None
@@ -132,7 +131,7 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(client: Support
assert prepared_chat_options.get("tools") is not None
assert base_tools is not prepared_chat_options["tools"]
prepared_chat_options["tools"].append(HostedCodeInterpreterTool()) # type: ignore[arg-type]
prepared_chat_options["tools"].append({"type": "code_interpreter"}) # type: ignore[arg-type]
assert len(agent.default_options["tools"]) == 1
@@ -144,7 +143,7 @@ async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChat
chat_client_base.run_responses = [mock_response]
agent = Agent(
client=chat_client_base,
tools=HostedCodeInterpreterTool(),
tools={"type": "code_interpreter"},
)
thread = agent.get_new_thread()
@@ -207,7 +206,7 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
)
]
agent = Agent(client=chat_client_base, tools=HostedCodeInterpreterTool())
agent = Agent(client=chat_client_base, tools={"type": "code_interpreter"})
result = await agent.run("Hello")
assert result.text == "test response"
@@ -806,7 +805,7 @@ def test_sanitize_agent_name_replaces_invalid_chars():
@pytest.mark.asyncio
async def test_agent_get_new_thread(chat_client_base: SupportsChatGetResponse, tool_tool: ToolProtocol):
async def test_agent_get_new_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test that get_new_thread returns a new AgentThread."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
@@ -818,7 +817,7 @@ async def test_agent_get_new_thread(chat_client_base: SupportsChatGetResponse, t
@pytest.mark.asyncio
async def test_agent_get_new_thread_with_context_provider(
chat_client_base: SupportsChatGetResponse, tool_tool: ToolProtocol
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
):
"""Test that get_new_thread passes context_provider to the thread."""
@@ -837,7 +836,7 @@ async def test_agent_get_new_thread_with_context_provider(
@pytest.mark.asyncio
async def test_agent_get_new_thread_with_service_thread_id(
chat_client_base: SupportsChatGetResponse, tool_tool: ToolProtocol
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
):
"""Test that get_new_thread passes kwargs like service_thread_id to the thread."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
@@ -849,7 +848,7 @@ async def test_agent_get_new_thread_with_service_thread_id(
@pytest.mark.asyncio
async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetResponse, tool_tool: ToolProtocol):
async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test deserialize_thread restores a thread from serialized state."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
@@ -8,6 +8,11 @@ from agent_framework import (
ChatResponse,
Message,
SupportsChatGetResponse,
SupportsCodeInterpreterTool,
SupportsFileSearchTool,
SupportsImageGenerationTool,
SupportsMCPTool,
SupportsWebSearchTool,
)
@@ -73,3 +78,66 @@ async def test_chat_client_instructions_handling(chat_client_base: SupportsChatG
assert appended_messages[0].text == "You are a helpful assistant."
assert appended_messages[1].role == "user"
assert appended_messages[1].text == "hello"
# region Tool Support Protocol Tests
def test_openai_responses_client_supports_all_tool_protocols():
"""Test that OpenAIResponsesClient supports all hosted tool protocols."""
from agent_framework.openai import OpenAIResponsesClient
assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool)
assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool)
assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool)
assert isinstance(OpenAIResponsesClient, SupportsMCPTool)
assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool)
def test_openai_chat_client_supports_web_search_only():
"""Test that OpenAIChatClient only supports web search tool."""
from agent_framework.openai import OpenAIChatClient
assert not isinstance(OpenAIChatClient, SupportsCodeInterpreterTool)
assert isinstance(OpenAIChatClient, SupportsWebSearchTool)
assert not isinstance(OpenAIChatClient, SupportsImageGenerationTool)
assert not isinstance(OpenAIChatClient, SupportsMCPTool)
assert not isinstance(OpenAIChatClient, SupportsFileSearchTool)
def test_openai_assistants_client_supports_code_interpreter_and_file_search():
"""Test that OpenAIAssistantsClient supports code interpreter and file search."""
from agent_framework.openai import OpenAIAssistantsClient
assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool)
assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool)
assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool)
assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool)
assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool)
def test_protocol_isinstance_with_client_instance():
"""Test that protocol isinstance works with client instances."""
from agent_framework.openai import OpenAIResponsesClient
# Create mock client instance (won't connect to API)
client = OpenAIResponsesClient.__new__(OpenAIResponsesClient)
assert isinstance(client, SupportsCodeInterpreterTool)
assert isinstance(client, SupportsWebSearchTool)
def test_protocol_tool_methods_return_dict():
"""Test that static tool methods return dict[str, Any]."""
from agent_framework.openai import OpenAIResponsesClient
code_tool = OpenAIResponsesClient.get_code_interpreter_tool()
assert isinstance(code_tool, dict)
assert code_tool.get("type") == "code_interpreter"
web_tool = OpenAIResponsesClient.get_web_search_tool()
assert isinstance(web_tool, dict)
assert web_tool.get("type") == "web_search"
# endregion
+7 -3
View File
@@ -18,7 +18,6 @@ from agent_framework import (
MCPStreamableHTTPTool,
MCPWebsocketTool,
Message,
ToolProtocol,
)
from agent_framework._mcp import (
MCPTool,
@@ -744,7 +743,10 @@ def test_get_input_model_from_mcp_prompt():
async def test_local_mcp_server_initialization():
"""Test MCPTool initialization."""
server = MCPTool(name="test_server")
assert isinstance(server, ToolProtocol)
# MCPTool has the same core attributes as FunctionTool
assert hasattr(server, "name")
assert hasattr(server, "description")
assert hasattr(server, "additional_properties")
assert server.name == "test_server"
assert server.session is None
assert server.functions == []
@@ -795,7 +797,9 @@ async def test_local_mcp_server_load_functions():
return None
server = TestServer(name="test_server")
assert isinstance(server, ToolProtocol)
# MCPTool has the same core attributes as FunctionTool
assert hasattr(server, "name")
assert hasattr(server, "description")
async with server:
await server.load_tools()
assert len(server.functions) == 1
+3 -213
View File
@@ -10,10 +10,6 @@ from pydantic import BaseModel, ValidationError
from agent_framework import (
Content,
FunctionTool,
HostedCodeInterpreterTool,
HostedImageGenerationTool,
HostedMCPTool,
ToolProtocol,
tool,
)
from agent_framework._tools import (
@@ -21,7 +17,6 @@ from agent_framework._tools import (
_parse_annotation,
_parse_inputs,
)
from agent_framework.exceptions import ToolException
from agent_framework.observability import OtelAttr
# region FunctionTool and tool decorator tests
@@ -35,7 +30,6 @@ def test_tool_decorator():
"""A simple function that adds two numbers."""
return x + y
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, FunctionTool)
assert test_tool.name == "test_tool"
assert test_tool.description == "A test tool"
@@ -56,7 +50,6 @@ def test_tool_decorator_without_args():
"""A simple function that adds two numbers."""
return x + y
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, FunctionTool)
assert test_tool.name == "test_tool"
assert test_tool.description == "A simple function that adds two numbers."
@@ -174,7 +167,7 @@ def test_tool_without_args():
"""A simple function that adds two numbers."""
return 1 + 2
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, FunctionTool)
assert isinstance(test_tool, FunctionTool)
assert test_tool.name == "test_tool"
assert test_tool.description == "A simple function that adds two numbers."
@@ -194,7 +187,6 @@ async def test_tool_decorator_with_async():
"""An async function that adds two numbers."""
return x + y
assert isinstance(async_test_tool, ToolProtocol)
assert isinstance(async_test_tool, FunctionTool)
assert async_test_tool.name == "async_test_tool"
assert async_test_tool.description == "An async test tool"
@@ -218,7 +210,6 @@ def test_tool_decorator_in_class():
test_tool = my_tools().test_tool
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, FunctionTool)
assert test_tool.name == "test_tool"
assert test_tool.description == "A test tool"
@@ -701,30 +692,7 @@ def test_tool_serialization():
assert restored_tool_2(10, 4) == 6
# region HostedCodeInterpreterTool and _parse_inputs
def test_hosted_code_interpreter_tool_default():
"""Test HostedCodeInterpreterTool with default parameters."""
tool = HostedCodeInterpreterTool()
assert tool.name == "code_interpreter"
assert tool.inputs == []
assert tool.description == ""
assert tool.additional_properties is None
assert str(tool) == "HostedCodeInterpreterTool(name=code_interpreter)"
def test_hosted_code_interpreter_tool_with_description():
"""Test HostedCodeInterpreterTool with description and additional properties."""
tool = HostedCodeInterpreterTool(
description="A test code interpreter",
additional_properties={"version": "1.0", "language": "python"},
)
assert tool.name == "code_interpreter"
assert tool.description == "A test code interpreter"
assert tool.additional_properties == {"version": "1.0", "language": "python"}
# region _parse_inputs tests
def test_parse_inputs_none():
@@ -853,185 +821,7 @@ def test_parse_inputs_unsupported_type():
_parse_inputs(123)
def test_hosted_code_interpreter_tool_with_string_input():
"""Test HostedCodeInterpreterTool with string input."""
tool = HostedCodeInterpreterTool(inputs="http://example.com")
assert len(tool.inputs) == 1
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."""
inputs = [{"uri": "http://example.com", "media_type": "text/html"}, {"file_id": "file-123"}]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert tool.inputs[0].type == "uri"
assert tool.inputs[0].uri == "http://example.com"
assert tool.inputs[0].media_type == "text/html"
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 Content instances."""
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 tool.inputs[0].type == "text"
assert tool.inputs[0].text == "Hello, world!"
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)."""
input_dict = {"file_id": "file-single"}
tool = HostedCodeInterpreterTool(inputs=input_dict)
assert len(tool.inputs) == 1
assert tool.inputs[0].type == "hosted_file"
assert tool.inputs[0].file_id == "file-single"
def test_hosted_code_interpreter_tool_with_unknown_input():
"""Test HostedCodeInterpreterTool with single unknown input."""
with pytest.raises(ValueError, match="Unsupported input type"):
HostedCodeInterpreterTool(inputs={"hosted_file": "file-single"})
def test_hosted_image_generation_tool_defaults():
"""HostedImageGenerationTool should default name and empty description."""
tool = HostedImageGenerationTool()
assert tool.name == "image_generation"
assert tool.description == ""
assert tool.options is None
assert str(tool) == "HostedImageGenerationTool(name=image_generation)"
def test_hosted_image_generation_tool_with_options():
"""HostedImageGenerationTool should store options."""
tool = HostedImageGenerationTool(
description="Generate images",
options={"format": "png", "size": "1024x1024"},
additional_properties={"quality": "high"},
)
assert tool.name == "image_generation"
assert tool.description == "Generate images"
assert tool.options == {"format": "png", "size": "1024x1024"}
assert tool.additional_properties == {"quality": "high"}
# region HostedMCPTool tests
def test_hosted_mcp_tool_with_other_fields():
"""Test creating a HostedMCPTool with a specific approval dict, headers and additional properties."""
tool = HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
description="A test MCP tool",
headers={"x": "y"},
additional_properties={"p": 1},
)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
assert tool.headers == {"x": "y"}
assert tool.additional_properties == {"p": 1}
assert tool.description == "A test MCP tool"
@pytest.mark.parametrize(
"approval_mode",
[
"always_require",
"never_require",
{
"always_require_approval": {"toolA"},
"never_require_approval": {"toolB"},
},
{
"always_require_approval": ["toolA"],
"never_require_approval": ("toolB",),
},
],
ids=["always_require", "never_require", "specific", "specific_with_parsing"],
)
def test_hosted_mcp_tool_with_approval_mode(approval_mode: str | dict[str, Any]):
"""Test creating a HostedMCPTool with a specific approval dict, headers and additional properties."""
tool = HostedMCPTool(name="mcp-tool", url="https://mcp.example", approval_mode=approval_mode)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
if not isinstance(approval_mode, dict):
assert tool.approval_mode == approval_mode
else:
# approval_mode parsed to sets
assert isinstance(tool.approval_mode["always_require_approval"], set)
assert isinstance(tool.approval_mode["never_require_approval"], set)
assert "toolA" in tool.approval_mode["always_require_approval"]
assert "toolB" in tool.approval_mode["never_require_approval"]
def test_hosted_mcp_tool_invalid_approval_mode_raises():
"""Invalid approval_mode string should raise ServiceInitializationError."""
with pytest.raises(ToolException):
HostedMCPTool(name="bad", url="https://x", approval_mode="invalid_mode")
@pytest.mark.parametrize(
"tools",
[
{"toolA", "toolB"},
("toolA", "toolB"),
["toolA", "toolB"],
["toolA", "toolB", "toolA"],
],
ids=[
"set",
"tuple",
"list",
"list_with_duplicates",
],
)
def test_hosted_mcp_tool_with_allowed_tools(tools: list[str] | tuple[str, ...] | set[str]):
"""Test creating a HostedMCPTool with a list of allowed tools."""
tool = HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
allowed_tools=tools,
)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
# approval_mode parsed to set
assert isinstance(tool.allowed_tools, set)
assert tool.allowed_tools == {"toolA", "toolB"}
def test_hosted_mcp_tool_with_dict_of_allowed_tools():
"""Test creating a HostedMCPTool with a dict of allowed tools."""
with pytest.raises(ToolException):
HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
allowed_tools={"toolA": "Tool A", "toolC": "Tool C"},
)
# endregion
async def test_ai_function_with_kwargs_injection():
+10 -16
View File
@@ -18,11 +18,11 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
FunctionTool,
Message,
ResponseStream,
TextSpanRegion,
ToolMode,
ToolProtocol,
UsageDetails,
detect_media_type_from_base64,
merge_chat_options,
@@ -41,26 +41,20 @@ from agent_framework.exceptions import ContentError
@fixture
def ai_tool() -> ToolProtocol:
"""Returns a generic ToolProtocol."""
def ai_tool() -> FunctionTool:
"""Returns a generic FunctionTool."""
class GenericTool(BaseModel):
name: str
description: str | None = None
additional_properties: dict[str, Any] | None = None
@tool
def generic_tool(name: str) -> str:
"""A generic tool that echoes the name."""
return f"Hello, {name}"
def parameters(self) -> dict[str, Any]:
"""Return the parameters of the tool as a JSON schema."""
return {
"name": {"type": "string"},
}
return GenericTool(name="generic_tool", description="A generic tool")
return generic_tool
@fixture
def tool_tool() -> ToolProtocol:
"""Returns a executable ToolProtocol."""
def tool_tool() -> FunctionTool:
"""Returns a executable FunctionTool."""
@tool
def simple_function(x: int, y: int) -> int:
@@ -8,9 +8,9 @@ import pytest
from openai.types.beta.assistant import Assistant
from pydantic import BaseModel, Field
from agent_framework import Agent, HostedCodeInterpreterTool, HostedFileSearchTool, normalize_tools, tool
from agent_framework import Agent, normalize_tools, tool
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.openai import OpenAIAssistantProvider
from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient
from agent_framework.openai._shared import from_assistant_tools, to_assistant_tools
# region Test Helpers
@@ -269,7 +269,7 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="CodeAgent",
model="gpt-4",
tools=[HostedCodeInterpreterTool()],
tools=[OpenAIAssistantsClient.get_code_interpreter_tool()],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -282,7 +282,7 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="SearchAgent",
model="gpt-4",
tools=[HostedFileSearchTool()],
tools=[OpenAIAssistantsClient.get_file_search_tool()],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -295,7 +295,7 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="SearchAgent",
model="gpt-4",
tools=[HostedFileSearchTool(max_results=10)],
tools=[OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -309,7 +309,11 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="MultiToolAgent",
model="gpt-4",
tools=[get_weather, HostedCodeInterpreterTool(), HostedFileSearchTool()],
tools=[
get_weather,
OpenAIAssistantsClient.get_code_interpreter_tool(),
OpenAIAssistantsClient.get_file_search_tool(),
],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -564,22 +568,22 @@ class TestToolConversion:
assert api_tools[0]["function"]["name"] == "get_weather"
def test_to_assistant_tools_code_interpreter(self) -> None:
"""Test HostedCodeInterpreterTool conversion."""
api_tools = to_assistant_tools([HostedCodeInterpreterTool()])
"""Test code_interpreter tool dict conversion."""
api_tools = to_assistant_tools([OpenAIAssistantsClient.get_code_interpreter_tool()])
assert len(api_tools) == 1
assert api_tools[0] == {"type": "code_interpreter"}
def test_to_assistant_tools_file_search(self) -> None:
"""Test HostedFileSearchTool conversion."""
api_tools = to_assistant_tools([HostedFileSearchTool()])
"""Test file_search tool dict conversion."""
api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool()])
assert len(api_tools) == 1
assert api_tools[0]["type"] == "file_search"
def test_to_assistant_tools_file_search_with_max_results(self) -> None:
"""Test HostedFileSearchTool with max_results conversion."""
api_tools = to_assistant_tools([HostedFileSearchTool(max_results=5)])
"""Test file_search tool with max_results conversion."""
api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool(max_num_results=5)])
assert api_tools[0]["file_search"]["max_num_results"] == 5
@@ -605,7 +609,7 @@ class TestToolConversion:
tools = from_assistant_tools(assistant_tools)
assert len(tools) == 1
assert isinstance(tools[0], HostedCodeInterpreterTool)
assert tools[0] == {"type": "code_interpreter"}
def test_from_assistant_tools_file_search(self) -> None:
"""Test converting file_search tool from OpenAI format."""
@@ -614,7 +618,7 @@ class TestToolConversion:
tools = from_assistant_tools(assistant_tools)
assert len(tools) == 1
assert isinstance(tools[0], HostedFileSearchTool)
assert tools[0] == {"type": "file_search"}
def test_from_assistant_tools_function_skipped(self) -> None:
"""Test that function tools are skipped (no implementations)."""
@@ -707,7 +711,7 @@ class TestToolMerging:
merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage]
assert len(merged) == 1
assert isinstance(merged[0], HostedCodeInterpreterTool)
assert merged[0] == {"type": "code_interpreter"}
def test_merge_file_search(self, mock_async_openai: MagicMock) -> None:
"""Test merging file search tool."""
@@ -717,7 +721,7 @@ class TestToolMerging:
merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage]
assert len(merged) == 1
assert isinstance(merged[0], HostedFileSearchTool)
assert merged[0] == {"type": "file_search"}
def test_merge_with_user_tools(self, mock_async_openai: MagicMock) -> None:
"""Test merging hosted and user tools."""
@@ -727,7 +731,7 @@ class TestToolMerging:
merged = provider._merge_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage]
assert len(merged) == 2
assert isinstance(merged[0], HostedCodeInterpreterTool)
assert merged[0] == {"type": "code_interpreter"}
def test_merge_multiple_hosted_tools(self, mock_async_openai: MagicMock) -> None:
"""Test merging multiple hosted tools."""
@@ -18,8 +18,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
Message,
SupportsChatGetResponse,
tool,
@@ -736,11 +734,11 @@ def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with HostedCodeInterpreterTool."""
"""Test _prepare_options with code interpreter tool."""
client = create_test_openai_assistants_client(mock_async_openai)
# Create a real HostedCodeInterpreterTool
code_tool = HostedCodeInterpreterTool()
# Create a code interpreter tool dict
code_tool = OpenAIAssistantsClient.get_code_interpreter_tool()
options = {
"tools": [code_tool],
@@ -831,12 +829,12 @@ def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None
def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with HostedFileSearchTool."""
"""Test _prepare_options with file_search tool."""
client = create_test_openai_assistants_client(mock_async_openai)
# Create a HostedFileSearchTool with max_results
file_search_tool = HostedFileSearchTool(max_results=10)
# Create a file_search tool with max_results
file_search_tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)
options = {
"tools": [file_search_tool],
@@ -851,7 +849,7 @@ def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) ->
# Check file search tool was set correctly
assert "tools" in run_options
assert len(run_options["tools"]) == 1
expected_tool = {"type": "file_search", "max_num_results": 10}
expected_tool = {"type": "file_search", "file_search": {"max_num_results": 10}}
assert run_options["tools"][0] == expected_tool
assert run_options["tool_choice"] == "auto"
@@ -1182,7 +1180,7 @@ async def test_file_search() -> None:
response = await openai_assistants_client.get_response(
messages=messages,
options={
"tools": [HostedFileSearchTool()],
"tools": [OpenAIAssistantsClient.get_file_search_tool()],
"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
},
)
@@ -1209,7 +1207,7 @@ async def test_file_search_streaming() -> None:
stream=True,
messages=messages,
options={
"tools": [HostedFileSearchTool()],
"tools": [OpenAIAssistantsClient.get_file_search_tool()],
"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
},
)
@@ -1346,7 +1344,7 @@ async def test_openai_assistants_agent_code_interpreter():
async with Agent(
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant that can write and execute Python code.",
tools=[HostedCodeInterpreterTool()],
tools=[OpenAIAssistantsClient.get_code_interpreter_tool()],
) as agent:
# Request code execution
response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.")
@@ -15,10 +15,8 @@ from pytest import param
from agent_framework import (
ChatResponse,
Content,
HostedWebSearchTool,
Message,
SupportsChatGetResponse,
ToolProtocol,
prepare_function_call_results,
tool,
)
@@ -172,18 +170,22 @@ async def test_content_filter_exception_handling(openai_unit_test_env: dict[str,
def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None:
"""Test that unsupported tool types are handled correctly."""
"""Test that unsupported tool types are passed through unchanged."""
client = OpenAIChatClient()
# Create a mock ToolProtocol that's not a FunctionTool
unsupported_tool = MagicMock(spec=ToolProtocol)
unsupported_tool.__class__.__name__ = "UnsupportedAITool"
# Create a random object that's not a FunctionTool, dict, or callable
# This simulates an unsupported tool type that gets passed through
class UnsupportedTool:
pass
# This should ignore the unsupported ToolProtocol and return empty list
unsupported_tool = UnsupportedTool()
# Unsupported tools are passed through for the API to handle/reject
result = client._prepare_tools_for_openai([unsupported_tool]) # type: ignore
assert result == {}
assert "tools" in result
assert len(result["tools"]) == 1
# Also test with a non-ToolProtocol that should be converted to dict
# Also test with a dict-based tool that should be passed through
dict_tool = {"type": "function", "name": "test"}
result = client._prepare_tools_for_openai([dict_tool]) # type: ignore
assert result["tools"] == [dict_tool]
@@ -770,8 +772,8 @@ def test_prepare_tools_with_web_search_no_location(openai_unit_test_env: dict[st
"""Test preparing web search tool without user location."""
client = OpenAIChatClient()
# Web search tool without additional_properties
web_search_tool = HostedWebSearchTool()
# Web search tool using static method
web_search_tool = OpenAIChatClient.get_web_search_tool()
result = client._prepare_tools_for_openai([web_search_tool])
@@ -1071,11 +1073,13 @@ async def test_integration_web_search() -> None:
client = OpenAIChatClient(model_id="gpt-4o-search-preview")
for streaming in [False, True]:
# Use static method for web search tool
web_search_tool = OpenAIChatClient.get_web_search_tool()
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
"tools": [web_search_tool],
},
}
if streaming:
@@ -1090,17 +1094,19 @@ async def test_integration_web_search() -> None:
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
web_search_tool_with_location = OpenAIChatClient.get_web_search_tool(
web_search_options={
"user_location": {
"type": "approximate",
"approximate": {"country": "US", "city": "Seattle"},
},
}
}
)
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
"tools": [web_search_tool_with_location],
},
}
if streaming:
@@ -31,11 +31,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedWebSearchTool,
Message,
SupportsChatGetResponse,
tool,
@@ -236,19 +231,18 @@ async def test_get_response_with_all_parameters() -> None:
)
@pytest.mark.asyncio
async def test_web_search_tool_with_location() -> None:
"""Test HostedWebSearchTool with location parameters."""
"""Test web search tool with location parameters."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test web search tool with location
web_search_tool = HostedWebSearchTool(
additional_properties={
"user_location": {
"country": "US",
"city": "Seattle",
"region": "WA",
"timezone": "America/Los_Angeles",
}
# Test web search tool with location using static method
web_search_tool = OpenAIResponsesClient.get_web_search_tool(
user_location={
"city": "Seattle",
"country": "US",
"region": "WA",
"timezone": "America/Los_Angeles",
}
)
@@ -260,38 +254,21 @@ async def test_web_search_tool_with_location() -> None:
)
async def test_file_search_tool_with_invalid_inputs() -> None:
"""Test HostedFileSearchTool with invalid vector store inputs."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test with invalid inputs type (should trigger ValueError)
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"):
await client.get_response(
messages=[Message(role="user", text="Search files")],
options={"tools": [file_search_tool]},
)
async def test_code_interpreter_tool_variations() -> None:
"""Test HostedCodeInterpreterTool with and without file inputs."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test code interpreter without files
code_tool_empty = HostedCodeInterpreterTool()
# Test code interpreter using static method
code_tool = OpenAIResponsesClient.get_code_interpreter_tool()
with pytest.raises(ServiceResponseException):
await client.get_response(
messages=[Message(role="user", text="Run some code")],
options={"tools": [code_tool_empty]},
messages=[Message("user", ["Run some code"])],
options={"tools": [code_tool]},
)
# Test code interpreter with files
code_tool_with_files = HostedCodeInterpreterTool(
inputs=[Content.from_hosted_file(file_id="file1"), Content.from_hosted_file(file_id="file2")]
)
# Test code interpreter with files using static method
code_tool_with_files = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file1", "file2"])
with pytest.raises(ServiceResponseException):
await client.get_response(
@@ -319,18 +296,20 @@ async def test_content_filter_exception() -> None:
assert "content error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_hosted_file_search_tool_validation() -> None:
"""Test get_response HostedFileSearchTool validation."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test HostedFileSearchTool without inputs (should raise ValueError)
empty_file_search_tool = HostedFileSearchTool()
# Test file search tool with vector store IDs
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=["vs_123"])
with pytest.raises((ValueError, ServiceInvalidRequestError)):
# Test using file search tool - may raise various exceptions depending on API response
with pytest.raises((ValueError, ServiceInvalidRequestError, ServiceResponseException)):
await client.get_response(
messages=[Message(role="user", text="Test")],
options={"tools": [empty_file_search_tool]},
messages=[Message("user", ["Test"])],
options={"tools": [file_search_tool]},
)
@@ -1074,18 +1053,17 @@ def test_streaming_chunk_with_usage_only() -> None:
assert update.contents[0].usage_details["total_token_count"] == 75
def test_prepare_tools_for_openai_with_hosted_mcp() -> None:
"""Test that HostedMCPTool is converted to the correct response tool dict."""
def test_prepare_tools_for_openai_with_mcp() -> None:
"""Test that MCP tool dict is converted to the correct response tool dict."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
tool = HostedMCPTool(
name="My MCP",
# Use static method to create MCP tool
tool = OpenAIResponsesClient.get_mcp_tool(
name="My_MCP",
url="https://mcp.example",
description="An MCP server",
approval_mode={"always_require_approval": ["tool_a", "tool_b"]},
allowed_tools={"tool_a", "tool_b"},
allowed_tools=["tool_a", "tool_b"],
headers={"X-Test": "yes"},
additional_properties={"custom": "value"},
approval_mode={"always_require_approval": ["tool_a", "tool_b"]},
)
resp_tools = client._prepare_tools_for_openai([tool])
@@ -1097,7 +1075,6 @@ def test_prepare_tools_for_openai_with_hosted_mcp() -> None:
assert mcp["server_label"] == "My_MCP"
# server_url may be normalized to include a trailing slash by the client
assert str(mcp["server_url"]).rstrip("/") == "https://mcp.example"
assert mcp["server_description"] == "An MCP server"
assert mcp["headers"]["X-Test"] == "yes"
assert set(mcp["allowed_tools"]) == {"tool_a", "tool_b"}
# approval mapping created from approval_mode dict
@@ -1258,13 +1235,15 @@ def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None:
assert len(image_tool) == 1
def test_prepare_tools_for_openai_with_hosted_image_generation() -> None:
"""Test HostedImageGenerationTool conversion."""
def test_prepare_tools_for_openai_with_image_generation_options() -> None:
"""Test image generation tool conversion with options."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
tool = HostedImageGenerationTool(
description="Generate images",
options={"output_format": "png", "size": "512x512"},
additional_properties={"quality": "high"},
# Use static method to create image generation tool
tool = OpenAIResponsesClient.get_image_generation_tool(
output_format="png",
size="512x512",
quality="high",
)
resp_tools = client._prepare_tools_for_openai([tool])
@@ -2324,11 +2303,13 @@ async def test_integration_web_search() -> None:
client = OpenAIResponsesClient(model_id="gpt-5")
for streaming in [False, True]:
# Use static method for web search tool
web_search_tool = OpenAIResponsesClient.get_web_search_tool()
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
"tools": [web_search_tool],
},
}
if streaming:
@@ -2343,17 +2324,14 @@ async def test_integration_web_search() -> None:
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
web_search_tool_with_location = OpenAIResponsesClient.get_web_search_tool(
user_location={"country": "US", "city": "Seattle"},
)
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
"tools": [web_search_tool_with_location],
},
}
if streaming:
@@ -2375,7 +2353,9 @@ async def test_integration_file_search() -> None:
assert isinstance(openai_responses_client, SupportsChatGetResponse)
file_id, vector_store = await create_vector_store(openai_responses_client)
# Test that the client will use the web search tool
# Use static method for file search tool
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the file search tool
response = await openai_responses_client.get_response(
messages=[
Message(
@@ -2385,7 +2365,7 @@ async def test_integration_file_search() -> None:
],
options={
"tool_choice": "auto",
"tools": [HostedFileSearchTool(inputs=vector_store)],
"tools": [file_search_tool],
},
)
@@ -2406,9 +2386,10 @@ async def test_integration_streaming_file_search() -> None:
assert isinstance(openai_responses_client, SupportsChatGetResponse)
file_id, vector_store = await create_vector_store(openai_responses_client)
# Use static method for file search tool
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the web search tool
response = openai_responses_client.get_response(
stream=True,
response = openai_responses_client.get_streaming_response(
messages=[
Message(
role="user",
@@ -2417,7 +2398,7 @@ async def test_integration_streaming_file_search() -> None:
],
options={
"tool_choice": "auto",
"tools": [HostedFileSearchTool(inputs=vector_store)],
"tools": [file_search_tool],
},
)