From 395cbcb0041c9ac3fa7e75a19d9d84954be6ea1c Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 25 Jun 2026 16:22:05 -0400 Subject: [PATCH 1/2] Support @function_tool on instance methods Make FunctionTool a descriptor: when a method decorated with @function_tool is accessed via an instance, return a copy bound to that instance. The bound method's signature omits self, so the JSON schema and invocation are correct and self is supplied automatically. Module-level function tools and class access are unaffected; bound tools are cached per instance. Closes #94 --- docs/tools.md | 18 +++++++ src/agents/tool.py | 57 +++++++++++++++++++- tests/test_function_tool_methods.py | 84 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tests/test_function_tool_methods.py diff --git a/docs/tools.md b/docs/tools.md index e79833548b..7a16218097 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -387,6 +387,24 @@ In addition to returning text outputs, you can return one or many images or file - Files: [`ToolOutputFileContent`][agents.tool.ToolOutputFileContent] (or the TypedDict version, [`ToolOutputFileContentDict`][agents.tool.ToolOutputFileContentDict]) - Text: either a string or stringable objects, or [`ToolOutputText`][agents.tool.ToolOutputText] (or the TypedDict version, [`ToolOutputTextDict`][agents.tool.ToolOutputTextDict]) +### Instance methods as tools + +You can decorate instance methods with `@function_tool` and pass the bound method from an instance. The `self` argument is supplied automatically and excluded from the tool's JSON schema: + +```python +class Calculator: + def __init__(self, base: int): + self.base = base + + @function_tool + def add_to_base(self, x: int) -> int: + """Add x to the calculator's base.""" + return self.base + x + +calc = Calculator(base=10) +agent = Agent(name="Math", tools=[calc.add_to_base]) +``` + ### Custom function tools Sometimes, you don't want to use a Python function as a tool. You can directly create a [`FunctionTool`][agents.tool.FunctionTool] if you prefer. You'll need to provide: diff --git a/src/agents/tool.py b/src/agents/tool.py index be9e49802c..23dc921d85 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -11,7 +11,7 @@ from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field from enum import Enum -from types import UnionType +from types import MethodType, UnionType from typing import ( TYPE_CHECKING, Annotated, @@ -389,6 +389,16 @@ class FunctionTool: _emit_tool_origin: bool = field(default=True, kw_only=True, repr=False) """Whether runtime item generation should emit tool origin metadata for this tool.""" + _bind_to_instance: Callable[[Any], FunctionTool] | None = field( + default=None, kw_only=True, repr=False, compare=False + ) + """Internal: builds an instance-bound copy of a method-backed tool (see __get__).""" + + _bound_instances: weakref.WeakKeyDictionary[Any, FunctionTool] | None = field( + default=None, kw_only=True, repr=False, compare=False + ) + """Internal per-instance cache of bound tools for method-backed function tools.""" + @property def qualified_name(self) -> str: """Return the public qualified name used to identify this function tool.""" @@ -406,6 +416,25 @@ def __post_init__(self): ) _validate_function_tool_timeout_config(self) + def __get__(self, instance: Any, owner: type[Any] | None = None) -> FunctionTool: + """Descriptor hook so ``@function_tool`` works on instance methods. + + When the tool is a class attribute accessed via an instance, return a copy + bound to that instance (``self`` is supplied automatically and excluded + from the JSON schema). Tools that are not method-backed return unchanged. + """ + if instance is None or self._bind_to_instance is None: + return self + cache = self._bound_instances + if cache is None: + cache = weakref.WeakKeyDictionary() + self._bound_instances = cache + bound = cache.get(instance) + if bound is None: + bound = self._bind_to_instance(instance) + cache[instance] = bound + return bound + def __copy__(self) -> FunctionTool: copied_tool = dataclasses.replace(self) dataclass_field_names = {tool_field.name for tool_field in dataclasses.fields(FunctionTool)} @@ -552,6 +581,31 @@ def get_function_tool_origin(function_tool: FunctionTool) -> ToolOrigin | None: return function_tool._tool_origin or ToolOrigin(type=ToolOriginType.FUNCTION) +def _attach_self_binder( + tool: FunctionTool, + the_func: ToolFunction[...], + create: Callable[[Any], FunctionTool], +) -> None: + """Let a method-backed function tool bind ``self`` when accessed via an instance. + + If ``the_func``'s first parameter is ``self`` (i.e. it is an instance method + decorated with ``@function_tool``), record a binder that rebuilds the tool from + the method bound to a given instance. The bound method's signature omits + ``self``, so the schema and invocation are correct without further changes. + """ + try: + params = list(inspect.signature(the_func).parameters) + except (TypeError, ValueError): + return + if not params or params[0] != "self": + return + + def _bind(instance: Any) -> FunctionTool: + return create(MethodType(the_func, instance)) + + tool._bind_to_instance = _bind + + @dataclass class FileSearchTool: """A hosted tool that lets the LLM search through a vector store. Currently only supported with @@ -1906,6 +1960,7 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: defer_loading=defer_loading, sync_invoker=is_sync_function_tool, ) + _attach_self_binder(function_tool, the_func, _create_function_tool) return function_tool # If func is actually a callable, we were used as @function_tool with no parentheses diff --git a/tests/test_function_tool_methods.py b/tests/test_function_tool_methods.py new file mode 100644 index 0000000000..bc2e30ae78 --- /dev/null +++ b/tests/test_function_tool_methods.py @@ -0,0 +1,84 @@ +"""@function_tool support on instance methods (#94).""" + +from __future__ import annotations + +import json + +from agents import Agent, FunctionTool, Runner, function_tool +from agents.tool_context import ToolContext +from tests.fake_model import FakeModel +from tests.test_responses import get_function_tool_call, get_text_message + + +class Calculator: + def __init__(self, base: int) -> None: + self.base = base + + @function_tool + def add_to_base(self, x: int) -> int: + """Add x to the calculator's base.""" + return self.base + x + + +def _ctx(tool: FunctionTool) -> ToolContext: + return ToolContext(context=None, tool_name=tool.name, tool_call_id="1", tool_arguments="") + + +def test_instance_access_binds_self_and_drops_it_from_schema() -> None: + calc = Calculator(10) + tool = calc.add_to_base # descriptor __get__ -> instance-bound tool + + assert isinstance(tool, FunctionTool) + properties = tool.params_json_schema.get("properties", {}) + assert "self" not in properties + assert "x" in properties + + +async def test_instance_method_tool_invokes_with_self() -> None: + calc = Calculator(10) + tool = calc.add_to_base + result = await tool.on_invoke_tool(_ctx(tool), json.dumps({"x": 5})) + assert result == 15 + + +async def test_distinct_instances_bind_independently() -> None: + ten, twenty = Calculator(10), Calculator(20) + assert await ten.add_to_base.on_invoke_tool(_ctx(ten.add_to_base), json.dumps({"x": 1})) == 11 + assert ( + await twenty.add_to_base.on_invoke_tool(_ctx(twenty.add_to_base), json.dumps({"x": 1})) + == 21 + ) + + +def test_bound_tool_is_cached_per_instance() -> None: + calc = Calculator(10) + assert calc.add_to_base is calc.add_to_base + + +def test_class_access_returns_unbound_tool() -> None: + # Accessing via the class (no instance) returns the original tool unchanged. + assert isinstance(Calculator.add_to_base, FunctionTool) + + +def test_module_level_function_tool_unaffected() -> None: + @function_tool + def free(x: int) -> int: + """A free function.""" + return x + + assert isinstance(free, FunctionTool) + assert "x" in free.params_json_schema.get("properties", {}) + + +async def test_instance_method_tool_runs_in_agent() -> None: + calc = Calculator(100) + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("add_to_base", json.dumps({"x": 5}))], + [get_text_message("done")], + ] + ) + agent = Agent(name="A", instructions="x", model=model, tools=[calc.add_to_base]) + result = await Runner.run(agent, "add 5") + assert result.final_output == "done" From b0b82cc0c5d5cc5c53a66679294e99265add0e3a Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 25 Jun 2026 17:15:09 -0400 Subject: [PATCH 2/2] Address Codex review on #94 (method tools) - Drop the per-instance WeakKeyDictionary cache: it raised TypeError for unhashable / __slots__ tool-holder classes and strongly retained instances via the bound closure. __get__ now rebuilds the bound tool per access (cheap; typically accessed once when building the agent). - Support methods that take RunContextWrapper: function_schema gains an opt-in skip_self so a leading self is skipped before context-position validation, fixing 'context param at non-first position' for def m(self, ctx, x). Gated by a __qualname__-based method check so a module-level function whose first arg is literally 'self' is unaffected. - Tests for context-taking methods and the module-level self guard. --- src/agents/function_schema.py | 23 +++++++++++- src/agents/tool.py | 57 ++++++++++++----------------- tests/test_function_tool_methods.py | 33 +++++++++++++++-- 3 files changed, 76 insertions(+), 37 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 8fe52df320..a30edb9efd 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -40,6 +40,8 @@ class FuncSchema: strict_json_schema: bool = True """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, as it increases the likelihood of correct JSON input.""" + skipped_self: bool = False + """Whether a leading ``self`` parameter was skipped (method-backed function tools).""" def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: """ @@ -50,8 +52,14 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: keyword_args: dict[str, Any] = {} seen_var_positional = False + # Skip a leading `self` for method-backed tools; it is supplied by binding, + # not by the model, and is not part of the schema. + param_items = list(self.signature.parameters.items()) + if self.skipped_self: + param_items = param_items[1:] + # Use enumerate() so we can skip the first parameter if it's context. - for idx, (name, param) in enumerate(self.signature.parameters.items()): + for idx, (name, param) in enumerate(param_items): # If the function takes a RunContextWrapper and this is the first parameter, skip it. if self.takes_context and idx == 0: continue @@ -228,6 +236,7 @@ def function_schema( description_override: str | None = None, use_docstring_info: bool = True, strict_json_schema: bool = True, + skip_self: bool = False, ) -> FuncSchema: """ Given a Python function, extracts a `FuncSchema` from it, capturing the name, description, @@ -286,6 +295,17 @@ def function_schema( # 2. Inspect function signature and get type hints sig = inspect.signature(func) params = list(sig.parameters.items()) + + # Skip a leading `self` so method-backed tools (decorated instance methods) + # validate and serialize correctly: `self` is supplied by binding, and the + # next parameter is what should be evaluated for context detection. Gated by + # `skip_self` (set by the caller for method-backed tools) so an ordinary + # function whose first argument is literally named `self` is unaffected. + skipped_self = False + if skip_self and params and params[0][0] == "self": + params = params[1:] + skipped_self = True + takes_context = False filtered_params = [] @@ -421,4 +441,5 @@ def function_schema( signature=sig, takes_context=takes_context, strict_json_schema=strict_json_schema, + skipped_self=skipped_self, ) diff --git a/src/agents/tool.py b/src/agents/tool.py index 23dc921d85..0eaae2b642 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -394,11 +394,6 @@ class FunctionTool: ) """Internal: builds an instance-bound copy of a method-backed tool (see __get__).""" - _bound_instances: weakref.WeakKeyDictionary[Any, FunctionTool] | None = field( - default=None, kw_only=True, repr=False, compare=False - ) - """Internal per-instance cache of bound tools for method-backed function tools.""" - @property def qualified_name(self) -> str: """Return the public qualified name used to identify this function tool.""" @@ -422,18 +417,14 @@ def __get__(self, instance: Any, owner: type[Any] | None = None) -> FunctionTool When the tool is a class attribute accessed via an instance, return a copy bound to that instance (``self`` is supplied automatically and excluded from the JSON schema). Tools that are not method-backed return unchanged. + + A fresh bound tool is built per access rather than cached, since caching on + the instance would require it to be weak-referenceable/hashable (not true + for every tool-holder class) and would otherwise retain instances. """ if instance is None or self._bind_to_instance is None: return self - cache = self._bound_instances - if cache is None: - cache = weakref.WeakKeyDictionary() - self._bound_instances = cache - bound = cache.get(instance) - if bound is None: - bound = self._bind_to_instance(instance) - cache[instance] = bound - return bound + return self._bind_to_instance(instance) def __copy__(self) -> FunctionTool: copied_tool = dataclasses.replace(self) @@ -581,29 +572,23 @@ def get_function_tool_origin(function_tool: FunctionTool) -> ToolOrigin | None: return function_tool._tool_origin or ToolOrigin(type=ToolOriginType.FUNCTION) -def _attach_self_binder( - tool: FunctionTool, - the_func: ToolFunction[...], - create: Callable[[Any], FunctionTool], -) -> None: - """Let a method-backed function tool bind ``self`` when accessed via an instance. +def _looks_like_method(func: Any) -> bool: + """Heuristic: is ``func`` an instance method decorated with ``@function_tool``? - If ``the_func``'s first parameter is ``self`` (i.e. it is an instance method - decorated with ``@function_tool``), record a binder that rebuilds the tool from - the method bound to a given instance. The bound method's signature omits - ``self``, so the schema and invocation are correct without further changes. + True only when the first parameter is ``self`` *and* the qualified name shows the + function is defined in a class body (e.g. ``Class.method``). This deliberately + excludes a plain module-level function whose first argument happens to be named + ``self`` (qualname has no class component), so its behavior is unchanged. """ try: - params = list(inspect.signature(the_func).parameters) + params = list(inspect.signature(func).parameters) except (TypeError, ValueError): - return + return False if not params or params[0] != "self": - return - - def _bind(instance: Any) -> FunctionTool: - return create(MethodType(the_func, instance)) - - tool._bind_to_instance = _bind + return False + qualname = getattr(func, "__qualname__", "") + parts = qualname.split(".") + return len(parts) >= 2 and parts[-2] != "" @dataclass @@ -1892,6 +1877,7 @@ def function_tool( def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: is_sync_function_tool = not inspect.iscoroutinefunction(the_func) + is_method = _looks_like_method(the_func) schema = function_schema( func=the_func, name_override=name_override, @@ -1899,6 +1885,7 @@ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: docstring_style=docstring_style, use_docstring_info=use_docstring_info, strict_json_schema=strict_mode, + skip_self=is_method, ) async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: @@ -1960,7 +1947,11 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any: defer_loading=defer_loading, sync_invoker=is_sync_function_tool, ) - _attach_self_binder(function_tool, the_func, _create_function_tool) + if is_method: + # Bind `self` when the tool is accessed via an instance (see __get__). + function_tool._bind_to_instance = lambda instance: _create_function_tool( + MethodType(the_func, instance) + ) return function_tool # If func is actually a callable, we were used as @function_tool with no parentheses diff --git a/tests/test_function_tool_methods.py b/tests/test_function_tool_methods.py index bc2e30ae78..0d12ba7af5 100644 --- a/tests/test_function_tool_methods.py +++ b/tests/test_function_tool_methods.py @@ -4,7 +4,7 @@ import json -from agents import Agent, FunctionTool, Runner, function_tool +from agents import Agent, FunctionTool, RunContextWrapper, Runner, function_tool from agents.tool_context import ToolContext from tests.fake_model import FakeModel from tests.test_responses import get_function_tool_call, get_text_message @@ -19,6 +19,11 @@ def add_to_base(self, x: int) -> int: """Add x to the calculator's base.""" return self.base + x + @function_tool + def add_with_context(self, ctx: RunContextWrapper[int], x: int) -> int: + """Add x to the base and the run context value.""" + return self.base + x + ctx.context + def _ctx(tool: FunctionTool) -> ToolContext: return ToolContext(context=None, tool_name=tool.name, tool_call_id="1", tool_arguments="") @@ -50,9 +55,31 @@ async def test_distinct_instances_bind_independently() -> None: ) -def test_bound_tool_is_cached_per_instance() -> None: +async def test_context_taking_method_binds_self_and_context() -> None: + # A method that takes RunContextWrapper after self must not raise at decoration + # and must receive both self and the run context when invoked. calc = Calculator(10) - assert calc.add_to_base is calc.add_to_base + tool = calc.add_with_context + assert "self" not in tool.params_json_schema.get("properties", {}) + assert "ctx" not in tool.params_json_schema.get("properties", {}) + assert "x" in tool.params_json_schema.get("properties", {}) + + ctx: ToolContext[int] = ToolContext( + context=5, tool_name=tool.name, tool_call_id="1", tool_arguments="" + ) + result = await tool.on_invoke_tool(ctx, json.dumps({"x": 2})) + assert result == 17 # base 10 + x 2 + context 5 + + +def test_module_level_self_named_function_is_not_treated_as_method() -> None: + # A plain function whose first arg happens to be named `self` is unaffected: + # `self` stays in the schema and is supplied by the model. + @function_tool + def weird(self: int, x: int) -> int: + """A free function with an unfortunate first argument name.""" + return self + x + + assert "self" in weird.params_json_schema.get("properties", {}) def test_class_access_returns_unbound_tool() -> None: