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
@@ -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],
},
)