diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index 1df1ba84e2..f1dba1b078 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -267,7 +267,7 @@ def _register_server_tool_placeholder(self, tool_name: str) -> None: if any(getattr(tool, "name", None) == tool_name for tool in additional_tools): return - placeholder: FunctionTool[Any, Any] = FunctionTool( + placeholder: FunctionTool[Any] = FunctionTool( name=tool_name, description="Server-managed tool placeholder (AG-UI)", func=None, diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 709d8f4887..efdb3a0f53 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -11,7 +11,6 @@ from agent_framework import ( Content, Message, - prepare_function_call_results, ) from ._utils import ( @@ -697,8 +696,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An elif content.type == "function_result": # Tool result content - extract call_id and result tool_result_call_id = content.call_id - # Serialize result to string using core utility - content_text = prepare_function_call_results(content.result) + content_text = content.result if content.result is not None else "" agui_msg: dict[str, Any] = { "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_run.py index 853127e630..c376120a5a 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run.py @@ -31,7 +31,6 @@ Content, Message, SupportsAgentRun, - prepare_function_call_results, ) from agent_framework._middleware import FunctionMiddlewarePipeline from agent_framework._tools import ( @@ -360,7 +359,7 @@ def _emit_tool_result( events.append(ToolCallEndEvent(tool_call_id=content.call_id)) flow.tool_calls_ended.add(content.call_id) # Track ended tool calls - result_content = prepare_function_call_results(content.result) + result_content = content.result if content.result is not None else "" message_id = generate_event_id() events.append( ToolCallResultEvent( diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index fd63202a47..abbfb88562 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -162,7 +162,7 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401 def convert_agui_tools_to_agent_framework( agui_tools: list[dict[str, Any]] | None, -) -> list[FunctionTool[Any, Any]] | None: +) -> list[FunctionTool[Any]] | None: """Convert AG-UI tool definitions to Agent Framework FunctionTool declarations. Creates declaration-only FunctionTool instances (no executable implementation). @@ -181,13 +181,13 @@ def convert_agui_tools_to_agent_framework( if not agui_tools: return None - result: list[FunctionTool[Any, Any]] = [] + result: list[FunctionTool[Any]] = [] for tool_def in agui_tools: # Create declaration-only FunctionTool (func=None means no implementation) # When func=None, the declaration_only property returns True, # which tells the function invocation mixin to return the function call # without executing it (so it can be sent back to the client) - func: FunctionTool[Any, Any] = FunctionTool( + func: FunctionTool[Any] = FunctionTool( name=tool_def.get("name", ""), description=tool_def.get("description", ""), func=None, # CRITICAL: Makes declaration_only=True diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py index 961f276603..7f5a4b0f2c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py @@ -23,7 +23,7 @@ from agent_framework import ChatOptions # Declaration-only tools (func=None) - actual rendering happens on the client side -generate_haiku = FunctionTool[Any, str]( +generate_haiku = FunctionTool[Any]( name="generate_haiku", description="""Generate a haiku with image and gradient background (FRONTEND_RENDER). @@ -71,7 +71,7 @@ }, ) -create_chart = FunctionTool[Any, str]( +create_chart = FunctionTool[Any]( name="create_chart", description="""Create an interactive chart (FRONTEND_RENDER). @@ -99,7 +99,7 @@ }, ) -display_timeline = FunctionTool[Any, str]( +display_timeline = FunctionTool[Any]( name="display_timeline", description="""Display an interactive timeline (FRONTEND_RENDER). @@ -127,7 +127,7 @@ }, ) -show_comparison_table = FunctionTool[Any, str]( +show_comparison_table = FunctionTool[Any]( name="show_comparison_table", description="""Show a comparison table (FRONTEND_RENDER). diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index 61cd9f1d06..4a715bed15 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -543,7 +543,7 @@ def test_agent_framework_to_agui_function_result_dict(): """Test converting FunctionResultContent with dict result to AG-UI.""" msg = Message( role="tool", - contents=[Content.from_function_result(call_id="call-123", result={"key": "value", "count": 42})], + contents=[Content.from_function_result(call_id="call-123", result='{"key": "value", "count": 42}')], message_id="msg-789", ) @@ -568,8 +568,8 @@ def test_agent_framework_to_agui_function_result_none(): assert len(messages) == 1 agui_msg = messages[0] - # None serializes as JSON null - assert agui_msg["content"] == "null" + # None result maps to empty string (FunctionTool.invoke returns "" for None) + assert agui_msg["content"] == "" def test_agent_framework_to_agui_function_result_string(): @@ -591,7 +591,7 @@ def test_agent_framework_to_agui_function_result_empty_list(): """Test converting FunctionResultContent with empty list result to AG-UI.""" msg = Message( role="tool", - contents=[Content.from_function_result(call_id="call-123", result=[])], + contents=[Content.from_function_result(call_id="call-123", result="[]")], message_id="msg-789", ) @@ -604,16 +604,10 @@ def test_agent_framework_to_agui_function_result_empty_list(): def test_agent_framework_to_agui_function_result_single_text_content(): - """Test converting FunctionResultContent with single TextContent-like item.""" - from dataclasses import dataclass - - @dataclass - class MockTextContent: - text: str - + """Test converting FunctionResultContent with single TextContent-like item (pre-parsed).""" msg = Message( role="tool", - contents=[Content.from_function_result(call_id="call-123", result=[MockTextContent("Hello from MCP!")])], + contents=[Content.from_function_result(call_id="call-123", result='["Hello from MCP!"]')], message_id="msg-789", ) @@ -626,19 +620,13 @@ class MockTextContent: def test_agent_framework_to_agui_function_result_multiple_text_contents(): - """Test converting FunctionResultContent with multiple TextContent-like items.""" - from dataclasses import dataclass - - @dataclass - class MockTextContent: - text: str - + """Test converting FunctionResultContent with multiple TextContent-like items (pre-parsed).""" msg = Message( role="tool", contents=[ Content.from_function_result( call_id="call-123", - result=[MockTextContent("First result"), MockTextContent("Second result")], + result='["First result", "Second result"]', ) ], message_id="msg-789", diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index bf9992d7ff..d3ea19dfa0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -25,7 +25,6 @@ TextSpanRegion, UsageDetails, get_logger, - prepare_function_call_results, ) from agent_framework._settings import SecretString, load_settings from agent_framework._types import _get_data_bytes_as_str # type: ignore @@ -653,7 +652,7 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: a_content.append({ "type": "tool_result", "tool_use_id": content.call_id, - "content": prepare_function_call_results(content.result), + "content": content.result if content.result is not None else "", "is_error": content.exception is not None, }) case "text_reasoning": diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index ffb39e6c25..a898117a92 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -33,7 +33,6 @@ TextSpanRegion, UsageDetails, get_logger, - prepare_function_call_results, ) from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException @@ -1390,7 +1389,7 @@ def _prepare_tool_outputs_for_azure_ai( if tool_outputs is None: tool_outputs = [] tool_outputs.append( - ToolOutput(tool_call_id=call_id, output=prepare_function_call_results(content.result)) + ToolOutput(tool_call_id=call_id, output=content.result if content.result is not None else "") ) elif content.type == "function_approval_response": if tool_approvals is None: diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index b9df6e5042..80c99f6e89 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -1024,9 +1024,10 @@ def __init__(self, name: str, value: int): client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - # Test with BaseModel result + # Test with BaseModel result (pre-parsed as it would be from FunctionTool.invoke) mock_result = MockResult(name="test", value=42) - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=mock_result) + expected_json = mock_result.to_json() + function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=expected_json) run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore @@ -1035,8 +1036,7 @@ def __init__(self, name: str, value: int): assert tool_outputs is not None assert len(tool_outputs) == 1 assert tool_outputs[0].tool_call_id == "call_456" - # Should use model_dump_json for BaseModel - expected_json = mock_result.to_json() + # Should use pre-parsed result string directly assert tool_outputs[0].output == expected_json @@ -1051,10 +1051,14 @@ def __init__(self, data: str): client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - # Test with multiple results - mix of BaseModel and regular objects + # Test with multiple results - pre-parsed as FunctionTool.invoke would produce mock_basemodel = MockResult(data="model_data") results_list = [mock_basemodel, {"key": "value"}, "string_result"] - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=results_list) + # FunctionTool.parse_result would serialize this to a JSON string + from agent_framework import FunctionTool + + pre_parsed = FunctionTool.parse_result(results_list) + function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=pre_parsed) run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore @@ -1063,14 +1067,8 @@ def __init__(self, data: str): assert len(tool_outputs) == 1 assert tool_outputs[0].tool_call_id == "call_456" - # Should JSON dump the entire results array since len > 1 - expected_results = [ - mock_basemodel.to_dict(), - {"key": "value"}, - "string_result", - ] - expected_output = json.dumps(expected_results) - assert tool_outputs[0].output == expected_output + # Result is pre-parsed string (already JSON) + assert tool_outputs[0].output == pre_parsed async def test_azure_ai_chat_client_convert_required_action_approval_response( diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index ba8573718e..3520d7b1a1 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -27,7 +27,6 @@ ResponseStream, UsageDetails, get_logger, - prepare_function_call_results, validate_tool_mode, ) from agent_framework._settings import SecretString, load_settings @@ -528,7 +527,7 @@ def _convert_content_to_bedrock_block(self, content: Content) -> dict[str, Any] return None def _convert_tool_result_to_blocks(self, result: Any) -> list[dict[str, Any]]: - prepared_result = prepare_function_call_results(result) + prepared_result = result if isinstance(result, str) else FunctionTool.parse_result(result) try: parsed_result = json.loads(prepared_result) except json.JSONDecodeError: diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py index 8be9ca95e4..016ed8ff05 100644 --- a/python/packages/bedrock/tests/test_bedrock_settings.py +++ b/python/packages/bedrock/tests/test_bedrock_settings.py @@ -68,7 +68,7 @@ def test_build_request_serializes_tool_history() -> None: ), Message( role="tool", - contents=[Content.from_function_result(call_id="call-1", result={"answer": "72F"})], + contents=[Content.from_function_result(call_id="call-1", result='{"answer": "72F"}')], ), ] diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 50c5b06d0f..3e900b8e27 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -483,7 +483,7 @@ def _prepare_tools( return create_sdk_mcp_server(name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools), tool_names - def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool[Any, Any]) -> SdkMcpTool[Any]: + def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool[Any]) -> SdkMcpTool[Any]: """Convert a FunctionTool to an SDK MCP tool. Args: diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index b7d8a739d5..f43abb9fa7 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -437,7 +437,7 @@ def as_tool( stream_callback: Callable[[AgentResponseUpdate], None] | Callable[[AgentResponseUpdate], Awaitable[None]] | None = None, - ) -> FunctionTool[BaseModel, str]: + ) -> FunctionTool[BaseModel]: """Create a FunctionTool that wraps this agent. Keyword Args: @@ -511,7 +511,7 @@ async def agent_wrapper(**kwargs: Any) -> str: # Create final text from accumulated updates return AgentResponse.from_updates(response_updates).text - agent_tool: FunctionTool[BaseModel, str] = FunctionTool( + agent_tool: FunctionTool[BaseModel] = FunctionTool( name=tool_name, description=tool_description, func=agent_wrapper, diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index a56e7f14db..64ff60fa7f 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -81,6 +81,51 @@ class MCPSpecificApproval(TypedDict, total=False): ] +def _parse_prompt_result_from_mcp( + mcp_type: types.GetPromptResult, +) -> str: + """Parse an MCP GetPromptResult directly into a string representation. + + Converts each message in the prompt result to its string form and combines them. + + Args: + mcp_type: The MCP GetPromptResult object to convert. + + Returns: + A string representation of the prompt result. + """ + import json + + parts: list[str] = [] + for message in mcp_type.messages: + content = message.content + if isinstance(content, types.TextContent): + parts.append(content.text) + elif isinstance(content, (types.ImageContent, types.AudioContent)): + parts.append(json.dumps({ + "type": "image" if isinstance(content, types.ImageContent) else "audio", + "data": content.data, + "mimeType": content.mimeType, + }, default=str)) + elif isinstance(content, types.EmbeddedResource): + match content.resource: + case types.TextResourceContents(): + parts.append(content.resource.text) + case types.BlobResourceContents(): + parts.append(json.dumps({ + "type": "blob", + "data": content.resource.blob, + "mimeType": content.resource.mimeType, + }, default=str)) + else: + parts.append(str(content)) + if not parts: + return "" + if len(parts) == 1: + return parts[0] + return json.dumps(parts, default=str) + + def _parse_message_from_mcp( mcp_type: types.PromptMessage | types.SamplingMessage, ) -> Message: @@ -92,54 +137,56 @@ def _parse_message_from_mcp( ) -def _parse_contents_from_mcp_tool_result( +def _parse_tool_result_from_mcp( mcp_type: types.CallToolResult, -) -> list[Content]: - """Parse an MCP CallToolResult into Agent Framework content types. +) -> str: + """Parse an MCP CallToolResult directly into a string representation. - This function extracts the complete _meta field from CallToolResult objects - and merges all metadata into the additional_properties field of converted - content items. - - Note: The _meta field from CallToolResult is applied to ALL content items - in the result, as the Agent Framework's content model doesn't have a - result-level metadata container. This ensures metadata is preserved but - means it will be duplicated across multiple content items if present. + Converts each content item in the MCP result to its string form and combines them. + This skips the intermediate Content object step for tool results. Args: mcp_type: The MCP CallToolResult object to convert. Returns: - A list of Agent Framework content items with metadata merged into - additional_properties. + A string representation of the tool result — either plain text or serialized JSON. """ - meta_data = mcp_type.meta - - # Prepare merged metadata once if present - merged_meta_props = None - if meta_data: - merged_meta_props = {} - if hasattr(meta_data, "__dict__"): - merged_meta_props.update(meta_data.__dict__) - elif isinstance(meta_data, dict): - merged_meta_props.update(meta_data) - else: - merged_meta_props["_meta"] = meta_data + import json - # Convert each content item and merge metadata - result_contents = [] + parts: list[str] = [] for item in mcp_type.content: - contents = _parse_content_from_mcp(item) - - if merged_meta_props: - for content in contents: - existing_props = getattr(content, "additional_properties", None) or {} - # Merge with content-specific properties, letting content-specific props override - final_props = merged_meta_props.copy() - final_props.update(existing_props) - content.additional_properties = final_props - result_contents.extend(contents) - return result_contents + match item: + case types.TextContent(): + parts.append(item.text) + case types.ImageContent() | types.AudioContent(): + parts.append(json.dumps({ + "type": "image" if isinstance(item, types.ImageContent) else "audio", + "data": item.data, + "mimeType": item.mimeType, + }, default=str)) + case types.ResourceLink(): + parts.append(json.dumps({ + "type": "resource_link", + "uri": str(item.uri), + "mimeType": item.mimeType, + }, default=str)) + case types.EmbeddedResource(): + match item.resource: + case types.TextResourceContents(): + parts.append(item.resource.text) + case types.BlobResourceContents(): + parts.append(json.dumps({ + "type": "blob", + "data": item.resource.blob, + "mimeType": item.resource.mimeType, + }, default=str)) + case _: + parts.append(str(item)) + if not parts: + return "" + if len(parts) == 1: + return parts[0] + return json.dumps(parts, default=str) def _parse_content_from_mcp( @@ -344,9 +391,9 @@ def __init__( approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, session: ClientSession | None = None, request_timeout: int | None = None, client: SupportsChatGetResponse | None = None, @@ -357,6 +404,30 @@ def __init__( Note: Do not use this method, use one of the subclasses: MCPStreamableHTTPTool, MCPWebsocketTool or MCPStdioTool. + + Args: + name: The name of the MCP tool. + description: A description of the MCP tool. + approval_mode: Whether approval is required to run tools. + allowed_tools: A collection of tool names to allow. + load_tools: Whether to load tools from the MCP server. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. + load_prompts: Whether to load prompts from the MCP server. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. + session: An existing MCP client session to use. + request_timeout: Timeout in seconds for MCP requests. + client: A chat client for sampling callbacks. + additional_properties: Additional properties for the tool. """ self.name = name self.description = description or "" @@ -371,7 +442,7 @@ def __init__( self.session = session self.request_timeout = request_timeout self.client = client - self._functions: list[FunctionTool[Any, Any]] = [] + self._functions: list[FunctionTool[Any]] = [] self.is_connected: bool = False self._tools_loaded: bool = False self._prompts_loaded: bool = False @@ -380,7 +451,7 @@ def __str__(self) -> str: return f"MCPTool(name={self.name}, description={self.description})" @property - def functions(self) -> list[FunctionTool[Any, Any]]: + def functions(self) -> list[FunctionTool[Any]]: """Get the list of functions that are allowed.""" if not self.allowed_tools: return self._functions @@ -648,7 +719,7 @@ async def load_prompts(self) -> None: input_model = _get_input_model_from_mcp_prompt(prompt) approval_mode = self._determine_approval_mode(local_name) - func: FunctionTool[BaseModel, list[Message] | Any | types.GetPromptResult] = FunctionTool( + func: FunctionTool[BaseModel] = FunctionTool( func=partial(self.get_prompt, prompt.name), name=local_name, description=prompt.description or "", @@ -692,7 +763,7 @@ async def load_tools(self) -> None: input_model = _get_input_model_from_mcp_tool(tool) approval_mode = self._determine_approval_mode(local_name) # Create FunctionTools out of each tool - func: FunctionTool[BaseModel, list[Content] | Any | types.CallToolResult] = FunctionTool( + func: FunctionTool[BaseModel] = FunctionTool( func=partial(self.call_tool, tool.name), name=local_name, description=tool.description or "", @@ -746,7 +817,7 @@ async def _ensure_connected(self) -> None: inner_exception=ex, ) from ex - async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any | types.CallToolResult: + async def call_tool(self, tool_name: str, **kwargs: Any) -> str: """Call a tool with the given arguments. Args: @@ -756,7 +827,7 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any kwargs: Arguments to pass to the tool. Returns: - A list of content items returned by the tool. + A string representation of the tool result — either plain text or serialized JSON. Raises: ToolExecutionException: If the MCP server is not connected, tools are not loaded, @@ -779,17 +850,13 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any not in {"chat_options", "tools", "tool_choice", "thread", "conversation_id", "options", "response_format"} } + parser = self.parse_tool_results or _parse_tool_result_from_mcp + # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: result = await self.session.call_tool(tool_name, arguments=filtered_kwargs) # type: ignore - if self.parse_tool_results is None: - return result - if self.parse_tool_results is True: - return _parse_contents_from_mcp_tool_result(result) - if callable(self.parse_tool_results): - return self.parse_tool_results(result) - return result + return parser(result) except ClosedResourceError as cl_ex: if attempt == 0: # First attempt failed, try reconnecting @@ -815,7 +882,7 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.") - async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[Message] | Any | types.GetPromptResult: + async def get_prompt(self, prompt_name: str, **kwargs: Any) -> str: """Call a prompt with the given arguments. Args: @@ -825,7 +892,7 @@ async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[Message] | A kwargs: Arguments to pass to the prompt. Returns: - A list of chat messages returned by the prompt. + A string representation of the prompt result — either plain text or serialized JSON. Raises: ToolExecutionException: If the MCP server is not connected, prompts are not loaded, @@ -836,17 +903,13 @@ async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[Message] | A "Prompts are not loaded for this server, please set load_prompts=True in the constructor." ) + parser = self.parse_prompt_results or _parse_prompt_result_from_mcp + # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs) # type: ignore - if self.parse_prompt_results is None: - return prompt_result - if self.parse_prompt_results is True: - return [_parse_message_from_mcp(message) for message in prompt_result.messages] - if callable(self.parse_prompt_results): - return self.parse_prompt_results(prompt_result) - return prompt_result + return parser(prompt_result) except ClosedResourceError as cl_ex: if attempt == 0: # First attempt failed, try reconnecting @@ -945,9 +1008,9 @@ def __init__( command: str, *, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, @@ -973,15 +1036,19 @@ def __init__( Keyword Args: load_tools: Whether to load tools from the MCP server. - parse_tool_results: How to parse tool results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP tool result. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. load_prompts: Whether to load prompts from the MCP server. - parse_prompt_results: How to parse prompt results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP prompt result. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. session: The session to use for the MCP connection. description: The description of the tool. @@ -1066,9 +1133,9 @@ def __init__( url: str, *, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, @@ -1094,15 +1161,19 @@ def __init__( Keyword Args: load_tools: Whether to load tools from the MCP server. - parse_tool_results: How to parse tool results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP tool result. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. load_prompts: Whether to load prompts from the MCP server. - parse_prompt_results: How to parse prompt results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP prompt result. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. session: The session to use for the MCP connection. description: The description of the tool. @@ -1181,9 +1252,9 @@ def __init__( url: str, *, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, @@ -1207,15 +1278,19 @@ def __init__( Keyword Args: load_tools: Whether to load tools from the MCP server. - parse_tool_results: How to parse tool results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP tool result. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. load_prompts: Whether to load prompts from the MCP server. - parse_prompt_results: How to parse prompt results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP prompt result. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. session: The session to use for the MCP connection. description: The description of the tool. diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index e595be76e3..d096e96c2a 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -234,7 +234,7 @@ async def process(self, context: FunctionInvocationContext, call_next): def __init__( self, - function: FunctionTool[Any, Any], + function: FunctionTool[Any], arguments: BaseModel, metadata: Mapping[str, Any] | None = None, result: Any = None, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index b838551f81..6362433892 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -90,7 +90,6 @@ # region Helpers ArgsT = TypeVar("ArgsT", bound=BaseModel, default=BaseModel) -ReturnT = TypeVar("ReturnT", default=Any) def _parse_inputs( @@ -188,7 +187,7 @@ class EmptyInputModel(BaseModel): """An empty input model for functions with no parameters.""" -class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]): +class FunctionTool(SerializationMixin, Generic[ArgsT]): """A tool that wraps a Python function to make it callable by AI models. This class wraps a Python function to make it callable by AI models with automatic @@ -252,8 +251,9 @@ def __init__( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, - func: Callable[..., Awaitable[ReturnT] | ReturnT] | None = None, + func: Callable[..., Any] | None = None, input_model: type[ArgsT] | Mapping[str, Any] | None = None, + result_parser: Callable[[Any], str] | None = None, **kwargs: Any, ) -> None: """Initialize the FunctionTool. @@ -281,6 +281,12 @@ def __init__( parameters, explicitly provide ``input_model`` (either a Pydantic ``BaseModel`` or a JSON schema dictionary) so the model can reason about the expected arguments. + result_parser: An optional callable with signature ``Callable[[Any], str]`` that + overrides the default result parsing behavior. When provided, this callable + is used to convert the raw function return value to a string instead of the + built-in :meth:`parse_result` logic. Depending on your function, it may be + easiest to just do the serialization directly in the function body rather + than providing a custom ``result_parser``. **kwargs: Additional keyword arguments. """ # Core attributes (formerly from BaseTool) @@ -306,6 +312,7 @@ def __init__( self.invocation_exception_count = 0 self._invocation_duration_histogram = _default_histogram() self.type: Literal["function_tool"] = "function_tool" + self.result_parser = result_parser self._forward_runtime_kwargs: bool = False if self.func: sig = inspect.signature(self.func) @@ -328,7 +335,7 @@ def declaration_only(self) -> bool: return True return self.func is None - def __get__(self, obj: Any, objtype: type | None = None) -> FunctionTool[ArgsT, ReturnT]: + def __get__(self, obj: Any, objtype: type | None = None) -> FunctionTool[ArgsT]: """Implement the descriptor protocol to support bound methods. When a FunctionTool is accessed as an attribute of a class instance, @@ -371,7 +378,7 @@ def _resolve_input_model(self, input_model: type[ArgsT] | Mapping[str, Any] | No return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model)) raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.") - def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]: + def __call__(self, *args: Any, **kwargs: Any) -> Any: """Call the wrapped function with the provided arguments.""" if self.declaration_only: raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.") @@ -402,15 +409,19 @@ async def invoke( *, arguments: ArgsT | None = None, **kwargs: Any, - ) -> ReturnT: + ) -> str: """Run the AI function with the provided arguments as a Pydantic model. + The raw return value of the wrapped function is automatically parsed into a ``str`` + (either plain text or serialized JSON) using :meth:`parse_result` or the custom + ``result_parser`` if one was provided. + Keyword Args: arguments: A Pydantic model instance containing the arguments for the function. kwargs: Keyword arguments to pass to the function, will not be used if ``arguments`` is provided. Returns: - The result of the function execution. + The parsed result as a string — either plain text or serialized JSON. Raises: TypeError: If arguments is not an instance of the expected input model. @@ -420,6 +431,8 @@ async def invoke( global OBSERVABILITY_SETTINGS from .observability import OBSERVABILITY_SETTINGS + parser = self.result_parser or FunctionTool.parse_result + original_kwargs = dict(kwargs) tool_call_id = original_kwargs.pop("tool_call_id", None) if arguments is not None: @@ -435,9 +448,14 @@ async def invoke( logger.debug(f"Function arguments: {kwargs}") res = self.__call__(**kwargs) result = await res if inspect.isawaitable(res) else res + try: + parsed = parser(result) + except Exception: + logger.warning(f"Function {self.name}: result parser failed, falling back to str().") + parsed = str(result) logger.info(f"Function {self.name} succeeded.") - logger.debug(f"Function result: {result or 'None'}") - return result # type: ignore[reportReturnType] + logger.debug(f"Function result: {parsed or 'None'}") + return parsed attributes = get_function_span_attributes(self, tool_call_id=tool_call_id) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] @@ -481,19 +499,16 @@ async def invoke( logger.error(f"Function failed. Error: {exception}") raise else: + try: + parsed = parser(result) + except Exception: + logger.warning(f"Function {self.name}: result parser failed, falling back to str().") + parsed = str(result) logger.info(f"Function {self.name} succeeded.") if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] - from ._types import prepare_function_call_results - - try: - json_result = prepare_function_call_results(result) - except (TypeError, OverflowError): - span.set_attribute(OtelAttr.TOOL_RESULT, "") - logger.debug("Function result: ") - else: - span.set_attribute(OtelAttr.TOOL_RESULT, json_result) - logger.debug(f"Function result: {json_result}") - return result # type: ignore[reportReturnType] + span.set_attribute(OtelAttr.TOOL_RESULT, parsed) + logger.debug(f"Function result: {parsed}") + return parsed finally: duration = (end_time_stamp or perf_counter()) - start_time_stamp span.set_attribute(OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, duration) @@ -511,6 +526,49 @@ def parameters(self) -> dict[str, Any]: self._cached_parameters = self.input_model.model_json_schema() return self._cached_parameters + @staticmethod + def _make_dumpable(value: Any) -> Any: + """Recursively convert a value to a JSON-dumpable form.""" + from ._types import Content + + if isinstance(value, list): + return [FunctionTool._make_dumpable(item) for item in value] + if isinstance(value, dict): + return {k: FunctionTool._make_dumpable(v) for k, v in value.items()} + if isinstance(value, Content): + return value.to_dict(exclude={"raw_representation", "additional_properties"}) + if isinstance(value, BaseModel): + return value.model_dump() + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "text") and isinstance(value.text, str): + return value.text + return value + + @staticmethod + def parse_result(result: Any) -> str: + """Convert a raw function return value to a string representation. + + The return value is always a ``str`` — either plain text or serialized JSON. + This is called automatically by :meth:`invoke` before returning the result, + ensuring that the result stored in ``Content.from_function_result`` is + already in a form that can be passed directly to LLM APIs. + + Args: + result: The raw return value from the wrapped function. + + Returns: + A string representation of the result, either plain text or serialized JSON. + """ + if result is None: + return "" + if isinstance(result, str): + return result + dumpable = FunctionTool._make_dumpable(result) + if isinstance(dumpable, str): + return dumpable + return json.dumps(dumpable, default=str) + def to_json_schema_spec(self) -> dict[str, Any]: """Convert a FunctionTool to the JSON Schema function specification format. @@ -874,7 +932,7 @@ def _create_model_from_json_schema(tool_name: str, schema_json: Mapping[str, Any @overload def tool( - func: Callable[..., ReturnT | Awaitable[ReturnT]], + func: Callable[..., Any], *, name: str | None = None, description: str | None = None, @@ -883,7 +941,8 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, -) -> FunctionTool[Any, ReturnT]: ... + result_parser: Callable[[Any], str] | None = None, +) -> FunctionTool[Any]: ... @overload @@ -897,11 +956,12 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, -) -> Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], FunctionTool[Any, ReturnT]]: ... + result_parser: Callable[[Any], str] | None = None, +) -> Callable[[Callable[..., Any]], FunctionTool[Any]]: ... def tool( - func: Callable[..., ReturnT | Awaitable[ReturnT]] | None = None, + func: Callable[..., Any] | None = None, *, name: str | None = None, description: str | None = None, @@ -910,7 +970,8 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, -) -> FunctionTool[Any, ReturnT] | Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], FunctionTool[Any, ReturnT]]: + result_parser: Callable[[Any], str] | None = None, +) -> FunctionTool[Any] | Callable[[Callable[..., Any]], FunctionTool[Any]]: """Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically. This decorator creates a Pydantic model from the function's signature, @@ -950,6 +1011,12 @@ def tool( max_invocation_exceptions: The maximum number of exceptions allowed during invocations. If None, there is no limit, should be at least 1. additional_properties: Additional properties to set on the function. + result_parser: An optional callable with signature ``Callable[[Any], str]`` that + overrides the default result parsing. When provided, this callable converts the + raw function return value to a string instead of using the built-in + :meth:`FunctionTool.parse_result`. Depending on your function, it may be + easiest to just do the serialization directly in the function body rather + than providing a custom ``result_parser``. Note: When approval_mode is set to "always_require", the function will not be executed @@ -1028,12 +1095,12 @@ def get_weather(location: str, unit: str = "celsius") -> str: """ - def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, ReturnT]: + def decorator(func: Callable[..., Any]) -> FunctionTool[Any]: @wraps(func) - def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, ReturnT]: + def wrapper(f: Callable[..., Any]) -> FunctionTool[Any]: tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment] tool_desc: str = description or (f.__doc__ or "") - return FunctionTool[Any, ReturnT]( + return FunctionTool[Any]( name=tool_name, description=tool_desc, approval_mode=approval_mode, @@ -1042,6 +1109,7 @@ def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, additional_properties=additional_properties or {}, func=f, input_model=schema, + result_parser=result_parser, ) return wrapper(func) @@ -1125,7 +1193,7 @@ async def _auto_invoke_function( custom_args: dict[str, Any] | None = None, *, config: FunctionInvocationConfiguration, - tool_map: dict[str, FunctionTool[BaseModel, Any]], + tool_map: dict[str, FunctionTool[BaseModel]], sequence_index: int | None = None, request_index: int | None = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, # Optional MiddlewarePipeline @@ -1157,7 +1225,7 @@ async def _auto_invoke_function( # this function is called. This function only handles the actual execution of approved, # non-declaration-only functions. - tool: FunctionTool[BaseModel, Any] | None = None + tool: FunctionTool[BaseModel] | None = None if function_call_content.type == "function_call": tool = tool_map.get(function_call_content.name) # type: ignore[arg-type] # Tool should exist because _try_execute_function_calls validates this @@ -1272,8 +1340,8 @@ def _get_tool_map( | Callable[..., Any] | MutableMapping[str, Any] | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]], -) -> dict[str, FunctionTool[Any, Any]]: - tool_list: dict[str, FunctionTool[Any, Any]] = {} +) -> dict[str, FunctionTool[Any]]: + tool_list: dict[str, FunctionTool[Any]] = {} for tool_item in tools if isinstance(tools, list) else [tools]: if isinstance(tool_item, FunctionTool): tool_list[tool_item.name] = tool_item diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 7d8b5a7909..79c41c1023 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -55,7 +55,6 @@ "merge_chat_options", "normalize_messages", "normalize_tools", - "prepare_function_call_results", "prepend_instructions_to_messages", "validate_chat_options", "validate_tool_mode", @@ -1377,36 +1376,6 @@ def parse_arguments(self) -> dict[str, Any | None] | None: # endregion -def _prepare_function_call_results_as_dumpable(content: Content | Any | list[Content | Any]) -> Any: - if isinstance(content, list): - # Particularly deal with lists of Content - return [_prepare_function_call_results_as_dumpable(item) for item in content] - if isinstance(content, dict): - return {k: _prepare_function_call_results_as_dumpable(v) for k, v in content.items()} - if isinstance(content, BaseModel): - return content.model_dump() - if hasattr(content, "to_dict"): - return content.to_dict(exclude={"raw_representation", "additional_properties"}) - # Handle objects with text attribute (e.g., MCP TextContent) - if hasattr(content, "text") and isinstance(content.text, str): - return content.text - return content - - -def prepare_function_call_results(content: Content | Any | list[Content | Any]) -> str: - """Prepare the values of the function call results.""" - if isinstance(content, Content): - # For BaseContent objects, use to_dict and serialize to JSON - # Use default=str to handle datetime and other non-JSON-serializable objects - return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}), default=str) - - dumpable = _prepare_function_call_results_as_dumpable(content) - if isinstance(dumpable, str): - return dumpable - # fallback - use default=str to handle datetime and other non-JSON-serializable objects - return json.dumps(dumpable, default=str) - - # region Chat Response constants RoleLiteral = Literal["system", "user", "assistant", "tool"] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index ac3c493309..64ceefe673 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1448,7 +1448,7 @@ async def _run() -> AgentResponse: # region Otel Helpers -def get_function_span_attributes(function: FunctionTool[Any, Any], tool_call_id: str | None = None) -> dict[str, str]: +def get_function_span_attributes(function: FunctionTool[Any], tool_call_id: str | None = None) -> dict[str, str]: """Get the span attributes for the given function. Args: @@ -1678,12 +1678,10 @@ def _to_otel_part(content: Content) -> dict[str, Any] | None: case "function_call": return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments} case "function_result": - from ._types import prepare_function_call_results - return { "type": "tool_call_response", "id": content.call_id, - "response": prepare_function_call_results(content), + "response": content.result if content.result is not None else "", } case _: # GenericPart in otel output messages json spec. diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 218dacbea8..03899d4891 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -45,7 +45,6 @@ Message, ResponseStream, UsageDetails, - prepare_function_call_results, ) from ..exceptions import ServiceInitializationError from ..observability import ChatTelemetryLayer @@ -805,10 +804,11 @@ def _prepare_tool_outputs_for_assistants( if tool_outputs is None: tool_outputs = [] - if function_result_content.result: - output = prepare_function_call_results(function_result_content.result) - else: - output = "No output received." + output = ( + function_result_content.result + if function_result_content.result is not None + else "No output received." + ) tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output)) return run_id, tool_outputs diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index b806848b75..fa232c20a1 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -37,7 +37,6 @@ Message, ResponseStream, UsageDetails, - prepare_function_call_results, ) from ..exceptions import ( ServiceInitializationError, @@ -556,9 +555,7 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: args["tool_call_id"] = content.call_id # Always include content for tool results - API requires it even if empty # Functions returning None should still have a tool result message - args["content"] = ( - prepare_function_call_results(content.result) if content.result is not None else "" - ) + args["content"] = content.result if content.result is not None else "" case "text_reasoning" if (protected_data := content.protected_data) is not None: all_messages[-1]["reasoning_details"] = json.loads(protected_data) case _: diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 9ec0751850..3b04ab6a25 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -57,7 +57,6 @@ TextSpanRegion, UsageDetails, detect_media_type_from_base64, - prepare_function_call_results, prepend_instructions_to_messages, validate_tool_mode, ) @@ -1029,7 +1028,7 @@ def _prepare_content_for_openai( args: dict[str, Any] = { "call_id": content.call_id, "type": "function_call_output", - "output": prepare_function_call_results(content.result), + "output": content.result if content.result is not None else "", } return args case "function_approval_request": diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index f3775a4f0a..21ff396a52 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -25,7 +25,7 @@ _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) - - 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" - - -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 + result = _parse_tool_result_from_mcp(mcp_result) + + # 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_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="Error occurred")], - _meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"}, + content=[types.TextContent(type="text", text="Simple result")] ) + result = _parse_tool_result_from_mcp(mcp_result) - 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" - - -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) + # Single text item returns just the text + assert result == "Simple 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 +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="Success message"), - types.ImageContent(type="image", data="YWJjMTIz", mimeType="image/jpeg"), # base64 for "abc123" - ] + content=[types.TextContent(type="text", text="Error occurred")], + _meta={"isError": True, "errorCode": "TOOL_ERROR"}, ) - 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" + result = _parse_tool_result_from_mcp(mcp_result) + assert result == "Error occurred" - 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 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: 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 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: 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 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: # 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 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: 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 call_tool_with_error(*args, **kwargs): # 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(): diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index e5bd23751f..f37c855ba3 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -74,7 +74,7 @@ def test_init_with_thread(self, mock_agent: SupportsAgentRun) -> None: 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 @@ def test_init_with_defaults(self, mock_function: FunctionTool[Any, Any]) -> None 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 @@ async def process(self, context: FunctionInvocationContext, call_next: Any) -> N 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 @@ async def final_handler(ctx: FunctionInvocationContext) -> str: # 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 @@ async def test_middleware(context: FunctionInvocationContext, call_next: Callabl 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 @@ async def final_handler(ctx: FunctionInvocationContext) -> str: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: ] 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 @@ async def _stream() -> AsyncIterable[AgentResponseUpdate]: 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 diff --git a/python/packages/core/tests/core/test_middleware_context_result.py b/python/packages/core/tests/core/test_middleware_context_result.py index c5744fdca5..ba6bfb9c4a 100644 --- a/python/packages/core/tests/core/test_middleware_context_result.py +++ b/python/packages/core/tests/core/test_middleware_context_result.py @@ -103,7 +103,7 @@ async def _stream() -> AsyncIterable[AgentResponseUpdate]: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 @@ async def final_handler(ctx: AgentContext) -> AgentResponse: 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 diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 436ae7fdd1..dbcf7aac6f 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -138,7 +138,7 @@ def calculate(a: int, b: int) -> int: 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 @@ def telemetry_test_tool(x: int, y: int) -> int: 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 @@ def telemetry_test_tool(x: int, y: int) -> int: 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 @@ def pydantic_test_tool(x: int, y: int) -> int: 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 async_telemetry_test(x: int, y: int) -> int: 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] diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 0be7b123bd..7a5acdedf7 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -26,7 +26,6 @@ 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 @@ class MockTextContent: 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 @@ class MockTextContent: 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 __init__(self): 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 diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index 6458a38402..e6e5de8314 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -17,7 +17,6 @@ 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) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index d1475f04a3..f8e60c9f3e 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -499,7 +499,7 @@ def _prepare_tools( return copilot_tools - def _tool_to_copilot_tool(self, ai_func: FunctionTool[Any, Any]) -> CopilotTool: + def _tool_to_copilot_tool(self, ai_func: FunctionTool[Any]) -> CopilotTool: """Convert an FunctionTool to a Copilot SDK tool.""" async def handler(invocation: ToolInvocation) -> ToolResult: diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py index b785eae6d7..42d03393f8 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py @@ -27,7 +27,7 @@ _original_set_state = Environment.set_state -def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool[Any, Any]: +def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool[Any]: """Convert a tau2 Tool to a FunctionTool for agent framework compatibility. Creates a wrapper that preserves the tool's interface while ensuring diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index e574528395..367855e4c2 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -325,7 +325,7 @@ def _apply_auto_tools(self, agent: Agent, targets: Sequence[HandoffConfiguration existing_tools = list(default_options.get("tools") or []) existing_names = {getattr(tool, "name", "") for tool in existing_tools if hasattr(tool, "name")} - new_tools: list[FunctionTool[Any, Any]] = [] + new_tools: list[FunctionTool[Any]] = [] for target in targets: handoff_tool = self._create_handoff_tool(target.target_id, target.description) if handoff_tool.name in existing_names: @@ -341,7 +341,7 @@ def _apply_auto_tools(self, agent: Agent, targets: Sequence[HandoffConfiguration else: default_options["tools"] = existing_tools - def _create_handoff_tool(self, target_id: str, description: str | None = None) -> FunctionTool[Any, Any]: + def _create_handoff_tool(self, target_id: str, description: str | None = None) -> FunctionTool[Any]: """Construct the synthetic handoff tool that signals routing to `target_id`.""" tool_name = get_handoff_tool_name(target_id) doc = description or f"Handoff to the {target_id} agent." diff --git a/python/uv.lock b/python/uv.lock index ff7328ebf1..bab915d16b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -84,14 +84,14 @@ wheels = [ [[package]] name = "ag-ui-protocol" -version = "0.1.10" +version = "0.1.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/bb/5a5ec893eea5805fb9a3db76a9888c3429710dfb6f24bbb37568f2cf7320/ag_ui_protocol-0.1.10.tar.gz", hash = "sha256:3213991c6b2eb24bb1a8c362ee270c16705a07a4c5962267a083d0959ed894f4", size = 6945, upload-time = "2025-11-06T15:17:17.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/c1/33ab11dc829c6c28d0d346988b2f394aa632d3ad63d1d2eb5f16eccd769b/ag_ui_protocol-0.1.11.tar.gz", hash = "sha256:b336dfebb5751e9cc2c676a3008a4bce4819004e6f6f8cba73169823564472ae", size = 6249, upload-time = "2026-02-11T12:41:36.085Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/5c6f4cb24d27d9cbe0c31ba2f3b4d1ff42bc6f87ba9facfa9e9d44046c6b/ag_ui_protocol-0.1.11-py3-none-any.whl", hash = "sha256:b0cc25570462a8eba8e57a098e0a2d6892a1f571a7bea7da2d4b60efd5d66789", size = 8392, upload-time = "2026-02-11T12:41:35.303Z" }, ] [[package]] @@ -1853,7 +1853,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.7" +version = "0.128.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1862,9 +1862,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, ] [[package]] @@ -3897,7 +3897,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.8.3" +version = "0.8.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3908,9 +3908,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/6b/f86002a00f16b387b0570860e461475660d81eb00e2817391926d3947933/openai_agents-0.8.3.tar.gz", hash = "sha256:07a6e900b0fe4b7fd8f91a06ed9ab4fec9df335ed676f1c9e1125f60cb57919b", size = 2378346, upload-time = "2026-02-10T00:11:07.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/e0/9fa9eac9baf2816bc63cee28967d35a7ed9dc2f25e9fd2004f48ed6c8820/openai_agents-0.8.4.tar.gz", hash = "sha256:5d4c4861aedd56a82b15c6ddf6c53031a39859a222f08bbd5645d5967efa05e8", size = 2389744, upload-time = "2026-02-11T19:14:30.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/38/d77602daf5308395ee067954ffa7e96cb9ecf9292ad3b5f398f1c77e0b36/openai_agents-0.8.3-py3-none-any.whl", hash = "sha256:e562ec1a70177abaa34ca6f0428241a9dbeb6b3d73f88a7f4ba3ee3d72b3b98d", size = 378042, upload-time = "2026-02-10T00:11:04.967Z" }, + { url = "https://files.pythonhosted.org/packages/55/dc/10df015aebb0797a8367aab65200ac4f5221df20bbae76930f5b6ac8e001/openai_agents-0.8.4-py3-none-any.whl", hash = "sha256:2383c6e8e59ed4146b89d1b6f53e34e55caf94bc14ae3fd704e7aad5021f4ff1", size = 380662, upload-time = "2026-02-11T19:14:28.864Z" }, ] [[package]] @@ -4539,7 +4539,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.8.5" +version = "7.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4549,9 +4549,9 @@ dependencies = [ { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/10/8e74a5e997c8286f0b63c69da522e503b1ab11627217ab76a06c7b62d647/posthog-7.8.5.tar.gz", hash = "sha256:e4f3796ce18323d8e05139bf419a04d318ccc4ad77b210f4d9d7c7546aea4f35", size = 169117, upload-time = "2026-02-09T22:59:49.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/c9/a7c67c039f23f16a0b87d17561ba2a1c863b01f054a226c92437c539a7b6/posthog-7.8.6.tar.gz", hash = "sha256:6f67e18b5f19bf20d7ef2e1a80fa1ad879a5cd309ca13cfb300f45a8105968c4", size = 169304, upload-time = "2026-02-11T13:59:42.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/b3/59b61d4b90e2efd138abaa34d98c7a89a4a352850cc3a079a60a46780655/posthog-7.8.5-py3-none-any.whl", hash = "sha256:979d306f07e61a8e837746e5dc432aafc49827fecac91bd6c624dcf3a1967448", size = 194647, upload-time = "2026-02-09T22:59:47.744Z" }, + { url = "https://files.pythonhosted.org/packages/56/c7/41664398a838f52ddfc89141e4c38b88eaa01b9e9a269c5ac184bd8586c6/posthog-7.8.6-py3-none-any.whl", hash = "sha256:21809f73e8e8f09d2bc273b09582f1a9f997b66f51fc626ef5bd3c5bdffd8bcd", size = 194801, upload-time = "2026-02-11T13:59:41.26Z" }, ] [[package]] @@ -6464,16 +6464,30 @@ wheels = [ ] [[package]] -name = "typer-slim" -version = "0.21.2" +name = "typer" +version = "0.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "shellingham", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/e6/44e073787aa57cd71c151f44855232feb0f748428fd5242d7366e3c4ae8b/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc", size = 120181, upload-time = "2026-02-11T15:22:18.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ed/d6fca788b51d0d4640c4bc82d0e85bad4b49809bca36bf4af01b4dcb66a7/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913", size = 56668, upload-time = "2026-02-11T15:22:21.075Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/8a/881cfd399a119db89619dc1b93d36e2fb6720ddb112bceff41203f1abd72/typer_slim-0.23.0.tar.gz", hash = "sha256:be8b60243df27cfee444c6db1b10a85f4f3e54d940574f31a996f78aa35a8254", size = 4773, upload-time = "2026-02-11T15:22:19.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" }, + { url = "https://files.pythonhosted.org/packages/07/3e/ba3a222c80ee070d9497ece3e1fe77253c142925dd4c90f04278aac0a9eb/typer_slim-0.23.0-py3-none-any.whl", hash = "sha256:1d693daf22d998a7b1edab8413cdcb8af07254154ce3956c1664dc11b01e2f8b", size = 3399, upload-time = "2026-02-11T15:22:17.792Z" }, ] [[package]]