Skip to content

Commit 6f2d7b8

Browse files
committed
fix(tool): add __get__ descriptor to Tool class for proper method binding
When a @tool decorated method calls another @tool method internally (e.g., BrowserToolSet.goto() calls self.browser_navigate()), the Tool object was not properly bound to the instance, causing 'missing self argument' TypeError. This fix implements Python's descriptor protocol by adding __get__ method to the Tool class, which automatically returns a bound Tool when accessed via instance attribute. Affected alias methods now work correctly: - CodeInterpreterToolSet: execute_code, list_directory - BrowserToolSet: goto, click, fill, html_content, evaluate Also adds comprehensive unit tests for the descriptor protocol. Change-Id: Id06c4781d6bf57f83f06e6786d2fa2b6ae345876 Co-developed-by: Cursor <[email protected]>
1 parent 2582fa6 commit 6f2d7b8

File tree

2 files changed

+123
-0
lines changed

2 files changed

+123
-0
lines changed

agentrun/integration/utils/tool.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,23 @@ def to_pydanticai(self) -> Any:
592592

593593
return func
594594

595+
def __get__(self, obj: Any, objtype: Any = None) -> "Tool":
596+
"""实现描述符协议,使 Tool 在类属性访问时自动绑定到实例
597+
598+
这允许在工具方法内部调用其他 @tool 装饰的方法时正常工作。
599+
例如:goto 方法调用 self.browser_navigate() 时,会自动获取绑定版本。
600+
"""
601+
if obj is None:
602+
# 通过类访问(如 BrowserToolSet.browser_navigate),返回未绑定的 Tool
603+
return self
604+
605+
# 通过实例访问,返回绑定到该实例的 Tool
606+
# 使用实例的 __dict__ 作为缓存,避免每次访问都创建新的 Tool 对象
607+
cache_key = f"_bound_tool_{id(self)}"
608+
if cache_key not in obj.__dict__:
609+
obj.__dict__[cache_key] = self.bind(obj)
610+
return obj.__dict__[cache_key]
611+
595612
def bind(self, instance: Any) -> "Tool":
596613
"""绑定工具到实例,便于在类中定义工具方法"""
597614

tests/unittests/integration/test_integration.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,112 @@ def _assert_tools(self, tools_payload):
314314
)
315315

316316

317+
class TestToolDescriptorProtocol:
318+
"""测试 Tool 类的描述符协议实现
319+
320+
确保工具方法内部调用其他 @tool 装饰的方法时能正常工作。
321+
这是修复 BrowserToolSet.goto() 调用 browser_navigate() 时缺少 self 参数问题的测试。
322+
"""
323+
324+
def test_tool_internal_call_works(self):
325+
"""测试工具内部调用其他工具时能正常工作"""
326+
from agentrun.integration.utils.tool import CommonToolSet, tool
327+
328+
class TestToolSet(CommonToolSet):
329+
330+
def __init__(self):
331+
self.call_log: List[str] = []
332+
super().__init__()
333+
334+
@tool(name="main_tool", description="主工具,会调用子工具")
335+
def main_tool(self, value: str) -> str:
336+
"""主工具,内部调用 sub_tool"""
337+
self.call_log.append(f"main_tool({value})")
338+
# 这里调用另一个 @tool 装饰的方法
339+
# 修复前会报错:TypeError: ... missing 1 required positional argument: 'self'
340+
result = self.sub_tool(value=f"from_main:{value}")
341+
return f"main_result:{result}"
342+
343+
@tool(name="sub_tool", description="子工具")
344+
def sub_tool(self, value: str) -> str:
345+
"""子工具"""
346+
self.call_log.append(f"sub_tool({value})")
347+
return f"sub_result:{value}"
348+
349+
ts = TestToolSet()
350+
351+
# 直接调用 main_tool,它内部会调用 sub_tool
352+
result = ts.main_tool(value="test_input")
353+
354+
# 验证两个工具都被正确调用
355+
assert ts.call_log == [
356+
"main_tool(test_input)",
357+
"sub_tool(from_main:test_input)",
358+
]
359+
assert result == "main_result:sub_result:from_main:test_input"
360+
361+
def test_tool_descriptor_returns_bound_tool(self):
362+
"""测试 Tool.__get__ 返回绑定到实例的 Tool"""
363+
from agentrun.integration.utils.tool import CommonToolSet, Tool, tool
364+
365+
class TestToolSet(CommonToolSet):
366+
367+
def __init__(self):
368+
super().__init__()
369+
370+
@tool(name="my_tool", description="测试工具")
371+
def my_tool(self, x: int) -> int:
372+
return x * 2
373+
374+
ts = TestToolSet()
375+
376+
# 通过实例访问应该返回绑定的 Tool
377+
bound_tool = ts.my_tool
378+
assert isinstance(bound_tool, Tool)
379+
380+
# 绑定的 Tool 应该可以直接调用,不需要传入 self
381+
result = bound_tool(x=5)
382+
assert result == 10
383+
384+
def test_tool_descriptor_class_access(self):
385+
"""测试通过类访问 Tool 时返回未绑定的 Tool"""
386+
from agentrun.integration.utils.tool import CommonToolSet, Tool, tool
387+
388+
class TestToolSet(CommonToolSet):
389+
390+
@tool(name="class_tool", description="类工具")
391+
def class_tool(self, x: int) -> int:
392+
return x * 2
393+
394+
# 通过类访问应该返回未绑定的 Tool
395+
unbound_tool = TestToolSet.class_tool
396+
assert isinstance(unbound_tool, Tool)
397+
398+
# 未绑定的 Tool 调用时需要手动传入实例
399+
instance = TestToolSet()
400+
# 通过实例访问会自动绑定
401+
bound_tool = instance.class_tool
402+
assert bound_tool(x=3) == 6
403+
404+
def test_tool_descriptor_caching(self):
405+
"""测试 Tool.__get__ 的缓存机制"""
406+
from agentrun.integration.utils.tool import CommonToolSet, tool
407+
408+
class TestToolSet(CommonToolSet):
409+
410+
@tool(name="cached_tool", description="缓存测试工具")
411+
def cached_tool(self) -> str:
412+
return "cached"
413+
414+
ts = TestToolSet()
415+
416+
# 多次访问应该返回同一个绑定的 Tool 对象(缓存)
417+
tool1 = ts.cached_tool
418+
tool2 = ts.cached_tool
419+
420+
assert tool1 is tool2 # 应该是同一个对象
421+
422+
317423
class TestIntegration:
318424

319425
def get_mocked_toolset(self, timezone="UTC"):

0 commit comments

Comments
 (0)