Skip to content

Commit d4f563c

Browse files
committed
Handle staticmethod function tools
1 parent d396ccb commit d4f563c

2 files changed

Lines changed: 102 additions & 2 deletions

File tree

src/agents/tool.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,53 @@ class FunctionTool:
396396
)
397397
"""Internal descriptor hook used for instance methods decorated with `@function_tool`."""
398398

399+
_staticmethod_tool_factory: Callable[[], FunctionTool] | None = field(
400+
default=None,
401+
kw_only=True,
402+
repr=False,
403+
)
404+
"""Internal fallback for class-scoped tools wrapped in `staticmethod`."""
405+
406+
_method_tool_bound_to_class: bool = field(default=False, kw_only=True, repr=False)
407+
"""Whether Python installed this tool directly on a class via `__set_name__`."""
408+
409+
def __set_name__(self, owner: type[Any], name: str) -> None:
410+
if self._staticmethod_tool_factory is not None:
411+
self._method_tool_bound_to_class = True
412+
413+
def __getattribute__(self, name: str) -> Any:
414+
if not name.startswith("_") and name not in {"__class__", "__dict__"}:
415+
object.__getattribute__(self, "_maybe_apply_staticmethod_tool")()
416+
return object.__getattribute__(self, name)
417+
399418
def __get__(self, instance: Any, owner: type[Any] | None = None) -> FunctionTool:
400419
if instance is None or self._method_tool_factory is None:
401420
return self
402421
return self._method_tool_factory(instance)
403422

423+
def _maybe_apply_staticmethod_tool(self) -> None:
424+
try:
425+
staticmethod_tool_factory = object.__getattribute__(self, "_staticmethod_tool_factory")
426+
method_tool_bound_to_class = object.__getattribute__(
427+
self, "_method_tool_bound_to_class"
428+
)
429+
except AttributeError:
430+
return
431+
432+
if staticmethod_tool_factory is None or method_tool_bound_to_class:
433+
return
434+
435+
# `staticmethod` does not forward `__set_name__` to the wrapped FunctionTool.
436+
# Rebuild as a normal tool before exposing schema or invocation state.
437+
object.__setattr__(self, "_staticmethod_tool_factory", None)
438+
staticmethod_tool = staticmethod_tool_factory()
439+
for tool_field in dataclasses.fields(FunctionTool):
440+
object.__setattr__(self, tool_field.name, getattr(staticmethod_tool, tool_field.name))
441+
442+
bind_to_function_tool = getattr(self.on_invoke_tool, "__agents_bind_function_tool__", None)
443+
if callable(bind_to_function_tool):
444+
self.on_invoke_tool = bind_to_function_tool(self)
445+
404446
@property
405447
def qualified_name(self) -> str:
406448
"""Return the public qualified name used to identify this function tool."""
@@ -1860,9 +1902,15 @@ def _create_function_tool(
18601902
the_func: ToolFunction[...],
18611903
*,
18621904
method_tool_instance: Any | None = None,
1905+
treat_as_instance_method: bool | None = None,
1906+
enable_method_binding: bool = True,
18631907
) -> FunctionTool:
18641908
is_sync_function_tool = not inspect.iscoroutinefunction(the_func)
1865-
is_instance_method_tool = _is_instance_method_tool(the_func)
1909+
is_instance_method_tool = (
1910+
_is_instance_method_tool(the_func)
1911+
if treat_as_instance_method is None
1912+
else treat_as_instance_method
1913+
)
18661914
schema = function_schema(
18671915
func=the_func,
18681916
name_override=name_override,
@@ -1937,10 +1985,17 @@ async def _on_invoke_tool_impl(ctx: ToolContext[Any], input: str) -> Any:
19371985
defer_loading=defer_loading,
19381986
sync_invoker=is_sync_function_tool,
19391987
)
1940-
if is_instance_method_tool and method_tool_instance is None:
1988+
if enable_method_binding and is_instance_method_tool and method_tool_instance is None:
19411989
function_tool._method_tool_factory = lambda instance: _create_function_tool(
19421990
the_func,
19431991
method_tool_instance=instance,
1992+
treat_as_instance_method=True,
1993+
enable_method_binding=False,
1994+
)
1995+
function_tool._staticmethod_tool_factory = lambda: _create_function_tool(
1996+
the_func,
1997+
treat_as_instance_method=False,
1998+
enable_method_binding=False,
19441999
)
19452000
return function_tool
19462001

tests/test_function_tool.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,51 @@ def lookup(self: str, account_id: str) -> str:
226226
assert result == "acct:123"
227227

228228

229+
@pytest.mark.asyncio
230+
async def test_staticmethod_function_tool_keeps_first_parameter():
231+
class AccountTools:
232+
@staticmethod
233+
@function_tool
234+
def lookup(account_id: str) -> str:
235+
"""Look up an account."""
236+
return f"acct:{account_id}"
237+
238+
tool = AccountTools.lookup
239+
240+
assert isinstance(tool, FunctionTool)
241+
assert "account_id" in tool.params_json_schema["properties"]
242+
243+
result = await tool.on_invoke_tool(
244+
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=""),
245+
'{"account_id": "123"}',
246+
)
247+
248+
assert result == "acct:123"
249+
250+
251+
@pytest.mark.asyncio
252+
async def test_staticmethod_function_tool_allows_self_named_parameter():
253+
class AccountTools:
254+
@staticmethod
255+
@function_tool
256+
def lookup(self: str, account_id: str) -> str:
257+
"""Look up an account."""
258+
return f"{self}:{account_id}"
259+
260+
tool = AccountTools.lookup
261+
262+
assert isinstance(tool, FunctionTool)
263+
assert "self" in tool.params_json_schema["properties"]
264+
assert "account_id" in tool.params_json_schema["properties"]
265+
266+
result = await tool.on_invoke_tool(
267+
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=""),
268+
'{"self": "acct", "account_id": "123"}',
269+
)
270+
271+
assert result == "acct:123"
272+
273+
229274
@pytest.mark.asyncio
230275
async def test_instance_method_function_tool_supports_context_after_self():
231276
class AccountTools:

0 commit comments

Comments
 (0)