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:
Eduard van Valkenburg
2026-02-12 13:49:42 +00:00
committed by GitHub
parent 6000b737e9
commit 8ed50009c6
31 changed files with 486 additions and 452 deletions
+52 -152
View File
@@ -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]
+44 -17
View File
@@ -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)