Skip to content

Commit 9efc9f9

Browse files
authored
Fix tool descriptor protocol (#26)
* 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]> * fix: enhance exception handling and improve example implementations Updated HTTPError exception handling to include status code 409 for resource already exists scenarios. Improved example code with better resource filtering and added execution role ARN configuration for model proxy operations. The changes enhance error handling robustness and provide more reliable resource management in example implementations. 更新了 HTTPError 异常处理以包含状态码 409 来处理资源已存在的情况。 改进了示例代码中的资源过滤功能,并为模型代理操作添加了执行角色 ARN 配置。 这些更改增强了错误处理的健壮性,并在示例实现中提供了更可靠的 资源管理。 Change-Id: I5245d48ababac407f46b59c13bbea0237f071139 Signed-off-by: OhYee <[email protected]> --------- Signed-off-by: OhYee <[email protected]>
1 parent 613723b commit 9efc9f9

File tree

5 files changed

+153
-11
lines changed

5 files changed

+153
-11
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

agentrun/utils/exception.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,11 @@ def to_resource_error(
7878
"does not exist" in self.message or "not found" in self.message
7979
):
8080
return ResourceNotExistError(resource_type, resource_id)
81-
elif (self.status_code == 400 or self.status_code == 500) and (
82-
"already exists" in self.message
83-
):
81+
elif (
82+
self.status_code == 400
83+
or self.status_code == 409
84+
or self.status_code == 500
85+
) and ("already exists" in self.message):
8486
# TODO: ModelProxy already exists returns 500
8587
return ResourceAlreadyExistError(resource_type, resource_id)
8688
else:

examples/agent_runtime.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,16 @@ def create_or_get_agentruntime():
7777
),
7878
)
7979
)
80-
except ResourceAlreadyExistError:
81-
logger.info("已存在,获取已有资源")
82-
83-
ar = client.list(
84-
AgentRuntimeListInput(agent_runtime_name=agent_runtime_name)
80+
except ResourceAlreadyExistError as e:
81+
logger.info("已存在,获取已有资源", e)
82+
83+
ar = list(
84+
filter(
85+
lambda a: a.agent_runtime_name == agent_runtime_name,
86+
client.list(
87+
AgentRuntimeListInput(agent_runtime_name=agent_runtime_name)
88+
),
89+
)
8590
)[0]
8691

8792
ar.wait_until_ready_or_failed()

examples/model.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from logging import config
12
import os
23
import re
34
import time
@@ -135,21 +136,25 @@ def create_or_get_model_proxy():
135136
"""
136137
logger.info("创建或获取已有的资源")
137138

139+
from agentrun.utils.config import Config
140+
141+
cfg = Config()
142+
138143
try:
139144
cred = client.create(
140145
ModelProxyCreateInput(
141146
model_proxy_name=model_proxy_name,
142147
description="测试模型治理",
143148
model_type=model.ModelType.LLM,
149+
execution_role_arn=f"acs:ram::{cfg.get_account_id()}:role/aliyunagentrundefaultrole",
144150
proxy_config=model.ProxyConfig(
145151
endpoints=[
146152
model.ProxyConfigEndpoint(
147153
model_names=[model_name],
148-
model_service_name="test-model-service",
154+
model_service_name=model_service_name,
149155
)
150156
for model_name in model_names
151157
],
152-
policies={},
153158
),
154159
)
155160
)
@@ -172,9 +177,16 @@ def update_model_proxy(mp: ModelProxy):
172177
"""
173178
logger.info("更新描述为当前时间")
174179

180+
from agentrun.utils.config import Config
181+
182+
cfg = Config()
183+
175184
# 也可以使用 client.update
176185
mp.update(
177-
ModelProxyUpdateInput(description=f"当前时间戳:{time.time()}"),
186+
ModelProxyUpdateInput(
187+
execution_role_arn=f"acs:ram::{cfg.get_account_id()}:role/aliyunagentrundefaultrole",
188+
description=f"当前时间戳:{time.time()}",
189+
),
178190
)
179191
mp.wait_until_ready_or_failed()
180192
if mp.status != Status.READY:

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)