mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
cd1e3110aa
commit
fc9c81b0b1
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user