From 6f2d7b850a4db2dae9b06330a4593b509073efa5 Mon Sep 17 00:00:00 2001 From: OhYee Date: Tue, 6 Jan 2026 11:34:12 +0800 Subject: [PATCH 1/2] 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 --- agentrun/integration/utils/tool.py | 17 +++ .../unittests/integration/test_integration.py | 106 ++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/agentrun/integration/utils/tool.py b/agentrun/integration/utils/tool.py index 8b566ed..bde72a5 100644 --- a/agentrun/integration/utils/tool.py +++ b/agentrun/integration/utils/tool.py @@ -592,6 +592,23 @@ def to_pydanticai(self) -> Any: return func + def __get__(self, obj: Any, objtype: Any = None) -> "Tool": + """实现描述符协议,使 Tool 在类属性访问时自动绑定到实例 + + 这允许在工具方法内部调用其他 @tool 装饰的方法时正常工作。 + 例如:goto 方法调用 self.browser_navigate() 时,会自动获取绑定版本。 + """ + if obj is None: + # 通过类访问(如 BrowserToolSet.browser_navigate),返回未绑定的 Tool + return self + + # 通过实例访问,返回绑定到该实例的 Tool + # 使用实例的 __dict__ 作为缓存,避免每次访问都创建新的 Tool 对象 + cache_key = f"_bound_tool_{id(self)}" + if cache_key not in obj.__dict__: + obj.__dict__[cache_key] = self.bind(obj) + return obj.__dict__[cache_key] + def bind(self, instance: Any) -> "Tool": """绑定工具到实例,便于在类中定义工具方法""" diff --git a/tests/unittests/integration/test_integration.py b/tests/unittests/integration/test_integration.py index 6156b93..ef3cf83 100644 --- a/tests/unittests/integration/test_integration.py +++ b/tests/unittests/integration/test_integration.py @@ -314,6 +314,112 @@ def _assert_tools(self, tools_payload): ) +class TestToolDescriptorProtocol: + """测试 Tool 类的描述符协议实现 + + 确保工具方法内部调用其他 @tool 装饰的方法时能正常工作。 + 这是修复 BrowserToolSet.goto() 调用 browser_navigate() 时缺少 self 参数问题的测试。 + """ + + def test_tool_internal_call_works(self): + """测试工具内部调用其他工具时能正常工作""" + from agentrun.integration.utils.tool import CommonToolSet, tool + + class TestToolSet(CommonToolSet): + + def __init__(self): + self.call_log: List[str] = [] + super().__init__() + + @tool(name="main_tool", description="主工具,会调用子工具") + def main_tool(self, value: str) -> str: + """主工具,内部调用 sub_tool""" + self.call_log.append(f"main_tool({value})") + # 这里调用另一个 @tool 装饰的方法 + # 修复前会报错:TypeError: ... missing 1 required positional argument: 'self' + result = self.sub_tool(value=f"from_main:{value}") + return f"main_result:{result}" + + @tool(name="sub_tool", description="子工具") + def sub_tool(self, value: str) -> str: + """子工具""" + self.call_log.append(f"sub_tool({value})") + return f"sub_result:{value}" + + ts = TestToolSet() + + # 直接调用 main_tool,它内部会调用 sub_tool + result = ts.main_tool(value="test_input") + + # 验证两个工具都被正确调用 + assert ts.call_log == [ + "main_tool(test_input)", + "sub_tool(from_main:test_input)", + ] + assert result == "main_result:sub_result:from_main:test_input" + + def test_tool_descriptor_returns_bound_tool(self): + """测试 Tool.__get__ 返回绑定到实例的 Tool""" + from agentrun.integration.utils.tool import CommonToolSet, Tool, tool + + class TestToolSet(CommonToolSet): + + def __init__(self): + super().__init__() + + @tool(name="my_tool", description="测试工具") + def my_tool(self, x: int) -> int: + return x * 2 + + ts = TestToolSet() + + # 通过实例访问应该返回绑定的 Tool + bound_tool = ts.my_tool + assert isinstance(bound_tool, Tool) + + # 绑定的 Tool 应该可以直接调用,不需要传入 self + result = bound_tool(x=5) + assert result == 10 + + def test_tool_descriptor_class_access(self): + """测试通过类访问 Tool 时返回未绑定的 Tool""" + from agentrun.integration.utils.tool import CommonToolSet, Tool, tool + + class TestToolSet(CommonToolSet): + + @tool(name="class_tool", description="类工具") + def class_tool(self, x: int) -> int: + return x * 2 + + # 通过类访问应该返回未绑定的 Tool + unbound_tool = TestToolSet.class_tool + assert isinstance(unbound_tool, Tool) + + # 未绑定的 Tool 调用时需要手动传入实例 + instance = TestToolSet() + # 通过实例访问会自动绑定 + bound_tool = instance.class_tool + assert bound_tool(x=3) == 6 + + def test_tool_descriptor_caching(self): + """测试 Tool.__get__ 的缓存机制""" + from agentrun.integration.utils.tool import CommonToolSet, tool + + class TestToolSet(CommonToolSet): + + @tool(name="cached_tool", description="缓存测试工具") + def cached_tool(self) -> str: + return "cached" + + ts = TestToolSet() + + # 多次访问应该返回同一个绑定的 Tool 对象(缓存) + tool1 = ts.cached_tool + tool2 = ts.cached_tool + + assert tool1 is tool2 # 应该是同一个对象 + + class TestIntegration: def get_mocked_toolset(self, timezone="UTC"): From 14ae7e4ce544fe86950e8f5662a0401f5b69824a Mon Sep 17 00:00:00 2001 From: OhYee Date: Tue, 6 Jan 2026 11:35:39 +0800 Subject: [PATCH 2/2] fix: enhance exception handling and improve example implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agentrun/model/model.py | 1 + agentrun/utils/exception.py | 8 +++++--- examples/agent_runtime.py | 15 ++++++++++----- examples/model.py | 18 +++++++++++++++--- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/agentrun/model/model.py b/agentrun/model/model.py index 73a5e2d..cc6157d 100644 --- a/agentrun/model/model.py +++ b/agentrun/model/model.py @@ -131,6 +131,7 @@ class ProxyConfigTokenRateLimiter(BaseModel): class ProxyConfigAIGuardrailConfig(BaseModel): """AI 防护配置""" + check_request: Optional[bool] = None check_response: Optional[bool] = None diff --git a/agentrun/utils/exception.py b/agentrun/utils/exception.py index acf0219..c98d01d 100644 --- a/agentrun/utils/exception.py +++ b/agentrun/utils/exception.py @@ -78,9 +78,11 @@ def to_resource_error( "does not exist" in self.message or "not found" in self.message ): return ResourceNotExistError(resource_type, resource_id) - elif (self.status_code == 400 or self.status_code == 500) and ( - "already exists" in self.message - ): + elif ( + self.status_code == 400 + or self.status_code == 409 + or self.status_code == 500 + ) and ("already exists" in self.message): # TODO: ModelProxy already exists returns 500 return ResourceAlreadyExistError(resource_type, resource_id) else: diff --git a/examples/agent_runtime.py b/examples/agent_runtime.py index 301ac77..c36a15e 100644 --- a/examples/agent_runtime.py +++ b/examples/agent_runtime.py @@ -77,11 +77,16 @@ def create_or_get_agentruntime(): ), ) ) - except ResourceAlreadyExistError: - logger.info("已存在,获取已有资源") - - ar = client.list( - AgentRuntimeListInput(agent_runtime_name=agent_runtime_name) + except ResourceAlreadyExistError as e: + logger.info("已存在,获取已有资源", e) + + ar = list( + filter( + lambda a: a.agent_runtime_name == agent_runtime_name, + client.list( + AgentRuntimeListInput(agent_runtime_name=agent_runtime_name) + ), + ) )[0] ar.wait_until_ready_or_failed() diff --git a/examples/model.py b/examples/model.py index cee914f..656cdf4 100644 --- a/examples/model.py +++ b/examples/model.py @@ -1,3 +1,4 @@ +from logging import config import os import re import time @@ -135,21 +136,25 @@ def create_or_get_model_proxy(): """ logger.info("创建或获取已有的资源") + from agentrun.utils.config import Config + + cfg = Config() + try: cred = client.create( ModelProxyCreateInput( model_proxy_name=model_proxy_name, description="测试模型治理", model_type=model.ModelType.LLM, + execution_role_arn=f"acs:ram::{cfg.get_account_id()}:role/aliyunagentrundefaultrole", proxy_config=model.ProxyConfig( endpoints=[ model.ProxyConfigEndpoint( model_names=[model_name], - model_service_name="test-model-service", + model_service_name=model_service_name, ) for model_name in model_names ], - policies={}, ), ) ) @@ -172,9 +177,16 @@ def update_model_proxy(mp: ModelProxy): """ logger.info("更新描述为当前时间") + from agentrun.utils.config import Config + + cfg = Config() + # 也可以使用 client.update mp.update( - ModelProxyUpdateInput(description=f"当前时间戳:{time.time()}"), + ModelProxyUpdateInput( + execution_role_arn=f"acs:ram::{cfg.get_account_id()}:role/aliyunagentrundefaultrole", + description=f"当前时间戳:{time.time()}", + ), ) mp.wait_until_ready_or_failed() if mp.status != Status.READY: