Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/agents/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
220 changes: 220 additions & 0 deletions tests/test_function_tool_func_attribute.py
Original file line number Diff line number Diff line change
@@ -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