diff --git a/src/agents/tool.py b/src/agents/tool.py index 0e96e4fee8..994d424f3e 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -467,6 +467,21 @@ class FunctionTool: output_json_schema: dict[str, Any] | None = field(default=None, kw_only=True) """Optional JSON Schema describing this tool's output for programmatic callers.""" + func: ToolFunction[...] | None = field(default=None, kw_only=True, repr=False) + """The Python callable that this tool invokes, when constructed via the `@function_tool` + decorator. Provided as a public, stable handle so downstream code (introspection, + sandboxed re-execution, direct unit testing, framework migration) can reach the tool + body without walking `on_invoke_tool` closures. + + This is the same object `on_invoke_tool` dispatches to, which keeps both surfaces in + sync across `copy.copy`, `copy.deepcopy`, and `dataclasses.replace`. For a decorated + function or class that is the decorated object itself; for a decorated callable + instance it is the adapter `function_tool` derived from its `__call__`, which takes the + same arguments and returns the same result as the instance. + + `None` when `FunctionTool` is constructed manually with a custom `on_invoke_tool` + implementation rather than through `@function_tool`.""" + _output_type_adapter: TypeAdapter[Any] | None = field( default=None, kw_only=True, @@ -649,6 +664,7 @@ def _build_wrapped_function_tool( sync_invoker: bool = False, mcp_title: str | None = None, tool_origin: ToolOrigin | None = None, + func: 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( @@ -676,6 +692,7 @@ def _build_wrapped_function_tool( custom_data_extractor=custom_data_extractor, allowed_callers=allowed_callers, output_json_schema=output_json_schema, + func=func, _output_type_adapter=output_type_adapter, _mcp_title=mcp_title, _tool_origin=tool_origin, @@ -2560,6 +2577,7 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: output_json_schema=resolved_output_json_schema, output_type_adapter=output_type_adapter, sync_invoker=is_sync_function_tool, + func=the_func, ) return function_tool diff --git a/tests/test_function_tool_func_attribute.py b/tests/test_function_tool_func_attribute.py new file mode 100644 index 0000000000..99f4dd4cf7 --- /dev/null +++ b/tests/test_function_tool_func_attribute.py @@ -0,0 +1,220 @@ +"""Tests for the public `FunctionTool.func` attribute (issue #3381). + +`@function_tool` historically captured the original callable only inside the +closure of `_on_invoke_tool_impl`. There was no public, stable way to reach +it, so downstream code had to walk +`tool.on_invoke_tool._invoke_tool_impl.__closure__` for the `the_func` free +variable, which silently breaks any time the internal indirection changes. + +`FunctionTool.func` exposes the underlying callable as a public, stable +handle so introspection, sandboxed re-execution, direct unit testing, and +framework migration no longer need to spelunk private attributes. +""" + +from __future__ import annotations + +import copy +import dataclasses +from collections.abc import Callable +from typing import Any, cast + +import pytest + +from agents import FunctionTool, function_tool +from agents.tool_context import ToolContext + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sample_sync(a: int, b: int) -> int: + """Return the sum of two ints.""" + return a + b + + +async def _sample_async(text: str) -> str: + """Echo the given text after upper-casing.""" + return text.upper() + + +def _sample_with_context(ctx: ToolContext[str], value: int) -> int: + return value * 2 + + +# --------------------------------------------------------------------------- +# Decorator wires `.func` to the original callable +# --------------------------------------------------------------------------- + + +def test_func_attribute_set_by_bare_decorator() -> None: + """`@function_tool` (no parens) exposes the underlying callable on `.func`.""" + tool = function_tool(_sample_sync) + assert tool.func is _sample_sync + + +def test_func_attribute_set_by_decorator_with_options() -> None: + """`@function_tool(...)` (with parens) also wires `.func` to the original.""" + tool = function_tool(name_override="adder")(_sample_sync) + assert tool.func is _sample_sync + # The override should not affect the underlying callable identity. + assert tool.name == "adder" + + +def test_func_attribute_set_for_async_function() -> None: + tool = function_tool(_sample_async) + assert tool.func is _sample_async + + +def test_func_attribute_set_for_context_function() -> None: + tool = function_tool(_sample_with_context) + assert tool.func is _sample_with_context + + +def test_func_attribute_invokes_original_directly() -> None: + """`.func(...)` calls the bare function, bypassing schema and ToolContext.""" + tool = function_tool(_sample_sync) + assert tool.func is not None # for type narrowing + # `tool.func` is typed as the `ToolFunction[...]` union (with-context, + # with-tool-context, or without). The decorator was applied to a + # no-context callable, so cast to the matching shape for the direct call. + direct = cast(Callable[[int, int], int], tool.func) + assert direct(2, 3) == 5 + + +# --------------------------------------------------------------------------- +# Manual constructor: `.func` defaults to None and stays kw-only +# --------------------------------------------------------------------------- + + +async def _noop_invoker(ctx: ToolContext[Any], input: str) -> str: + return "" + + +def test_manual_construction_defaults_func_to_none() -> None: + """A `FunctionTool` built manually without `@function_tool` has `func is None`.""" + tool = FunctionTool( + name="manual", + description="", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=_noop_invoker, + ) + assert tool.func is None + + +def test_func_remains_keyword_only_for_positional_compat() -> None: + """`.func` is `kw_only` so v0.7.0 positional `FunctionTool(...)` callers + keep working — this is the same contract documented in AGENTS.md + ("Public API Positional Compatibility").""" + tool = FunctionTool( + "tool_name", + "tool_description", + {"type": "object", "properties": {}}, + _noop_invoker, + True, + True, + None, + None, + ) + # Underlying callable defaults to None when not supplied. + assert tool.func is None + # Sanity-check that the positional call path still binds the way callers expect. + assert tool.name == "tool_name" + assert tool.description == "tool_description" + + +# --------------------------------------------------------------------------- +# `.func` survives the standard FunctionTool clone paths +# --------------------------------------------------------------------------- + + +def test_func_preserved_by_dataclass_replace() -> None: + tool = function_tool(_sample_sync) + clone = dataclasses.replace(tool, name="renamed") + assert clone.func is _sample_sync + assert clone.name == "renamed" + + +def test_func_preserved_by_copy() -> None: + tool = function_tool(_sample_sync) + clone = copy.copy(tool) + assert clone.func is _sample_sync + + +def test_func_preserved_by_deepcopy() -> None: + tool = function_tool(_sample_sync) + clone = copy.deepcopy(tool) + assert clone.func is _sample_sync + + +@pytest.mark.asyncio +async def test_func_and_on_invoke_tool_stay_bound_after_deepcopy() -> None: + """`.func` and `on_invoke_tool` must dispatch to one callable, including on clones. + + `copy.deepcopy` copies dataclass fields but treats functions as atomic, so storing + anything other than the callable `on_invoke_tool` already closes over would leave a + deep copy with its two invocation surfaces bound to different objects. A callable + instance makes that divergence observable because it carries state. + """ + + class Counter: + """Add two ints and count the calls.""" + + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, a: int, b: int) -> int: + """Add two ints. + + Args: + a: The first addend. + b: The second addend. + """ + self.calls += 1 + return a + b + + counter = Counter() + tool = function_tool(counter) + clone = copy.deepcopy(tool) + + assert clone.func is tool.func + + assert cast(Callable[[int, int], int], clone.func)(1, 2) == 3 + await clone.on_invoke_tool( + ToolContext( + None, tool_name=clone.name, tool_call_id="1", tool_arguments='{"a": 1, "b": 2}' + ), + '{"a": 1, "b": 2}', + ) + + # Both calls landed on the single instance the tool was built from; a divergent + # clone would have split them across two objects and left this at 1. + assert counter.calls == 2 + + +def test_func_hidden_from_repr() -> None: + """`repr=False` keeps the (potentially noisy) callable out of `repr(tool)`.""" + tool = function_tool(_sample_sync) + assert "func=" not in repr(tool) + + +# --------------------------------------------------------------------------- +# Backward-compat sanity: the closure-walk workaround stays equivalent +# --------------------------------------------------------------------------- + + +def test_func_matches_closure_walk_workaround() -> None: + """The new `.func` returns the same callable that today's closure-walking + workaround finds at `on_invoke_tool._invoke_tool_impl.__closure__`. This + guards against future refactors silently changing identity.""" + tool = function_tool(_sample_sync) + invoke_impl = getattr(tool.on_invoke_tool, "_invoke_tool_impl", None) + if invoke_impl is None or invoke_impl.__closure__ is None: + pytest.skip("Internal closure layout changed; the workaround is no longer applicable.") + + free_vars = dict(zip(invoke_impl.__code__.co_freevars, invoke_impl.__closure__, strict=False)) + closure_func = free_vars.get("the_func") + if closure_func is None: + pytest.skip("`the_func` is no longer captured in the closure.") + + assert tool.func is closure_func.cell_contents