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 @@ -228,6 +228,10 @@ class FunctionTool:
and returns whether the tool is enabled. You can use this to dynamically enable/disable a tool
based on your context/state."""

_func: ToolFunction[...] | None = field(default=None, repr=False)
"""The function that implements the tool. Ensures that a reference to the
original function exists when @function_tool is used."""

# Tool-specific guardrails
tool_input_guardrails: list[ToolInputGuardrail[Any]] | None = None
"""Optional list of input guardrails to run before invoking this tool."""
Expand All @@ -239,6 +243,19 @@ def __post_init__(self):
if self.strict_json_schema:
self.params_json_schema = ensure_strict_json_schema(self.params_json_schema)

# Dress the FunctionTool object with the name and docstring of the wrapped function
if self._func:
self.__name__ = self._func.__name__
self.__doc__ = self._func.__doc__

def __call__(self, *args, **kwargs):
if not self._func:
raise AttributeError("""FunctionTool has no attribute `_func` and is not callable.
Likely because it was created directly without the
@function_tool decorator.""")

return self._func(*args, **kwargs)


@dataclass
class FileSearchTool:
Expand Down Expand Up @@ -845,6 +862,7 @@ async def _on_invoke_tool(ctx: ToolContext[Any], input: str) -> Any:
on_invoke_tool=_on_invoke_tool,
strict_json_schema=strict_mode,
is_enabled=is_enabled,
_func=func,
Comment on lines 862 to +865

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Store wrapped function when decorator is parameterized

When @function_tool(...) is used with parentheses, the outer func is None, but _create_function_tool stores _func=func. That leaves _func unset for the common decorator-with-args path, so FunctionTool.__post_init__ won’t copy __name__/__doc__, and the new __call__ will raise AttributeError even though the tool was created from a real function. This breaks the stated goal for users who pass any decorator options (e.g. @function_tool(name_override=...)). The wrapped function should be stored from the inner the_func/real_func instead.

Useful? React with 👍 / 👎.

)

# If func is actually a callable, we were used as @function_tool with no parentheses
Expand Down
56 changes: 56 additions & 0 deletions tests/test_function_tool.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import inspect
import json
from dataclasses import asdict
from typing import Any

import pytest
Expand Down Expand Up @@ -81,6 +83,44 @@ async def test_simple_function():
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=""), ""
)

# Direct call
result = tool(2, 2)
assert result == 4


async def async_function(a: int, b: int = 5):
return a + b


@pytest.mark.asyncio
async def test_async_function():
tool = function_tool(async_function, failure_error_function=None)
assert tool.name == "async_function"

result = await tool.on_invoke_tool(
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments='{"a": 1}'),
'{"a": 1}',
)
assert result == 6

result = await tool.on_invoke_tool(
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments='{"a": 1, "b": 2}'),
'{"a": 1, "b": 2}',
)
assert result == 3

# Missing required argument should raise an error
with pytest.raises(ModelBehaviorError):
await tool.on_invoke_tool(
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=""), ""
)

# Direct call
result = await tool(2, 2)
assert result == 4

assert not inspect.iscoroutinefunction(tool.__call__), "tool.__call__ should sync."


class Foo(BaseModel):
a: int
Expand Down Expand Up @@ -148,6 +188,22 @@ async def test_complex_args_function():
)


def test_func_tool_name_doc_inheritance():
tool = function_tool(simple_function)
assert tool.__name__ == simple_function.__name__
assert tool.__doc__ == simple_function.__doc__


def test_absent_func_tool():
tool = function_tool(simple_function)
kwargs = asdict(tool)
kwargs.pop("_func")
manually_defined_tool = FunctionTool(**kwargs)

with pytest.raises(AttributeError, match="not callable"):
manually_defined_tool(1, 1)


def test_function_config_overrides():
tool = function_tool(simple_function, name_override="custom_name")
assert tool.name == "custom_name"
Expand Down