Python: Centralize tool result parsing in FunctionTool.invoke() (#3854)

* Centralize tool result parsing in FunctionTool.invoke()

- Add parse_result static method to FunctionTool that converts raw
  function return values to strings at invocation time
- Add result_parser parameter to FunctionTool and @tool decorator
  for custom parsing
- Remove prepare_function_call_results from all 9 consumer files
  and from the public API
- Update MCPTool to parse MCP types directly to strings via
  _parse_tool_result_from_mcp and _parse_prompt_result_from_mcp
- Change MCPTool parse_tool_results/parse_prompt_results type from
  Literal[True] | Callable | None to Callable | None
- Remove ReturnT type parameter from FunctionTool (now single
  generic ArgsT since invoke() always returns str)
- Update all subclass signatures and docstrings

Fixes #1147

* Fix test_mcp_tool_call_tool_with_meta_integration for string results

The test was still accessing result[0].additional_properties but
invoke() now returns a string, not a list of Content objects.

* Fix SIM108 lint: use binary operator for output assignment

* Fix bedrock: use FunctionTool.parse_result instead of str() fallback

str(result) turns None into literal 'None' and dicts into Python reprs
with single quotes, breaking JSON parsing. Use the shared parse_result
which handles None as '' and serializes via json.dumps.

* updated lock

* updates from feedback
This commit is contained in:
Eduard van Valkenburg
2026-02-12 14:49:42 +01:00
committed by GitHub
Unverified
parent 6000b737e9
commit 8ed50009c6
31 changed files with 486 additions and 452 deletions
@@ -267,7 +267,7 @@ class AGUIChatClient(
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,
@@ -11,7 +11,6 @@ from typing import Any, cast
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
@@ -31,7 +31,6 @@ from agent_framework import (
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(
@@ -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
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
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 @@ generate_haiku = FunctionTool[Any, str](
},
)
create_chart = FunctionTool[Any, str](
create_chart = FunctionTool[Any](
name="create_chart",
description="""Create an interactive chart (FRONTEND_RENDER).
@@ -99,7 +99,7 @@ create_chart = FunctionTool[Any, str](
},
)
display_timeline = FunctionTool[Any, str](
display_timeline = FunctionTool[Any](
name="display_timeline",
description="""Display an interactive timeline (FRONTEND_RENDER).
@@ -127,7 +127,7 @@ display_timeline = FunctionTool[Any, str](
},
)
show_comparison_table = FunctionTool[Any, str](
show_comparison_table = FunctionTool[Any](
name="show_comparison_table",
description="""Show a comparison table (FRONTEND_RENDER).
@@ -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 @@ def test_agent_framework_to_agui_function_result_single_text_content():
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",
@@ -25,7 +25,6 @@ from agent_framework import (
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 @@ class AnthropicClient(
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":
@@ -33,7 +33,6 @@ from agent_framework import (
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 @@ class AzureAIAgentClient(
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:
@@ -1024,9 +1024,10 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
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 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
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 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results(
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 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results(
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(
@@ -27,7 +27,6 @@ from agent_framework import (
ResponseStream,
UsageDetails,
get_logger,
prepare_function_call_results,
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
@@ -528,7 +527,7 @@ class BedrockChatClient(
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:
@@ -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"}')],
),
]
@@ -483,7 +483,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
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:
@@ -437,7 +437,7 @@ class BaseAgent(SerializationMixin):
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 @@ class BaseAgent(SerializationMixin):
# 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,
+167 -92
View File
@@ -81,6 +81,51 @@ __all__ = [
]
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
import json
# 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
# 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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
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 @@ class MCPTool:
"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 @@ class MCPStdioTool(MCPTool):
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 @@ class MCPStdioTool(MCPTool):
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 @@ class MCPStreamableHTTPTool(MCPTool):
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 @@ class MCPStreamableHTTPTool(MCPTool):
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 @@ class MCPWebsocketTool(MCPTool):
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 @@ class MCPWebsocketTool(MCPTool):
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.
@@ -234,7 +234,7 @@ class FunctionInvocationContext:
def __init__(
self,
function: FunctionTool[Any, Any],
function: FunctionTool[Any],
arguments: BaseModel,
metadata: Mapping[str, Any] | None = None,
result: Any = None,
+100 -32
View File
@@ -90,7 +90,6 @@ ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
# 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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
*,
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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, "<non-serializable result>")
logger.debug("Function result: <non-serializable 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 @@ class FunctionTool(SerializationMixin, Generic[ArgsT, ReturnT]):
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 tool(
"""
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 tool(
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
@@ -55,7 +55,6 @@ __all__ = [
"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 @@ class Content:
# 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"]
@@ -1448,7 +1448,7 @@ class AgentTelemetryLayer:
# 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.
@@ -45,7 +45,6 @@ from .._types import (
Message,
ResponseStream,
UsageDetails,
prepare_function_call_results,
)
from ..exceptions import ServiceInitializationError
from ..observability import ChatTelemetryLayer
@@ -805,10 +804,11 @@ class OpenAIAssistantsClient( # type: ignore[misc]
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
@@ -37,7 +37,6 @@ from .._types import (
Message,
ResponseStream,
UsageDetails,
prepare_function_call_results,
)
from ..exceptions import (
ServiceInitializationError,
@@ -556,9 +555,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
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 _:
@@ -57,7 +57,6 @@ from .._types import (
TextSpanRegion,
UsageDetails,
detect_media_type_from_base64,
prepare_function_call_results,
prepend_instructions_to_messages,
validate_tool_mode,
)
@@ -1037,7 +1036,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
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":
+52 -152
View File
@@ -25,7 +25,7 @@ from agent_framework._mcp import (
_get_input_model_from_mcp_tool,
_normalize_mcp_name,
_parse_content_from_mcp,
_parse_contents_from_mcp_tool_result,
_parse_tool_result_from_mcp,
_parse_message_from_mcp,
_prepare_content_for_mcp,
_prepare_message_for_mcp,
@@ -68,144 +68,60 @@ def test_mcp_prompt_message_to_ai_content():
assert ai_content.raw_representation == mcp_message
def test_parse_contents_from_mcp_tool_result():
"""Test conversion from MCP tool result to AI contents."""
def test_parse_tool_result_from_mcp():
"""Test conversion from MCP tool result to string representation."""
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Result text"),
types.ImageContent(type="image", data="eHl6", mimeType="image/png"), # base64 for "xyz"
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), # base64 for "abc"
types.ImageContent(type="image", data="eHl6", mimeType="image/png"),
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"),
]
)
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
result = _parse_tool_result_from_mcp(mcp_result)
assert len(ai_contents) == 3
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Result text"
assert ai_contents[1].type == "data"
assert ai_contents[1].uri == "data:image/png;base64,eHl6"
assert ai_contents[1].media_type == "image/png"
assert ai_contents[2].type == "data"
assert ai_contents[2].uri == "data:image/webp;base64,YWJj"
assert ai_contents[2].media_type == "image/webp"
# Multiple items produce a JSON array of strings
assert isinstance(result, str)
import json
parsed = json.loads(result)
assert len(parsed) == 3
assert parsed[0] == "Result text"
# Image items are JSON-encoded strings within the array
img1 = json.loads(parsed[1])
assert img1["type"] == "image"
assert img1["data"] == "eHl6"
img2 = json.loads(parsed[2])
assert img2["type"] == "image"
assert img2["data"] == "YWJj"
def test_mcp_call_tool_result_with_meta_error():
"""Test conversion from MCP tool result with _meta field containing isError=True."""
# Create a mock CallToolResult with _meta field containing error information
def test_parse_tool_result_from_mcp_single_text():
"""Test conversion from MCP tool result with a single text item."""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Simple result")]
)
result = _parse_tool_result_from_mcp(mcp_result)
# Single text item returns just the text
assert result == "Simple result"
def test_parse_tool_result_from_mcp_meta_not_in_string():
"""Test that _meta data is not included in the string result (it's tool-level, not content-level)."""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Error occurred")],
_meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"},
_meta={"isError": True, "errorCode": "TOOL_ERROR"},
)
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Error occurred"
# Check that _meta data is merged into additional_properties
assert ai_contents[0].additional_properties is not None
assert ai_contents[0].additional_properties["isError"] is True
assert ai_contents[0].additional_properties["errorCode"] == "TOOL_ERROR"
assert ai_contents[0].additional_properties["errorMessage"] == "Tool execution failed"
result = _parse_tool_result_from_mcp(mcp_result)
assert result == "Error occurred"
def test_mcp_call_tool_result_with_meta_arbitrary_data():
"""Test conversion from MCP tool result with _meta field containing arbitrary metadata.
Note: The _meta field is optional and can contain any structure that a specific
MCP server chooses to provide. This test uses example metadata to verify that
whatever is provided gets preserved in additional_properties.
"""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Success result")],
_meta={
"serverVersion": "2.1.0",
"executionId": "exec_abc123",
"metrics": {"responseTime": 1.25, "memoryUsed": "64MB"},
"source": "example-mcp-server",
"customField": "arbitrary_value",
},
)
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Success result"
# Check that _meta data is preserved in additional_properties
props = ai_contents[0].additional_properties
assert props is not None
assert props["serverVersion"] == "2.1.0"
assert props["executionId"] == "exec_abc123"
assert props["metrics"] == {"responseTime": 1.25, "memoryUsed": "64MB"}
assert props["source"] == "example-mcp-server"
assert props["customField"] == "arbitrary_value"
def test_mcp_call_tool_result_with_meta_merging_existing_properties():
"""Test that _meta data merges correctly with existing additional_properties."""
# Create content with existing additional_properties
text_content = types.TextContent(type="text", text="Test content")
mcp_result = types.CallToolResult(content=[text_content], _meta={"newField": "newValue", "isError": False})
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
content = ai_contents[0]
# Check that _meta data is present in additional_properties
assert content.additional_properties is not None
assert content.additional_properties["newField"] == "newValue"
assert content.additional_properties["isError"] is False
def test_mcp_call_tool_result_with_meta_none():
"""Test that missing _meta field is handled gracefully."""
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="No meta test")])
# No _meta field set
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "No meta test"
# Should handle gracefully when no _meta field exists
# additional_properties may be None or empty dict
props = ai_contents[0].additional_properties
assert props is None or props == {}
def test_mcp_call_tool_result_regression_successful_workflow():
"""Regression test to ensure existing successful workflows remain unchanged."""
# Test the original successful workflow still works
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Success message"),
types.ImageContent(type="image", data="YWJjMTIz", mimeType="image/jpeg"), # base64 for "abc123"
]
)
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
# Verify basic conversion still works correctly
assert len(ai_contents) == 2
text_content = ai_contents[0]
assert text_content.type == "text"
assert text_content.text == "Success message"
image_content = ai_contents[1]
assert image_content.type == "data"
assert image_content.uri == "data:image/jpeg;base64,YWJjMTIz"
assert image_content.media_type == "image/jpeg"
# Should have no additional_properties when no _meta field
assert text_content.additional_properties is None or text_content.additional_properties == {}
assert image_content.additional_properties is None or image_content.additional_properties == {}
def test_parse_tool_result_from_mcp_empty_content():
"""Test that empty content produces empty string."""
mcp_result = types.CallToolResult(content=[])
result = _parse_tool_result_from_mcp(mcp_result)
assert result == ""
def test_mcp_content_types_to_ai_content_text():
@@ -874,17 +790,7 @@ async def test_mcp_tool_call_tool_with_meta_integration():
func = server.functions[0]
result = await func.invoke(param="test_value")
assert len(result) == 1
assert result[0].type == "text"
assert result[0].text == "Tool executed with metadata"
# Verify that _meta data is present in additional_properties
props = result[0].additional_properties
assert props is not None
assert props["executionTime"] == 1.5
assert props["cost"] == {"usd": 0.002}
assert props["isError"] is False
assert props["toolVersion"] == "1.2.3"
assert result == "Tool executed with metadata"
async def test_local_mcp_server_function_execution():
@@ -923,9 +829,7 @@ async def test_local_mcp_server_function_execution():
func = server.functions[0]
result = await func.invoke(param="test_value")
assert len(result) == 1
assert result[0].type == "text"
assert result[0].text == "Tool executed successfully"
assert result == "Tool executed successfully"
async def test_local_mcp_server_function_execution_with_nested_object():
@@ -972,8 +876,7 @@ async def test_local_mcp_server_function_execution_with_nested_object():
# Call with nested object
result = await func.invoke(params={"customer_id": 251})
assert len(result) == 1
assert result[0].type == "text"
assert result == '{"name": "John Doe", "id": 251}'
# Verify the session.call_tool was called with the correct nested structure
server.session.call_tool.assert_called_once()
@@ -1057,11 +960,7 @@ async def test_local_mcp_server_prompt_execution():
prompt = server.functions[0]
result = await prompt.invoke(arg="test_value")
assert len(result) == 1
assert isinstance(result[0], Message)
assert result[0].role == "user"
assert len(result[0].contents) == 1
assert result[0].contents[0].text == "Test message"
assert result == "Test message"
@pytest.mark.parametrize(
@@ -1249,7 +1148,8 @@ async def test_streamable_http_integration():
assert hasattr(func, "description")
result = await func.invoke(query="What is Agent Framework?")
assert result[0].text is not None
assert isinstance(result, str)
assert len(result) > 0
@pytest.mark.flaky
@@ -1314,11 +1214,11 @@ async def test_mcp_connection_reset_integration():
# Verify tools are still available after reconnection
assert len(tool.functions) > 0
# Both results should be valid (we don't compare content as it may vary)
if hasattr(first_result[0], "text"):
assert first_result[0].text is not None
if hasattr(second_result[0], "text"):
assert second_result[0].text is not None
# Both results should be valid strings (we don't compare content as it may vary)
assert isinstance(first_result, str)
assert len(first_result) > 0
assert isinstance(second_result, str)
assert len(second_result) > 0
async def test_mcp_tool_message_handler_notification():
@@ -74,7 +74,7 @@ class TestAgentContext:
class TestFunctionInvocationContext:
"""Test cases for FunctionInvocationContext."""
def test_init_with_defaults(self, mock_function: FunctionTool[Any, Any]) -> None:
def test_init_with_defaults(self, mock_function: FunctionTool[Any]) -> None:
"""Test FunctionInvocationContext initialization with default values."""
arguments = FunctionTestArgs(name="test")
context = FunctionInvocationContext(function=mock_function, arguments=arguments)
@@ -83,7 +83,7 @@ class TestFunctionInvocationContext:
assert context.arguments == arguments
assert context.metadata == {}
def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any, Any]) -> None:
def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any]) -> None:
"""Test FunctionInvocationContext initialization with custom metadata."""
arguments = FunctionTestArgs(name="test")
metadata = {"key": "value"}
@@ -420,7 +420,7 @@ class TestFunctionMiddlewarePipeline:
await call_next()
raise MiddlewareTermination
async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any]) -> None:
"""Test pipeline execution with termination before next() raises MiddlewareTermination."""
middleware = self.PreNextTerminateFunctionMiddleware()
pipeline = FunctionMiddlewarePipeline(middleware)
@@ -439,7 +439,7 @@ class TestFunctionMiddlewarePipeline:
# Handler should not be called when terminated before next()
assert execution_order == []
async def test_execute_with_post_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_execute_with_post_next_termination(self, mock_function: FunctionTool[Any]) -> None:
"""Test pipeline execution with termination after next() raises MiddlewareTermination."""
middleware = self.PostNextTerminateFunctionMiddleware()
pipeline = FunctionMiddlewarePipeline(middleware)
@@ -480,7 +480,7 @@ class TestFunctionMiddlewarePipeline:
pipeline = FunctionMiddlewarePipeline(test_middleware)
assert pipeline.has_middlewares
async def test_execute_no_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_execute_no_middleware(self, mock_function: FunctionTool[Any]) -> None:
"""Test pipeline execution with no middleware."""
pipeline = FunctionMiddlewarePipeline()
arguments = FunctionTestArgs(name="test")
@@ -494,7 +494,7 @@ class TestFunctionMiddlewarePipeline:
result = await pipeline.execute(context, final_handler)
assert result == expected_result
async def test_execute_with_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_execute_with_middleware(self, mock_function: FunctionTool[Any]) -> None:
"""Test pipeline execution with middleware."""
execution_order: list[str] = []
@@ -787,7 +787,7 @@ class TestClassBasedMiddleware:
assert context.metadata["after"] is True
assert metadata_updates == ["before", "handler", "after"]
async def test_function_middleware_execution(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_execution(self, mock_function: FunctionTool[Any]) -> None:
"""Test class-based function middleware execution."""
metadata_updates: list[str] = []
@@ -847,7 +847,7 @@ class TestFunctionBasedMiddleware:
assert context.metadata["function_middleware"] is True
assert execution_order == ["function_before", "handler", "function_after"]
async def test_function_function_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_function_middleware(self, mock_function: FunctionTool[Any]) -> None:
"""Test function-based function middleware."""
execution_order: list[str] = []
@@ -905,7 +905,7 @@ class TestMixedMiddleware:
assert result is not None
assert execution_order == ["class_before", "function_before", "handler", "function_after", "class_after"]
async def test_mixed_function_middleware(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_mixed_function_middleware(self, mock_function: FunctionTool[Any]) -> None:
"""Test mixed class and function-based function middleware."""
execution_order: list[str] = []
@@ -1017,7 +1017,7 @@ class TestMultipleMiddlewareOrdering:
]
assert execution_order == expected_order
async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any]) -> None:
"""Test that multiple function middleware execute in registration order."""
execution_order: list[str] = []
@@ -1143,7 +1143,7 @@ class TestContextContentValidation:
result = await pipeline.execute(context, final_handler)
assert result is not None
async def test_function_context_validation(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_context_validation(self, mock_function: FunctionTool[Any]) -> None:
"""Test that function context contains expected data."""
class ContextValidationMiddleware(FunctionMiddleware):
@@ -1489,7 +1489,7 @@ class TestMiddlewareExecutionControl:
assert not handler_called
assert context.result is None
async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any]) -> None:
"""Test that when function middleware doesn't call next(), no execution happens."""
class FunctionTestArgs(BaseModel):
@@ -1666,9 +1666,9 @@ def mock_agent() -> SupportsAgentRun:
@pytest.fixture
def mock_function() -> FunctionTool[Any, Any]:
def mock_function() -> FunctionTool[Any]:
"""Mock function for testing."""
function = MagicMock(spec=FunctionTool[Any, Any])
function = MagicMock(spec=FunctionTool[Any])
function.name = "test_function"
return function
@@ -103,7 +103,7 @@ class TestResultOverrideMiddleware:
assert updates[0].text == "overridden"
assert updates[1].text == " stream"
async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any]) -> None:
"""Test that function middleware can override result."""
override_result = "overridden function result"
@@ -252,7 +252,7 @@ class TestResultOverrideMiddleware:
assert execute_result.messages[0].text == "executed response"
assert handler_called
async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool[Any]) -> None:
"""Test that when function middleware conditionally doesn't call next(), no execution happens."""
class ConditionalNoNextFunctionMiddleware(FunctionMiddleware):
@@ -335,7 +335,7 @@ class TestResultObservability:
assert observed_responses[0].messages[0].text == "executed response"
assert result == observed_responses[0]
async def test_function_middleware_result_observability(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_result_observability(self, mock_function: FunctionTool[Any]) -> None:
"""Test that middleware can observe function result after execution."""
observed_results: list[str] = []
@@ -402,7 +402,7 @@ class TestResultObservability:
assert result is not None
assert result.messages[0].text == "modified after execution"
async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool[Any, Any]) -> None:
async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool[Any]) -> None:
"""Test that middleware can override function result after observing execution."""
class PostExecutionOverrideMiddleware(FunctionMiddleware):
@@ -444,8 +444,8 @@ def mock_agent() -> SupportsAgentRun:
@pytest.fixture
def mock_function() -> FunctionTool[Any, Any]:
def mock_function() -> FunctionTool[Any]:
"""Mock function for testing."""
function = MagicMock(spec=FunctionTool[Any, Any])
function = MagicMock(spec=FunctionTool[Any])
function.name = "test_function"
return function
@@ -138,7 +138,7 @@ async def test_tool_decorator_with_schema_invoke():
return a + b
result = await calculate.invoke(arguments=CalcInput(a=3, b=7))
assert result == 10
assert result == "10"
def test_tool_decorator_with_schema_overrides_annotations():
@@ -436,7 +436,7 @@ async def test_tool_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
# Verify result
assert result == 3
assert result == "3"
# Verify telemetry calls
spans = span_exporter.get_finished_spans()
@@ -480,7 +480,7 @@ async def test_tool_invoke_telemetry_sensitive_disabled(span_exporter: InMemoryS
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
# Verify result
assert result == 3
assert result == "3"
# Verify telemetry calls
spans = span_exporter.get_finished_spans()
@@ -545,7 +545,7 @@ async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemoryS
result = await pydantic_test_tool.invoke(arguments=args_model, tool_call_id="pydantic_call")
# Verify result
assert result == 15
assert result == "15"
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
@@ -613,7 +613,7 @@ async def test_tool_invoke_telemetry_async_function(span_exporter: InMemorySpanE
result = await async_telemetry_test.invoke(x=3, y=4, tool_call_id="async_call")
# Verify result
assert result == 12
assert result == "12"
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
+44 -17
View File
@@ -26,7 +26,6 @@ from agent_framework import (
UsageDetails,
detect_media_type_from_base64,
merge_chat_options,
prepare_function_call_results,
tool,
)
from agent_framework._types import (
@@ -2072,7 +2071,7 @@ def test_text_content_with_annotations_serialization():
assert all(isinstance(ann["annotated_regions"][0], dict) for ann in reconstructed.annotations)
# region prepare_function_call_results with Pydantic models
# region FunctionTool.parse_result with Pydantic models
class WeatherResult(BaseModel):
@@ -2089,10 +2088,10 @@ class NestedModel(BaseModel):
weather: WeatherResult
def test_prepare_function_call_results_pydantic_model():
def test_parse_result_pydantic_model():
"""Test that Pydantic BaseModel subclasses are properly serialized using model_dump()."""
result = WeatherResult(temperature=22.5, condition="sunny")
json_result = prepare_function_call_results(result)
json_result = FunctionTool.parse_result(result)
# The result should be a valid JSON string
assert isinstance(json_result, str)
@@ -2100,13 +2099,13 @@ def test_prepare_function_call_results_pydantic_model():
assert '"condition": "sunny"' in json_result or '"condition":"sunny"' in json_result
def test_prepare_function_call_results_pydantic_model_in_list():
def test_parse_result_pydantic_model_in_list():
"""Test that lists containing Pydantic models are properly serialized."""
results = [
WeatherResult(temperature=20.0, condition="cloudy"),
WeatherResult(temperature=25.0, condition="sunny"),
]
json_result = prepare_function_call_results(results)
json_result = FunctionTool.parse_result(results)
# The result should be a valid JSON string representing a list
assert isinstance(json_result, str)
@@ -2116,13 +2115,13 @@ def test_prepare_function_call_results_pydantic_model_in_list():
assert "sunny" in json_result
def test_prepare_function_call_results_pydantic_model_in_dict():
def test_parse_result_pydantic_model_in_dict():
"""Test that dicts containing Pydantic models are properly serialized."""
results = {
"current": WeatherResult(temperature=22.0, condition="partly cloudy"),
"forecast": WeatherResult(temperature=24.0, condition="sunny"),
}
json_result = prepare_function_call_results(results)
json_result = FunctionTool.parse_result(results)
# The result should be a valid JSON string representing a dict
assert isinstance(json_result, str)
@@ -2132,10 +2131,10 @@ def test_prepare_function_call_results_pydantic_model_in_dict():
assert "sunny" in json_result
def test_prepare_function_call_results_nested_pydantic_model():
def test_parse_result_nested_pydantic_model():
"""Test that nested Pydantic models are properly serialized."""
result = NestedModel(name="Seattle", weather=WeatherResult(temperature=18.0, condition="rainy"))
json_result = prepare_function_call_results(result)
json_result = FunctionTool.parse_result(result)
# The result should be a valid JSON string
assert isinstance(json_result, str)
@@ -2144,10 +2143,10 @@ def test_prepare_function_call_results_nested_pydantic_model():
assert "18.0" in json_result or "18" in json_result
# region prepare_function_call_results with MCP TextContent-like objects
# region FunctionTool.parse_result with MCP TextContent-like objects
def test_prepare_function_call_results_text_content_single():
def test_parse_result_text_content_single():
"""Test that objects with text attribute (like MCP TextContent) are properly handled."""
@dataclass
@@ -2155,14 +2154,14 @@ def test_prepare_function_call_results_text_content_single():
text: str
result = [MockTextContent("Hello from MCP tool!")]
json_result = prepare_function_call_results(result)
json_result = FunctionTool.parse_result(result)
# Should extract text and serialize as JSON array of strings
assert isinstance(json_result, str)
assert json_result == '["Hello from MCP tool!"]'
def test_prepare_function_call_results_text_content_multiple():
def test_parse_result_text_content_multiple():
"""Test that multiple TextContent-like objects are serialized correctly."""
@dataclass
@@ -2170,14 +2169,14 @@ def test_prepare_function_call_results_text_content_multiple():
text: str
result = [MockTextContent("First result"), MockTextContent("Second result")]
json_result = prepare_function_call_results(result)
json_result = FunctionTool.parse_result(result)
# Should extract text from each and serialize as JSON array
assert isinstance(json_result, str)
assert json_result == '["First result", "Second result"]'
def test_prepare_function_call_results_text_content_with_non_string_text():
def test_parse_result_text_content_with_non_string_text():
"""Test that objects with non-string text attribute are not treated as TextContent."""
class BadTextContent:
@@ -2185,12 +2184,40 @@ def test_prepare_function_call_results_text_content_with_non_string_text():
self.text = 12345 # Not a string!
result = [BadTextContent()]
json_result = prepare_function_call_results(result)
json_result = FunctionTool.parse_result(result)
# Should not extract text since it's not a string, will serialize the object
assert isinstance(json_result, str)
def test_parse_result_none_returns_empty_string():
"""Test that None returns an empty string."""
assert FunctionTool.parse_result(None) == ""
def test_parse_result_string_passthrough():
"""Test that strings are returned as-is."""
assert FunctionTool.parse_result("hello world") == "hello world"
assert FunctionTool.parse_result('{"key": "value"}') == '{"key": "value"}'
def test_parse_result_content_object():
"""Test that Content objects are serialized via to_dict."""
content = Content.from_text("hello")
result = FunctionTool.parse_result(content)
assert isinstance(result, str)
assert "hello" in result
def test_parse_result_list_of_content():
"""Test that list[Content] is serialized to JSON."""
contents = [Content.from_text("hello"), Content.from_text("world")]
result = FunctionTool.parse_result(contents)
assert isinstance(result, str)
assert "hello" in result
assert "world" in result
# endregion
@@ -17,7 +17,6 @@ from agent_framework import (
Content,
Message,
SupportsChatGetResponse,
prepare_function_call_results,
tool,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
@@ -281,17 +280,21 @@ def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env
def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, str]):
"""Test that falsy values (like empty list) in function result are properly handled."""
"""Test that falsy values (like empty list) in function result are properly handled.
Note: In practice, FunctionTool.invoke() always returns a pre-parsed string.
These tests verify that the OpenAI client correctly passes through string results.
"""
client = OpenAIChatClient()
# Test with empty list (falsy but not None)
# Test with empty list serialized as JSON string (as FunctionTool.invoke would produce)
message_with_empty_list = Message(
role="tool", contents=[Content.from_function_result(call_id="call-123", result=[])]
role="tool", contents=[Content.from_function_result(call_id="call-123", result="[]")]
)
openai_messages = client._prepare_message_for_openai(message_with_empty_list)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "[]" # Empty list should be JSON serialized
assert openai_messages[0]["content"] == "[]" # Empty list JSON string
# Test with empty string (falsy but not None)
message_with_empty_string = Message(
@@ -302,12 +305,14 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "" # Empty string should be preserved
# Test with False (falsy but not None)
message_with_false = Message(role="tool", contents=[Content.from_function_result(call_id="call-789", result=False)])
# Test with False serialized as JSON string (as FunctionTool.invoke would produce)
message_with_false = Message(
role="tool", contents=[Content.from_function_result(call_id="call-789", result="false")]
)
openai_messages = client._prepare_message_for_openai(message_with_false)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "false" # False should be JSON serialized
assert openai_messages[0]["content"] == "false" # False JSON string
def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]):
@@ -332,9 +337,11 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
assert openai_messages[0]["tool_call_id"] == "call-123"
def test_prepare_function_call_results_string_passthrough():
def test_parse_result_string_passthrough():
"""Test that string values are passed through directly without JSON encoding."""
result = prepare_function_call_results("simple string")
from agent_framework import FunctionTool
result = FunctionTool.parse_result("simple string")
assert result == "simple string"
assert isinstance(result, str)
@@ -499,7 +499,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
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:
@@ -27,7 +27,7 @@ from tau2.environment.tool import Tool # type: ignore[import-untyped]
_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
@@ -325,7 +325,7 @@ class HandoffAgentExecutor(AgentExecutor):
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 @@ class HandoffAgentExecutor(AgentExecutor):
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."
+30 -16
View File
@@ -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/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/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/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/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/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/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]]