mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e728c56a | ||
|
|
5508081e14 | ||
|
|
b153dc8515 | ||
|
|
d84d7e348f | ||
|
|
fc9c81b0b1 | ||
|
|
cd1e3110aa | ||
|
|
e563849be3 | ||
|
|
9506fb28f6 |
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use OpenAPI Tools with AI Agents.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// Warning: DefaultAzureCredential is intended for simplicity in development. For production scenarios, consider using a more specific credential.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code.";
|
||||
|
||||
// A simple OpenAPI specification for the REST Countries API
|
||||
const string CountriesOpenApiSpec = """
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "REST Countries API",
|
||||
"description": "Retrieve information about countries by currency code",
|
||||
"version": "v3.1"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://restcountries.com/v3.1"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/currency/{currency}": {
|
||||
"get": {
|
||||
"description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)",
|
||||
"operationId": "GetCountriesByCurrency",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "currency",
|
||||
"in": "path",
|
||||
"description": "Currency code (e.g., USD, EUR, GBP)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response with list of countries",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No countries found for the currency"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create the OpenAPI function definition
|
||||
var openApiFunction = new OpenAPIFunctionDefinition(
|
||||
"get_countries",
|
||||
BinaryData.FromString(CountriesOpenApiSpec),
|
||||
new OpenAPIAnonymousAuthenticationDetails())
|
||||
{
|
||||
Description = "Retrieve information about countries by currency code"
|
||||
};
|
||||
|
||||
AIAgent agent = await CreateAgentWithMEAI();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDK();
|
||||
|
||||
// Run the agent with a question about countries
|
||||
Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them."));
|
||||
|
||||
// Cleanup by deleting the agent
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
|
||||
// Option 1 - Using AsAITool wrapping for OpenApiTool (MEAI + AgentFramework)
|
||||
async Task<AIAgent> CreateAgentWithMEAI()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
name: "OpenAPIToolsAgent-MEAI",
|
||||
instructions: AgentInstructions,
|
||||
tools: [((ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction)).AsAITool()]);
|
||||
}
|
||||
|
||||
// Option 2 - Using PromptAgentDefinition with AgentTool.CreateOpenApiTool (Native SDK)
|
||||
async Task<AIAgent> CreateAgentWithNativeSDK()
|
||||
{
|
||||
return await aiProjectClient.CreateAIAgentAsync(
|
||||
name: "OpenAPIToolsAgent-NATIVE",
|
||||
creationOptions: new AgentVersionCreationOptions(
|
||||
new PromptAgentDefinition(model: deploymentName)
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) }
|
||||
})
|
||||
);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Using OpenAPI Tools with AI Agents
|
||||
|
||||
This sample demonstrates how to use OpenAPI tools with AI agents. OpenAPI tools allow agents to call external REST APIs defined by OpenAPI specifications.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating agents with OpenAPI tool capabilities
|
||||
- Using AgentTool.CreateOpenApiTool with an embedded OpenAPI specification
|
||||
- Anonymous authentication for public APIs
|
||||
- Running an agent that can call external REST APIs
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses `DefaultAzureCredential` for authentication, which supports multiple authentication methods including Azure CLI, managed identity, and more. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step19_OpenAPITools
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create an agent with an OpenAPI tool configured to call the REST Countries API
|
||||
2. Ask the agent: "What countries use the Euro (EUR) as their currency?"
|
||||
3. The agent will use the OpenAPI tool to call the REST Countries API
|
||||
4. Display the response containing the list of countries that use EUR
|
||||
5. Clean up resources by deleting the agent
|
||||
@@ -267,7 +267,7 @@ class AGUIChatClient(
|
||||
if any(getattr(tool, "name", None) == tool_name for tool in additional_tools):
|
||||
return
|
||||
|
||||
placeholder: FunctionTool[Any] = FunctionTool(
|
||||
placeholder: FunctionTool = FunctionTool(
|
||||
name=tool_name,
|
||||
description="Server-managed tool placeholder (AG-UI)",
|
||||
func=None,
|
||||
|
||||
@@ -162,7 +162,7 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401
|
||||
|
||||
def convert_agui_tools_to_agent_framework(
|
||||
agui_tools: list[dict[str, Any]] | None,
|
||||
) -> list[FunctionTool[Any]] | None:
|
||||
) -> list[FunctionTool] | None:
|
||||
"""Convert AG-UI tool definitions to Agent Framework FunctionTool declarations.
|
||||
|
||||
Creates declaration-only FunctionTool instances (no executable implementation).
|
||||
@@ -181,13 +181,13 @@ def convert_agui_tools_to_agent_framework(
|
||||
if not agui_tools:
|
||||
return None
|
||||
|
||||
result: list[FunctionTool[Any]] = []
|
||||
result: list[FunctionTool] = []
|
||||
for tool_def in agui_tools:
|
||||
# Create declaration-only FunctionTool (func=None means no implementation)
|
||||
# When func=None, the declaration_only property returns True,
|
||||
# which tells the function invocation mixin to return the function call
|
||||
# without executing it (so it can be sent back to the client)
|
||||
func: FunctionTool[Any] = FunctionTool(
|
||||
func: FunctionTool = FunctionTool(
|
||||
name=tool_def.get("name", ""),
|
||||
description=tool_def.get("description", ""),
|
||||
func=None, # CRITICAL: Makes declaration_only=True
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from agent_framework import Agent, FunctionTool, SupportsChatGetResponse
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
||||
from agent_framework import ChatOptions
|
||||
|
||||
# Declaration-only tools (func=None) - actual rendering happens on the client side
|
||||
generate_haiku = FunctionTool[Any](
|
||||
generate_haiku = FunctionTool(
|
||||
name="generate_haiku",
|
||||
description="""Generate a haiku with image and gradient background (FRONTEND_RENDER).
|
||||
|
||||
@@ -71,7 +71,7 @@ generate_haiku = FunctionTool[Any](
|
||||
},
|
||||
)
|
||||
|
||||
create_chart = FunctionTool[Any](
|
||||
create_chart = FunctionTool(
|
||||
name="create_chart",
|
||||
description="""Create an interactive chart (FRONTEND_RENDER).
|
||||
|
||||
@@ -99,7 +99,7 @@ create_chart = FunctionTool[Any](
|
||||
},
|
||||
)
|
||||
|
||||
display_timeline = FunctionTool[Any](
|
||||
display_timeline = FunctionTool(
|
||||
name="display_timeline",
|
||||
description="""Display an interactive timeline (FRONTEND_RENDER).
|
||||
|
||||
@@ -127,7 +127,7 @@ display_timeline = FunctionTool[Any](
|
||||
},
|
||||
)
|
||||
|
||||
show_comparison_table = FunctionTool[Any](
|
||||
show_comparison_table = FunctionTool(
|
||||
name="show_comparison_table",
|
||||
description="""Show a comparison table (FRONTEND_RENDER).
|
||||
|
||||
|
||||
@@ -1164,5 +1164,158 @@ class TestMCPToolEndpoint:
|
||||
assert body["agents"][0]["mcp_tool_enabled"] is True
|
||||
|
||||
|
||||
class TestAgentFunctionAppErrorPaths:
|
||||
"""Test suite for error handling paths."""
|
||||
|
||||
def test_init_with_invalid_max_poll_retries(self) -> None:
|
||||
"""Test initialization handles invalid max_poll_retries by falling back to default."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
# Test with invalid type
|
||||
app = AgentFunctionApp(agents=[mock_agent], max_poll_retries="invalid")
|
||||
assert app.max_poll_retries >= 1 # Should use default
|
||||
|
||||
# Test with None
|
||||
app2 = AgentFunctionApp(agents=[mock_agent], max_poll_retries=None)
|
||||
assert app2.max_poll_retries >= 1 # Should use default
|
||||
|
||||
def test_init_with_invalid_poll_interval_seconds(self) -> None:
|
||||
"""Test initialization handles invalid poll_interval_seconds by falling back to default."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
# Test with invalid type
|
||||
app = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds="invalid")
|
||||
assert app.poll_interval_seconds > 0 # Should use default
|
||||
|
||||
# Test with None
|
||||
app2 = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds=None)
|
||||
assert app2.poll_interval_seconds > 0 # Should use default
|
||||
|
||||
def test_get_agent_raises_for_unregistered_agent(self) -> None:
|
||||
"""Test get_agent raises ValueError for unregistered agent."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "RegisteredAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
|
||||
|
||||
# Create mock orchestration context
|
||||
mock_context = Mock()
|
||||
|
||||
# Should raise ValueError for unregistered agent
|
||||
with pytest.raises(ValueError, match="Agent 'UnknownAgent' is not registered"):
|
||||
app.get_agent(mock_context, "UnknownAgent")
|
||||
|
||||
def test_convert_payload_to_text_with_response_key(self) -> None:
|
||||
"""Test _convert_payload_to_text returns response key value."""
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
# Test with response key
|
||||
payload = {"response": "Test response"}
|
||||
result = app._convert_payload_to_text(payload)
|
||||
assert result == "Test response"
|
||||
|
||||
# Test with error key
|
||||
payload = {"error": "Error message"}
|
||||
result = app._convert_payload_to_text(payload)
|
||||
assert result == "Error message"
|
||||
|
||||
# Test with message key
|
||||
payload = {"message": "Message text"}
|
||||
result = app._convert_payload_to_text(payload)
|
||||
assert result == "Message text"
|
||||
|
||||
# Test with no matching keys - should return JSON string
|
||||
payload = {"other": "value"}
|
||||
result = app._convert_payload_to_text(payload)
|
||||
assert "other" in result
|
||||
assert "value" in result
|
||||
|
||||
def test_create_session_id_with_thread_id(self) -> None:
|
||||
"""Test _create_session_id with provided thread_id."""
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
# With thread_id provided
|
||||
session_id = app._create_session_id("TestAgent", "my-thread-123")
|
||||
assert session_id.key == "my-thread-123"
|
||||
|
||||
# Without thread_id (None) - should generate random
|
||||
session_id = app._create_session_id("TestAgent", None)
|
||||
assert session_id.key is not None
|
||||
assert len(session_id.key) > 0
|
||||
|
||||
def test_resolve_thread_id_from_body(self) -> None:
|
||||
"""Test _resolve_thread_id extracts from body."""
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
mock_req = Mock()
|
||||
mock_req.params = {}
|
||||
|
||||
# Thread ID in body - field name is "thread_id"
|
||||
req_body = {"thread_id": "body-thread-123"}
|
||||
result = app._resolve_thread_id(mock_req, req_body)
|
||||
assert result == "body-thread-123"
|
||||
|
||||
def test_select_body_parser_json_content_type(self) -> None:
|
||||
"""Test _select_body_parser for JSON content type."""
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
# Test with application/json
|
||||
parser, format_str = app._select_body_parser("application/json")
|
||||
assert parser == app._parse_json_body
|
||||
assert format_str == "json"
|
||||
|
||||
# Test with +json suffix
|
||||
parser, format_str = app._select_body_parser("application/vnd.api+json")
|
||||
assert parser == app._parse_json_body
|
||||
assert format_str == "json"
|
||||
|
||||
def test_accepts_json_response_with_accept_header(self) -> None:
|
||||
"""Test _accepts_json_response checks accept header."""
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
# With application/json in accept header
|
||||
headers = {"accept": "application/json"}
|
||||
result = app._accepts_json_response(headers)
|
||||
assert result is True
|
||||
|
||||
# Without accept header
|
||||
headers = {}
|
||||
result = app._accepts_json_response(headers)
|
||||
assert result is False
|
||||
|
||||
def test_parse_json_body_invalid_type(self) -> None:
|
||||
"""Test _parse_json_body raises error for invalid JSON."""
|
||||
from agent_framework_azurefunctions._errors import IncomingRequestError
|
||||
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
# Mock request with non-dict JSON
|
||||
mock_req = Mock()
|
||||
mock_req.get_json.return_value = ["not", "a", "dict"]
|
||||
|
||||
with pytest.raises(IncomingRequestError, match="Invalid JSON payload"):
|
||||
app._parse_json_body(mock_req)
|
||||
|
||||
def test_coerce_to_bool_with_none(self) -> None:
|
||||
"""Test _coerce_to_bool handles None and various value types."""
|
||||
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
||||
|
||||
# None returns False
|
||||
assert app._coerce_to_bool(None) is False
|
||||
|
||||
# Integer
|
||||
assert app._coerce_to_bool(1) is True
|
||||
assert app._coerce_to_bool(0) is False
|
||||
|
||||
# String
|
||||
assert app._coerce_to_bool("true") is True
|
||||
assert app._coerce_to_bool("false") is False
|
||||
|
||||
# Other type returns False
|
||||
assert app._coerce_to_bool([]) is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
|
||||
@@ -198,6 +198,114 @@ class TestCreateAgentEntity:
|
||||
persisted_state = mock_context.set_state.call_args[0][0]
|
||||
assert persisted_state["data"]["conversationHistory"] == []
|
||||
|
||||
def test_entity_function_handles_string_input(self) -> None:
|
||||
"""Test that the entity function handles non-dict input by converting to string."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("String response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context with non-dict input (like a number)
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.entity_key = "conv-456"
|
||||
# Use a number to test the str() conversion path
|
||||
mock_context.get_input.return_value = 12345
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute - entity will convert non-dict input to string
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify the result was set
|
||||
assert mock_context.set_result.called
|
||||
|
||||
def test_entity_function_handles_none_input(self) -> None:
|
||||
"""Test that the entity function handles None input by converting to empty string."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Empty response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context with None input
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.entity_key = "conv-789"
|
||||
mock_context.get_input.return_value = None
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute - should hit error path since entity expects dict or valid JSON string
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify the result was set (likely error result)
|
||||
assert mock_context.set_result.called
|
||||
|
||||
def test_entity_function_handles_event_loop_runtime_error(self) -> None:
|
||||
"""Test that the entity function handles RuntimeError from get_event_loop by creating a new loop."""
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.entity_key = "conv-loop-test"
|
||||
mock_context.get_input.return_value = {"message": "Test"}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Simulate RuntimeError when getting event loop
|
||||
with (
|
||||
patch("asyncio.get_event_loop", side_effect=RuntimeError("No event loop")),
|
||||
patch("asyncio.new_event_loop") as mock_new_loop,
|
||||
patch("asyncio.set_event_loop") as mock_set_loop,
|
||||
):
|
||||
mock_loop = Mock()
|
||||
mock_loop.is_running.return_value = False
|
||||
mock_loop.run_until_complete = Mock()
|
||||
mock_new_loop.return_value = mock_loop
|
||||
|
||||
# Execute
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify new event loop was created
|
||||
mock_new_loop.assert_called_once()
|
||||
mock_set_loop.assert_called_once_with(mock_loop)
|
||||
|
||||
def test_entity_function_handles_running_event_loop(self) -> None:
|
||||
"""Test that the entity function handles a running event loop by creating a temporary loop."""
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.entity_key = "conv-running-loop"
|
||||
mock_context.get_input.return_value = {"message": "Test"}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Simulate a running event loop
|
||||
mock_existing_loop = Mock()
|
||||
mock_existing_loop.is_running.return_value = True
|
||||
|
||||
mock_temp_loop = Mock()
|
||||
mock_temp_loop.run_until_complete = Mock()
|
||||
mock_temp_loop.close = Mock()
|
||||
|
||||
with (
|
||||
patch("asyncio.get_event_loop", return_value=mock_existing_loop),
|
||||
patch("asyncio.new_event_loop", return_value=mock_temp_loop),
|
||||
):
|
||||
# Execute
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify temporary loop was created and closed
|
||||
mock_temp_loop.run_until_complete.assert_called_once()
|
||||
mock_temp_loop.close.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for custom exception types."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_azurefunctions._errors import IncomingRequestError
|
||||
|
||||
|
||||
class TestIncomingRequestError:
|
||||
"""Test suite for IncomingRequestError exception."""
|
||||
|
||||
def test_incoming_request_error_default_status_code(self) -> None:
|
||||
"""Test that IncomingRequestError has a default status code of 400."""
|
||||
error = IncomingRequestError("Invalid request")
|
||||
|
||||
assert str(error) == "Invalid request"
|
||||
assert error.status_code == 400
|
||||
|
||||
def test_incoming_request_error_custom_status_code(self) -> None:
|
||||
"""Test that IncomingRequestError can have a custom status code."""
|
||||
error = IncomingRequestError("Unauthorized", status_code=401)
|
||||
|
||||
assert str(error) == "Unauthorized"
|
||||
assert error.status_code == 401
|
||||
|
||||
def test_incoming_request_error_is_value_error(self) -> None:
|
||||
"""Test that IncomingRequestError inherits from ValueError."""
|
||||
error = IncomingRequestError("Test error")
|
||||
|
||||
assert isinstance(error, ValueError)
|
||||
|
||||
def test_incoming_request_error_can_be_raised_and_caught(self) -> None:
|
||||
"""Test that IncomingRequestError can be raised and caught."""
|
||||
with pytest.raises(IncomingRequestError) as exc_info:
|
||||
raise IncomingRequestError("Bad request", status_code=400)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -129,6 +129,25 @@ def executor_with_context(mock_context_with_uuid: tuple[Mock, str]) -> tuple[Any
|
||||
class TestAgentResponseHelpers:
|
||||
"""Tests for response handling through public AgentTask API."""
|
||||
|
||||
def test_try_set_value_exception_handling(self) -> None:
|
||||
"""Test try_set_value handles exceptions raised when converting a successful task result to AgentResponse."""
|
||||
entity_task = _create_entity_task()
|
||||
task = AgentTask(entity_task, None, "correlation-id")
|
||||
|
||||
# Simulate successful entity task with invalid result that causes exception
|
||||
entity_task.state = TaskState.SUCCEEDED
|
||||
entity_task.result = {"invalid": "format"} # Missing required fields for AgentResponse
|
||||
|
||||
# Clear pending_tasks to simulate that parent has processed the child
|
||||
task.pending_tasks.clear()
|
||||
|
||||
# Call try_set_value - should catch exception and set error
|
||||
task.try_set_value(entity_task)
|
||||
|
||||
# Verify task failed due to conversion exception
|
||||
assert task.state == TaskState.FAILED
|
||||
assert isinstance(task.result, Exception)
|
||||
|
||||
def test_try_set_value_success(self) -> None:
|
||||
"""Test try_set_value correctly processes successful task completion."""
|
||||
entity_task = _create_entity_task()
|
||||
@@ -279,6 +298,27 @@ class TestAzureFunctionsFireAndForget:
|
||||
assert isinstance(result, AgentTask)
|
||||
|
||||
|
||||
class TestAzureFunctionsAgentExecutor:
|
||||
"""Tests for AzureFunctionsAgentExecutor."""
|
||||
|
||||
def test_generate_unique_id(self, mock_context_with_uuid: tuple[Mock, str]) -> None:
|
||||
"""Test generate_unique_id method returns UUID from orchestration context."""
|
||||
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
|
||||
|
||||
context, _ = mock_context_with_uuid
|
||||
executor = AzureFunctionsAgentExecutor(context)
|
||||
|
||||
# Call generate_unique_id
|
||||
unique_id = executor.generate_unique_id()
|
||||
|
||||
# Verify it returns the UUID from context (as string with dashes)
|
||||
# The UUID is returned in standard format with dashes
|
||||
context.new_uuid.assert_called_once()
|
||||
# Just verify it's a string representation of UUID
|
||||
assert isinstance(unique_id, str)
|
||||
assert len(unique_id) > 0
|
||||
|
||||
|
||||
class TestOrchestrationIntegration:
|
||||
"""Integration tests for orchestration scenarios."""
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
return create_sdk_mcp_server(name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools), tool_names
|
||||
|
||||
def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool[Any]) -> SdkMcpTool[Any]:
|
||||
def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool) -> SdkMcpTool[Any]:
|
||||
"""Convert a FunctionTool to an SDK MCP tool.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -439,7 +439,7 @@ class BaseAgent(SerializationMixin):
|
||||
stream_callback: Callable[[AgentResponseUpdate], None]
|
||||
| Callable[[AgentResponseUpdate], Awaitable[None]]
|
||||
| None = None,
|
||||
) -> FunctionTool[BaseModel]:
|
||||
) -> FunctionTool:
|
||||
"""Create a FunctionTool that wraps this agent.
|
||||
|
||||
Keyword Args:
|
||||
@@ -513,7 +513,7 @@ class BaseAgent(SerializationMixin):
|
||||
# Create final text from accumulated updates
|
||||
return AgentResponse.from_updates(response_updates).text
|
||||
|
||||
agent_tool: FunctionTool[BaseModel] = FunctionTool(
|
||||
agent_tool: FunctionTool = FunctionTool(
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
func=agent_wrapper,
|
||||
@@ -1258,17 +1258,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
@server.list_tools() # type: ignore
|
||||
async def _list_tools() -> list[types.Tool]: # type: ignore
|
||||
"""List all tools in the agent."""
|
||||
# Get the JSON schema from the Pydantic model
|
||||
schema = agent_tool.input_model.model_json_schema()
|
||||
schema = agent_tool.parameters()
|
||||
|
||||
tool = types.Tool(
|
||||
name=agent_tool.name,
|
||||
description=agent_tool.description,
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": schema.get("properties", {}),
|
||||
"required": schema.get("required", []),
|
||||
},
|
||||
inputSchema=schema,
|
||||
)
|
||||
|
||||
await _log(level="debug", data=f"Agent tool: {agent_tool}")
|
||||
@@ -1291,7 +1286,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
|
||||
# Create an instance of the input model with the arguments
|
||||
try:
|
||||
args_instance = agent_tool.input_model(**arguments)
|
||||
args_instance: BaseModel | dict[str, Any] = (
|
||||
agent_tool.input_model(**arguments) if agent_tool.input_model is not None else arguments
|
||||
)
|
||||
result = await agent_tool.invoke(arguments=args_instance)
|
||||
except Exception as e:
|
||||
raise McpError(
|
||||
|
||||
@@ -24,11 +24,9 @@ from mcp.client.websocket import websocket_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.session import RequestResponder
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
from ._tools import (
|
||||
FunctionTool,
|
||||
_build_pydantic_model_from_json_schema,
|
||||
)
|
||||
from ._types import (
|
||||
Content,
|
||||
@@ -355,11 +353,14 @@ def _prepare_message_for_mcp(
|
||||
return messages
|
||||
|
||||
|
||||
def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> type[BaseModel]:
|
||||
"""Creates a Pydantic model from a prompt's parameters."""
|
||||
def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> dict[str, Any]:
|
||||
"""Get the input model from an MCP prompt.
|
||||
|
||||
Returns a JSON schema dictionary for prompt arguments.
|
||||
"""
|
||||
# Check if 'arguments' is missing or empty
|
||||
if not prompt.arguments:
|
||||
return create_model(f"{prompt.name}_input")
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
# Convert prompt arguments to JSON schema format
|
||||
properties: dict[str, Any] = {}
|
||||
@@ -374,13 +375,10 @@ def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> type[BaseModel]:
|
||||
if prompt_argument.required:
|
||||
required.append(prompt_argument.name)
|
||||
|
||||
schema = {"properties": properties, "required": required}
|
||||
return _build_pydantic_model_from_json_schema(prompt.name, schema)
|
||||
|
||||
|
||||
def _get_input_model_from_mcp_tool(tool: types.Tool) -> type[BaseModel]:
|
||||
"""Creates a Pydantic model from a tools parameters."""
|
||||
return _build_pydantic_model_from_json_schema(tool.name, tool.inputSchema)
|
||||
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
||||
if required:
|
||||
schema["required"] = required
|
||||
return schema
|
||||
|
||||
|
||||
def _normalize_mcp_name(name: str) -> str:
|
||||
@@ -467,7 +465,7 @@ class MCPTool:
|
||||
self.session = session
|
||||
self.request_timeout = request_timeout
|
||||
self.client = client
|
||||
self._functions: list[FunctionTool[Any]] = []
|
||||
self._functions: list[FunctionTool] = []
|
||||
self.is_connected: bool = False
|
||||
self._tools_loaded: bool = False
|
||||
self._prompts_loaded: bool = False
|
||||
@@ -476,7 +474,7 @@ class MCPTool:
|
||||
return f"MCPTool(name={self.name}, description={self.description})"
|
||||
|
||||
@property
|
||||
def functions(self) -> list[FunctionTool[Any]]:
|
||||
def functions(self) -> list[FunctionTool]:
|
||||
"""Get the list of functions that are allowed."""
|
||||
if not self.allowed_tools:
|
||||
return self._functions
|
||||
@@ -744,7 +742,7 @@ class MCPTool:
|
||||
|
||||
input_model = _get_input_model_from_mcp_prompt(prompt)
|
||||
approval_mode = self._determine_approval_mode(local_name)
|
||||
func: FunctionTool[BaseModel] = FunctionTool(
|
||||
func: FunctionTool = FunctionTool(
|
||||
func=partial(self.get_prompt, prompt.name),
|
||||
name=local_name,
|
||||
description=prompt.description or "",
|
||||
@@ -785,15 +783,14 @@ class MCPTool:
|
||||
if local_name in existing_names:
|
||||
continue
|
||||
|
||||
input_model = _get_input_model_from_mcp_tool(tool)
|
||||
approval_mode = self._determine_approval_mode(local_name)
|
||||
# Create FunctionTools out of each tool
|
||||
func: FunctionTool[BaseModel] = FunctionTool(
|
||||
func: FunctionTool = FunctionTool(
|
||||
func=partial(self.call_tool, tool.name),
|
||||
name=local_name,
|
||||
description=tool.description or "",
|
||||
approval_mode=approval_mode,
|
||||
input_model=input_model,
|
||||
input_model=tool.inputSchema,
|
||||
)
|
||||
self._functions.append(func)
|
||||
existing_names.add(local_name)
|
||||
|
||||
@@ -234,8 +234,8 @@ class FunctionInvocationContext:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
function: FunctionTool[Any],
|
||||
arguments: BaseModel,
|
||||
function: FunctionTool,
|
||||
arguments: BaseModel | Mapping[str, Any],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
result: Any = None,
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
|
||||
@@ -26,7 +26,6 @@ from typing import (
|
||||
Literal,
|
||||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
get_origin,
|
||||
overload,
|
||||
@@ -89,8 +88,6 @@ DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3
|
||||
ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
|
||||
# region Helpers
|
||||
|
||||
ArgsT = TypeVar("ArgsT", bound=BaseModel, default=BaseModel)
|
||||
|
||||
|
||||
def _parse_inputs(
|
||||
inputs: Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None,
|
||||
@@ -183,11 +180,7 @@ def _default_histogram() -> Histogram:
|
||||
ClassT = TypeVar("ClassT", bound="SerializationMixin")
|
||||
|
||||
|
||||
class EmptyInputModel(BaseModel):
|
||||
"""An empty input model for functions with no parameters."""
|
||||
|
||||
|
||||
class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
class FunctionTool(SerializationMixin):
|
||||
"""A tool that wraps a Python function to make it callable by AI models.
|
||||
|
||||
This class wraps a Python function to make it callable by AI models with automatic
|
||||
@@ -240,6 +233,8 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
"input_model",
|
||||
"_invocation_duration_histogram",
|
||||
"_cached_parameters",
|
||||
"_input_schema",
|
||||
"_schema_supplied",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -252,7 +247,7 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
func: Callable[..., Any] | None = None,
|
||||
input_model: type[ArgsT] | Mapping[str, Any] | None = None,
|
||||
input_model: type[BaseModel] | Mapping[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
@@ -299,7 +294,16 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
# FunctionTool-specific attributes
|
||||
self.func = func
|
||||
self._instance = None # Store the instance for bound methods
|
||||
self.input_model = self._resolve_input_model(input_model)
|
||||
|
||||
# Track if schema was supplied as JSON dict (for optimization)
|
||||
if isinstance(input_model, Mapping):
|
||||
self._schema_supplied = True
|
||||
self._input_schema: dict[str, Any] = dict(input_model)
|
||||
self.input_model: type[BaseModel] | None = None
|
||||
else:
|
||||
self._schema_supplied = False
|
||||
self.input_model = self._resolve_input_model(input_model)
|
||||
self._input_schema = self.input_model.model_json_schema()
|
||||
self._cached_parameters: dict[str, Any] | None = None
|
||||
self.approval_mode = approval_mode or "never_require"
|
||||
if max_invocations is not None and max_invocations < 1:
|
||||
@@ -335,7 +339,7 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
return True
|
||||
return self.func is None
|
||||
|
||||
def __get__(self, obj: Any, objtype: type | None = None) -> FunctionTool[ArgsT]:
|
||||
def __get__(self, obj: Any, objtype: type | None = None) -> FunctionTool:
|
||||
"""Implement the descriptor protocol to support bound methods.
|
||||
|
||||
When a FunctionTool is accessed as an attribute of a class instance,
|
||||
@@ -366,17 +370,30 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
|
||||
return self
|
||||
|
||||
def _resolve_input_model(self, input_model: type[ArgsT] | Mapping[str, Any] | None) -> type[ArgsT]:
|
||||
def _resolve_input_model(self, input_model: type[BaseModel] | None) -> type[BaseModel]:
|
||||
"""Resolve the input model for the function."""
|
||||
if input_model is None:
|
||||
if self.func is None:
|
||||
return cast(type[ArgsT], EmptyInputModel)
|
||||
return cast(type[ArgsT], _create_input_model_from_func(func=self.func, name=self.name))
|
||||
if inspect.isclass(input_model) and issubclass(input_model, BaseModel):
|
||||
return input_model
|
||||
if isinstance(input_model, Mapping):
|
||||
return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model))
|
||||
raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.")
|
||||
if input_model is not None:
|
||||
if inspect.isclass(input_model) and issubclass(input_model, BaseModel):
|
||||
return input_model
|
||||
raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.")
|
||||
|
||||
if self.func is None:
|
||||
return create_model(f"{self.name}_input")
|
||||
|
||||
func = self.func.func if isinstance(self.func, FunctionTool) else self.func
|
||||
if func is None:
|
||||
return create_model(f"{self.name}_input")
|
||||
sig = inspect.signature(func)
|
||||
fields: dict[str, Any] = {
|
||||
pname: (
|
||||
_parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str,
|
||||
param.default if param.default is not inspect.Parameter.empty else ...,
|
||||
)
|
||||
for pname, param in sig.parameters.items()
|
||||
if pname not in {"self", "cls"}
|
||||
and param.kind not in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}
|
||||
}
|
||||
return create_model(f"{self.name}_input", **fields)
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Call the wrapped function with the provided arguments."""
|
||||
@@ -407,7 +424,7 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
arguments: ArgsT | None = None,
|
||||
arguments: BaseModel | Mapping[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Run the AI function with the provided arguments as a Pydantic model.
|
||||
@@ -417,14 +434,14 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
``result_parser`` if one was provided.
|
||||
|
||||
Keyword Args:
|
||||
arguments: A Pydantic model instance containing the arguments for the function.
|
||||
arguments: A mapping or model instance containing the arguments for the function.
|
||||
kwargs: Keyword arguments to pass to the function, will not be used if ``arguments`` is provided.
|
||||
|
||||
Returns:
|
||||
The parsed result as a string — either plain text or serialized JSON.
|
||||
|
||||
Raises:
|
||||
TypeError: If arguments is not an instance of the expected input model.
|
||||
TypeError: If arguments is not mapping-like or fails schema checks.
|
||||
"""
|
||||
if self.declaration_only:
|
||||
raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.")
|
||||
@@ -436,9 +453,32 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
original_kwargs = dict(kwargs)
|
||||
tool_call_id = original_kwargs.pop("tool_call_id", None)
|
||||
if arguments is not None:
|
||||
if not isinstance(arguments, self.input_model):
|
||||
raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}")
|
||||
kwargs = arguments.model_dump(exclude_none=True)
|
||||
try:
|
||||
if isinstance(arguments, Mapping):
|
||||
parsed_arguments = dict(arguments)
|
||||
if self.input_model is not None and not self._schema_supplied:
|
||||
parsed_arguments = self.input_model.model_validate(parsed_arguments).model_dump(
|
||||
exclude_none=True
|
||||
)
|
||||
elif isinstance(arguments, BaseModel):
|
||||
if (
|
||||
self.input_model is not None
|
||||
and not self._schema_supplied
|
||||
and not isinstance(arguments, self.input_model)
|
||||
):
|
||||
raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}")
|
||||
parsed_arguments = arguments.model_dump(exclude_none=True)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}"
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise TypeError(f"Invalid arguments for '{self.name}': {exc}") from exc
|
||||
kwargs = _validate_arguments_against_schema(
|
||||
arguments=parsed_arguments,
|
||||
schema=self.parameters(),
|
||||
tool_name=self.name,
|
||||
)
|
||||
if getattr(self, "_forward_runtime_kwargs", False) and original_kwargs:
|
||||
kwargs.update(original_kwargs)
|
||||
else:
|
||||
@@ -458,34 +498,34 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
return parsed
|
||||
|
||||
attributes = get_function_span_attributes(self, tool_call_id=tool_call_id)
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
|
||||
# Filter out framework kwargs that are not JSON serializable
|
||||
serializable_kwargs = {
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in {
|
||||
"chat_options",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"session",
|
||||
"conversation_id",
|
||||
"options",
|
||||
"response_format",
|
||||
}
|
||||
# Filter out framework kwargs that are not JSON serializable.
|
||||
serializable_kwargs = {
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in {
|
||||
"chat_options",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"session",
|
||||
"conversation_id",
|
||||
"options",
|
||||
"response_format",
|
||||
}
|
||||
}
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
|
||||
attributes.update({
|
||||
OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json(ensure_ascii=False)
|
||||
if arguments
|
||||
else json.dumps(serializable_kwargs, default=str, ensure_ascii=False)
|
||||
if serializable_kwargs
|
||||
else "None"
|
||||
OtelAttr.TOOL_ARGUMENTS: (
|
||||
json.dumps(serializable_kwargs, default=str, ensure_ascii=False)
|
||||
if serializable_kwargs
|
||||
else "None"
|
||||
)
|
||||
})
|
||||
with get_function_span(attributes=attributes) as span:
|
||||
attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] = self.name
|
||||
logger.info(f"Function name: {self.name}")
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
|
||||
logger.debug(f"Function arguments: {kwargs}")
|
||||
logger.debug(f"Function arguments: {serializable_kwargs}")
|
||||
start_time_stamp = perf_counter()
|
||||
end_time_stamp: float | None = None
|
||||
try:
|
||||
@@ -523,7 +563,7 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
|
||||
The result is cached after the first call for performance.
|
||||
"""
|
||||
if self._cached_parameters is None:
|
||||
self._cached_parameters = self.input_model.model_json_schema()
|
||||
self._cached_parameters = self._input_schema
|
||||
return self._cached_parameters
|
||||
|
||||
@staticmethod
|
||||
@@ -677,23 +717,79 @@ def _parse_annotation(annotation: Any) -> Any:
|
||||
return annotation
|
||||
|
||||
|
||||
def _create_input_model_from_func(func: Callable[..., Any], name: str) -> type[BaseModel]:
|
||||
"""Create a Pydantic model from a function's signature."""
|
||||
# Unwrap FunctionTool objects to get the underlying function
|
||||
if isinstance(func, FunctionTool):
|
||||
func = func.func # type: ignore[assignment]
|
||||
def _matches_json_schema_type(value: Any, schema_type: str) -> bool:
|
||||
"""Check a value against a simple JSON schema primitive type."""
|
||||
match schema_type:
|
||||
case "string":
|
||||
return isinstance(value, str)
|
||||
case "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
case "number":
|
||||
return (isinstance(value, int | float)) and not isinstance(value, bool)
|
||||
case "boolean":
|
||||
return isinstance(value, bool)
|
||||
case "array":
|
||||
return isinstance(value, list)
|
||||
case "object":
|
||||
return isinstance(value, dict)
|
||||
case "null":
|
||||
return value is None
|
||||
case _:
|
||||
return True
|
||||
|
||||
sig = inspect.signature(func)
|
||||
fields = {
|
||||
pname: (
|
||||
_parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str,
|
||||
param.default if param.default is not inspect.Parameter.empty else ...,
|
||||
)
|
||||
for pname, param in sig.parameters.items()
|
||||
if pname not in {"self", "cls"}
|
||||
and param.kind not in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}
|
||||
}
|
||||
return create_model(f"{name}_input", **fields) # type: ignore[call-overload, no-any-return]
|
||||
|
||||
def _validate_arguments_against_schema(
|
||||
*,
|
||||
arguments: Mapping[str, Any],
|
||||
schema: Mapping[str, Any],
|
||||
tool_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Run lightweight argument checks for schema-supplied tools."""
|
||||
parsed_arguments = dict(arguments)
|
||||
|
||||
required_raw = schema.get("required", [])
|
||||
required_fields = [field for field in required_raw if isinstance(field, str)]
|
||||
missing_fields = [field for field in required_fields if field not in parsed_arguments]
|
||||
if missing_fields:
|
||||
raise TypeError(f"Missing required argument(s) for '{tool_name}': {', '.join(sorted(missing_fields))}")
|
||||
|
||||
properties_raw = schema.get("properties")
|
||||
properties = properties_raw if isinstance(properties_raw, Mapping) else {}
|
||||
|
||||
if schema.get("additionalProperties") is False:
|
||||
unexpected_fields = sorted(field for field in parsed_arguments if field not in properties)
|
||||
if unexpected_fields:
|
||||
raise TypeError(f"Unexpected argument(s) for '{tool_name}': {', '.join(unexpected_fields)}")
|
||||
|
||||
for field_name, field_value in parsed_arguments.items():
|
||||
field_schema = properties.get(field_name)
|
||||
if not isinstance(field_schema, Mapping):
|
||||
continue
|
||||
|
||||
enum_values = field_schema.get("enum")
|
||||
if isinstance(enum_values, list) and enum_values and field_value not in enum_values:
|
||||
raise TypeError(
|
||||
f"Invalid value for '{field_name}' in '{tool_name}': {field_value!r} is not in {enum_values!r}"
|
||||
)
|
||||
|
||||
schema_type = field_schema.get("type")
|
||||
if isinstance(schema_type, str):
|
||||
if not _matches_json_schema_type(field_value, schema_type):
|
||||
raise TypeError(
|
||||
f"Invalid type for '{field_name}' in '{tool_name}': "
|
||||
f"expected {schema_type}, got {type(field_value).__name__}"
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(schema_type, list):
|
||||
allowed_types = [item for item in schema_type if isinstance(item, str)]
|
||||
if allowed_types and not any(_matches_json_schema_type(field_value, item) for item in allowed_types):
|
||||
raise TypeError(
|
||||
f"Invalid type for '{field_name}' in '{tool_name}': expected one of "
|
||||
f"{allowed_types}, got {type(field_value).__name__}"
|
||||
)
|
||||
|
||||
return parsed_arguments
|
||||
|
||||
|
||||
# Map JSON Schema types to Pydantic types
|
||||
@@ -942,7 +1038,7 @@ def tool(
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str] | None = None,
|
||||
) -> FunctionTool[Any]: ...
|
||||
) -> FunctionTool: ...
|
||||
|
||||
|
||||
@overload
|
||||
@@ -957,7 +1053,7 @@ def tool(
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str] | None = None,
|
||||
) -> Callable[[Callable[..., Any]], FunctionTool[Any]]: ...
|
||||
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
|
||||
|
||||
|
||||
def tool(
|
||||
@@ -971,7 +1067,7 @@ def tool(
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str] | None = None,
|
||||
) -> FunctionTool[Any] | Callable[[Callable[..., Any]], FunctionTool[Any]]:
|
||||
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
|
||||
"""Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically.
|
||||
|
||||
This decorator creates a Pydantic model from the function's signature,
|
||||
@@ -1095,12 +1191,12 @@ def tool(
|
||||
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> FunctionTool[Any]:
|
||||
def decorator(func: Callable[..., Any]) -> FunctionTool:
|
||||
@wraps(func)
|
||||
def wrapper(f: Callable[..., Any]) -> FunctionTool[Any]:
|
||||
def wrapper(f: Callable[..., Any]) -> FunctionTool:
|
||||
tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment]
|
||||
tool_desc: str = description or (f.__doc__ or "")
|
||||
return FunctionTool[Any](
|
||||
return FunctionTool(
|
||||
name=tool_name,
|
||||
description=tool_desc,
|
||||
approval_mode=approval_mode,
|
||||
@@ -1193,7 +1289,7 @@ async def _auto_invoke_function(
|
||||
custom_args: dict[str, Any] | None = None,
|
||||
*,
|
||||
config: FunctionInvocationConfiguration,
|
||||
tool_map: dict[str, FunctionTool[BaseModel]],
|
||||
tool_map: dict[str, FunctionTool],
|
||||
sequence_index: int | None = None,
|
||||
request_index: int | None = None,
|
||||
middleware_pipeline: FunctionMiddlewarePipeline | None = None, # Optional MiddlewarePipeline
|
||||
@@ -1225,7 +1321,7 @@ async def _auto_invoke_function(
|
||||
# this function is called. This function only handles the actual execution of approved,
|
||||
# non-declaration-only functions.
|
||||
|
||||
tool: FunctionTool[BaseModel] | None = None
|
||||
tool: FunctionTool | None = None
|
||||
if function_call_content.type == "function_call":
|
||||
tool = tool_map.get(function_call_content.name) # type: ignore[arg-type]
|
||||
# Tool should exist because _try_execute_function_calls validates this
|
||||
@@ -1258,8 +1354,16 @@ async def _auto_invoke_function(
|
||||
if key not in {"_function_middleware_pipeline", "middleware", "conversation_id"}
|
||||
}
|
||||
try:
|
||||
args = tool.input_model.model_validate(parsed_args)
|
||||
except ValidationError as exc:
|
||||
if not tool._schema_supplied and tool.input_model is not None:
|
||||
args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True)
|
||||
else:
|
||||
args = dict(parsed_args)
|
||||
args = _validate_arguments_against_schema(
|
||||
arguments=args,
|
||||
schema=tool.parameters(),
|
||||
tool_name=tool.name,
|
||||
)
|
||||
except (TypeError, ValidationError) as exc:
|
||||
message = "Error: Argument parsing failed."
|
||||
if config["include_detailed_errors"]:
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1340,8 +1444,8 @@ def _get_tool_map(
|
||||
| Callable[..., Any]
|
||||
| MutableMapping[str, Any]
|
||||
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]],
|
||||
) -> dict[str, FunctionTool[Any]]:
|
||||
tool_list: dict[str, FunctionTool[Any]] = {}
|
||||
) -> dict[str, FunctionTool]:
|
||||
tool_list: dict[str, FunctionTool] = {}
|
||||
for tool_item in tools if isinstance(tools, list) else [tools]:
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
tool_list[tool_item.name] = tool_item
|
||||
|
||||
@@ -1448,7 +1448,7 @@ class AgentTelemetryLayer:
|
||||
# region Otel Helpers
|
||||
|
||||
|
||||
def get_function_span_attributes(function: FunctionTool[Any], tool_call_id: str | None = None) -> dict[str, str]:
|
||||
def get_function_span_attributes(function: FunctionTool, tool_call_id: str | None = None) -> dict[str, str]:
|
||||
"""Get the span attributes for the given function.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
from pydantic import AnyUrl, BaseModel, ValidationError
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
Content,
|
||||
@@ -22,7 +22,6 @@ from agent_framework import (
|
||||
from agent_framework._mcp import (
|
||||
MCPTool,
|
||||
_get_input_model_from_mcp_prompt,
|
||||
_get_input_model_from_mcp_tool,
|
||||
_normalize_mcp_name,
|
||||
_parse_content_from_mcp,
|
||||
_parse_message_from_mcp,
|
||||
@@ -276,363 +275,338 @@ def test_prepare_message_for_mcp():
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_id,input_schema,valid_data,expected_values,invalid_data,validation_check",
|
||||
"test_id,input_schema",
|
||||
[
|
||||
# Basic types with required/optional fields
|
||||
(
|
||||
"basic_types",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}, "param2": {"type": "number"}},
|
||||
"required": ["param1"],
|
||||
},
|
||||
{"param1": "test", "param2": 42},
|
||||
{"param1": "test", "param2": 42},
|
||||
{"param2": 42}, # Missing required param1
|
||||
None,
|
||||
),
|
||||
# Nested object
|
||||
(
|
||||
"nested_object",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
(test_id, input_schema)
|
||||
for test_id, input_schema, _, _, _, _ in [
|
||||
# Basic types with required/optional fields
|
||||
(
|
||||
"basic_types",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}, "param2": {"type": "number"}},
|
||||
"required": ["param1"],
|
||||
},
|
||||
"required": ["params"],
|
||||
},
|
||||
{"params": {"customer_id": 251}},
|
||||
{"params.customer_id": 251},
|
||||
{"params": {}}, # Missing required customer_id
|
||||
lambda instance: isinstance(instance.params, BaseModel),
|
||||
),
|
||||
# $ref resolution
|
||||
(
|
||||
"ref_schema",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"params": {"$ref": "#/$defs/CustomerIdParam"}},
|
||||
"required": ["params"],
|
||||
"$defs": {
|
||||
"CustomerIdParam": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
},
|
||||
},
|
||||
{"params": {"customer_id": 251}},
|
||||
{"params.customer_id": 251},
|
||||
{"params": {}}, # Missing required customer_id
|
||||
lambda instance: isinstance(instance.params, BaseModel),
|
||||
),
|
||||
# Array of strings (typed)
|
||||
(
|
||||
"array_of_strings",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "List of tags",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
"required": ["tags"],
|
||||
},
|
||||
{"tags": ["tag1", "tag2", "tag3"]},
|
||||
{"tags": ["tag1", "tag2", "tag3"]},
|
||||
None, # No validation error test for this case
|
||||
None,
|
||||
),
|
||||
# Array of integers (typed)
|
||||
(
|
||||
"array_of_integers",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"numbers": {
|
||||
"type": "array",
|
||||
"description": "List of integers",
|
||||
"items": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
"required": ["numbers"],
|
||||
},
|
||||
{"numbers": [1, 2, 3]},
|
||||
{"numbers": [1, 2, 3]},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Array of objects (complex nested)
|
||||
(
|
||||
"array_of_objects",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"description": "List of users",
|
||||
"items": {
|
||||
{"param1": "test", "param2": 42},
|
||||
{"param1": "test", "param2": 42},
|
||||
{"param2": 42}, # Missing required param1
|
||||
None,
|
||||
),
|
||||
# Nested object
|
||||
(
|
||||
"nested_object",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer", "description": "User ID"},
|
||||
"name": {"type": "string", "description": "User name"},
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
},
|
||||
}
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
},
|
||||
"required": ["params"],
|
||||
},
|
||||
"required": ["users"],
|
||||
},
|
||||
{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]},
|
||||
{"users[0].id": 1, "users[0].name": "Alice", "users[1].id": 2, "users[1].name": "Bob"},
|
||||
{"users": [{"id": 1}]}, # Missing required 'name'
|
||||
lambda instance: all(isinstance(user, BaseModel) for user in instance.users),
|
||||
),
|
||||
# Deeply nested objects (3+ levels)
|
||||
(
|
||||
"deeply_nested",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filters": {
|
||||
{"params": {"customer_id": 251}},
|
||||
{"params.customer_id": 251},
|
||||
{"params": {}}, # Missing required customer_id
|
||||
lambda instance: isinstance(instance.params, BaseModel),
|
||||
),
|
||||
# $ref resolution
|
||||
(
|
||||
"ref_schema",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"params": {"$ref": "#/$defs/CustomerIdParam"}},
|
||||
"required": ["params"],
|
||||
"$defs": {
|
||||
"CustomerIdParam": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
},
|
||||
},
|
||||
{"params": {"customer_id": 251}},
|
||||
{"params.customer_id": 251},
|
||||
{"params": {}}, # Missing required customer_id
|
||||
lambda instance: isinstance(instance.params, BaseModel),
|
||||
),
|
||||
# Array of strings (typed)
|
||||
(
|
||||
"array_of_strings",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "List of tags",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
"required": ["tags"],
|
||||
},
|
||||
{"tags": ["tag1", "tag2", "tag3"]},
|
||||
{"tags": ["tag1", "tag2", "tag3"]},
|
||||
None, # No validation error test for this case
|
||||
None,
|
||||
),
|
||||
# Array of integers (typed)
|
||||
(
|
||||
"array_of_integers",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"numbers": {
|
||||
"type": "array",
|
||||
"description": "List of integers",
|
||||
"items": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
"required": ["numbers"],
|
||||
},
|
||||
{"numbers": [1, 2, 3]},
|
||||
{"numbers": [1, 2, 3]},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Array of objects (complex nested)
|
||||
(
|
||||
"array_of_objects",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"description": "List of users",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_range": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {"type": "string"},
|
||||
"end": {"type": "string"},
|
||||
},
|
||||
"required": ["start", "end"],
|
||||
},
|
||||
"categories": {"type": "array", "items": {"type": "string"}},
|
||||
"id": {"type": "integer", "description": "User ID"},
|
||||
"name": {"type": "string", "description": "User name"},
|
||||
},
|
||||
"required": ["date_range"],
|
||||
}
|
||||
},
|
||||
"required": ["filters"],
|
||||
"required": ["id", "name"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["users"],
|
||||
},
|
||||
{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]},
|
||||
{"users[0].id": 1, "users[0].name": "Alice", "users[1].id": 2, "users[1].name": "Bob"},
|
||||
{"users": [{"id": 1}]}, # Missing required 'name'
|
||||
lambda instance: all(isinstance(user, BaseModel) for user in instance.users),
|
||||
),
|
||||
# Deeply nested objects (3+ levels)
|
||||
(
|
||||
"deeply_nested",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_range": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {"type": "string"},
|
||||
"end": {"type": "string"},
|
||||
},
|
||||
"required": ["start", "end"],
|
||||
},
|
||||
"categories": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["date_range"],
|
||||
}
|
||||
},
|
||||
"required": ["filters"],
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
{
|
||||
"query": {
|
||||
"filters": {
|
||||
"date_range": {"start": "2024-01-01", "end": "2024-12-31"},
|
||||
"categories": ["tech", "science"],
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
{
|
||||
"query": {
|
||||
"filters": {
|
||||
"date_range": {"start": "2024-01-01", "end": "2024-12-31"},
|
||||
"categories": ["tech", "science"],
|
||||
{
|
||||
"query.filters.date_range.start": "2024-01-01",
|
||||
"query.filters.date_range.end": "2024-12-31",
|
||||
"query.filters.categories": ["tech", "science"],
|
||||
},
|
||||
{"query": {"filters": {"date_range": {}}}}, # Missing required start and end
|
||||
None,
|
||||
),
|
||||
# Complex $ref with nested structure
|
||||
(
|
||||
"ref_nested_structure",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"order": {"$ref": "#/$defs/OrderParams"}},
|
||||
"required": ["order"],
|
||||
"$defs": {
|
||||
"OrderParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {"$ref": "#/$defs/Customer"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/OrderItem"}},
|
||||
},
|
||||
"required": ["customer", "items"],
|
||||
},
|
||||
"Customer": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "integer"}, "email": {"type": "string"}},
|
||||
"required": ["id", "email"],
|
||||
},
|
||||
"OrderItem": {
|
||||
"type": "object",
|
||||
"properties": {"product_id": {"type": "string"}, "quantity": {"type": "integer"}},
|
||||
"required": ["product_id", "quantity"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"order": {
|
||||
"customer": {"id": 123, "email": "test@example.com"},
|
||||
"items": [{"product_id": "prod1", "quantity": 2}],
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"query.filters.date_range.start": "2024-01-01",
|
||||
"query.filters.date_range.end": "2024-12-31",
|
||||
"query.filters.categories": ["tech", "science"],
|
||||
},
|
||||
{"query": {"filters": {"date_range": {}}}}, # Missing required start and end
|
||||
None,
|
||||
),
|
||||
# Complex $ref with nested structure
|
||||
(
|
||||
"ref_nested_structure",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"order": {"$ref": "#/$defs/OrderParams"}},
|
||||
"required": ["order"],
|
||||
"$defs": {
|
||||
"OrderParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {"$ref": "#/$defs/Customer"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/OrderItem"}},
|
||||
},
|
||||
{
|
||||
"order.customer.id": 123,
|
||||
"order.customer.email": "test@example.com",
|
||||
"order.items[0].product_id": "prod1",
|
||||
"order.items[0].quantity": 2,
|
||||
},
|
||||
{"order": {"customer": {"id": 123}, "items": []}}, # Missing email
|
||||
lambda instance: isinstance(instance.order.customer, BaseModel),
|
||||
),
|
||||
# Mixed types (primitives, arrays, nested objects)
|
||||
(
|
||||
"mixed_types",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"simple_string": {"type": "string"},
|
||||
"simple_number": {"type": "integer"},
|
||||
"string_array": {"type": "array", "items": {"type": "string"}},
|
||||
"nested_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["enabled"],
|
||||
},
|
||||
"required": ["customer", "items"],
|
||||
},
|
||||
"Customer": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "integer"}, "email": {"type": "string"}},
|
||||
"required": ["id", "email"],
|
||||
},
|
||||
"OrderItem": {
|
||||
"type": "object",
|
||||
"properties": {"product_id": {"type": "string"}, "quantity": {"type": "integer"}},
|
||||
"required": ["product_id", "quantity"],
|
||||
"required": ["simple_string", "nested_config"],
|
||||
},
|
||||
{
|
||||
"simple_string": "test",
|
||||
"simple_number": 42,
|
||||
"string_array": ["a", "b"],
|
||||
"nested_config": {"enabled": True, "options": ["opt1", "opt2"]},
|
||||
},
|
||||
{
|
||||
"simple_string": "test",
|
||||
"simple_number": 42,
|
||||
"string_array": ["a", "b"],
|
||||
"nested_config.enabled": True,
|
||||
"nested_config.options": ["opt1", "opt2"],
|
||||
},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Empty schema (no properties)
|
||||
(
|
||||
"empty_schema",
|
||||
{"type": "object", "properties": {}},
|
||||
{},
|
||||
{},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# All primitive types
|
||||
(
|
||||
"all_primitives",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string_field": {"type": "string"},
|
||||
"integer_field": {"type": "integer"},
|
||||
"number_field": {"type": "number"},
|
||||
"boolean_field": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"order": {
|
||||
"customer": {"id": 123, "email": "test@example.com"},
|
||||
"items": [{"product_id": "prod1", "quantity": 2}],
|
||||
}
|
||||
},
|
||||
{
|
||||
"order.customer.id": 123,
|
||||
"order.customer.email": "test@example.com",
|
||||
"order.items[0].product_id": "prod1",
|
||||
"order.items[0].quantity": 2,
|
||||
},
|
||||
{"order": {"customer": {"id": 123}, "items": []}}, # Missing email
|
||||
lambda instance: isinstance(instance.order.customer, BaseModel),
|
||||
),
|
||||
# Mixed types (primitives, arrays, nested objects)
|
||||
(
|
||||
"mixed_types",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"simple_string": {"type": "string"},
|
||||
"simple_number": {"type": "integer"},
|
||||
"string_array": {"type": "array", "items": {"type": "string"}},
|
||||
"nested_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["enabled"],
|
||||
},
|
||||
{"string_field": "test", "integer_field": 42, "number_field": 3.14, "boolean_field": True},
|
||||
{"string_field": "test", "integer_field": 42, "number_field": 3.14, "boolean_field": True},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: unresolvable $ref (fallback to dict)
|
||||
(
|
||||
"unresolvable_ref",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"data": {"$ref": "#/$defs/NonExistent"}},
|
||||
"$defs": {},
|
||||
},
|
||||
"required": ["simple_string", "nested_config"],
|
||||
},
|
||||
{
|
||||
"simple_string": "test",
|
||||
"simple_number": 42,
|
||||
"string_array": ["a", "b"],
|
||||
"nested_config": {"enabled": True, "options": ["opt1", "opt2"]},
|
||||
},
|
||||
{
|
||||
"simple_string": "test",
|
||||
"simple_number": 42,
|
||||
"string_array": ["a", "b"],
|
||||
"nested_config.enabled": True,
|
||||
"nested_config.options": ["opt1", "opt2"],
|
||||
},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Empty schema (no properties)
|
||||
(
|
||||
"empty_schema",
|
||||
{"type": "object", "properties": {}},
|
||||
{},
|
||||
{},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# All primitive types
|
||||
(
|
||||
"all_primitives",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string_field": {"type": "string"},
|
||||
"integer_field": {"type": "integer"},
|
||||
"number_field": {"type": "number"},
|
||||
"boolean_field": {"type": "boolean"},
|
||||
{"data": {"key": "value"}},
|
||||
{"data": {"key": "value"}},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: array without items schema (fallback to bare list)
|
||||
(
|
||||
"array_no_items",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array"}},
|
||||
},
|
||||
},
|
||||
{"string_field": "test", "integer_field": 42, "number_field": 3.14, "boolean_field": True},
|
||||
{"string_field": "test", "integer_field": 42, "number_field": 3.14, "boolean_field": True},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: unresolvable $ref (fallback to dict)
|
||||
(
|
||||
"unresolvable_ref",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"data": {"$ref": "#/$defs/NonExistent"}},
|
||||
"$defs": {},
|
||||
},
|
||||
{"data": {"key": "value"}},
|
||||
{"data": {"key": "value"}},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: array without items schema (fallback to bare list)
|
||||
(
|
||||
"array_no_items",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array"}},
|
||||
},
|
||||
{"items": [1, "two", 3.0]},
|
||||
{"items": [1, "two", 3.0]},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: object without properties (fallback to dict)
|
||||
(
|
||||
"object_no_properties",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"config": {"type": "object"}},
|
||||
},
|
||||
{"config": {"arbitrary": "data", "nested": {"key": "value"}}},
|
||||
{"config": {"arbitrary": "data", "nested": {"key": "value"}}},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
{"items": [1, "two", 3.0]},
|
||||
{"items": [1, "two", 3.0]},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: object without properties (fallback to dict)
|
||||
(
|
||||
"object_no_properties",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"config": {"type": "object"}},
|
||||
},
|
||||
{"config": {"arbitrary": "data", "nested": {"key": "value"}}},
|
||||
{"config": {"arbitrary": "data", "nested": {"key": "value"}}},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
]
|
||||
],
|
||||
)
|
||||
def test_get_input_model_from_mcp_tool_parametrized(
|
||||
test_id, input_schema, valid_data, expected_values, invalid_data, validation_check
|
||||
):
|
||||
"""Parametrized test for JSON schema to Pydantic model conversion.
|
||||
def test_get_input_model_from_mcp_tool_parametrized(test_id: str, input_schema: dict[str, Any]) -> None:
|
||||
"""Parametrized test for MCP tool input schema passthrough.
|
||||
|
||||
This test covers various edge cases including:
|
||||
- Basic types with required/optional fields
|
||||
- Nested objects
|
||||
- $ref resolution
|
||||
- Typed arrays (strings, integers, objects)
|
||||
- Deeply nested structures
|
||||
- Complex $ref with nested structures
|
||||
- Mixed types
|
||||
This test verifies that MCP tool schemas are passed through as-is
|
||||
without Pydantic conversion, which improves performance and preserves
|
||||
the original schema structure.
|
||||
|
||||
To add a new test case, add a tuple to the parametrize decorator with:
|
||||
- test_id: A descriptive name for the test case
|
||||
- input_schema: The JSON schema (inputSchema dict)
|
||||
- valid_data: Valid data to instantiate the model
|
||||
- expected_values: Dict of expected values (supports dot notation for nested access)
|
||||
- invalid_data: Invalid data to test validation errors (None to skip)
|
||||
- validation_check: Optional callable to perform additional validation checks
|
||||
"""
|
||||
tool = types.Tool(name="test_tool", description="A test tool", inputSchema=input_schema)
|
||||
model = _get_input_model_from_mcp_tool(tool)
|
||||
schema = tool.inputSchema
|
||||
|
||||
# Test valid data
|
||||
instance = model(**valid_data)
|
||||
|
||||
# Check expected values
|
||||
for field_path, expected_value in expected_values.items():
|
||||
# Support dot notation and array indexing for nested access
|
||||
current = instance
|
||||
parts = field_path.replace("]", "").replace("[", ".").split(".")
|
||||
for part in parts:
|
||||
current = current[int(part)] if part.isdigit() else getattr(current, part)
|
||||
assert current == expected_value, f"Field {field_path} = {current}, expected {expected_value}"
|
||||
|
||||
# Run additional validation checks if provided
|
||||
if validation_check:
|
||||
assert validation_check(instance), f"Validation check failed for {test_id}"
|
||||
|
||||
# Test invalid data if provided
|
||||
if invalid_data is not None:
|
||||
with pytest.raises(ValidationError):
|
||||
model(**invalid_data)
|
||||
# Verify schema is returned as-is (dict)
|
||||
assert isinstance(schema, dict), f"Expected dict, got {type(schema)}"
|
||||
assert schema == input_schema, "Schema should be passed through unchanged"
|
||||
|
||||
|
||||
def test_get_input_model_from_mcp_prompt():
|
||||
"""Test creation of input model from MCP prompt."""
|
||||
"""Test creation of input schema from MCP prompt."""
|
||||
prompt = types.Prompt(
|
||||
name="test_prompt",
|
||||
description="A test prompt",
|
||||
@@ -641,16 +615,24 @@ def test_get_input_model_from_mcp_prompt():
|
||||
types.PromptArgument(name="arg2", description="Second argument", required=False),
|
||||
],
|
||||
)
|
||||
model = _get_input_model_from_mcp_prompt(prompt)
|
||||
result = _get_input_model_from_mcp_prompt(prompt)
|
||||
|
||||
# Create an instance to verify the model works
|
||||
instance = model(arg1="test", arg2="optional")
|
||||
assert instance.arg1 == "test"
|
||||
assert instance.arg2 == "optional"
|
||||
# Should return a dict (schema)
|
||||
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
|
||||
assert result["type"] == "object"
|
||||
assert "arg1" in result["properties"]
|
||||
assert "arg2" in result["properties"]
|
||||
assert "arg1" in result["required"]
|
||||
assert "arg2" not in result["required"]
|
||||
|
||||
# Test validation
|
||||
with pytest.raises(ValidationError): # Missing required arg1
|
||||
model(arg2="optional")
|
||||
|
||||
def test_get_input_model_from_mcp_prompt_without_arguments():
|
||||
"""Test prompt schema generation when no prompt arguments are defined."""
|
||||
prompt = types.Prompt(name="empty_prompt", description="No args prompt", arguments=[])
|
||||
result = _get_input_model_from_mcp_prompt(prompt)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
# MCPTool tests
|
||||
|
||||
@@ -74,7 +74,7 @@ class TestAgentContext:
|
||||
class TestFunctionInvocationContext:
|
||||
"""Test cases for FunctionInvocationContext."""
|
||||
|
||||
def test_init_with_defaults(self, mock_function: FunctionTool[Any]) -> None:
|
||||
def test_init_with_defaults(self, mock_function: FunctionTool) -> None:
|
||||
"""Test FunctionInvocationContext initialization with default values."""
|
||||
arguments = FunctionTestArgs(name="test")
|
||||
context = FunctionInvocationContext(function=mock_function, arguments=arguments)
|
||||
@@ -83,7 +83,7 @@ class TestFunctionInvocationContext:
|
||||
assert context.arguments == arguments
|
||||
assert context.metadata == {}
|
||||
|
||||
def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any]) -> None:
|
||||
def test_init_with_custom_metadata(self, mock_function: FunctionTool) -> None:
|
||||
"""Test FunctionInvocationContext initialization with custom metadata."""
|
||||
arguments = FunctionTestArgs(name="test")
|
||||
metadata = {"key": "value"}
|
||||
@@ -420,7 +420,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
await call_next()
|
||||
raise MiddlewareTermination
|
||||
|
||||
async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool) -> None:
|
||||
"""Test pipeline execution with termination before next() raises MiddlewareTermination."""
|
||||
middleware = self.PreNextTerminateFunctionMiddleware()
|
||||
pipeline = FunctionMiddlewarePipeline(middleware)
|
||||
@@ -439,7 +439,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: FunctionTool[Any]) -> None:
|
||||
async def test_execute_with_post_next_termination(self, mock_function: FunctionTool) -> None:
|
||||
"""Test pipeline execution with termination after next() raises MiddlewareTermination."""
|
||||
middleware = self.PostNextTerminateFunctionMiddleware()
|
||||
pipeline = FunctionMiddlewarePipeline(middleware)
|
||||
@@ -480,7 +480,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
pipeline = FunctionMiddlewarePipeline(test_middleware)
|
||||
assert pipeline.has_middlewares
|
||||
|
||||
async def test_execute_no_middleware(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_execute_no_middleware(self, mock_function: FunctionTool) -> None:
|
||||
"""Test pipeline execution with no middleware."""
|
||||
pipeline = FunctionMiddlewarePipeline()
|
||||
arguments = FunctionTestArgs(name="test")
|
||||
@@ -494,7 +494,7 @@ class TestFunctionMiddlewarePipeline:
|
||||
result = await pipeline.execute(context, final_handler)
|
||||
assert result == expected_result
|
||||
|
||||
async def test_execute_with_middleware(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_execute_with_middleware(self, mock_function: FunctionTool) -> None:
|
||||
"""Test pipeline execution with middleware."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -787,7 +787,7 @@ class TestClassBasedMiddleware:
|
||||
assert context.metadata["after"] is True
|
||||
assert metadata_updates == ["before", "handler", "after"]
|
||||
|
||||
async def test_function_middleware_execution(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_execution(self, mock_function: FunctionTool) -> None:
|
||||
"""Test class-based function middleware execution."""
|
||||
metadata_updates: list[str] = []
|
||||
|
||||
@@ -847,7 +847,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: FunctionTool[Any]) -> None:
|
||||
async def test_function_function_middleware(self, mock_function: FunctionTool) -> None:
|
||||
"""Test function-based function middleware."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -905,7 +905,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: FunctionTool[Any]) -> None:
|
||||
async def test_mixed_function_middleware(self, mock_function: FunctionTool) -> None:
|
||||
"""Test mixed class and function-based function middleware."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -1017,7 +1017,7 @@ class TestMultipleMiddlewareOrdering:
|
||||
]
|
||||
assert execution_order == expected_order
|
||||
|
||||
async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_execution_order(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that multiple function middleware execute in registration order."""
|
||||
execution_order: list[str] = []
|
||||
|
||||
@@ -1143,7 +1143,7 @@ class TestContextContentValidation:
|
||||
result = await pipeline.execute(context, final_handler)
|
||||
assert result is not None
|
||||
|
||||
async def test_function_context_validation(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_function_context_validation(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that function context contains expected data."""
|
||||
|
||||
class ContextValidationMiddleware(FunctionMiddleware):
|
||||
@@ -1489,7 +1489,7 @@ class TestMiddlewareExecutionControl:
|
||||
assert not handler_called
|
||||
assert context.result is None
|
||||
|
||||
async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that when function middleware doesn't call next(), no execution happens."""
|
||||
|
||||
class FunctionTestArgs(BaseModel):
|
||||
@@ -1666,9 +1666,9 @@ def mock_agent() -> SupportsAgentRun:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_function() -> FunctionTool[Any]:
|
||||
def mock_function() -> FunctionTool:
|
||||
"""Mock function for testing."""
|
||||
function = MagicMock(spec=FunctionTool[Any])
|
||||
function = MagicMock(spec=FunctionTool)
|
||||
function.name = "test_function"
|
||||
return function
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -103,7 +102,7 @@ class TestResultOverrideMiddleware:
|
||||
assert updates[0].text == "overridden"
|
||||
assert updates[1].text == " stream"
|
||||
|
||||
async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_result_override(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that function middleware can override result."""
|
||||
override_result = "overridden function result"
|
||||
|
||||
@@ -252,7 +251,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: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that when function middleware conditionally doesn't call next(), no execution happens."""
|
||||
|
||||
class ConditionalNoNextFunctionMiddleware(FunctionMiddleware):
|
||||
@@ -335,7 +334,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: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_result_observability(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that middleware can observe function result after execution."""
|
||||
observed_results: list[str] = []
|
||||
|
||||
@@ -402,7 +401,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: FunctionTool[Any]) -> None:
|
||||
async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool) -> None:
|
||||
"""Test that middleware can override function result after observing execution."""
|
||||
|
||||
class PostExecutionOverrideMiddleware(FunctionMiddleware):
|
||||
@@ -444,8 +443,8 @@ def mock_agent() -> SupportsAgentRun:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_function() -> FunctionTool[Any]:
|
||||
def mock_function() -> FunctionTool:
|
||||
"""Mock function for testing."""
|
||||
function = MagicMock(spec=FunctionTool[Any])
|
||||
function = MagicMock(spec=FunctionTool)
|
||||
function.name = "test_function"
|
||||
return function
|
||||
|
||||
@@ -108,6 +108,90 @@ def test_tool_decorator_with_json_schema_dict():
|
||||
assert search("hello") == "Searching for: hello (max 10)"
|
||||
|
||||
|
||||
async def test_tool_decorator_with_json_schema_invoke_uses_mapping():
|
||||
"""Test that schema-based tools can be invoked directly with mapping arguments."""
|
||||
|
||||
json_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"max_results": {"type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
@tool(name="search", description="Search tool", schema=json_schema)
|
||||
def search(query: str, max_results: int = 10) -> str:
|
||||
return f"{query}:{max_results}"
|
||||
|
||||
result = await search.invoke(arguments={"query": "hello", "max_results": 3})
|
||||
assert result == "hello:3"
|
||||
|
||||
|
||||
async def test_tool_decorator_with_json_schema_invoke_missing_required():
|
||||
"""Test schema-required fields are checked for mapping arguments."""
|
||||
|
||||
json_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
@tool(name="search", description="Search tool", schema=json_schema)
|
||||
def search(query: str) -> str:
|
||||
return query
|
||||
|
||||
with pytest.raises(TypeError, match="Missing required argument"):
|
||||
await search.invoke(arguments={})
|
||||
|
||||
|
||||
async def test_tool_decorator_with_json_schema_invoke_invalid_type():
|
||||
"""Test schema type checks run for mapping arguments."""
|
||||
|
||||
json_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"max_results": {"type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
@tool(name="search", description="Search tool", schema=json_schema)
|
||||
def search(query: str, max_results: int = 10) -> str:
|
||||
return f"{query}:{max_results}"
|
||||
|
||||
with pytest.raises(TypeError, match="Invalid type for 'max_results'"):
|
||||
await search.invoke(arguments={"query": "hello", "max_results": "three"})
|
||||
|
||||
|
||||
def test_tool_decorator_with_json_schema_preserves_custom_properties():
|
||||
"""Test schema passthrough keeps custom JSON schema properties."""
|
||||
|
||||
json_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"x-custom-field": "custom-value",
|
||||
},
|
||||
},
|
||||
"required": ["priority"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
@tool(name="process", description="Process tool", schema=json_schema)
|
||||
def process(priority: str) -> str:
|
||||
return priority
|
||||
|
||||
params = process.parameters()
|
||||
assert not params.get("additionalProperties")
|
||||
assert params["properties"]["priority"]["x-custom-field"] == "custom-value"
|
||||
|
||||
|
||||
def test_tool_decorator_schema_none_default():
|
||||
"""Test that schema=None (default) still infers from function signature."""
|
||||
|
||||
@@ -555,7 +639,7 @@ async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemoryS
|
||||
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "pydantic_call"
|
||||
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
|
||||
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "A test tool with Pydantic args"
|
||||
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x":5,"y":10}'
|
||||
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x": 5, "y": 10}'
|
||||
|
||||
|
||||
async def test_tool_invoke_telemetry_with_exception(span_exporter: InMemorySpanExporter):
|
||||
|
||||
@@ -499,7 +499,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
return copilot_tools
|
||||
|
||||
def _tool_to_copilot_tool(self, ai_func: FunctionTool[Any]) -> CopilotTool:
|
||||
def _tool_to_copilot_tool(self, ai_func: FunctionTool) -> CopilotTool:
|
||||
"""Convert an FunctionTool to a Copilot SDK tool."""
|
||||
|
||||
async def handler(invocation: ToolInvocation) -> ToolResult:
|
||||
|
||||
@@ -27,7 +27,7 @@ from tau2.environment.tool import Tool # type: ignore[import-untyped]
|
||||
_original_set_state = Environment.set_state
|
||||
|
||||
|
||||
def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool[Any]:
|
||||
def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool:
|
||||
"""Convert a tau2 Tool to a FunctionTool for agent framework compatibility.
|
||||
|
||||
Creates a wrapper that preserves the tool's interface while ensuring
|
||||
|
||||
@@ -324,7 +324,7 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
existing_tools = list(default_options.get("tools") or [])
|
||||
existing_names = {getattr(tool, "name", "") for tool in existing_tools if hasattr(tool, "name")}
|
||||
|
||||
new_tools: list[FunctionTool[Any]] = []
|
||||
new_tools: list[FunctionTool] = []
|
||||
for target in targets:
|
||||
handoff_tool = self._create_handoff_tool(target.target_id, target.description)
|
||||
if handoff_tool.name in existing_names:
|
||||
@@ -340,7 +340,7 @@ class HandoffAgentExecutor(AgentExecutor):
|
||||
else:
|
||||
default_options["tools"] = existing_tools
|
||||
|
||||
def _create_handoff_tool(self, target_id: str, description: str | None = None) -> FunctionTool[Any]:
|
||||
def _create_handoff_tool(self, target_id: str, description: str | None = None) -> FunctionTool:
|
||||
"""Construct the synthetic handoff tool that signals routing to `target_id`."""
|
||||
tool_name = get_handoff_tool_name(target_id)
|
||||
doc = description or f"Handoff to the {target_id} agent."
|
||||
|
||||
@@ -1,60 +1,42 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
"""Host your agent with Azure Functions.
|
||||
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
"""
|
||||
Host Your Agent — Minimal A2A hosting stub
|
||||
|
||||
This sample shows the pattern for exposing an agent via the Agent-to-Agent
|
||||
(A2A) protocol. It creates the agent and demonstrates how to wrap it with
|
||||
the A2A hosting layer.
|
||||
This sample shows the Python hosting pattern used in docs:
|
||||
- Create an agent with `AzureOpenAIChatClient`
|
||||
- Register it with `AgentFunctionApp`
|
||||
- Run with Azure Functions Core Tools (`func start`)
|
||||
|
||||
Prerequisites:
|
||||
pip install agent-framework[a2a] --pre
|
||||
pip install agent-framework-azurefunctions --pre
|
||||
|
||||
Environment variables:
|
||||
AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o)
|
||||
|
||||
To run a full A2A server, see samples/04-hosting/a2a/ for a complete example.
|
||||
AZURE_OPENAI_ENDPOINT
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
async def main() -> None:
|
||||
# <create_agent>
|
||||
credential = AzureCliCredential()
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"],
|
||||
credential=credential,
|
||||
)
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
agent = client.as_agent(
|
||||
|
||||
# <create_agent>
|
||||
def _create_agent() -> Any:
|
||||
"""Create a hosted agent backed by Azure OpenAI."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
|
||||
name="HostedAgent",
|
||||
instructions="You are a helpful assistant exposed via A2A.",
|
||||
instructions="You are a helpful assistant hosted in Azure Functions.",
|
||||
)
|
||||
# </create_agent>
|
||||
|
||||
# <host_agent>
|
||||
# The A2A hosting integration wraps your agent behind an HTTP endpoint.
|
||||
# Import is gated so this sample can run without the a2a extra installed.
|
||||
try:
|
||||
from agent_framework.a2a import A2AAgent # noqa: F401
|
||||
|
||||
print("A2A support is available.")
|
||||
print("See samples/04-hosting/a2a/ for a runnable A2A server example.")
|
||||
except ImportError:
|
||||
print("Install a2a extras: pip install agent-framework[a2a] --pre")
|
||||
# </create_agent>
|
||||
|
||||
# Quick smoke-test: run the agent locally to verify it works
|
||||
result = await agent.run("Hello! What can you do?")
|
||||
print(f"Agent: {result}")
|
||||
# </host_agent>
|
||||
# <host_agent>
|
||||
app = AgentFunctionApp(agents=[_create_agent()], enable_health_check=True, max_poll_retries=50)
|
||||
# </host_agent>
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
print("Start the Functions host with: func start")
|
||||
print("Then call: POST /api/agents/HostedAgent/run")
|
||||
|
||||
@@ -25,7 +25,7 @@ export OPENAI_RESPONSES_MODEL_ID="gpt-4o" # optional, defaults to gpt-4o
|
||||
| 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentThread`. |
|
||||
| 4 | [04_memory.py](04_memory.py) | Add dynamic context with a custom `ContextProvider`. |
|
||||
| 5 | [05_first_workflow.py](05_first_workflow.py) | Chain executors into a workflow with edges. |
|
||||
| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Prepare your agent for A2A hosting. |
|
||||
| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Host a single agent with Azure Functions. |
|
||||
|
||||
Run any sample with:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user