Skip to content

Commit 8ed5000

Browse files
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
1 parent 6000b73 commit 8ed5000

31 files changed

Lines changed: 485 additions & 451 deletions

File tree

python/packages/ag-ui/agent_framework_ag_ui/_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ def _register_server_tool_placeholder(self, tool_name: str) -> None:
267267
if any(getattr(tool, "name", None) == tool_name for tool in additional_tools):
268268
return
269269

270-
placeholder: FunctionTool[Any, Any] = FunctionTool(
270+
placeholder: FunctionTool[Any] = FunctionTool(
271271
name=tool_name,
272272
description="Server-managed tool placeholder (AG-UI)",
273273
func=None,

python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from agent_framework import (
1212
Content,
1313
Message,
14-
prepare_function_call_results,
1514
)
1615

1716
from ._utils import (
@@ -697,8 +696,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An
697696
elif content.type == "function_result":
698697
# Tool result content - extract call_id and result
699698
tool_result_call_id = content.call_id
700-
# Serialize result to string using core utility
701-
content_text = prepare_function_call_results(content.result)
699+
content_text = content.result if content.result is not None else ""
702700

703701
agui_msg: dict[str, Any] = {
704702
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id

python/packages/ag-ui/agent_framework_ag_ui/_run.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
Content,
3232
Message,
3333
SupportsAgentRun,
34-
prepare_function_call_results,
3534
)
3635
from agent_framework._middleware import FunctionMiddlewarePipeline
3736
from agent_framework._tools import (
@@ -360,7 +359,7 @@ def _emit_tool_result(
360359
events.append(ToolCallEndEvent(tool_call_id=content.call_id))
361360
flow.tool_calls_ended.add(content.call_id) # Track ended tool calls
362361

363-
result_content = prepare_function_call_results(content.result)
362+
result_content = content.result if content.result is not None else ""
364363
message_id = generate_event_id()
365364
events.append(
366365
ToolCallResultEvent(

python/packages/ag-ui/agent_framework_ag_ui/_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401
162162

163163
def convert_agui_tools_to_agent_framework(
164164
agui_tools: list[dict[str, Any]] | None,
165-
) -> list[FunctionTool[Any, Any]] | None:
165+
) -> list[FunctionTool[Any]] | None:
166166
"""Convert AG-UI tool definitions to Agent Framework FunctionTool declarations.
167167
168168
Creates declaration-only FunctionTool instances (no executable implementation).
@@ -181,13 +181,13 @@ def convert_agui_tools_to_agent_framework(
181181
if not agui_tools:
182182
return None
183183

184-
result: list[FunctionTool[Any, Any]] = []
184+
result: list[FunctionTool[Any]] = []
185185
for tool_def in agui_tools:
186186
# Create declaration-only FunctionTool (func=None means no implementation)
187187
# When func=None, the declaration_only property returns True,
188188
# which tells the function invocation mixin to return the function call
189189
# without executing it (so it can be sent back to the client)
190-
func: FunctionTool[Any, Any] = FunctionTool(
190+
func: FunctionTool[Any] = FunctionTool(
191191
name=tool_def.get("name", ""),
192192
description=tool_def.get("description", ""),
193193
func=None, # CRITICAL: Makes declaration_only=True

python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from agent_framework import ChatOptions
2424

2525
# Declaration-only tools (func=None) - actual rendering happens on the client side
26-
generate_haiku = FunctionTool[Any, str](
26+
generate_haiku = FunctionTool[Any](
2727
name="generate_haiku",
2828
description="""Generate a haiku with image and gradient background (FRONTEND_RENDER).
2929
@@ -71,7 +71,7 @@
7171
},
7272
)
7373

74-
create_chart = FunctionTool[Any, str](
74+
create_chart = FunctionTool[Any](
7575
name="create_chart",
7676
description="""Create an interactive chart (FRONTEND_RENDER).
7777
@@ -99,7 +99,7 @@
9999
},
100100
)
101101

102-
display_timeline = FunctionTool[Any, str](
102+
display_timeline = FunctionTool[Any](
103103
name="display_timeline",
104104
description="""Display an interactive timeline (FRONTEND_RENDER).
105105
@@ -127,7 +127,7 @@
127127
},
128128
)
129129

130-
show_comparison_table = FunctionTool[Any, str](
130+
show_comparison_table = FunctionTool[Any](
131131
name="show_comparison_table",
132132
description="""Show a comparison table (FRONTEND_RENDER).
133133

python/packages/ag-ui/tests/ag_ui/test_message_adapters.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ def test_agent_framework_to_agui_function_result_dict():
543543
"""Test converting FunctionResultContent with dict result to AG-UI."""
544544
msg = Message(
545545
role="tool",
546-
contents=[Content.from_function_result(call_id="call-123", result={"key": "value", "count": 42})],
546+
contents=[Content.from_function_result(call_id="call-123", result='{"key": "value", "count": 42}')],
547547
message_id="msg-789",
548548
)
549549

@@ -568,8 +568,8 @@ def test_agent_framework_to_agui_function_result_none():
568568

569569
assert len(messages) == 1
570570
agui_msg = messages[0]
571-
# None serializes as JSON null
572-
assert agui_msg["content"] == "null"
571+
# None result maps to empty string (FunctionTool.invoke returns "" for None)
572+
assert agui_msg["content"] == ""
573573

574574

575575
def test_agent_framework_to_agui_function_result_string():
@@ -591,7 +591,7 @@ def test_agent_framework_to_agui_function_result_empty_list():
591591
"""Test converting FunctionResultContent with empty list result to AG-UI."""
592592
msg = Message(
593593
role="tool",
594-
contents=[Content.from_function_result(call_id="call-123", result=[])],
594+
contents=[Content.from_function_result(call_id="call-123", result="[]")],
595595
message_id="msg-789",
596596
)
597597

@@ -604,16 +604,10 @@ def test_agent_framework_to_agui_function_result_empty_list():
604604

605605

606606
def test_agent_framework_to_agui_function_result_single_text_content():
607-
"""Test converting FunctionResultContent with single TextContent-like item."""
608-
from dataclasses import dataclass
609-
610-
@dataclass
611-
class MockTextContent:
612-
text: str
613-
607+
"""Test converting FunctionResultContent with single TextContent-like item (pre-parsed)."""
614608
msg = Message(
615609
role="tool",
616-
contents=[Content.from_function_result(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
610+
contents=[Content.from_function_result(call_id="call-123", result='["Hello from MCP!"]')],
617611
message_id="msg-789",
618612
)
619613

@@ -626,19 +620,13 @@ class MockTextContent:
626620

627621

628622
def test_agent_framework_to_agui_function_result_multiple_text_contents():
629-
"""Test converting FunctionResultContent with multiple TextContent-like items."""
630-
from dataclasses import dataclass
631-
632-
@dataclass
633-
class MockTextContent:
634-
text: str
635-
623+
"""Test converting FunctionResultContent with multiple TextContent-like items (pre-parsed)."""
636624
msg = Message(
637625
role="tool",
638626
contents=[
639627
Content.from_function_result(
640628
call_id="call-123",
641-
result=[MockTextContent("First result"), MockTextContent("Second result")],
629+
result='["First result", "Second result"]',
642630
)
643631
],
644632
message_id="msg-789",

python/packages/anthropic/agent_framework_anthropic/_chat_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
TextSpanRegion,
2626
UsageDetails,
2727
get_logger,
28-
prepare_function_call_results,
2928
)
3029
from agent_framework._settings import SecretString, load_settings
3130
from agent_framework._types import _get_data_bytes_as_str # type: ignore
@@ -653,7 +652,7 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]:
653652
a_content.append({
654653
"type": "tool_result",
655654
"tool_use_id": content.call_id,
656-
"content": prepare_function_call_results(content.result),
655+
"content": content.result if content.result is not None else "",
657656
"is_error": content.exception is not None,
658657
})
659658
case "text_reasoning":

python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
TextSpanRegion,
3434
UsageDetails,
3535
get_logger,
36-
prepare_function_call_results,
3736
)
3837
from agent_framework._settings import load_settings
3938
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException
@@ -1390,7 +1389,7 @@ def _prepare_tool_outputs_for_azure_ai(
13901389
if tool_outputs is None:
13911390
tool_outputs = []
13921391
tool_outputs.append(
1393-
ToolOutput(tool_call_id=call_id, output=prepare_function_call_results(content.result))
1392+
ToolOutput(tool_call_id=call_id, output=content.result if content.result is not None else "")
13941393
)
13951394
elif content.type == "function_approval_response":
13961395
if tool_approvals is None:

python/packages/azure-ai/tests/test_azure_ai_agent_client.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,9 +1024,10 @@ def __init__(self, name: str, value: int):
10241024

10251025
client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
10261026

1027-
# Test with BaseModel result
1027+
# Test with BaseModel result (pre-parsed as it would be from FunctionTool.invoke)
10281028
mock_result = MockResult(name="test", value=42)
1029-
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=mock_result)
1029+
expected_json = mock_result.to_json()
1030+
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=expected_json)
10301031

10311032
run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
10321033

@@ -1035,8 +1036,7 @@ def __init__(self, name: str, value: int):
10351036
assert tool_outputs is not None
10361037
assert len(tool_outputs) == 1
10371038
assert tool_outputs[0].tool_call_id == "call_456"
1038-
# Should use model_dump_json for BaseModel
1039-
expected_json = mock_result.to_json()
1039+
# Should use pre-parsed result string directly
10401040
assert tool_outputs[0].output == expected_json
10411041

10421042

@@ -1051,10 +1051,14 @@ def __init__(self, data: str):
10511051

10521052
client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
10531053

1054-
# Test with multiple results - mix of BaseModel and regular objects
1054+
# Test with multiple results - pre-parsed as FunctionTool.invoke would produce
10551055
mock_basemodel = MockResult(data="model_data")
10561056
results_list = [mock_basemodel, {"key": "value"}, "string_result"]
1057-
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=results_list)
1057+
# FunctionTool.parse_result would serialize this to a JSON string
1058+
from agent_framework import FunctionTool
1059+
1060+
pre_parsed = FunctionTool.parse_result(results_list)
1061+
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=pre_parsed)
10581062

10591063
run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
10601064

@@ -1063,14 +1067,8 @@ def __init__(self, data: str):
10631067
assert len(tool_outputs) == 1
10641068
assert tool_outputs[0].tool_call_id == "call_456"
10651069

1066-
# Should JSON dump the entire results array since len > 1
1067-
expected_results = [
1068-
mock_basemodel.to_dict(),
1069-
{"key": "value"},
1070-
"string_result",
1071-
]
1072-
expected_output = json.dumps(expected_results)
1073-
assert tool_outputs[0].output == expected_output
1070+
# Result is pre-parsed string (already JSON)
1071+
assert tool_outputs[0].output == pre_parsed
10741072

10751073

10761074
async def test_azure_ai_chat_client_convert_required_action_approval_response(

python/packages/bedrock/agent_framework_bedrock/_chat_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
ResponseStream,
2828
UsageDetails,
2929
get_logger,
30-
prepare_function_call_results,
3130
validate_tool_mode,
3231
)
3332
from agent_framework._settings import SecretString, load_settings
@@ -528,7 +527,7 @@ def _convert_content_to_bedrock_block(self, content: Content) -> dict[str, Any]
528527
return None
529528

530529
def _convert_tool_result_to_blocks(self, result: Any) -> list[dict[str, Any]]:
531-
prepared_result = prepare_function_call_results(result)
530+
prepared_result = result if isinstance(result, str) else FunctionTool.parse_result(result)
532531
try:
533532
parsed_result = json.loads(prepared_result)
534533
except json.JSONDecodeError:

0 commit comments

Comments
 (0)