mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] changed AIFunction to FunctionTool and @ai_function to @tool (#3413)
* changed AIFunction to FunctionTool and @ai_function to @tool * test and mypy fixes * mypy fix * switch function tool to always_require * fix noop * fix github copilot imports * test fixes * fix ollama test * fixes for tests * fix tests * reverted change to always_require and extended timeout * fix test
This commit is contained in:
committed by
GitHub
Unverified
parent
15b43f2abe
commit
a7d924a7d2
@@ -18,6 +18,7 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
HostedCodeInterpreterTool,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIAssistantsClient
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
@@ -253,6 +254,7 @@ def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str,
|
||||
assert "User-Agent" not in dumped_settings["default_headers"]
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
|
||||
@@ -25,7 +25,7 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._telemetry import USER_AGENT_KEY
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
@@ -631,7 +631,7 @@ async def test_streaming_with_none_delta(
|
||||
assert any(msg.contents for msg in results)
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def get_story_text() -> str:
|
||||
"""Returns a story about Emily and David."""
|
||||
return (
|
||||
@@ -642,7 +642,7 @@ def get_story_text() -> str:
|
||||
)
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location."""
|
||||
return f"The weather in {location} is sunny and 72°F."
|
||||
|
||||
@@ -20,7 +20,7 @@ from agent_framework import (
|
||||
HostedFileSearchTool,
|
||||
HostedMCPTool,
|
||||
HostedWebSearchTool,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
@@ -41,7 +41,7 @@ class OutputStruct(BaseModel):
|
||||
weather: str
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
# Implementation of the tool to get weather
|
||||
|
||||
@@ -23,7 +23,7 @@ from agent_framework import (
|
||||
Content,
|
||||
Role,
|
||||
ToolProtocol,
|
||||
ai_function,
|
||||
tool,
|
||||
use_chat_middleware,
|
||||
use_function_invocation,
|
||||
)
|
||||
@@ -65,10 +65,10 @@ def ai_tool() -> ToolProtocol:
|
||||
|
||||
|
||||
@fixture
|
||||
def ai_function_tool() -> ToolProtocol:
|
||||
def tool_tool() -> ToolProtocol:
|
||||
"""Returns a executable ToolProtocol."""
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def simple_function(x: int, y: int) -> int:
|
||||
"""A simple function that adds two numbers."""
|
||||
return x + y
|
||||
|
||||
@@ -23,7 +23,7 @@ from agent_framework import (
|
||||
ContextProvider,
|
||||
HostedCodeInterpreterTool,
|
||||
Role,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework.exceptions import AgentExecutionException
|
||||
@@ -423,7 +423,7 @@ async def test_chat_agent_as_tool_no_name(chat_client: ChatClientProtocol) -> No
|
||||
|
||||
|
||||
async def test_chat_agent_as_tool_function_execution(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that the generated AIFunction can be executed."""
|
||||
"""Test that the generated FunctionTool can be executed."""
|
||||
agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent")
|
||||
|
||||
tool = agent.as_tool()
|
||||
@@ -564,7 +564,7 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@ai_function(name="echo_thread_info")
|
||||
@tool(name="echo_thread_info", approval_mode="never_require")
|
||||
def echo_thread_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
|
||||
thread = kwargs.get("thread")
|
||||
captured["has_thread"] = thread is not None
|
||||
@@ -596,9 +596,7 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
|
||||
assert captured.get("has_message_store") is True
|
||||
|
||||
|
||||
async def test_chat_agent_tool_choice_run_level_overrides_agent_level(
|
||||
chat_client_base: Any, ai_function_tool: Any
|
||||
) -> None:
|
||||
async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_client_base: Any, tool_tool: Any) -> None:
|
||||
"""Verify that tool_choice passed to run() overrides agent-level tool_choice."""
|
||||
|
||||
captured_options: list[dict[str, Any]] = []
|
||||
@@ -617,7 +615,7 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(
|
||||
# Create agent with agent-level tool_choice="auto" and a tool (tools required for tool_choice to be meaningful)
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client_base,
|
||||
tools=[ai_function_tool],
|
||||
tools=[tool_tool],
|
||||
options={"tool_choice": "auto"},
|
||||
)
|
||||
|
||||
@@ -630,7 +628,7 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(
|
||||
|
||||
|
||||
async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specified(
|
||||
chat_client_base: Any, ai_function_tool: Any
|
||||
chat_client_base: Any, tool_tool: Any
|
||||
) -> None:
|
||||
"""Verify that agent-level tool_choice is used when run() doesn't specify one."""
|
||||
from agent_framework import ChatOptions
|
||||
@@ -650,7 +648,7 @@ async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specif
|
||||
# Create agent with agent-level tool_choice="required" and a tool
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client_base,
|
||||
tools=[ai_function_tool],
|
||||
tools=[tool_tool],
|
||||
default_options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
@@ -664,9 +662,7 @@ async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specif
|
||||
assert captured_options[0]["tool_choice"] == "required"
|
||||
|
||||
|
||||
async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(
|
||||
chat_client_base: Any, ai_function_tool: Any
|
||||
) -> None:
|
||||
async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(chat_client_base: Any, tool_tool: Any) -> None:
|
||||
"""Verify that tool_choice=None at run() uses agent-level default."""
|
||||
from agent_framework import ChatOptions
|
||||
|
||||
@@ -685,7 +681,7 @@ async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(
|
||||
# Create agent with agent-level tool_choice="auto" and a tool
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client_base,
|
||||
tools=[ai_function_tool],
|
||||
tools=[tool_tool],
|
||||
default_options={"tool_choice": "auto"},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
ChatAgent,
|
||||
HostedCodeInterpreterTool,
|
||||
HostedImageGenerationTool,
|
||||
HostedMCPTool,
|
||||
MCPStreamableHTTPTool,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
|
||||
or os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"),
|
||||
reason="No real OPENAI_API_KEY provided; skipping integration tests."
|
||||
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
|
||||
else "Integration tests are disabled.",
|
||||
)
|
||||
|
||||
|
||||
@ai_function
|
||||
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
# Implementation of the tool to get weather
|
||||
return f"The current weather in {location} is sunny."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_basic_run_streaming():
|
||||
"""Test OpenAI Responses Client agent basic streaming functionality with OpenAIResponsesClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
) as agent:
|
||||
# Test streaming run
|
||||
full_text = ""
|
||||
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
|
||||
assert isinstance(chunk, AgentResponseUpdate)
|
||||
if chunk.text:
|
||||
full_text += chunk.text
|
||||
|
||||
assert len(full_text) > 0
|
||||
assert "streaming response test" in full_text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_thread_persistence():
|
||||
"""Test OpenAI Responses Client agent thread persistence across runs with OpenAIResponsesClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent:
|
||||
# Create a new thread that will be reused
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# First interaction
|
||||
first_response = await agent.run("My favorite programming language is Python. Remember this.", thread=thread)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
|
||||
# Second interaction - test memory
|
||||
second_response = await agent.run("What is my favorite programming language?", thread=thread)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_thread_storage_with_store_true():
|
||||
"""Test OpenAI Responses Client agent with store=True to verify service_thread_id is returned."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant.",
|
||||
) as agent:
|
||||
# Create a new thread
|
||||
thread = AgentThread()
|
||||
|
||||
# Initially, service_thread_id should be None
|
||||
assert thread.service_thread_id is None
|
||||
|
||||
# Run with store=True to store messages on OpenAI side
|
||||
response = await agent.run(
|
||||
"Hello! Please remember that my name is Alex.",
|
||||
thread=thread,
|
||||
options={"store": True},
|
||||
)
|
||||
|
||||
# Validate response
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
# After store=True, service_thread_id should be populated
|
||||
assert thread.service_thread_id is not None
|
||||
assert isinstance(thread.service_thread_id, str)
|
||||
assert len(thread.service_thread_id) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_existing_thread():
|
||||
"""Test OpenAI Responses Client agent with existing thread to continue conversations across agent instances."""
|
||||
# First conversation - capture the thread
|
||||
preserved_thread = None
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
# Start a conversation and capture the thread
|
||||
thread = first_agent.get_new_thread()
|
||||
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
|
||||
# Preserve the thread for reuse
|
||||
preserved_thread = thread
|
||||
|
||||
# Second conversation - reuse the thread in a new agent instance
|
||||
if preserved_thread:
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
# Reuse the preserved thread
|
||||
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
assert "photography" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_hosted_code_interpreter_tool():
|
||||
"""Test OpenAI Responses Client agent with HostedCodeInterpreterTool through OpenAIResponsesClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant that can execute Python code.",
|
||||
tools=[HostedCodeInterpreterTool()],
|
||||
) as agent:
|
||||
# Test code interpreter functionality
|
||||
response = await agent.run("Calculate the sum of numbers from 1 to 10 using Python code.")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
# Should contain calculation result (sum of 1-10 = 55) or code execution content
|
||||
contains_relevant_content = any(
|
||||
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
|
||||
)
|
||||
assert contains_relevant_content or len(response.text.strip()) > 10
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_image_generation_tool():
|
||||
"""Test OpenAI Responses Client agent with raw image_generation tool through OpenAIResponsesClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant that can generate images.",
|
||||
tools=HostedImageGenerationTool(options={"image_size": "1024x1024", "media_type": "png"}),
|
||||
) as agent:
|
||||
# Test image generation functionality
|
||||
response = await agent.run("Generate an image of a cute red panda sitting on a tree branch in a forest.")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.messages
|
||||
|
||||
# Verify we got image content - look for ImageGenerationToolResultContent
|
||||
image_content_found = False
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
if content.type == "image_generation_tool_result" and content.outputs:
|
||||
image_content_found = True
|
||||
break
|
||||
if image_content_found:
|
||||
break
|
||||
|
||||
# The test passes if we got image content
|
||||
assert image_content_found, "Expected to find image content in response"
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with OpenAI Responses Client."""
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant that uses available tools.",
|
||||
tools=[get_weather], # Agent-level tool
|
||||
) as agent:
|
||||
# First run - agent-level tool should be available
|
||||
first_response = await agent.run("What's the weather like in Chicago?")
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
# Should use the agent-level weather tool
|
||||
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
|
||||
|
||||
# Second run - agent-level tool should still be available (persistence test)
|
||||
second_response = await agent.run("What's the weather in Miami?")
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
# Should use the agent-level weather tool again
|
||||
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_run_level_tool_isolation():
|
||||
"""Test that run-level tools are isolated to specific runs and don't persist with OpenAI Responses Client."""
|
||||
# Counter to track how many times the weather tool is called
|
||||
call_count = 0
|
||||
|
||||
@ai_function
|
||||
async def get_weather_with_counter(
|
||||
location: Annotated[str, "The location as a city name"],
|
||||
) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return f"The weather in {location} is sunny and 72°F."
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant.",
|
||||
) as agent:
|
||||
# First run - use run-level tool
|
||||
first_response = await agent.run(
|
||||
"What's the weather like in Chicago?",
|
||||
tools=[get_weather_with_counter], # Run-level tool
|
||||
)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
# Should use the run-level weather tool (call count should be 1)
|
||||
assert call_count == 1
|
||||
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
|
||||
|
||||
# Second run - run-level tool should NOT persist (key isolation test)
|
||||
second_response = await agent.run("What's the weather like in Miami?")
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
# Should NOT use the weather tool since it was only run-level in previous call
|
||||
# Call count should still be 1 (no additional calls)
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_chat_options_agent_level() -> None:
|
||||
"""Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant.",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"max_tokens": 100,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"seed": 123,
|
||||
"user": "comprehensive-test-user",
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"Provide a brief, helpful response.",
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_hosted_mcp_tool() -> None:
|
||||
"""Integration test for HostedMCPTool with OpenAI Response Agent using Microsoft Learn MCP."""
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
|
||||
tools=HostedMCPTool(
|
||||
name="Microsoft Learn MCP",
|
||||
url="https://learn.microsoft.com/api/mcp",
|
||||
description="A Microsoft Learn MCP server for documentation questions",
|
||||
approval_mode="never_require",
|
||||
),
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"How to create an Azure storage account using az cli?",
|
||||
# this needs to be high enough to handle the full MCP tool response.
|
||||
options={"max_tokens": 5000},
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text
|
||||
# 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"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_local_mcp_tool() -> None:
|
||||
"""Integration test for MCPStreamableHTTPTool with OpenAI Response Agent using Microsoft Learn MCP."""
|
||||
|
||||
mcp_tool = MCPStreamableHTTPTool(
|
||||
name="Microsoft Learn MCP",
|
||||
url="https://learn.microsoft.com/api/mcp",
|
||||
)
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
|
||||
tools=[mcp_tool],
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"How to create an Azure storage account using az cli?",
|
||||
options={"max_tokens": 200},
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
# 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"])
|
||||
|
||||
|
||||
class ReleaseBrief(BaseModel):
|
||||
"""Structured output model for release brief testing."""
|
||||
|
||||
title: str
|
||||
summary: str
|
||||
highlights: list[str]
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_with_response_format_pydantic() -> None:
|
||||
"""Integration test for response_format with Pydantic model using OpenAI Responses Client."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="You are a helpful assistant that returns structured JSON responses.",
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"Summarize the following release notes into a ReleaseBrief:\n\n"
|
||||
"Version 2.0 Release Notes:\n"
|
||||
"- Added new streaming API for real-time responses\n"
|
||||
"- Improved error handling with detailed messages\n"
|
||||
"- Performance boost of 50% in batch processing\n"
|
||||
"- Fixed memory leak in connection pooling",
|
||||
options={
|
||||
"response_format": ReleaseBrief,
|
||||
},
|
||||
)
|
||||
|
||||
# Validate response
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.value is not None
|
||||
assert isinstance(response.value, ReleaseBrief)
|
||||
|
||||
# Validate structured output fields
|
||||
brief = response.value
|
||||
assert len(brief.title) > 0
|
||||
assert len(brief.summary) > 0
|
||||
assert len(brief.highlights) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_with_runtime_json_schema() -> None:
|
||||
"""Integration test for response_format with runtime JSON schema using OpenAI Responses Client."""
|
||||
runtime_schema = {
|
||||
"title": "WeatherDigest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"conditions": {"type": "string"},
|
||||
"temperature_c": {"type": "number"},
|
||||
"advisory": {"type": "string"},
|
||||
},
|
||||
"required": ["location", "conditions", "temperature_c", "advisory"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
instructions="Return only JSON that matches the provided schema. Do not add commentary.",
|
||||
) as agent:
|
||||
response = await agent.run(
|
||||
"Give a brief weather digest for Seattle.",
|
||||
options={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": runtime_schema["title"],
|
||||
"strict": True,
|
||||
"schema": runtime_schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Validate response
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
|
||||
# Parse JSON and validate structure
|
||||
parsed = json.loads(response.text)
|
||||
assert "location" in parsed
|
||||
assert "conditions" in parsed
|
||||
assert "temperature_c" in parsed
|
||||
assert "advisory" in parsed
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -13,7 +14,7 @@ from agent_framework import (
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
Role,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware
|
||||
|
||||
@@ -21,7 +22,7 @@ from agent_framework._middleware import FunctionInvocationContext, FunctionMiddl
|
||||
async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol):
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -57,7 +58,7 @@ async def test_base_client_with_function_calling(chat_client_base: ChatClientPro
|
||||
async def test_base_client_with_function_calling_resets(chat_client_base: ChatClientProtocol):
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -99,7 +100,7 @@ async def test_base_client_with_function_calling_resets(chat_client_base: ChatCl
|
||||
async def test_base_client_with_streaming_function_calling(chat_client_base: ChatClientProtocol):
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -142,7 +143,7 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: ChatC
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="start_todo_investigation")
|
||||
@tool(name="start_todo_investigation", approval_mode="never_require")
|
||||
def ai_func(user_query: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -199,7 +200,7 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Cha
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="start_threaded_investigation")
|
||||
@tool(name="start_threaded_investigation", approval_mode="never_require")
|
||||
def ai_func(user_query: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -319,13 +320,13 @@ async def test_function_invocation_scenarios(
|
||||
# Simulate a service-side thread with conversation_id
|
||||
conversation_id = "test-thread-123"
|
||||
|
||||
@ai_function(name="no_approval_func")
|
||||
@tool(name="no_approval_func", approval_mode="never_require")
|
||||
def func_no_approval(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return f"Processed {arg1}"
|
||||
|
||||
@ai_function(name="approval_func", approval_mode="always_require")
|
||||
@tool(name="approval_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -473,13 +474,13 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol):
|
||||
exec_counter_approved = 0
|
||||
exec_counter_rejected = 0
|
||||
|
||||
@ai_function(name="approved_func", approval_mode="always_require")
|
||||
@tool(name="approved_func", approval_mode="always_require")
|
||||
def func_approved(arg1: str) -> str:
|
||||
nonlocal exec_counter_approved
|
||||
exec_counter_approved += 1
|
||||
return f"Approved {arg1}"
|
||||
|
||||
@ai_function(name="rejected_func", approval_mode="always_require")
|
||||
@tool(name="rejected_func", approval_mode="always_require")
|
||||
def func_rejected(arg1: str) -> str:
|
||||
nonlocal exec_counter_rejected
|
||||
exec_counter_rejected += 1
|
||||
@@ -569,7 +570,7 @@ async def test_approval_requests_in_assistant_message(chat_client_base: ChatClie
|
||||
"""Approval requests should be added to the assistant message that contains the function call."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_func", approval_mode="always_require")
|
||||
@tool(name="test_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -604,7 +605,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_func", approval_mode="always_require")
|
||||
@tool(name="test_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -656,7 +657,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch
|
||||
async def test_no_duplicate_function_calls_after_approval_processing(chat_client_base: ChatClientProtocol):
|
||||
"""Processing approval should not create duplicate function calls in messages."""
|
||||
|
||||
@ai_function(name="test_func", approval_mode="always_require")
|
||||
@tool(name="test_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
return f"Result {arg1}"
|
||||
|
||||
@@ -700,7 +701,7 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client
|
||||
async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClientProtocol):
|
||||
"""Rejection error result should use the function call's call_id, not the approval's id."""
|
||||
|
||||
@ai_function(name="test_func", approval_mode="always_require")
|
||||
@tool(name="test_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
return f"Result {arg1}"
|
||||
|
||||
@@ -745,7 +746,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol):
|
||||
"""Test that MAX_ITERATIONS in additional_properties limits function call loops."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -790,7 +791,7 @@ async def test_function_invocation_config_enabled_false(chat_client_base: ChatCl
|
||||
"""Test that setting enabled=False disables function invocation."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -814,7 +815,7 @@ async def test_function_invocation_config_enabled_false(chat_client_base: ChatCl
|
||||
async def test_function_invocation_config_max_consecutive_errors(chat_client_base: ChatClientProtocol):
|
||||
"""Test that max_consecutive_errors_per_request limits error retries."""
|
||||
|
||||
@ai_function(name="error_function")
|
||||
@tool(name="error_function", approval_mode="never_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
raise ValueError("Function error")
|
||||
|
||||
@@ -882,7 +883,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_
|
||||
"""Test that terminate_on_unknown_calls=False returns error message for unknown functions."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="known_function")
|
||||
@tool(name="known_function")
|
||||
def known_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -917,7 +918,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_c
|
||||
"""Test that terminate_on_unknown_calls=True stops execution on unknown functions."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="known_function")
|
||||
@tool(name="known_function")
|
||||
def known_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -949,13 +950,13 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Cha
|
||||
exec_counter_visible = 0
|
||||
exec_counter_hidden = 0
|
||||
|
||||
@ai_function(name="visible_function")
|
||||
@tool(name="visible_function")
|
||||
def visible_func(arg1: str) -> str:
|
||||
nonlocal exec_counter_visible
|
||||
exec_counter_visible += 1
|
||||
return f"Visible {arg1}"
|
||||
|
||||
@ai_function(name="hidden_function")
|
||||
@tool(name="hidden_function")
|
||||
def hidden_func(arg1: str) -> str:
|
||||
nonlocal exec_counter_hidden
|
||||
exec_counter_hidden += 1
|
||||
@@ -996,7 +997,7 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Cha
|
||||
async def test_function_invocation_config_include_detailed_errors_false(chat_client_base: ChatClientProtocol):
|
||||
"""Test that include_detailed_errors=False returns generic error messages."""
|
||||
|
||||
@ai_function(name="error_function")
|
||||
@tool(name="error_function", approval_mode="never_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
raise ValueError("Specific error message that should not appear")
|
||||
|
||||
@@ -1030,7 +1031,7 @@ async def test_function_invocation_config_include_detailed_errors_false(chat_cli
|
||||
async def test_function_invocation_config_include_detailed_errors_true(chat_client_base: ChatClientProtocol):
|
||||
"""Test that include_detailed_errors=True returns detailed error information."""
|
||||
|
||||
@ai_function(name="error_function")
|
||||
@tool(name="error_function", approval_mode="never_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
raise ValueError("Specific error message that should appear")
|
||||
|
||||
@@ -1100,7 +1101,7 @@ async def test_function_invocation_config_validation_max_consecutive_errors():
|
||||
async def test_argument_validation_error_with_detailed_errors(chat_client_base: ChatClientProtocol):
|
||||
"""Test that argument validation errors include details when include_detailed_errors=True."""
|
||||
|
||||
@ai_function(name="typed_function")
|
||||
@tool(name="typed_function", approval_mode="never_require")
|
||||
def typed_func(arg1: int) -> str: # Expects int, not str
|
||||
return f"Got {arg1}"
|
||||
|
||||
@@ -1134,7 +1135,7 @@ async def test_argument_validation_error_with_detailed_errors(chat_client_base:
|
||||
async def test_argument_validation_error_without_detailed_errors(chat_client_base: ChatClientProtocol):
|
||||
"""Test that argument validation errors are generic when include_detailed_errors=False."""
|
||||
|
||||
@ai_function(name="typed_function")
|
||||
@tool(name="typed_function", approval_mode="never_require")
|
||||
def typed_func(arg1: int) -> str: # Expects int, not str
|
||||
return f"Got {arg1}"
|
||||
|
||||
@@ -1168,7 +1169,7 @@ async def test_argument_validation_error_without_detailed_errors(chat_client_bas
|
||||
async def test_hosted_tool_approval_response(chat_client_base: ChatClientProtocol):
|
||||
"""Test handling of approval responses for hosted tools (tools not in tool_map)."""
|
||||
|
||||
@ai_function(name="local_function")
|
||||
@tool(name="local_function")
|
||||
def local_func(arg1: str) -> str:
|
||||
return f"Local {arg1}"
|
||||
|
||||
@@ -1201,7 +1202,7 @@ async def test_hosted_tool_approval_response(chat_client_base: ChatClientProtoco
|
||||
async def test_unapproved_tool_execution_raises_exception(chat_client_base: ChatClientProtocol):
|
||||
"""Test that attempting to execute an unapproved tool raises ToolException."""
|
||||
|
||||
@ai_function(name="test_function", approval_mode="always_require")
|
||||
@tool(name="test_function", approval_mode="always_require")
|
||||
def test_func(arg1: str) -> str:
|
||||
return f"Result {arg1}"
|
||||
|
||||
@@ -1256,7 +1257,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="error_func", approval_mode="always_require")
|
||||
@tool(name="error_func", approval_mode="always_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1319,7 +1320,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="error_func", approval_mode="always_require")
|
||||
@tool(name="error_func", approval_mode="always_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1380,7 +1381,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Ch
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="typed_func", approval_mode="always_require")
|
||||
@tool(name="typed_func", approval_mode="always_require")
|
||||
def typed_func(arg1: int) -> str: # Expects int, not str
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1441,7 +1442,7 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha
|
||||
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="success_func", approval_mode="always_require")
|
||||
@tool(name="success_func", approval_mode="always_require")
|
||||
def success_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1493,10 +1494,10 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha
|
||||
|
||||
async def test_declaration_only_tool(chat_client_base: ChatClientProtocol):
|
||||
"""Test that declaration_only tools without implementation (func=None) are not executed."""
|
||||
from agent_framework import AIFunction
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
# Create a truly declaration-only function with no implementation
|
||||
declaration_func = AIFunction(
|
||||
declaration_func = FunctionTool(
|
||||
name="declaration_func",
|
||||
func=None,
|
||||
description="A declaration-only function for testing",
|
||||
@@ -1547,14 +1548,14 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Chat
|
||||
|
||||
exec_order = []
|
||||
|
||||
@ai_function(name="func1")
|
||||
@tool(name="func1", approval_mode="never_require")
|
||||
async def func1(arg1: str) -> str:
|
||||
exec_order.append("func1_start")
|
||||
await asyncio.sleep(0.01) # Small delay
|
||||
exec_order.append("func1_end")
|
||||
return f"Result1 {arg1}"
|
||||
|
||||
@ai_function(name="func2")
|
||||
@tool(name="func2", approval_mode="never_require")
|
||||
async def func2(arg1: str) -> str:
|
||||
exec_order.append("func2_start")
|
||||
await asyncio.sleep(0.01) # Small delay
|
||||
@@ -1587,10 +1588,11 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Chat
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
async def test_callable_function_converted_to_ai_function(chat_client_base: ChatClientProtocol):
|
||||
"""Test that plain callable functions are converted to AIFunction."""
|
||||
async def test_callable_function_converted_to_tool(chat_client_base: ChatClientProtocol):
|
||||
"""Test that plain callable functions are converted to FunctionTool."""
|
||||
exec_counter = 0
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def plain_function(arg1: str) -> str:
|
||||
"""A plain function without decorator."""
|
||||
nonlocal exec_counter
|
||||
@@ -1621,7 +1623,7 @@ async def test_callable_function_converted_to_ai_function(chat_client_base: Chat
|
||||
async def test_conversation_id_handling(chat_client_base: ChatClientProtocol):
|
||||
"""Test that conversation_id is properly handled and messages are cleared."""
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def test_func(arg1: str) -> str:
|
||||
return f"Result {arg1}"
|
||||
|
||||
@@ -1653,7 +1655,7 @@ async def test_conversation_id_handling(chat_client_base: ChatClientProtocol):
|
||||
async def test_function_result_appended_to_existing_assistant_message(chat_client_base: ChatClientProtocol):
|
||||
"""Test that function results are appended to existing assistant message when appropriate."""
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def test_func(arg1: str) -> str:
|
||||
return f"Result {arg1}"
|
||||
|
||||
@@ -1685,7 +1687,7 @@ async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtoco
|
||||
|
||||
call_count = 0
|
||||
|
||||
@ai_function(name="sometimes_fails")
|
||||
@tool(name="sometimes_fails", approval_mode="never_require")
|
||||
def sometimes_fails(arg1: str) -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -1741,7 +1743,7 @@ async def test_streaming_approval_request_generated(chat_client_base: ChatClient
|
||||
"""Test that approval requests are generated correctly in streaming mode."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_func", approval_mode="always_require")
|
||||
@tool(name="test_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1777,7 +1779,7 @@ async def test_streaming_max_iterations_limit(chat_client_base: ChatClientProtoc
|
||||
"""Test that MAX_ITERATIONS in streaming mode limits function call loops."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1829,7 +1831,7 @@ async def test_streaming_function_invocation_config_enabled_false(chat_client_ba
|
||||
"""Test that setting enabled=False disables function invocation in streaming mode."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1857,7 +1859,7 @@ async def test_streaming_function_invocation_config_enabled_false(chat_client_ba
|
||||
async def test_streaming_function_invocation_config_max_consecutive_errors(chat_client_base: ChatClientProtocol):
|
||||
"""Test that max_consecutive_errors_per_request limits error retries in streaming mode."""
|
||||
|
||||
@ai_function(name="error_function")
|
||||
@tool(name="error_function", approval_mode="never_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
raise ValueError("Function error")
|
||||
|
||||
@@ -1920,7 +1922,7 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_f
|
||||
"""Test that terminate_on_unknown_calls=False returns error message for unknown functions in streaming mode."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="known_function")
|
||||
@tool(name="known_function", approval_mode="never_require")
|
||||
def known_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1963,7 +1965,7 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_t
|
||||
"""Test that terminate_on_unknown_calls=True stops execution on unknown functions in streaming mode."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="known_function")
|
||||
@tool(name="known_function", approval_mode="never_require")
|
||||
def known_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -1996,7 +1998,7 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_t
|
||||
async def test_streaming_function_invocation_config_include_detailed_errors_true(chat_client_base: ChatClientProtocol):
|
||||
"""Test that include_detailed_errors=True returns detailed error information in streaming mode."""
|
||||
|
||||
@ai_function(name="error_function")
|
||||
@tool(name="error_function", approval_mode="never_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
raise ValueError("Specific error message that should appear")
|
||||
|
||||
@@ -2036,7 +2038,7 @@ async def test_streaming_function_invocation_config_include_detailed_errors_fals
|
||||
):
|
||||
"""Test that include_detailed_errors=False returns generic error messages in streaming mode."""
|
||||
|
||||
@ai_function(name="error_function")
|
||||
@tool(name="error_function", approval_mode="never_require")
|
||||
def error_func(arg1: str) -> str:
|
||||
raise ValueError("Specific error message that should not appear")
|
||||
|
||||
@@ -2074,7 +2076,7 @@ async def test_streaming_function_invocation_config_include_detailed_errors_fals
|
||||
async def test_streaming_argument_validation_error_with_detailed_errors(chat_client_base: ChatClientProtocol):
|
||||
"""Test that argument validation errors include details when include_detailed_errors=True in streaming mode."""
|
||||
|
||||
@ai_function(name="typed_function")
|
||||
@tool(name="typed_function", approval_mode="never_require")
|
||||
def typed_func(arg1: int) -> str: # Expects int, not str
|
||||
return f"Got {arg1}"
|
||||
|
||||
@@ -2112,7 +2114,7 @@ async def test_streaming_argument_validation_error_with_detailed_errors(chat_cli
|
||||
async def test_streaming_argument_validation_error_without_detailed_errors(chat_client_base: ChatClientProtocol):
|
||||
"""Test that argument validation errors are generic when include_detailed_errors=False in streaming mode."""
|
||||
|
||||
@ai_function(name="typed_function")
|
||||
@tool(name="typed_function", approval_mode="never_require")
|
||||
def typed_func(arg1: int) -> str: # Expects int, not str
|
||||
return f"Got {arg1}"
|
||||
|
||||
@@ -2149,18 +2151,17 @@ async def test_streaming_argument_validation_error_without_detailed_errors(chat_
|
||||
|
||||
async def test_streaming_multiple_function_calls_parallel_execution(chat_client_base: ChatClientProtocol):
|
||||
"""Test that multiple function calls are executed in parallel in streaming mode."""
|
||||
import asyncio
|
||||
|
||||
exec_order = []
|
||||
|
||||
@ai_function(name="func1")
|
||||
@tool(name="func1", approval_mode="never_require")
|
||||
async def func1(arg1: str) -> str:
|
||||
exec_order.append("func1_start")
|
||||
await asyncio.sleep(0.01) # Small delay
|
||||
exec_order.append("func1_end")
|
||||
return f"Result1 {arg1}"
|
||||
|
||||
@ai_function(name="func2")
|
||||
@tool(name="func2", approval_mode="never_require")
|
||||
async def func2(arg1: str) -> str:
|
||||
exec_order.append("func2_start")
|
||||
await asyncio.sleep(0.01) # Small delay
|
||||
@@ -2202,7 +2203,7 @@ async def test_streaming_approval_requests_in_assistant_message(chat_client_base
|
||||
"""Approval requests should be added to assistant updates in streaming mode."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_func", approval_mode="always_require")
|
||||
@tool(name="test_func", approval_mode="always_require")
|
||||
def func_with_approval(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -2238,7 +2239,7 @@ async def test_streaming_error_recovery_resets_counter(chat_client_base: ChatCli
|
||||
|
||||
call_count = 0
|
||||
|
||||
@ai_function(name="sometimes_fails")
|
||||
@tool(name="sometimes_fails", approval_mode="never_require")
|
||||
def sometimes_fails(arg1: str) -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
@@ -2306,7 +2307,7 @@ async def test_terminate_loop_single_function_call(chat_client_base: ChatClientP
|
||||
"""Test that terminate_loop=True exits the function calling loop after single function call."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
@@ -2367,13 +2368,13 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client
|
||||
normal_call_count = 0
|
||||
terminating_call_count = 0
|
||||
|
||||
@ai_function(name="normal_function")
|
||||
@tool(name="normal_function", approval_mode="never_require")
|
||||
def normal_func(arg1: str) -> str:
|
||||
nonlocal normal_call_count
|
||||
normal_call_count += 1
|
||||
return f"Normal {arg1}"
|
||||
|
||||
@ai_function(name="terminating_function")
|
||||
@tool(name="terminating_function", approval_mode="never_require")
|
||||
def terminating_func(arg1: str) -> str:
|
||||
nonlocal terminating_call_count
|
||||
terminating_call_count += 1
|
||||
@@ -2423,7 +2424,7 @@ async def test_terminate_loop_streaming_single_function_call(chat_client_base: C
|
||||
"""Test that terminate_loop=True exits the streaming function calling loop."""
|
||||
exec_counter = 0
|
||||
|
||||
@ai_function(name="test_function")
|
||||
@tool(name="test_function", approval_mode="never_require")
|
||||
def ai_func(arg1: str) -> str:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for kwargs propagation from get_response() to @ai_function tools."""
|
||||
"""Tests for kwargs propagation from get_response() to @tool functions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -9,19 +9,19 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._tools import _handle_function_calls_response, _handle_function_calls_streaming_response
|
||||
|
||||
|
||||
class TestKwargsPropagationToAIFunction:
|
||||
"""Test cases for kwargs flowing from get_response() to @ai_function tools."""
|
||||
class TestKwargsPropagationToFunctionTool:
|
||||
"""Test cases for kwargs flowing from get_response() to @tool functions."""
|
||||
|
||||
async def test_kwargs_propagate_to_ai_function_with_kwargs(self) -> None:
|
||||
"""Test that kwargs passed to get_response() are available in @ai_function **kwargs."""
|
||||
async def test_kwargs_propagate_to_tool_with_kwargs(self) -> None:
|
||||
"""Test that kwargs passed to get_response() are available in @tool **kwargs."""
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def capture_kwargs_tool(x: int, **kwargs: Any) -> str:
|
||||
"""A tool that captures kwargs for testing."""
|
||||
captured_kwargs.update(kwargs)
|
||||
@@ -75,10 +75,10 @@ class TestKwargsPropagationToAIFunction:
|
||||
# Verify result
|
||||
assert result.messages[-1].text == "Done!"
|
||||
|
||||
async def test_kwargs_not_forwarded_to_ai_function_without_kwargs(self) -> None:
|
||||
"""Test that kwargs are NOT forwarded to @ai_function that doesn't accept **kwargs."""
|
||||
async def test_kwargs_not_forwarded_to_tool_without_kwargs(self) -> None:
|
||||
"""Test that kwargs are NOT forwarded to @tool that doesn't accept **kwargs."""
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def simple_tool(x: int) -> str:
|
||||
"""A simple tool without **kwargs."""
|
||||
# This should not receive any extra kwargs
|
||||
@@ -120,7 +120,7 @@ class TestKwargsPropagationToAIFunction:
|
||||
"""Test that kwargs don't leak between different function call invocations."""
|
||||
invocation_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def tracking_tool(name: str, **kwargs: Any) -> str:
|
||||
"""A tool that tracks kwargs from each invocation."""
|
||||
invocation_kwargs.append(dict(kwargs))
|
||||
@@ -170,10 +170,10 @@ class TestKwargsPropagationToAIFunction:
|
||||
assert result.messages[-1].text == "All done!"
|
||||
|
||||
async def test_streaming_response_kwargs_propagation(self) -> None:
|
||||
"""Test that kwargs propagate to @ai_function in streaming mode."""
|
||||
"""Test that kwargs propagate to @tool in streaming mode."""
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def streaming_capture_tool(value: str, **kwargs: Any) -> str:
|
||||
"""A tool that captures kwargs during streaming."""
|
||||
captured_kwargs.update(kwargs)
|
||||
|
||||
@@ -1228,7 +1228,7 @@ async def test_streamable_http_integration():
|
||||
if not url.startswith("http"):
|
||||
pytest.skip("LOCAL_MCP_URL is not an HTTP URL")
|
||||
|
||||
tool = MCPStreamableHTTPTool(name="integration_test", url=url)
|
||||
tool = MCPStreamableHTTPTool(name="integration_test", url=url, approval_mode="never_require")
|
||||
|
||||
async with tool:
|
||||
# Test that we can connect and load tools
|
||||
@@ -1260,7 +1260,7 @@ async def test_mcp_connection_reset_integration():
|
||||
"""
|
||||
url = os.environ.get("LOCAL_MCP_URL")
|
||||
|
||||
tool = MCPStreamableHTTPTool(name="integration_test", url=url)
|
||||
tool = MCPStreamableHTTPTool(name="integration_test", url=url, approval_mode="never_require")
|
||||
|
||||
async with tool:
|
||||
# Verify initial connection
|
||||
@@ -1620,7 +1620,7 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs():
|
||||
async def test_mcp_tool_deduplication():
|
||||
"""Test that MCP tools are not duplicated in MCPTool"""
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework._tools import AIFunction
|
||||
from agent_framework._tools import FunctionTool
|
||||
|
||||
# Create MCPStreamableHTTPTool instance
|
||||
tool = MCPTool(name="test_mcp_tool")
|
||||
@@ -1629,12 +1629,12 @@ async def test_mcp_tool_deduplication():
|
||||
tool._functions = []
|
||||
|
||||
# Add initial functions
|
||||
func1 = AIFunction(
|
||||
func1 = FunctionTool(
|
||||
func=lambda x: f"Result: {x}",
|
||||
name="analyze_content",
|
||||
description="Analyzes content",
|
||||
)
|
||||
func2 = AIFunction(
|
||||
func2 = FunctionTool(
|
||||
func=lambda x: f"Extract: {x}",
|
||||
name="extract_info",
|
||||
description="Extracts information",
|
||||
@@ -1662,7 +1662,7 @@ async def test_mcp_tool_deduplication():
|
||||
if tool_name in existing_names:
|
||||
continue # Skip duplicates
|
||||
|
||||
new_func = AIFunction(func=lambda x: f"Process: {x}", name=tool_name, description=description)
|
||||
new_func = FunctionTool(func=lambda x: f"Process: {x}", name=tool_name, description=description)
|
||||
tool._functions.append(new_func)
|
||||
existing_names.add(tool_name)
|
||||
added_count += 1
|
||||
|
||||
@@ -28,7 +28,7 @@ from agent_framework._middleware import (
|
||||
FunctionMiddleware,
|
||||
FunctionMiddlewarePipeline,
|
||||
)
|
||||
from agent_framework._tools import AIFunction
|
||||
from agent_framework._tools import FunctionTool
|
||||
|
||||
|
||||
class TestAgentRunContext:
|
||||
@@ -73,7 +73,7 @@ class TestAgentRunContext:
|
||||
class TestFunctionInvocationContext:
|
||||
"""Test cases for FunctionInvocationContext."""
|
||||
|
||||
def test_init_with_defaults(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
def test_init_with_defaults(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test FunctionInvocationContext initialization with default values."""
|
||||
arguments = FunctionTestArgs(name="test")
|
||||
context = FunctionInvocationContext(function=mock_function, arguments=arguments)
|
||||
@@ -82,7 +82,7 @@ class TestFunctionInvocationContext:
|
||||
assert context.arguments == arguments
|
||||
assert context.metadata == {}
|
||||
|
||||
def test_init_with_custom_metadata(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test FunctionInvocationContext initialization with custom metadata."""
|
||||
arguments = FunctionTestArgs(name="test")
|
||||
metadata = {"key": "value"}
|
||||
@@ -419,7 +419,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
await next(context)
|
||||
context.terminate = True
|
||||
|
||||
async def test_execute_with_pre_next_termination(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test pipeline execution with termination before next()."""
|
||||
middleware = self.PreNextTerminateFunctionMiddleware()
|
||||
pipeline = FunctionMiddlewarePipeline([middleware])
|
||||
@@ -438,7 +438,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
# Handler should not be called when terminated before next()
|
||||
assert execution_order == []
|
||||
|
||||
async def test_execute_with_post_next_termination(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_execute_with_post_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test pipeline execution with termination after next()."""
|
||||
middleware = self.PostNextTerminateFunctionMiddleware()
|
||||
pipeline = FunctionMiddlewarePipeline([middleware])
|
||||
@@ -477,7 +477,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
pipeline = FunctionMiddlewarePipeline([test_middleware])
|
||||
assert pipeline.has_middlewares
|
||||
|
||||
async def test_execute_no_middleware(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_execute_no_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test pipeline execution with no middleware."""
|
||||
pipeline = FunctionMiddlewarePipeline()
|
||||
arguments = FunctionTestArgs(name="test")
|
||||
@@ -491,7 +491,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
result = await pipeline.execute(mock_function, arguments, context, final_handler)
|
||||
assert result == expected_result
|
||||
|
||||
async def test_execute_with_middleware(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_execute_with_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test pipeline execution with middleware."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -778,7 +778,7 @@ class TestClassBasedMiddleware:
|
||||
assert context.metadata["after"] is True
|
||||
assert metadata_updates == ["before", "handler", "after"]
|
||||
|
||||
async def test_function_middleware_execution(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_execution(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test class-based function middleware execution."""
|
||||
metadata_updates: list[str] = []
|
||||
|
||||
@@ -840,7 +840,7 @@ class TestFunctionBasedMiddleware:
|
||||
assert context.metadata["function_middleware"] is True
|
||||
assert execution_order == ["function_before", "handler", "function_after"]
|
||||
|
||||
async def test_function_function_middleware(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_function_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test function-based function middleware."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -902,7 +902,7 @@ class TestMixedMiddleware:
|
||||
assert result is not None
|
||||
assert execution_order == ["class_before", "function_before", "handler", "function_after", "class_after"]
|
||||
|
||||
async def test_mixed_function_middleware(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_mixed_function_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test mixed class and function-based function middleware."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -1022,7 +1022,7 @@ class TestMultipleMiddlewareOrdering:
|
||||
]
|
||||
assert execution_order == expected_order
|
||||
|
||||
async def test_function_middleware_execution_order(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that multiple function middleware execute in registration order."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -1150,7 +1150,7 @@ class TestContextContentValidation:
|
||||
result = await pipeline.execute(mock_agent, messages, context, final_handler)
|
||||
assert result is not None
|
||||
|
||||
async def test_function_context_validation(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_context_validation(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that function context contains expected data."""
|
||||
|
||||
class ContextValidationMiddleware(FunctionMiddleware):
|
||||
@@ -1498,7 +1498,7 @@ class TestMiddlewareExecutionControl:
|
||||
assert not handler_called
|
||||
assert context.result is None
|
||||
|
||||
async def test_function_middleware_no_next_no_execution(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that when function middleware doesn't call next(), no execution happens."""
|
||||
|
||||
class FunctionTestArgs(BaseModel):
|
||||
@@ -1672,9 +1672,9 @@ def mock_agent() -> AgentProtocol:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_function() -> AIFunction[Any, Any]:
|
||||
def mock_function() -> FunctionTool[Any, Any]:
|
||||
"""Mock function for testing."""
|
||||
function = MagicMock(spec=AIFunction[Any, Any])
|
||||
function = MagicMock(spec=FunctionTool[Any, Any])
|
||||
function.name = "test_function"
|
||||
return function
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from agent_framework._middleware import (
|
||||
FunctionMiddleware,
|
||||
FunctionMiddlewarePipeline,
|
||||
)
|
||||
from agent_framework._tools import AIFunction
|
||||
from agent_framework._tools import FunctionTool
|
||||
|
||||
from .conftest import MockChatClient
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestResultOverrideMiddleware:
|
||||
assert updates[0].text == "overridden"
|
||||
assert updates[1].text == " stream"
|
||||
|
||||
async def test_function_middleware_result_override(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that function middleware can override result."""
|
||||
override_result = "overridden function result"
|
||||
|
||||
@@ -260,7 +260,7 @@ class TestResultOverrideMiddleware:
|
||||
assert execute_result.messages[0].text == "executed response"
|
||||
assert handler_called
|
||||
|
||||
async def test_function_middleware_conditional_no_next(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that when function middleware conditionally doesn't call next(), no execution happens."""
|
||||
|
||||
class ConditionalNoNextFunctionMiddleware(FunctionMiddleware):
|
||||
@@ -345,7 +345,7 @@ class TestResultObservability:
|
||||
assert observed_responses[0].messages[0].text == "executed response"
|
||||
assert result == observed_responses[0]
|
||||
|
||||
async def test_function_middleware_result_observability(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_result_observability(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that middleware can observe function result after execution."""
|
||||
observed_results: list[str] = []
|
||||
|
||||
@@ -414,7 +414,7 @@ class TestResultObservability:
|
||||
assert result is not None
|
||||
assert result.messages[0].text == "modified after execution"
|
||||
|
||||
async def test_function_middleware_post_execution_override(self, mock_function: AIFunction[Any, Any]) -> None:
|
||||
async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
"""Test that middleware can override function result after observing execution."""
|
||||
|
||||
class PostExecutionOverrideMiddleware(FunctionMiddleware):
|
||||
@@ -456,8 +456,8 @@ def mock_agent() -> AgentProtocol:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_function() -> AIFunction[Any, Any]:
|
||||
def mock_function() -> FunctionTool[Any, Any]:
|
||||
"""Mock function for testing."""
|
||||
function = MagicMock(spec=AIFunction[Any, Any])
|
||||
function = MagicMock(spec=FunctionTool[Any, Any])
|
||||
function.name = "test_function"
|
||||
return function
|
||||
|
||||
@@ -14,6 +14,7 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionTool,
|
||||
Role,
|
||||
agent_middleware,
|
||||
chat_middleware,
|
||||
@@ -214,9 +215,13 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
execution_order.append("function_called")
|
||||
return "test_result"
|
||||
|
||||
test_function_tool = FunctionTool(
|
||||
func=test_function, name="test_function", description="Test function", approval_mode="never_require"
|
||||
)
|
||||
|
||||
# Create ChatAgent with function middleware and test function
|
||||
middleware = PreTerminationFunctionMiddleware()
|
||||
agent = ChatAgent(chat_client=chat_client, middleware=[middleware], tools=[test_function])
|
||||
agent = ChatAgent(chat_client=chat_client, middleware=[middleware], tools=[test_function_tool])
|
||||
|
||||
# Execute the agent
|
||||
await agent.run(messages)
|
||||
@@ -271,9 +276,13 @@ class TestChatAgentFunctionBasedMiddleware:
|
||||
execution_order.append("function_called")
|
||||
return "test_result"
|
||||
|
||||
test_function_tool = FunctionTool(
|
||||
func=test_function, name="test_function", description="Test function", approval_mode="never_require"
|
||||
)
|
||||
|
||||
# Create ChatAgent with function middleware and test function
|
||||
middleware = PostTerminationFunctionMiddleware()
|
||||
agent = ChatAgent(chat_client=chat_client, middleware=[middleware], tools=[test_function])
|
||||
agent = ChatAgent(chat_client=chat_client, middleware=[middleware], tools=[test_function_tool])
|
||||
|
||||
# Execute the agent
|
||||
response = await agent.run(messages)
|
||||
@@ -518,11 +527,19 @@ class TestChatAgentMultipleMiddlewareOrdering:
|
||||
# region Tool Functions for Testing
|
||||
|
||||
|
||||
def sample_tool_function(location: str) -> str:
|
||||
def _sample_tool_function_impl(location: str) -> str:
|
||||
"""A simple tool function for middleware testing."""
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
|
||||
sample_tool_function = FunctionTool(
|
||||
func=_sample_tool_function_impl,
|
||||
name="sample_tool_function",
|
||||
description="A simple tool function for middleware testing.",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
|
||||
# region ChatAgent Function Middleware Tests with Tools
|
||||
|
||||
|
||||
@@ -1157,6 +1174,10 @@ class TestRunLevelMiddleware:
|
||||
execution_log.append("tool_executed")
|
||||
return f"Tool response: {message}"
|
||||
|
||||
custom_tool_wrapped = FunctionTool(
|
||||
func=custom_tool, name="custom_tool", description="Custom tool", approval_mode="never_require"
|
||||
)
|
||||
|
||||
# Set up mock to return a function call first, then a regular response
|
||||
function_call_response = ChatResponse(
|
||||
messages=[
|
||||
@@ -1179,7 +1200,7 @@ class TestRunLevelMiddleware:
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client,
|
||||
middleware=[AgentLevelAgentMiddleware(), AgentLevelFunctionMiddleware()],
|
||||
tools=[custom_tool],
|
||||
tools=[custom_tool_wrapped],
|
||||
)
|
||||
|
||||
# Execute with run-level middleware
|
||||
@@ -1246,6 +1267,10 @@ class TestMiddlewareDecoratorLogic:
|
||||
execution_order.append("tool_executed")
|
||||
return f"Tool response: {message}"
|
||||
|
||||
custom_tool_wrapped = FunctionTool(
|
||||
func=custom_tool, name="custom_tool", description="Custom tool", approval_mode="never_require"
|
||||
)
|
||||
|
||||
# Set up mock to return a function call first, then a regular response
|
||||
function_call_response = ChatResponse(
|
||||
messages=[
|
||||
@@ -1268,7 +1293,7 @@ class TestMiddlewareDecoratorLogic:
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client,
|
||||
middleware=[matching_agent_middleware, matching_function_middleware],
|
||||
tools=[custom_tool],
|
||||
tools=[custom_tool_wrapped],
|
||||
)
|
||||
|
||||
response = await agent.run([ChatMessage(role=Role.USER, text="test")])
|
||||
@@ -1313,6 +1338,10 @@ class TestMiddlewareDecoratorLogic:
|
||||
execution_order.append("tool_executed")
|
||||
return f"Tool response: {message}"
|
||||
|
||||
custom_tool_wrapped = FunctionTool(
|
||||
func=custom_tool, name="custom_tool", description="Custom tool", approval_mode="never_require"
|
||||
)
|
||||
|
||||
# Set up mock to return a function call first, then a regular response
|
||||
function_call_response = ChatResponse(
|
||||
messages=[
|
||||
@@ -1333,7 +1362,9 @@ class TestMiddlewareDecoratorLogic:
|
||||
|
||||
# Should work - relies on decorator
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client, middleware=[decorator_only_agent, decorator_only_function], tools=[custom_tool]
|
||||
chat_client=chat_client,
|
||||
middleware=[decorator_only_agent, decorator_only_function],
|
||||
tools=[custom_tool_wrapped],
|
||||
)
|
||||
|
||||
response = await agent.run([ChatMessage(role=Role.USER, text="test")])
|
||||
@@ -1363,6 +1394,10 @@ class TestMiddlewareDecoratorLogic:
|
||||
execution_order.append("tool_executed")
|
||||
return f"Tool response: {message}"
|
||||
|
||||
custom_tool_wrapped = FunctionTool(
|
||||
func=custom_tool, name="custom_tool", description="Custom tool", approval_mode="never_require"
|
||||
)
|
||||
|
||||
# Set up mock to return a function call first, then a regular response
|
||||
function_call_response = ChatResponse(
|
||||
messages=[
|
||||
@@ -1383,7 +1418,7 @@ class TestMiddlewareDecoratorLogic:
|
||||
|
||||
# Should work - relies on type annotations
|
||||
agent = ChatAgent(
|
||||
chat_client=chat_client, middleware=[type_only_agent, type_only_function], tools=[custom_tool]
|
||||
chat_client=chat_client, middleware=[type_only_agent, type_only_function], tools=[custom_tool_wrapped]
|
||||
)
|
||||
|
||||
response = await agent.run([ChatMessage(role=Role.USER, text="test")])
|
||||
|
||||
@@ -11,6 +11,7 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
Content,
|
||||
FunctionInvocationContext,
|
||||
FunctionTool,
|
||||
Role,
|
||||
chat_middleware,
|
||||
function_middleware,
|
||||
@@ -337,6 +338,13 @@ class TestChatMiddleware:
|
||||
"""Get weather for a location."""
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
sample_tool_wrapped = FunctionTool(
|
||||
func=sample_tool,
|
||||
name="sample_tool",
|
||||
description="Get weather for a location",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# Create function-invocation enabled chat client
|
||||
chat_client = use_chat_middleware(use_function_invocation(MockBaseChatClient))()
|
||||
|
||||
@@ -366,7 +374,7 @@ class TestChatMiddleware:
|
||||
|
||||
# Execute the chat client directly with tools - this should trigger function invocation and middleware
|
||||
messages = [ChatMessage(role=Role.USER, text="What's the weather in San Francisco?")]
|
||||
response = await chat_client.get_response(messages, options={"tools": [sample_tool]})
|
||||
response = await chat_client.get_response(messages, options={"tools": [sample_tool_wrapped]})
|
||||
|
||||
# Verify response
|
||||
assert response is not None
|
||||
@@ -396,6 +404,13 @@ class TestChatMiddleware:
|
||||
"""Get weather for a location."""
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
sample_tool_wrapped = FunctionTool(
|
||||
func=sample_tool,
|
||||
name="sample_tool",
|
||||
description="Get weather for a location",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# Create function-invocation enabled chat client
|
||||
chat_client = use_function_invocation(MockBaseChatClient)()
|
||||
|
||||
@@ -423,7 +438,7 @@ class TestChatMiddleware:
|
||||
# Execute the chat client directly with run-level middleware and tools
|
||||
messages = [ChatMessage(role=Role.USER, text="What's the weather in New York?")]
|
||||
response = await chat_client.get_response(
|
||||
messages, options={"tools": [sample_tool]}, middleware=[run_level_function_middleware]
|
||||
messages, options={"tools": [sample_tool_wrapped]}, middleware=[run_level_function_middleware]
|
||||
)
|
||||
|
||||
# Verify response
|
||||
|
||||
@@ -21,8 +21,8 @@ from agent_framework import (
|
||||
ChatResponseUpdate,
|
||||
Role,
|
||||
UsageDetails,
|
||||
ai_function,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import AgentInitializationError, ChatClientInitializationError
|
||||
from agent_framework.observability import (
|
||||
@@ -606,7 +606,7 @@ async def test_function_call_with_error_handling(span_exporter: InMemorySpanExpo
|
||||
"""Test that function call errors are properly captured in telemetry."""
|
||||
|
||||
# Create a function that raises an error using the decorator
|
||||
@ai_function(name="failing_function", description="A function that fails")
|
||||
@tool(name="failing_function", description="A function that fails")
|
||||
async def failing_function(param: str) -> str:
|
||||
raise ValueError("Function execution failed")
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from agent_framework import (
|
||||
AIFunction,
|
||||
Content,
|
||||
FunctionTool,
|
||||
HostedCodeInterpreterTool,
|
||||
HostedImageGenerationTool,
|
||||
HostedMCPTool,
|
||||
ToolProtocol,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._tools import (
|
||||
_build_pydantic_model_from_json_schema,
|
||||
@@ -24,19 +24,19 @@ from agent_framework._tools import (
|
||||
from agent_framework.exceptions import ToolException
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
# region AIFunction and ai_function decorator tests
|
||||
# region FunctionTool and tool decorator tests
|
||||
|
||||
|
||||
def test_ai_function_decorator():
|
||||
"""Test the ai_function decorator."""
|
||||
def test_tool_decorator():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
@ai_function(name="test_tool", description="A test tool")
|
||||
@tool(name="test_tool", description="A test tool")
|
||||
def test_tool(x: int, y: int) -> int:
|
||||
"""A simple function that adds two numbers."""
|
||||
return x + y
|
||||
|
||||
assert isinstance(test_tool, ToolProtocol)
|
||||
assert isinstance(test_tool, AIFunction)
|
||||
assert isinstance(test_tool, FunctionTool)
|
||||
assert test_tool.name == "test_tool"
|
||||
assert test_tool.description == "A test tool"
|
||||
assert test_tool.parameters() == {
|
||||
@@ -48,16 +48,16 @@ def test_ai_function_decorator():
|
||||
assert test_tool(1, 2) == 3
|
||||
|
||||
|
||||
def test_ai_function_decorator_without_args():
|
||||
"""Test the ai_function decorator."""
|
||||
def test_tool_decorator_without_args():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def test_tool(x: int, y: int) -> int:
|
||||
"""A simple function that adds two numbers."""
|
||||
return x + y
|
||||
|
||||
assert isinstance(test_tool, ToolProtocol)
|
||||
assert isinstance(test_tool, AIFunction)
|
||||
assert isinstance(test_tool, FunctionTool)
|
||||
assert test_tool.name == "test_tool"
|
||||
assert test_tool.description == "A simple function that adds two numbers."
|
||||
assert test_tool.parameters() == {
|
||||
@@ -67,18 +67,19 @@ def test_ai_function_decorator_without_args():
|
||||
"type": "object",
|
||||
}
|
||||
assert test_tool(1, 2) == 3
|
||||
assert test_tool.approval_mode == "never_require"
|
||||
|
||||
|
||||
def test_ai_function_without_args():
|
||||
"""Test the ai_function decorator."""
|
||||
def test_tool_without_args():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def test_tool() -> int:
|
||||
"""A simple function that adds two numbers."""
|
||||
return 1 + 2
|
||||
|
||||
assert isinstance(test_tool, ToolProtocol)
|
||||
assert isinstance(test_tool, AIFunction)
|
||||
assert isinstance(test_tool, FunctionTool)
|
||||
assert test_tool.name == "test_tool"
|
||||
assert test_tool.description == "A simple function that adds two numbers."
|
||||
assert test_tool.parameters() == {
|
||||
@@ -89,16 +90,16 @@ def test_ai_function_without_args():
|
||||
assert test_tool() == 3
|
||||
|
||||
|
||||
async def test_ai_function_decorator_with_async():
|
||||
"""Test the ai_function decorator with an async function."""
|
||||
async def test_tool_decorator_with_async():
|
||||
"""Test the tool decorator with an async function."""
|
||||
|
||||
@ai_function(name="async_test_tool", description="An async test tool")
|
||||
@tool(name="async_test_tool", description="An async test tool")
|
||||
async def async_test_tool(x: int, y: int) -> int:
|
||||
"""An async function that adds two numbers."""
|
||||
return x + y
|
||||
|
||||
assert isinstance(async_test_tool, ToolProtocol)
|
||||
assert isinstance(async_test_tool, AIFunction)
|
||||
assert isinstance(async_test_tool, FunctionTool)
|
||||
assert async_test_tool.name == "async_test_tool"
|
||||
assert async_test_tool.description == "An async test tool"
|
||||
assert async_test_tool.parameters() == {
|
||||
@@ -110,11 +111,11 @@ async def test_ai_function_decorator_with_async():
|
||||
assert (await async_test_tool(1, 2)) == 3
|
||||
|
||||
|
||||
def test_ai_function_decorator_in_class():
|
||||
"""Test the ai_function decorator."""
|
||||
def test_tool_decorator_in_class():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
class my_tools:
|
||||
@ai_function(name="test_tool", description="A test tool")
|
||||
@tool(name="test_tool", description="A test tool")
|
||||
def test_tool(self, x: int, y: int) -> int:
|
||||
"""A simple function that adds two numbers."""
|
||||
return x + y
|
||||
@@ -122,7 +123,7 @@ def test_ai_function_decorator_in_class():
|
||||
test_tool = my_tools().test_tool
|
||||
|
||||
assert isinstance(test_tool, ToolProtocol)
|
||||
assert isinstance(test_tool, AIFunction)
|
||||
assert isinstance(test_tool, FunctionTool)
|
||||
assert test_tool.name == "test_tool"
|
||||
assert test_tool.description == "A test tool"
|
||||
assert test_tool.parameters() == {
|
||||
@@ -134,15 +135,15 @@ def test_ai_function_decorator_in_class():
|
||||
assert test_tool(1, 2) == 3
|
||||
|
||||
|
||||
def test_ai_function_with_literal_type_parameter():
|
||||
"""Test ai_function decorator with Literal type parameter (issue #2891)."""
|
||||
def test_tool_with_literal_type_parameter():
|
||||
"""Test tool decorator with Literal type parameter (issue #2891)."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def search_flows(category: Literal["Data", "Security", "Network"], issue: str) -> str:
|
||||
"""Search flows by category."""
|
||||
return f"{category}: {issue}"
|
||||
|
||||
assert isinstance(search_flows, AIFunction)
|
||||
assert isinstance(search_flows, FunctionTool)
|
||||
schema = search_flows.parameters()
|
||||
assert schema == {
|
||||
"properties": {
|
||||
@@ -157,18 +158,18 @@ def test_ai_function_with_literal_type_parameter():
|
||||
assert search_flows("Data", "test issue") == "Data: test issue"
|
||||
|
||||
|
||||
def test_ai_function_with_literal_type_in_class_method():
|
||||
"""Test ai_function decorator with Literal type parameter in a class method (issue #2891)."""
|
||||
def test_tool_with_literal_type_in_class_method():
|
||||
"""Test tool decorator with Literal type parameter in a class method (issue #2891)."""
|
||||
|
||||
class MyTools:
|
||||
@ai_function
|
||||
@tool
|
||||
def search_flows(self, category: Literal["Data", "Security", "Network"], issue: str) -> str:
|
||||
"""Search flows by category."""
|
||||
return f"{category}: {issue}"
|
||||
|
||||
tools = MyTools()
|
||||
search_tool = tools.search_flows
|
||||
assert isinstance(search_tool, AIFunction)
|
||||
assert isinstance(search_tool, FunctionTool)
|
||||
schema = search_tool.parameters()
|
||||
assert schema == {
|
||||
"properties": {
|
||||
@@ -183,15 +184,15 @@ def test_ai_function_with_literal_type_in_class_method():
|
||||
assert search_tool("Security", "test issue") == "Security: test issue"
|
||||
|
||||
|
||||
def test_ai_function_with_literal_int_type():
|
||||
"""Test ai_function decorator with Literal int type parameter."""
|
||||
def test_tool_with_literal_int_type():
|
||||
"""Test tool decorator with Literal int type parameter."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def set_priority(priority: Literal[1, 2, 3], task: str) -> str:
|
||||
"""Set priority for a task."""
|
||||
return f"Priority {priority}: {task}"
|
||||
|
||||
assert isinstance(set_priority, AIFunction)
|
||||
assert isinstance(set_priority, FunctionTool)
|
||||
schema = set_priority.parameters()
|
||||
assert schema == {
|
||||
"properties": {
|
||||
@@ -205,10 +206,10 @@ def test_ai_function_with_literal_int_type():
|
||||
assert set_priority(1, "important task") == "Priority 1: important task"
|
||||
|
||||
|
||||
def test_ai_function_with_literal_and_annotated():
|
||||
"""Test ai_function decorator with Literal type combined with Annotated for description."""
|
||||
def test_tool_with_literal_and_annotated():
|
||||
"""Test tool decorator with Literal type combined with Annotated for description."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def categorize(
|
||||
category: Annotated[Literal["A", "B", "C"], "The category to assign"],
|
||||
name: str,
|
||||
@@ -216,14 +217,14 @@ def test_ai_function_with_literal_and_annotated():
|
||||
"""Categorize an item."""
|
||||
return f"{category}: {name}"
|
||||
|
||||
assert isinstance(categorize, AIFunction)
|
||||
assert isinstance(categorize, FunctionTool)
|
||||
schema = categorize.parameters()
|
||||
# Literal type inside Annotated should preserve enum values
|
||||
assert schema["properties"]["category"]["enum"] == ["A", "B", "C"]
|
||||
assert categorize("A", "test") == "A: test"
|
||||
|
||||
|
||||
async def test_ai_function_decorator_shared_state():
|
||||
async def test_tool_decorator_shared_state():
|
||||
"""Test that decorated methods maintain shared state across multiple calls and tool usage."""
|
||||
|
||||
class StatefulCounter:
|
||||
@@ -233,20 +234,20 @@ async def test_ai_function_decorator_shared_state():
|
||||
self.counter = initial_value
|
||||
self.operation_log: list[str] = []
|
||||
|
||||
@ai_function(name="increment", description="Increment the counter")
|
||||
@tool(name="increment", description="Increment the counter")
|
||||
def increment(self, amount: int) -> str:
|
||||
"""Increment the counter by the given amount."""
|
||||
self.counter += amount
|
||||
self.operation_log.append(f"increment({amount})")
|
||||
return f"Counter incremented by {amount}. New value: {self.counter}"
|
||||
|
||||
@ai_function(name="get_value", description="Get the current counter value")
|
||||
@tool(name="get_value", description="Get the current counter value")
|
||||
def get_value(self) -> str:
|
||||
"""Get the current counter value."""
|
||||
self.operation_log.append("get_value()")
|
||||
return f"Current counter value: {self.counter}"
|
||||
|
||||
@ai_function(name="multiply", description="Multiply the counter")
|
||||
@tool(name="multiply", description="Multiply the counter")
|
||||
def multiply(self, factor: int) -> str:
|
||||
"""Multiply the counter by the given factor."""
|
||||
self.counter *= factor
|
||||
@@ -261,10 +262,10 @@ async def test_ai_function_decorator_shared_state():
|
||||
get_value_tool = counter_instance.get_value
|
||||
multiply_tool = counter_instance.multiply
|
||||
|
||||
# Verify they are AIFunction instances
|
||||
assert isinstance(increment_tool, AIFunction)
|
||||
assert isinstance(get_value_tool, AIFunction)
|
||||
assert isinstance(multiply_tool, AIFunction)
|
||||
# Verify they are FunctionTool instances
|
||||
assert isinstance(increment_tool, FunctionTool)
|
||||
assert isinstance(get_value_tool, FunctionTool)
|
||||
assert isinstance(multiply_tool, FunctionTool)
|
||||
|
||||
# Tool 1 (increment) is used
|
||||
result1 = increment_tool(5)
|
||||
@@ -329,10 +330,10 @@ async def test_ai_function_decorator_shared_state():
|
||||
assert counter_instance.counter == 60
|
||||
|
||||
|
||||
async def test_ai_function_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter):
|
||||
"""Test the ai_function invoke method with telemetry enabled."""
|
||||
async def test_tool_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter):
|
||||
"""Test the tool invoke method with telemetry enabled."""
|
||||
|
||||
@ai_function(
|
||||
@tool(
|
||||
name="telemetry_test_tool",
|
||||
description="A test tool for telemetry",
|
||||
)
|
||||
@@ -373,10 +374,10 @@ async def test_ai_function_invoke_telemetry_enabled(span_exporter: InMemorySpanE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
|
||||
async def test_ai_function_invoke_telemetry_sensitive_disabled(span_exporter: InMemorySpanExporter):
|
||||
"""Test the ai_function invoke method with telemetry enabled."""
|
||||
async def test_tool_invoke_telemetry_sensitive_disabled(span_exporter: InMemorySpanExporter):
|
||||
"""Test the tool invoke method with telemetry enabled."""
|
||||
|
||||
@ai_function(
|
||||
@tool(
|
||||
name="telemetry_test_tool",
|
||||
description="A test tool for telemetry",
|
||||
)
|
||||
@@ -416,10 +417,10 @@ async def test_ai_function_invoke_telemetry_sensitive_disabled(span_exporter: In
|
||||
assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id"
|
||||
|
||||
|
||||
async def test_ai_function_invoke_ignores_additional_kwargs() -> None:
|
||||
"""Ensure ai_function tools drop unknown kwargs when invoked with validated arguments."""
|
||||
async def test_tool_invoke_ignores_additional_kwargs() -> None:
|
||||
"""Ensure tools drop unknown kwargs when invoked with validated arguments."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
async def simple_tool(message: str) -> str:
|
||||
"""Echo tool."""
|
||||
return message.upper()
|
||||
@@ -436,10 +437,10 @@ async def test_ai_function_invoke_ignores_additional_kwargs() -> None:
|
||||
assert result == "HELLO WORLD"
|
||||
|
||||
|
||||
async def test_ai_function_invoke_telemetry_with_pydantic_args(span_exporter: InMemorySpanExporter):
|
||||
"""Test the ai_function invoke method with Pydantic model arguments."""
|
||||
async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemorySpanExporter):
|
||||
"""Test the tool invoke method with Pydantic model arguments."""
|
||||
|
||||
@ai_function(
|
||||
@tool(
|
||||
name="pydantic_test_tool",
|
||||
description="A test tool with Pydantic args",
|
||||
)
|
||||
@@ -470,10 +471,10 @@ async def test_ai_function_invoke_telemetry_with_pydantic_args(span_exporter: In
|
||||
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x":5,"y":10}'
|
||||
|
||||
|
||||
async def test_ai_function_invoke_telemetry_with_exception(span_exporter: InMemorySpanExporter):
|
||||
"""Test the ai_function invoke method with telemetry when an exception occurs."""
|
||||
async def test_tool_invoke_telemetry_with_exception(span_exporter: InMemorySpanExporter):
|
||||
"""Test the tool invoke method with telemetry when an exception occurs."""
|
||||
|
||||
@ai_function(
|
||||
@tool(
|
||||
name="exception_test_tool",
|
||||
description="A test tool that raises an exception",
|
||||
)
|
||||
@@ -507,10 +508,10 @@ async def test_ai_function_invoke_telemetry_with_exception(span_exporter: InMemo
|
||||
assert attributes[OtelAttr.ERROR_TYPE] == ValueError.__name__
|
||||
|
||||
|
||||
async def test_ai_function_invoke_telemetry_async_function(span_exporter: InMemorySpanExporter):
|
||||
"""Test the ai_function invoke method with telemetry on async function."""
|
||||
async def test_tool_invoke_telemetry_async_function(span_exporter: InMemorySpanExporter):
|
||||
"""Test the tool invoke method with telemetry on async function."""
|
||||
|
||||
@ai_function(
|
||||
@tool(
|
||||
name="async_telemetry_test",
|
||||
description="An async test tool for telemetry",
|
||||
)
|
||||
@@ -544,10 +545,10 @@ async def test_ai_function_invoke_telemetry_async_function(span_exporter: InMemo
|
||||
assert attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] == "async_telemetry_test"
|
||||
|
||||
|
||||
async def test_ai_function_invoke_invalid_pydantic_args():
|
||||
"""Test the ai_function invoke method with invalid Pydantic model arguments."""
|
||||
async def test_tool_invoke_invalid_pydantic_args():
|
||||
"""Test the tool invoke method with invalid Pydantic model arguments."""
|
||||
|
||||
@ai_function(name="invalid_args_test", description="A test tool for invalid args")
|
||||
@tool(name="invalid_args_test", description="A test tool for invalid args")
|
||||
def invalid_args_test(x: int, y: int) -> int:
|
||||
"""A function for testing invalid Pydantic args."""
|
||||
return x + y
|
||||
@@ -564,20 +565,18 @@ async def test_ai_function_invoke_invalid_pydantic_args():
|
||||
await invalid_args_test.invoke(arguments=wrong_args)
|
||||
|
||||
|
||||
def test_ai_function_serialization():
|
||||
"""Test AIFunction serialization and deserialization."""
|
||||
def test_tool_serialization():
|
||||
"""Test FunctionTool serialization and deserialization."""
|
||||
|
||||
def serialize_test(x: int, y: int) -> int:
|
||||
"""A function for testing serialization."""
|
||||
return x - y
|
||||
|
||||
serialize_test_ai_function = ai_function(name="serialize_test", description="A test tool for serialization")(
|
||||
serialize_test
|
||||
)
|
||||
serialize_test_tool = tool(name="serialize_test", description="A test tool for serialization")(serialize_test)
|
||||
|
||||
# Serialize to dict
|
||||
tool_dict = serialize_test_ai_function.to_dict()
|
||||
assert tool_dict["type"] == "ai_function"
|
||||
tool_dict = serialize_test_tool.to_dict()
|
||||
assert tool_dict["type"] == "function_tool"
|
||||
assert tool_dict["name"] == "serialize_test"
|
||||
assert tool_dict["description"] == "A test tool for serialization"
|
||||
assert tool_dict["input_model"] == {
|
||||
@@ -588,21 +587,21 @@ def test_ai_function_serialization():
|
||||
}
|
||||
|
||||
# Deserialize from dict
|
||||
restored_tool = AIFunction.from_dict(tool_dict, dependencies={"ai_function": {"func": serialize_test}})
|
||||
assert isinstance(restored_tool, AIFunction)
|
||||
restored_tool = FunctionTool.from_dict(tool_dict, dependencies={"function_tool": {"func": serialize_test}})
|
||||
assert isinstance(restored_tool, FunctionTool)
|
||||
assert restored_tool.name == "serialize_test"
|
||||
assert restored_tool.description == "A test tool for serialization"
|
||||
assert restored_tool.parameters() == serialize_test_ai_function.parameters()
|
||||
assert restored_tool.parameters() == serialize_test_tool.parameters()
|
||||
assert restored_tool(10, 4) == 6
|
||||
|
||||
# Deserialize from dict with instance name
|
||||
restored_tool_2 = AIFunction.from_dict(
|
||||
tool_dict, dependencies={"ai_function": {"name:serialize_test": {"func": serialize_test}}}
|
||||
restored_tool_2 = FunctionTool.from_dict(
|
||||
tool_dict, dependencies={"function_tool": {"name:serialize_test": {"func": serialize_test}}}
|
||||
)
|
||||
assert isinstance(restored_tool_2, AIFunction)
|
||||
assert isinstance(restored_tool_2, FunctionTool)
|
||||
assert restored_tool_2.name == "serialize_test"
|
||||
assert restored_tool_2.description == "A test tool for serialization"
|
||||
assert restored_tool_2.parameters() == serialize_test_ai_function.parameters()
|
||||
assert restored_tool_2.parameters() == serialize_test_tool.parameters()
|
||||
assert restored_tool_2(10, 4) == 6
|
||||
|
||||
|
||||
@@ -979,13 +978,17 @@ def mock_chat_client():
|
||||
return MockChatClient()
|
||||
|
||||
|
||||
@ai_function(name="no_approval_tool", description="Tool that doesn't require approval")
|
||||
@tool(
|
||||
name="no_approval_tool",
|
||||
description="Tool that doesn't require approval",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
def no_approval_tool(x: int) -> int:
|
||||
"""A tool that doesn't require approval."""
|
||||
return x * 2
|
||||
|
||||
|
||||
@ai_function(
|
||||
@tool(
|
||||
name="requires_approval_tool",
|
||||
description="Tool that requires approval",
|
||||
approval_mode="always_require",
|
||||
@@ -1451,10 +1454,10 @@ async def test_streaming_two_functions_mixed_approval():
|
||||
assert all(c.type == "function_approval_request" for c in updates[2].contents)
|
||||
|
||||
|
||||
async def test_ai_function_with_kwargs_injection():
|
||||
"""Test that ai_function correctly handles kwargs injection and hides them from schema."""
|
||||
async def test_tool_with_kwargs_injection():
|
||||
"""Test that tool correctly handles kwargs injection and hides them from schema."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def tool_with_kwargs(x: int, **kwargs: Any) -> str:
|
||||
"""A tool that accepts kwargs."""
|
||||
user_id = kwargs.get("user_id", "unknown")
|
||||
@@ -1647,11 +1650,11 @@ def test_build_pydantic_model_from_json_schema_array_of_objects_issue():
|
||||
# CRITICAL: Validate using the same methods that actual chat clients use
|
||||
# This is what would actually be sent to the LLM
|
||||
|
||||
# Create an AIFunction wrapper to access the client-facing APIs
|
||||
# Create a FunctionTool wrapper to access the client-facing APIs
|
||||
def dummy_func(**kwargs):
|
||||
return kwargs
|
||||
|
||||
test_func = AIFunction(
|
||||
test_func = FunctionTool(
|
||||
func=dummy_func,
|
||||
name="create_sales_order",
|
||||
description="Create a sales order",
|
||||
|
||||
@@ -24,10 +24,10 @@ from agent_framework import (
|
||||
ToolMode,
|
||||
ToolProtocol,
|
||||
UsageDetails,
|
||||
ai_function,
|
||||
detect_media_type_from_base64,
|
||||
merge_chat_options,
|
||||
prepare_function_call_results,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import ContentError
|
||||
|
||||
@@ -51,10 +51,10 @@ def ai_tool() -> ToolProtocol:
|
||||
|
||||
|
||||
@fixture
|
||||
def ai_function_tool() -> ToolProtocol:
|
||||
def tool_tool() -> ToolProtocol:
|
||||
"""Returns a executable ToolProtocol."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def simple_function(x: int, y: int) -> int:
|
||||
"""A simple function that adds two numbers."""
|
||||
return x + y
|
||||
@@ -1017,13 +1017,13 @@ def test_chat_options_tool_choice_validation():
|
||||
validate_tool_mode({"mode": "auto", "required_function_name": "should_not_be_here"})
|
||||
|
||||
|
||||
def test_chat_options_merge(ai_function_tool, ai_tool) -> None:
|
||||
def test_chat_options_merge(tool_tool, ai_tool) -> None:
|
||||
"""Test merge_chat_options utility function."""
|
||||
from agent_framework import merge_chat_options
|
||||
|
||||
options1: ChatOptions = {
|
||||
"model_id": "gpt-4o",
|
||||
"tools": [ai_function_tool],
|
||||
"tools": [tool_tool],
|
||||
"logit_bias": {"x": 1},
|
||||
"metadata": {"a": "b"},
|
||||
}
|
||||
@@ -1034,7 +1034,7 @@ def test_chat_options_merge(ai_function_tool, ai_tool) -> None:
|
||||
options3 = merge_chat_options(options1, options2)
|
||||
|
||||
assert options3.get("model_id") == "gpt-4.1"
|
||||
assert options3.get("tools") == [ai_function_tool, ai_tool] # tools are combined
|
||||
assert options3.get("tools") == [tool_tool, ai_tool] # tools are combined
|
||||
assert options3.get("logit_bias") == {"x": 1} # base value preserved
|
||||
assert options3.get("metadata") == {"a": "b"} # base value preserved
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from openai.types.beta.assistant import Assistant
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework import ChatAgent, HostedCodeInterpreterTool, HostedFileSearchTool, ai_function, normalize_tools
|
||||
from agent_framework import ChatAgent, HostedCodeInterpreterTool, HostedFileSearchTool, normalize_tools, tool
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai import OpenAIAssistantProvider
|
||||
from agent_framework.openai._shared import from_assistant_tools, to_assistant_tools
|
||||
@@ -244,11 +244,11 @@ class TestOpenAIAssistantProviderCreateAgent:
|
||||
assert call_kwargs["tools"][0]["type"] == "function"
|
||||
assert call_kwargs["tools"][0]["function"]["name"] == "get_weather"
|
||||
|
||||
async def test_create_agent_with_ai_function(self, mock_async_openai: MagicMock) -> None:
|
||||
"""Test assistant creation with AIFunction."""
|
||||
async def test_create_agent_with_tool(self, mock_async_openai: MagicMock) -> None:
|
||||
"""Test assistant creation with FunctionTool."""
|
||||
provider = OpenAIAssistantProvider(mock_async_openai)
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def my_function(x: int) -> int:
|
||||
"""Double a number."""
|
||||
return x * 2
|
||||
@@ -537,10 +537,10 @@ class TestOpenAIAssistantProviderAsAgent:
|
||||
class TestToolConversion:
|
||||
"""Tests for tool conversion utilities (shared functions)."""
|
||||
|
||||
def test_to_assistant_tools_ai_function(self) -> None:
|
||||
"""Test AIFunction conversion to API format."""
|
||||
def test_to_assistant_tools_tool(self) -> None:
|
||||
"""Test FunctionTool conversion to API format."""
|
||||
|
||||
@ai_function
|
||||
@tool
|
||||
def test_func(x: int) -> int:
|
||||
"""Test function."""
|
||||
return x
|
||||
@@ -555,7 +555,7 @@ class TestToolConversion:
|
||||
|
||||
def test_to_assistant_tools_callable(self) -> None:
|
||||
"""Test raw callable conversion via normalize_tools."""
|
||||
# normalize_tools converts callables to AIFunction
|
||||
# normalize_tools converts callables to FunctionTool
|
||||
normalized = normalize_tools([get_weather])
|
||||
api_tools = to_assistant_tools(normalized)
|
||||
|
||||
@@ -666,12 +666,12 @@ class TestToolValidation:
|
||||
# Should not raise
|
||||
provider._validate_function_tools(assistant_tools, None) # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_validate_with_ai_function(self, mock_async_openai: MagicMock) -> None:
|
||||
"""Test validation with AIFunction."""
|
||||
def test_validate_with_tool(self, mock_async_openai: MagicMock) -> None:
|
||||
"""Test validation with FunctionTool."""
|
||||
provider = OpenAIAssistantProvider(mock_async_openai)
|
||||
assistant_tools = [create_function_tool("get_weather")]
|
||||
|
||||
wrapped = ai_function(get_weather)
|
||||
wrapped = tool(get_weather)
|
||||
|
||||
# Should not raise
|
||||
provider._validate_function_tools(assistant_tools, [wrapped]) # type: ignore[reportPrivateUsage]
|
||||
@@ -789,6 +789,7 @@ class TestOpenAIAssistantProviderIntegration:
|
||||
"""Integration test with function tools."""
|
||||
provider = OpenAIAssistantProvider()
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_current_time() -> str:
|
||||
"""Get the current time."""
|
||||
from datetime import datetime
|
||||
|
||||
@@ -23,7 +23,7 @@ from agent_framework import (
|
||||
HostedCodeInterpreterTool,
|
||||
HostedFileSearchTool,
|
||||
Role,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai import OpenAIAssistantsClient
|
||||
@@ -709,13 +709,13 @@ def test_prepare_options_basic(mock_async_openai: MagicMock) -> None:
|
||||
assert tool_results is None
|
||||
|
||||
|
||||
def test_prepare_options_with_ai_function_tool(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _prepare_options with AIFunction tool."""
|
||||
def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _prepare_options with a FunctionTool."""
|
||||
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
|
||||
# Create a simple function for testing and decorate it
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def test_function(query: str) -> str:
|
||||
"""A test function."""
|
||||
return f"Result for {query}"
|
||||
@@ -998,6 +998,7 @@ def test_update_agent_name_and_description_none(mock_async_openai: MagicMock) ->
|
||||
assert chat_client.assistant_name is None
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
|
||||
@@ -19,8 +19,8 @@ from agent_framework import (
|
||||
Content,
|
||||
HostedWebSearchTool,
|
||||
ToolProtocol,
|
||||
ai_function,
|
||||
prepare_function_call_results,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
@@ -175,7 +175,7 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None
|
||||
"""Test that unsupported tool types are handled correctly."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Create a mock ToolProtocol that's not an AIFunction
|
||||
# Create a mock ToolProtocol that's not a FunctionTool
|
||||
unsupported_tool = MagicMock(spec=ToolProtocol)
|
||||
unsupported_tool.__class__.__name__ = "UnsupportedAITool"
|
||||
|
||||
@@ -189,7 +189,7 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None
|
||||
assert result["tools"] == [dict_tool]
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def get_story_text() -> str:
|
||||
"""Returns a story about Emily and David."""
|
||||
return (
|
||||
@@ -200,7 +200,7 @@ def get_story_text() -> str:
|
||||
)
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location."""
|
||||
return f"The weather in {location} is sunny and 72°F."
|
||||
|
||||
@@ -40,7 +40,7 @@ from agent_framework import (
|
||||
HostedMCPTool,
|
||||
HostedWebSearchTool,
|
||||
Role,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import (
|
||||
ServiceInitializationError,
|
||||
@@ -96,7 +96,7 @@ async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vecto
|
||||
await client.client.files.delete(file_id=file_id)
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
# Implementation of the tool to get weather
|
||||
@@ -642,7 +642,7 @@ def test_response_content_creation_with_function_call() -> None:
|
||||
assert function_call.arguments == '{"location": "Seattle"}'
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_function_approval_response() -> None:
|
||||
def test_prepare_content_for_opentool_approval_response() -> None:
|
||||
"""Test _prepare_content_for_openai with function approval response content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
ai_function,
|
||||
executor,
|
||||
tool,
|
||||
use_function_invocation,
|
||||
)
|
||||
|
||||
@@ -132,7 +132,7 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
|
||||
assert "sunny" in events[3].data.contents[0].text
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
@tool(approval_mode="always_require")
|
||||
def mock_tool_requiring_approval(query: str) -> str:
|
||||
"""Mock tool that requires approval before execution."""
|
||||
return f"Executed tool with query: {query}"
|
||||
|
||||
@@ -20,7 +20,7 @@ from agent_framework import (
|
||||
SequentialBuilder,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
ai_function,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
|
||||
@@ -28,7 +28,7 @@ from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
_received_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@ai_function
|
||||
@tool(approval_mode="never_require")
|
||||
def tool_with_kwargs(
|
||||
action: Annotated[str, "The action to perform"],
|
||||
**kwargs: Any,
|
||||
|
||||
Reference in New Issue
Block a user