mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Centralize tool result parsing in FunctionTool.invoke() (#3854)
* Centralize tool result parsing in FunctionTool.invoke() - Add parse_result static method to FunctionTool that converts raw function return values to strings at invocation time - Add result_parser parameter to FunctionTool and @tool decorator for custom parsing - Remove prepare_function_call_results from all 9 consumer files and from the public API - Update MCPTool to parse MCP types directly to strings via _parse_tool_result_from_mcp and _parse_prompt_result_from_mcp - Change MCPTool parse_tool_results/parse_prompt_results type from Literal[True] | Callable | None to Callable | None - Remove ReturnT type parameter from FunctionTool (now single generic ArgsT since invoke() always returns str) - Update all subclass signatures and docstrings Fixes #1147 * Fix test_mcp_tool_call_tool_with_meta_integration for string results The test was still accessing result[0].additional_properties but invoke() now returns a string, not a list of Content objects. * Fix SIM108 lint: use binary operator for output assignment * Fix bedrock: use FunctionTool.parse_result instead of str() fallback str(result) turns None into literal 'None' and dicts into Python reprs with single quotes, breaking JSON parsing. Use the shared parse_result which handles None as '' and serializes via json.dumps. * updated lock * updates from feedback
This commit is contained in:
@@ -25,7 +25,7 @@ from agent_framework._mcp import (
|
||||
_get_input_model_from_mcp_tool,
|
||||
_normalize_mcp_name,
|
||||
_parse_content_from_mcp,
|
||||
_parse_contents_from_mcp_tool_result,
|
||||
_parse_tool_result_from_mcp,
|
||||
_parse_message_from_mcp,
|
||||
_prepare_content_for_mcp,
|
||||
_prepare_message_for_mcp,
|
||||
@@ -68,144 +68,60 @@ def test_mcp_prompt_message_to_ai_content():
|
||||
assert ai_content.raw_representation == mcp_message
|
||||
|
||||
|
||||
def test_parse_contents_from_mcp_tool_result():
|
||||
"""Test conversion from MCP tool result to AI contents."""
|
||||
def test_parse_tool_result_from_mcp():
|
||||
"""Test conversion from MCP tool result to string representation."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(type="text", text="Result text"),
|
||||
types.ImageContent(type="image", data="eHl6", mimeType="image/png"), # base64 for "xyz"
|
||||
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), # base64 for "abc"
|
||||
types.ImageContent(type="image", data="eHl6", mimeType="image/png"),
|
||||
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"),
|
||||
]
|
||||
)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 3
|
||||
assert ai_contents[0].type == "text"
|
||||
assert ai_contents[0].text == "Result text"
|
||||
assert ai_contents[1].type == "data"
|
||||
assert ai_contents[1].uri == "data:image/png;base64,eHl6"
|
||||
assert ai_contents[1].media_type == "image/png"
|
||||
assert ai_contents[2].type == "data"
|
||||
assert ai_contents[2].uri == "data:image/webp;base64,YWJj"
|
||||
assert ai_contents[2].media_type == "image/webp"
|
||||
# Multiple items produce a JSON array of strings
|
||||
assert isinstance(result, str)
|
||||
import json
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert len(parsed) == 3
|
||||
assert parsed[0] == "Result text"
|
||||
# Image items are JSON-encoded strings within the array
|
||||
img1 = json.loads(parsed[1])
|
||||
assert img1["type"] == "image"
|
||||
assert img1["data"] == "eHl6"
|
||||
img2 = json.loads(parsed[2])
|
||||
assert img2["type"] == "image"
|
||||
assert img2["data"] == "YWJj"
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_with_meta_error():
|
||||
"""Test conversion from MCP tool result with _meta field containing isError=True."""
|
||||
# Create a mock CallToolResult with _meta field containing error information
|
||||
def test_parse_tool_result_from_mcp_single_text():
|
||||
"""Test conversion from MCP tool result with a single text item."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Simple result")]
|
||||
)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
# Single text item returns just the text
|
||||
assert result == "Simple result"
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_meta_not_in_string():
|
||||
"""Test that _meta data is not included in the string result (it's tool-level, not content-level)."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Error occurred")],
|
||||
_meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"},
|
||||
_meta={"isError": True, "errorCode": "TOOL_ERROR"},
|
||||
)
|
||||
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
assert ai_contents[0].type == "text"
|
||||
assert ai_contents[0].text == "Error occurred"
|
||||
|
||||
# Check that _meta data is merged into additional_properties
|
||||
assert ai_contents[0].additional_properties is not None
|
||||
assert ai_contents[0].additional_properties["isError"] is True
|
||||
assert ai_contents[0].additional_properties["errorCode"] == "TOOL_ERROR"
|
||||
assert ai_contents[0].additional_properties["errorMessage"] == "Tool execution failed"
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
assert result == "Error occurred"
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_with_meta_arbitrary_data():
|
||||
"""Test conversion from MCP tool result with _meta field containing arbitrary metadata.
|
||||
|
||||
Note: The _meta field is optional and can contain any structure that a specific
|
||||
MCP server chooses to provide. This test uses example metadata to verify that
|
||||
whatever is provided gets preserved in additional_properties.
|
||||
"""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Success result")],
|
||||
_meta={
|
||||
"serverVersion": "2.1.0",
|
||||
"executionId": "exec_abc123",
|
||||
"metrics": {"responseTime": 1.25, "memoryUsed": "64MB"},
|
||||
"source": "example-mcp-server",
|
||||
"customField": "arbitrary_value",
|
||||
},
|
||||
)
|
||||
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
assert ai_contents[0].type == "text"
|
||||
assert ai_contents[0].text == "Success result"
|
||||
|
||||
# Check that _meta data is preserved in additional_properties
|
||||
props = ai_contents[0].additional_properties
|
||||
assert props is not None
|
||||
assert props["serverVersion"] == "2.1.0"
|
||||
assert props["executionId"] == "exec_abc123"
|
||||
assert props["metrics"] == {"responseTime": 1.25, "memoryUsed": "64MB"}
|
||||
assert props["source"] == "example-mcp-server"
|
||||
assert props["customField"] == "arbitrary_value"
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_with_meta_merging_existing_properties():
|
||||
"""Test that _meta data merges correctly with existing additional_properties."""
|
||||
# Create content with existing additional_properties
|
||||
text_content = types.TextContent(type="text", text="Test content")
|
||||
mcp_result = types.CallToolResult(content=[text_content], _meta={"newField": "newValue", "isError": False})
|
||||
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
content = ai_contents[0]
|
||||
|
||||
# Check that _meta data is present in additional_properties
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties["newField"] == "newValue"
|
||||
assert content.additional_properties["isError"] is False
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_with_meta_none():
|
||||
"""Test that missing _meta field is handled gracefully."""
|
||||
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="No meta test")])
|
||||
# No _meta field set
|
||||
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
assert ai_contents[0].type == "text"
|
||||
assert ai_contents[0].text == "No meta test"
|
||||
|
||||
# Should handle gracefully when no _meta field exists
|
||||
# additional_properties may be None or empty dict
|
||||
props = ai_contents[0].additional_properties
|
||||
assert props is None or props == {}
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_regression_successful_workflow():
|
||||
"""Regression test to ensure existing successful workflows remain unchanged."""
|
||||
# Test the original successful workflow still works
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(type="text", text="Success message"),
|
||||
types.ImageContent(type="image", data="YWJjMTIz", mimeType="image/jpeg"), # base64 for "abc123"
|
||||
]
|
||||
)
|
||||
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
# Verify basic conversion still works correctly
|
||||
assert len(ai_contents) == 2
|
||||
|
||||
text_content = ai_contents[0]
|
||||
assert text_content.type == "text"
|
||||
assert text_content.text == "Success message"
|
||||
|
||||
image_content = ai_contents[1]
|
||||
assert image_content.type == "data"
|
||||
assert image_content.uri == "data:image/jpeg;base64,YWJjMTIz"
|
||||
assert image_content.media_type == "image/jpeg"
|
||||
|
||||
# Should have no additional_properties when no _meta field
|
||||
assert text_content.additional_properties is None or text_content.additional_properties == {}
|
||||
assert image_content.additional_properties is None or image_content.additional_properties == {}
|
||||
def test_parse_tool_result_from_mcp_empty_content():
|
||||
"""Test that empty content produces empty string."""
|
||||
mcp_result = types.CallToolResult(content=[])
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_mcp_content_types_to_ai_content_text():
|
||||
@@ -874,17 +790,7 @@ async def test_mcp_tool_call_tool_with_meta_integration():
|
||||
func = server.functions[0]
|
||||
result = await func.invoke(param="test_value")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "Tool executed with metadata"
|
||||
|
||||
# Verify that _meta data is present in additional_properties
|
||||
props = result[0].additional_properties
|
||||
assert props is not None
|
||||
assert props["executionTime"] == 1.5
|
||||
assert props["cost"] == {"usd": 0.002}
|
||||
assert props["isError"] is False
|
||||
assert props["toolVersion"] == "1.2.3"
|
||||
assert result == "Tool executed with metadata"
|
||||
|
||||
|
||||
async def test_local_mcp_server_function_execution():
|
||||
@@ -923,9 +829,7 @@ async def test_local_mcp_server_function_execution():
|
||||
func = server.functions[0]
|
||||
result = await func.invoke(param="test_value")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "Tool executed successfully"
|
||||
assert result == "Tool executed successfully"
|
||||
|
||||
|
||||
async def test_local_mcp_server_function_execution_with_nested_object():
|
||||
@@ -972,8 +876,7 @@ async def test_local_mcp_server_function_execution_with_nested_object():
|
||||
# Call with nested object
|
||||
result = await func.invoke(params={"customer_id": 251})
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
assert result == '{"name": "John Doe", "id": 251}'
|
||||
|
||||
# Verify the session.call_tool was called with the correct nested structure
|
||||
server.session.call_tool.assert_called_once()
|
||||
@@ -1057,11 +960,7 @@ async def test_local_mcp_server_prompt_execution():
|
||||
prompt = server.functions[0]
|
||||
result = await prompt.invoke(arg="test_value")
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Message)
|
||||
assert result[0].role == "user"
|
||||
assert len(result[0].contents) == 1
|
||||
assert result[0].contents[0].text == "Test message"
|
||||
assert result == "Test message"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1249,7 +1148,8 @@ async def test_streamable_http_integration():
|
||||
assert hasattr(func, "description")
|
||||
|
||||
result = await func.invoke(query="What is Agent Framework?")
|
||||
assert result[0].text is not None
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@@ -1314,11 +1214,11 @@ async def test_mcp_connection_reset_integration():
|
||||
# Verify tools are still available after reconnection
|
||||
assert len(tool.functions) > 0
|
||||
|
||||
# Both results should be valid (we don't compare content as it may vary)
|
||||
if hasattr(first_result[0], "text"):
|
||||
assert first_result[0].text is not None
|
||||
if hasattr(second_result[0], "text"):
|
||||
assert second_result[0].text is not None
|
||||
# Both results should be valid strings (we don't compare content as it may vary)
|
||||
assert isinstance(first_result, str)
|
||||
assert len(first_result) > 0
|
||||
assert isinstance(second_result, str)
|
||||
assert len(second_result) > 0
|
||||
|
||||
|
||||
async def test_mcp_tool_message_handler_notification():
|
||||
|
||||
@@ -74,7 +74,7 @@ class TestAgentContext:
|
||||
class TestFunctionInvocationContext:
|
||||
"""Test cases for FunctionInvocationContext."""
|
||||
|
||||
def test_init_with_defaults(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
def test_init_with_defaults(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_execute_with_post_next_termination(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_execute_no_middleware(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_execute_with_middleware(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_function_middleware_execution(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_function_function_middleware(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_mixed_function_middleware(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_function_context_validation(self, mock_function: FunctionTool[Any]) -> 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, Any]) -> None:
|
||||
async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any]) -> 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, Any]:
|
||||
def mock_function() -> FunctionTool[Any]:
|
||||
"""Mock function for testing."""
|
||||
function = MagicMock(spec=FunctionTool[Any, Any])
|
||||
function = MagicMock(spec=FunctionTool[Any])
|
||||
function.name = "test_function"
|
||||
return function
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestResultOverrideMiddleware:
|
||||
assert updates[0].text == "overridden"
|
||||
assert updates[1].text == " stream"
|
||||
|
||||
async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any, Any]) -> None:
|
||||
async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any]) -> None:
|
||||
"""Test that function middleware can override result."""
|
||||
override_result = "overridden function result"
|
||||
|
||||
@@ -252,7 +252,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, Any]) -> None:
|
||||
async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool[Any]) -> None:
|
||||
"""Test that when function middleware conditionally doesn't call next(), no execution happens."""
|
||||
|
||||
class ConditionalNoNextFunctionMiddleware(FunctionMiddleware):
|
||||
@@ -335,7 +335,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, Any]) -> None:
|
||||
async def test_function_middleware_result_observability(self, mock_function: FunctionTool[Any]) -> None:
|
||||
"""Test that middleware can observe function result after execution."""
|
||||
observed_results: list[str] = []
|
||||
|
||||
@@ -402,7 +402,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, Any]) -> None:
|
||||
async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool[Any]) -> None:
|
||||
"""Test that middleware can override function result after observing execution."""
|
||||
|
||||
class PostExecutionOverrideMiddleware(FunctionMiddleware):
|
||||
@@ -444,8 +444,8 @@ def mock_agent() -> SupportsAgentRun:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_function() -> FunctionTool[Any, Any]:
|
||||
def mock_function() -> FunctionTool[Any]:
|
||||
"""Mock function for testing."""
|
||||
function = MagicMock(spec=FunctionTool[Any, Any])
|
||||
function = MagicMock(spec=FunctionTool[Any])
|
||||
function.name = "test_function"
|
||||
return function
|
||||
|
||||
@@ -138,7 +138,7 @@ async def test_tool_decorator_with_schema_invoke():
|
||||
return a + b
|
||||
|
||||
result = await calculate.invoke(arguments=CalcInput(a=3, b=7))
|
||||
assert result == 10
|
||||
assert result == "10"
|
||||
|
||||
|
||||
def test_tool_decorator_with_schema_overrides_annotations():
|
||||
@@ -436,7 +436,7 @@ async def test_tool_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter
|
||||
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
|
||||
|
||||
# Verify result
|
||||
assert result == 3
|
||||
assert result == "3"
|
||||
|
||||
# Verify telemetry calls
|
||||
spans = span_exporter.get_finished_spans()
|
||||
@@ -480,7 +480,7 @@ async def test_tool_invoke_telemetry_sensitive_disabled(span_exporter: InMemoryS
|
||||
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
|
||||
|
||||
# Verify result
|
||||
assert result == 3
|
||||
assert result == "3"
|
||||
|
||||
# Verify telemetry calls
|
||||
spans = span_exporter.get_finished_spans()
|
||||
@@ -545,7 +545,7 @@ async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemoryS
|
||||
result = await pydantic_test_tool.invoke(arguments=args_model, tool_call_id="pydantic_call")
|
||||
|
||||
# Verify result
|
||||
assert result == 15
|
||||
assert result == "15"
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
@@ -613,7 +613,7 @@ async def test_tool_invoke_telemetry_async_function(span_exporter: InMemorySpanE
|
||||
result = await async_telemetry_test.invoke(x=3, y=4, tool_call_id="async_call")
|
||||
|
||||
# Verify result
|
||||
assert result == 12
|
||||
assert result == "12"
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
|
||||
@@ -26,7 +26,6 @@ from agent_framework import (
|
||||
UsageDetails,
|
||||
detect_media_type_from_base64,
|
||||
merge_chat_options,
|
||||
prepare_function_call_results,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._types import (
|
||||
@@ -2072,7 +2071,7 @@ def test_text_content_with_annotations_serialization():
|
||||
assert all(isinstance(ann["annotated_regions"][0], dict) for ann in reconstructed.annotations)
|
||||
|
||||
|
||||
# region prepare_function_call_results with Pydantic models
|
||||
# region FunctionTool.parse_result with Pydantic models
|
||||
|
||||
|
||||
class WeatherResult(BaseModel):
|
||||
@@ -2089,10 +2088,10 @@ class NestedModel(BaseModel):
|
||||
weather: WeatherResult
|
||||
|
||||
|
||||
def test_prepare_function_call_results_pydantic_model():
|
||||
def test_parse_result_pydantic_model():
|
||||
"""Test that Pydantic BaseModel subclasses are properly serialized using model_dump()."""
|
||||
result = WeatherResult(temperature=22.5, condition="sunny")
|
||||
json_result = prepare_function_call_results(result)
|
||||
json_result = FunctionTool.parse_result(result)
|
||||
|
||||
# The result should be a valid JSON string
|
||||
assert isinstance(json_result, str)
|
||||
@@ -2100,13 +2099,13 @@ def test_prepare_function_call_results_pydantic_model():
|
||||
assert '"condition": "sunny"' in json_result or '"condition":"sunny"' in json_result
|
||||
|
||||
|
||||
def test_prepare_function_call_results_pydantic_model_in_list():
|
||||
def test_parse_result_pydantic_model_in_list():
|
||||
"""Test that lists containing Pydantic models are properly serialized."""
|
||||
results = [
|
||||
WeatherResult(temperature=20.0, condition="cloudy"),
|
||||
WeatherResult(temperature=25.0, condition="sunny"),
|
||||
]
|
||||
json_result = prepare_function_call_results(results)
|
||||
json_result = FunctionTool.parse_result(results)
|
||||
|
||||
# The result should be a valid JSON string representing a list
|
||||
assert isinstance(json_result, str)
|
||||
@@ -2116,13 +2115,13 @@ def test_prepare_function_call_results_pydantic_model_in_list():
|
||||
assert "sunny" in json_result
|
||||
|
||||
|
||||
def test_prepare_function_call_results_pydantic_model_in_dict():
|
||||
def test_parse_result_pydantic_model_in_dict():
|
||||
"""Test that dicts containing Pydantic models are properly serialized."""
|
||||
results = {
|
||||
"current": WeatherResult(temperature=22.0, condition="partly cloudy"),
|
||||
"forecast": WeatherResult(temperature=24.0, condition="sunny"),
|
||||
}
|
||||
json_result = prepare_function_call_results(results)
|
||||
json_result = FunctionTool.parse_result(results)
|
||||
|
||||
# The result should be a valid JSON string representing a dict
|
||||
assert isinstance(json_result, str)
|
||||
@@ -2132,10 +2131,10 @@ def test_prepare_function_call_results_pydantic_model_in_dict():
|
||||
assert "sunny" in json_result
|
||||
|
||||
|
||||
def test_prepare_function_call_results_nested_pydantic_model():
|
||||
def test_parse_result_nested_pydantic_model():
|
||||
"""Test that nested Pydantic models are properly serialized."""
|
||||
result = NestedModel(name="Seattle", weather=WeatherResult(temperature=18.0, condition="rainy"))
|
||||
json_result = prepare_function_call_results(result)
|
||||
json_result = FunctionTool.parse_result(result)
|
||||
|
||||
# The result should be a valid JSON string
|
||||
assert isinstance(json_result, str)
|
||||
@@ -2144,10 +2143,10 @@ def test_prepare_function_call_results_nested_pydantic_model():
|
||||
assert "18.0" in json_result or "18" in json_result
|
||||
|
||||
|
||||
# region prepare_function_call_results with MCP TextContent-like objects
|
||||
# region FunctionTool.parse_result with MCP TextContent-like objects
|
||||
|
||||
|
||||
def test_prepare_function_call_results_text_content_single():
|
||||
def test_parse_result_text_content_single():
|
||||
"""Test that objects with text attribute (like MCP TextContent) are properly handled."""
|
||||
|
||||
@dataclass
|
||||
@@ -2155,14 +2154,14 @@ def test_prepare_function_call_results_text_content_single():
|
||||
text: str
|
||||
|
||||
result = [MockTextContent("Hello from MCP tool!")]
|
||||
json_result = prepare_function_call_results(result)
|
||||
json_result = FunctionTool.parse_result(result)
|
||||
|
||||
# Should extract text and serialize as JSON array of strings
|
||||
assert isinstance(json_result, str)
|
||||
assert json_result == '["Hello from MCP tool!"]'
|
||||
|
||||
|
||||
def test_prepare_function_call_results_text_content_multiple():
|
||||
def test_parse_result_text_content_multiple():
|
||||
"""Test that multiple TextContent-like objects are serialized correctly."""
|
||||
|
||||
@dataclass
|
||||
@@ -2170,14 +2169,14 @@ def test_prepare_function_call_results_text_content_multiple():
|
||||
text: str
|
||||
|
||||
result = [MockTextContent("First result"), MockTextContent("Second result")]
|
||||
json_result = prepare_function_call_results(result)
|
||||
json_result = FunctionTool.parse_result(result)
|
||||
|
||||
# Should extract text from each and serialize as JSON array
|
||||
assert isinstance(json_result, str)
|
||||
assert json_result == '["First result", "Second result"]'
|
||||
|
||||
|
||||
def test_prepare_function_call_results_text_content_with_non_string_text():
|
||||
def test_parse_result_text_content_with_non_string_text():
|
||||
"""Test that objects with non-string text attribute are not treated as TextContent."""
|
||||
|
||||
class BadTextContent:
|
||||
@@ -2185,12 +2184,40 @@ def test_prepare_function_call_results_text_content_with_non_string_text():
|
||||
self.text = 12345 # Not a string!
|
||||
|
||||
result = [BadTextContent()]
|
||||
json_result = prepare_function_call_results(result)
|
||||
json_result = FunctionTool.parse_result(result)
|
||||
|
||||
# Should not extract text since it's not a string, will serialize the object
|
||||
assert isinstance(json_result, str)
|
||||
|
||||
|
||||
def test_parse_result_none_returns_empty_string():
|
||||
"""Test that None returns an empty string."""
|
||||
assert FunctionTool.parse_result(None) == ""
|
||||
|
||||
|
||||
def test_parse_result_string_passthrough():
|
||||
"""Test that strings are returned as-is."""
|
||||
assert FunctionTool.parse_result("hello world") == "hello world"
|
||||
assert FunctionTool.parse_result('{"key": "value"}') == '{"key": "value"}'
|
||||
|
||||
|
||||
def test_parse_result_content_object():
|
||||
"""Test that Content objects are serialized via to_dict."""
|
||||
content = Content.from_text("hello")
|
||||
result = FunctionTool.parse_result(content)
|
||||
assert isinstance(result, str)
|
||||
assert "hello" in result
|
||||
|
||||
|
||||
def test_parse_result_list_of_content():
|
||||
"""Test that list[Content] is serialized to JSON."""
|
||||
contents = [Content.from_text("hello"), Content.from_text("world")]
|
||||
result = FunctionTool.parse_result(contents)
|
||||
assert isinstance(result, str)
|
||||
assert "hello" in result
|
||||
assert "world" in result
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from agent_framework import (
|
||||
Content,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
prepare_function_call_results,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
@@ -281,17 +280,21 @@ def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env
|
||||
|
||||
|
||||
def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, str]):
|
||||
"""Test that falsy values (like empty list) in function result are properly handled."""
|
||||
"""Test that falsy values (like empty list) in function result are properly handled.
|
||||
|
||||
Note: In practice, FunctionTool.invoke() always returns a pre-parsed string.
|
||||
These tests verify that the OpenAI client correctly passes through string results.
|
||||
"""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Test with empty list (falsy but not None)
|
||||
# Test with empty list serialized as JSON string (as FunctionTool.invoke would produce)
|
||||
message_with_empty_list = Message(
|
||||
role="tool", contents=[Content.from_function_result(call_id="call-123", result=[])]
|
||||
role="tool", contents=[Content.from_function_result(call_id="call-123", result="[]")]
|
||||
)
|
||||
|
||||
openai_messages = client._prepare_message_for_openai(message_with_empty_list)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "[]" # Empty list should be JSON serialized
|
||||
assert openai_messages[0]["content"] == "[]" # Empty list JSON string
|
||||
|
||||
# Test with empty string (falsy but not None)
|
||||
message_with_empty_string = Message(
|
||||
@@ -302,12 +305,14 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "" # Empty string should be preserved
|
||||
|
||||
# Test with False (falsy but not None)
|
||||
message_with_false = Message(role="tool", contents=[Content.from_function_result(call_id="call-789", result=False)])
|
||||
# Test with False serialized as JSON string (as FunctionTool.invoke would produce)
|
||||
message_with_false = Message(
|
||||
role="tool", contents=[Content.from_function_result(call_id="call-789", result="false")]
|
||||
)
|
||||
|
||||
openai_messages = client._prepare_message_for_openai(message_with_false)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "false" # False should be JSON serialized
|
||||
assert openai_messages[0]["content"] == "false" # False JSON string
|
||||
|
||||
|
||||
def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]):
|
||||
@@ -332,9 +337,11 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
|
||||
assert openai_messages[0]["tool_call_id"] == "call-123"
|
||||
|
||||
|
||||
def test_prepare_function_call_results_string_passthrough():
|
||||
def test_parse_result_string_passthrough():
|
||||
"""Test that string values are passed through directly without JSON encoding."""
|
||||
result = prepare_function_call_results("simple string")
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
result = FunctionTool.parse_result("simple string")
|
||||
assert result == "simple string"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user