From a76a6dae920b649b6c8e079635dfadbeb4cef8ec Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 25 Jun 2026 15:22:22 -0400 Subject: [PATCH] Expose the wrapped callable on FunctionTool Add a public FunctionTool.function attribute holding the original Python callable that @function_tool wrapped (None for tools not backed by a plain function). Gives code outside the runtime a stable hook to introspect, directly test, or re-run the underlying function without a ToolContext/JSON round-trip. Closes #3381 --- src/agents/tool.py | 11 +++++ tests/test_function_tool_exposes_function.py | 45 ++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/test_function_tool_exposes_function.py diff --git a/src/agents/tool.py b/src/agents/tool.py index c8563e2e1b..ee99bced36 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -455,6 +455,14 @@ class FunctionTool: ) """Optional callback that attaches SDK-only custom data to the tool output item.""" + function: ToolFunction[...] | None = field(default=None, kw_only=True, repr=False) + """The original Python callable wrapped by `function_tool`, when available. + + This is `None` for tools that are not backed by a plain function (for example, + agent-as-tool or hosted-tool wrappers). It gives code outside the Agents + runtime a stable hook to introspect, directly test, or re-run the underlying + function without going through a `ToolContext`/JSON round-trip.""" + _failure_error_function: ToolErrorFunction | None = field( default=None, kw_only=True, @@ -619,6 +627,7 @@ def _build_wrapped_function_tool( sync_invoker: bool = False, mcp_title: str | None = None, tool_origin: ToolOrigin | None = None, + function: ToolFunction[...] | None = None, ) -> FunctionTool: """Create a FunctionTool with copied-tool-aware failure handling bound in one place.""" on_invoke_tool = _with_context_function_tool_failure_error_handler( @@ -644,6 +653,7 @@ def _build_wrapped_function_tool( timeout_error_function=timeout_error_function, defer_loading=defer_loading, custom_data_extractor=custom_data_extractor, + function=function, _mcp_title=mcp_title, _tool_origin=tool_origin, ), @@ -2033,6 +2043,7 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: timeout_error_function=timeout_error_function, defer_loading=defer_loading, custom_data_extractor=custom_data_extractor, + function=the_func, sync_invoker=is_sync_function_tool, ) return function_tool diff --git a/tests/test_function_tool_exposes_function.py b/tests/test_function_tool_exposes_function.py new file mode 100644 index 0000000000..5a2993d6f8 --- /dev/null +++ b/tests/test_function_tool_exposes_function.py @@ -0,0 +1,45 @@ +"""Tests for FunctionTool.function — public access to the wrapped callable (#3381).""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import cast + +from agents import FunctionTool, function_tool + + +def test_sync_function_tool_exposes_underlying_function() -> None: + @function_tool + def add(x: int, y: int) -> int: + """Add two numbers.""" + return x + y + + assert add.function is not None + # The original callable is reachable and runnable without a ToolContext. + fn = cast(Callable[[int, int], int], add.function) + assert fn(2, 3) == 5 + + +async def test_async_function_tool_exposes_underlying_function() -> None: + @function_tool + async def slow_add(x: int, y: int) -> int: + """Add two numbers.""" + return x + y + + assert slow_add.function is not None + fn = cast(Callable[[int, int], Awaitable[int]], slow_add.function) + assert await fn(2, 3) == 5 + + +def test_function_tool_function_defaults_to_none() -> None: + # Tools not built from a plain function (constructed directly) expose None. + async def _invoke(ctx: object, input: str) -> str: + return "ok" + + tool = FunctionTool( + name="manual", + description="", + params_json_schema={"type": "object", "properties": {}, "additionalProperties": False}, + on_invoke_tool=_invoke, + ) + assert tool.function is None