Python: [BREAKING] Remove FunctionTool[Any] compatibility shim for schema passthrough (#3600) (#3907)

* Fix #3600: Pass JSON schemas through without Pydantic conversion

This change optimizes FunctionTool and MCP flows by passing JSON schemas
directly to providers without converting them to Pydantic models first.

Key changes:
- Store JSON schema as-is when supplied to FunctionTool
- Skip Pydantic model_validate for schema-supplied tools in invoke()
- Return MCP tool schemas directly without conversion
- Add comprehensive tests for schema passthrough behavior

Performance benefits:
- Eliminates expensive Pydantic model creation for supplied schemas
- Preserves exact schema structure (additionalProperties, custom fields, etc.)
- Reduces memory overhead and initialization time

Maintains backward compatibility:
- Function signature inference still uses Pydantic models
- Explicit Pydantic models passed as input_model work as before
- All existing tests pass

* Fix schema passthrough validation and remove helper

* Simplify FunctionTool without generic model dependency

* Fix FunctionTool typing fallout in 3600

* Remove FunctionTool[Any] compatibility shim

* Use serializable kwargs in OTEL tool args
This commit is contained in:
Eduard van Valkenburg
2026-02-14 11:12:21 +01:00
committed by GitHub
Unverified
parent cd1e3110aa
commit fc9c81b0b1
16 changed files with 645 additions and 482 deletions
+320 -338
View File
@@ -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
+85 -1
View File
@@ -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):