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