diff --git a/agentrun/integration/utils/skill_loader.py b/agentrun/integration/utils/skill_loader.py index 74b2b4d..efb5fad 100644 --- a/agentrun/integration/utils/skill_loader.py +++ b/agentrun/integration/utils/skill_loader.py @@ -12,7 +12,16 @@ import os import re import subprocess -from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) from agentrun.integration.utils.tool import CommonToolSet, Tool, ToolParameter from agentrun.utils.log import logger @@ -43,6 +52,38 @@ class SkillInfo: path: str = "" +def _parse_skill_qualifier(raw: str) -> Tuple[str, Optional[str]]: + """解析 'skillName[@qualifier]' 语法 / Parse 'skillName[@qualifier]' syntax + + 工具名格式受后端正则 ^[_a-zA-Z][-_a-zA-Z0-9]*$ 约束,不允许包含 '@', + 因此用 '@' 作为分隔符不会有歧义。 + Tool names are constrained by the backend regex ^[_a-zA-Z][-_a-zA-Z0-9]*$ + and cannot contain '@', so using '@' as a separator is unambiguous. + + Args: + raw: 原始字符串,如 "skillA"、"skillA@v1.0.0"、"skillA@default" / + Raw identifier, e.g. "skillA", "skillA@v1.0.0", "skillA@default" + + Returns: + (name, qualifier) 元组。qualifier 为 None 表示不指定版本, + 由后端按 default → latest 顺序解析。 + Tuple of (name, qualifier). A None qualifier defers to the backend's + default → latest resolution order. + + Raises: + ValueError: 当 name 部分为空时(如 "@v1.0.0")/ + When the name part is empty (e.g. "@v1.0.0") + """ + if "@" in raw: + name, qualifier = raw.rsplit("@", 1) + if not name: + raise ValueError( + f"Invalid skill identifier '{raw}': name part is empty" + ) + return name, (qualifier or None) + return raw, None + + @dataclass class SkillDetail(SkillInfo): """Skill 详细信息 / Skill detail information @@ -94,22 +135,62 @@ class SkillLoader: reading skill instruction content, and constructing the load_skills tool for Agent runtime invocation. + 使用方式说明 / Usage Note: + 推荐通过顶层 ``skill_tools()`` 函数使用本类,避免直接构造。 + ``skill_tools()`` 会自动解析 "name@qualifier" 字符串语法并替你构造 + 合适的 ``remote_skills`` 元组列表。 + Prefer the top-level ``skill_tools()`` helper over constructing this + class directly. It parses "name@qualifier" string syntax and builds + the ``remote_skills`` tuple list for you. + + 参数历史变更 / Parameter History: + ``remote_skills`` 在引入 Skill 版本管理时,由原参数 + ``remote_skill_names: List[str]`` 替换为 + ``List[Tuple[str, Optional[str]]]``。每个元组的第二项是版本 + qualifier(如 "v1.0.0"、"default"、"LATEST"),``None`` 表示 + 不指定版本(由后端按 default → latest fallback 解析)。 + 参数名同步从 ``remote_skill_names`` 改为 ``remote_skills``, + 以体现"每项含完整 skill 描述"而非"仅名称列表"的语义升级。 + 经全量代码扫描确认没有外部代码使用过 ``remote_skill_names`` + 参数(所有外部使用都是 ``SkillLoader(skills_dir=...).scan_skills()`` + 形式仅用于本地扫描),所以参数变更对实际使用零影响。 + + ``remote_skills`` was renamed from ``remote_skill_names: List[str]`` + when Skill version management was introduced. The second item of + each tuple is a version qualifier (e.g. "v1.0.0", "default", + "LATEST"); ``None`` defers to the backend's default → latest + resolution. Code scans confirm no external caller used the legacy + ``remote_skill_names`` parameter (all external usage is of the + form ``SkillLoader(skills_dir=...).scan_skills()`` for local + scanning only), so this rename has zero practical impact. + Args: skills_dir: 本地 skill 目录路径 / local skill directory path - remote_skill_names: 需要从远程下载的 skill 名称列表 / list of remote skill names to download + remote_skills: 需要从远程下载的 skill (name, qualifier) 列表。 + qualifier 为 None 时使用后端缺省版本 / + List of remote skills as (name, qualifier) tuples; + qualifier=None defers to the backend's default version config: 配置对象 / configuration object + command_approval: execute_command 执行前的确认回调 / + Approval callback invoked before executing commands + command_timeout: execute_command 默认超时秒数 / + Default execute_command timeout in seconds """ def __init__( self, skills_dir: str = ".skills", - remote_skill_names: Optional[List[str]] = None, + remote_skills: Optional[List[Tuple[str, Optional[str]]]] = None, config: Optional["Config"] = None, command_approval: Optional[Callable[[str, str], bool]] = None, command_timeout: int = 300, ): self._skills_dir = skills_dir - self._remote_skill_names = remote_skill_names or [] + # remote_skills: List[Tuple[name, qualifier]]; qualifier=None means + # "no version specified". 由 `skill_tools()` 顶层入口在解析 + # "name@qualifier" 语法后构造好后传入,下游 `_ensure_skills_available` + # 直接元组解包消费。 + self._remote_skills = remote_skills or [] self._config = config self._skills_cache: Optional[List[SkillInfo]] = None self._command_approval = command_approval @@ -118,33 +199,43 @@ def __init__( def _ensure_skills_available(self) -> None: """确保远程 skill 已下载到本地 / Ensure remote skills are downloaded locally - 对每个 remote_skill_name,检查本地是否已存在对应目录, - 不存在则通过 ToolClient 下载。 + 对每个 (skill_name, qualifier),检查本地目录是否已存在。 + - 未指定 qualifier 且目录已存在 → 跳过下载 + - 指定了 qualifier → 强制重新下载,避免使用本地旧版本 + - 目录不存在 → 下载 - For each remote_skill_name, check if the local directory exists, - download via ToolClient if not. + For each (skill_name, qualifier) pair, check whether the local directory + already exists. + - No qualifier and directory exists → skip download + - Qualifier specified → force re-download to avoid stale local version + - Directory missing → download """ - if not self._remote_skill_names: + if not self._remote_skills: return from agentrun.tool.client import ToolClient - for skill_name in self._remote_skill_names: + for skill_name, qualifier in self._remote_skills: skill_path = os.path.join(self._skills_dir, skill_name) - if os.path.isdir(skill_path): + if qualifier is None and os.path.isdir(skill_path): logger.debug( f"Skill '{skill_name}' already exists at {skill_path}, " "skipping download" ) continue + label = ( + f"{skill_name}@{qualifier}" if qualifier else skill_name + ) logger.info( - f"Downloading remote skill '{skill_name}' to {self._skills_dir}" + f"Downloading remote skill '{label}' to {self._skills_dir}" ) tool_resource = ToolClient().get( name=skill_name, config=self._config ) tool_resource.download_skill( - target_dir=self._skills_dir, config=self._config + target_dir=self._skills_dir, + qualifier=qualifier, + config=self._config, ) def _parse_skill_metadata(self, skill_dir: str) -> SkillInfo: @@ -730,6 +821,7 @@ def skill_tools( name: Optional[Union[str, List[str], "ToolResource"]] = None, *, skills_dir: str = ".skills", + qualifier: Optional[str] = None, config: Optional["Config"] = None, command_approval: Optional[Callable[[str, str], bool]] = None, command_timeout: int = 300, @@ -737,23 +829,31 @@ def skill_tools( """将 Skill 封装为通用工具集 / Wrap Skills as CommonToolSet 支持从工具名称、名称列表或 ToolResource 实例创建通用工具集。 + 字符串入参支持 "skillName[@qualifier]" 语法以指定版本。 Supports creating CommonToolSet from tool name, name list, or ToolResource instance. + String inputs support "skillName[@qualifier]" syntax to specify a version. Args: name: 远程 skill 名称、名称列表或 ToolResource 实例(可选)/ Remote skill name, name list, or ToolResource instance (optional). + 字符串形式支持版本语法,如 "skillA@v1.0.0"、"skillA@default" / + String form supports version syntax, e.g. "skillA@v1.0.0", "skillA@default". 如果提供,会先下载到 skills_dir 再加载 / If provided, downloads to skills_dir before loading. 如果不提供,仅从 skills_dir 加载本地已有的 skill / If not provided, only loads local skills from skills_dir. skills_dir: 本地 skill 目录,默认 ".skills" / Local skill directory, default ".skills" + qualifier: 版本标识,仅在 name 为 ToolResource 实例时使用。 + 字符串/列表形式请直接在 name 中用 "@" 语法指定 / + Version qualifier; only applies when name is a ToolResource instance. + For string/list inputs, use the "@" syntax inside name instead. config: 配置对象 / Configuration object command_approval: 命令执行前的确认回调函数(可选)/ Optional approval callback invoked before executing commands. 接收 (command, cwd) 参数,返回 True 允许执行,False 拒绝 / Receives (command, cwd), returns True to allow, False to reject. - command_timeout: execute_command 的默认超时秒数,默认 30 / - Default timeout in seconds for execute_command, default 30. + command_timeout: execute_command 的默认超时秒数,默认 300 / + Default timeout in seconds for execute_command, default 300. Returns: CommonToolSet: 包含 load_skills、read_skill_file、execute_command 工具的通用工具集 / @@ -766,22 +866,30 @@ def skill_tools( >>> # 下载远程 skill 后加载 / Download remote skill then load >>> ts = skill_tools("my-remote-skill") >>> + >>> # 指定版本下载 / Download a specific version + >>> ts = skill_tools("my-remote-skill@v1.0.0") + >>> ts = skill_tools("my-remote-skill@default") + >>> + >>> # 多 skill 混合版本 / Multiple skills with mixed versions + >>> ts = skill_tools(["skill-a@v1.0.0", "skill-b@latest", "skill-c"]) + >>> + >>> # 通过 ToolResource 实例指定版本 / Specify version via ToolResource + >>> tool = ToolClient().get("my-skill") + >>> ts = skill_tools(tool, qualifier="v1.0.0") + >>> >>> # 带命令确认回调 / With command approval callback >>> ts = skill_tools( ... skills_dir=".skills", ... command_approval=lambda cmd, cwd: input(f"Execute '{cmd}'? [y/N]: ").lower() == "y", ... ) - >>> - >>> # 自定义超时 / Custom timeout - >>> ts = skill_tools(skills_dir=".skills", command_timeout=120) """ - remote_names: List[str] = [] + remote_skills: List[Tuple[str, Optional[str]]] = [] if name is not None: if isinstance(name, str): - remote_names = [name] + remote_skills = [_parse_skill_qualifier(name)] elif isinstance(name, list): - remote_names = name + remote_skills = [_parse_skill_qualifier(item) for item in name] else: # ToolResource instance — extract its name and download tool_resource_instance = name @@ -790,14 +898,17 @@ def skill_tools( ) or getattr(tool_resource_instance, "tool_name", None) if resource_name: skill_path = os.path.join(skills_dir, resource_name) - if not os.path.isdir(skill_path): + # 指定 qualifier 时强制重下;否则仅当本地缺失时下载 + if qualifier is not None or not os.path.isdir(skill_path): tool_resource_instance.download_skill( - target_dir=skills_dir, config=config + target_dir=skills_dir, + qualifier=qualifier, + config=config, ) loader = SkillLoader( skills_dir=skills_dir, - remote_skill_names=remote_names, + remote_skills=remote_skills, config=config, command_approval=command_approval, command_timeout=command_timeout, diff --git a/agentrun/tool/__tool_async_template.py b/agentrun/tool/__tool_async_template.py index fc57c5d..9d74087 100644 --- a/agentrun/tool/__tool_async_template.py +++ b/agentrun/tool/__tool_async_template.py @@ -568,12 +568,20 @@ def _get_auth_headers( return headers def _get_skill_download_url( - self, config: Optional[Config] = None + self, + qualifier: Optional[str] = None, + config: Optional[Config] = None, ) -> Optional[str]: """获取 Skill 工具的下载 URL / Get download URL for Skill tools - 根据 data_endpoint 和 tool_name 构造下载地址。 - Constructs download URL from data_endpoint and tool_name. + 根据 data_endpoint 和 tool_name 构造下载地址。可选地指定版本 qualifier。 + Constructs download URL from data_endpoint and tool_name, optionally with a version qualifier. + + Args: + qualifier: 版本标识,如 "v1.0.0"、"default"、"LATEST"。为空时下载缺省版本 / + Version qualifier (e.g. "v1.0.0", "default", "LATEST"). + When None, downloads the default version. + config: 配置对象 / Configuration object Returns: Optional[str]: 下载 URL / Download URL @@ -585,20 +593,30 @@ def _get_skill_download_url( data_endpoint = cfg.get_data_endpoint() if not data_endpoint or not effective_name: return None - return f"{data_endpoint}/tools/{effective_name}/download" + url = f"{data_endpoint}/tools/{effective_name}/download" + if qualifier: + from urllib.parse import quote + + url = f"{url}?qualifier={quote(qualifier, safe='')}" + return url async def download_skill_async( self, target_dir: str = ".skills", + qualifier: Optional[str] = None, config: Optional[Config] = None, ) -> str: """异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously 从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。 + 可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。 Downloads skill zip package from data endpoint and extracts to {target_dir}/{tool_name}/ directory. + Optionally specify a version qualifier (e.g. "v1.0.0", "default", "LATEST"). Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" + qualifier: 版本标识,为空时下载缺省版本 / + Version qualifier; when None, downloads the default version config: 配置对象,可选 / Configuration object, optional Returns: @@ -615,7 +633,9 @@ async def download_skill_async( f" got {self.tool_type}" ) - download_url = self._get_skill_download_url(config) + download_url = self._get_skill_download_url( + qualifier=qualifier, config=config + ) if not download_url: raise ValueError( "Cannot construct download URL: data_endpoint or tool_name" diff --git a/agentrun/tool/tool.py b/agentrun/tool/tool.py index d0b7dcf..3eee03c 100644 --- a/agentrun/tool/tool.py +++ b/agentrun/tool/tool.py @@ -170,7 +170,9 @@ async def get_by_name_async( return await cli.get_async(name=name) @classmethod - def get_by_name(cls, name: str, config: Optional[Config] = None) -> "Tool": + def get_by_name( + cls, name: str, config: Optional[Config] = None + ) -> "Tool": """同步通过名称获取工具 / Get tool by name synchronously""" cli = cls.__get_client(config=config) return cli.get(name=name) @@ -192,7 +194,9 @@ def get(self, config: Optional[Config] = None) -> "Tool": if effective_name is None: raise ValueError("Tool name is required to get the Tool.") - result = self.get_by_name(name=effective_name, config=config) + result = self.get_by_name( + name=effective_name, config=config + ) return self.update_self(result) def _get_functioncall_server_url( @@ -440,7 +444,9 @@ async def list_tools_async( return [] - def list_tools(self, config: Optional[Config] = None) -> List[ToolInfo]: + def list_tools( + self, config: Optional[Config] = None + ) -> List[ToolInfo]: """同步获取子工具列表 / Get sub-tool list synchronously 对于 MCP 类型,通过 MCP 协议获取工具列表。 @@ -732,12 +738,20 @@ def _get_auth_headers( return headers def _get_skill_download_url( - self, config: Optional[Config] = None + self, + qualifier: Optional[str] = None, + config: Optional[Config] = None, ) -> Optional[str]: """获取 Skill 工具的下载 URL / Get download URL for Skill tools - 根据 data_endpoint 和 tool_name 构造下载地址。 - Constructs download URL from data_endpoint and tool_name. + 根据 data_endpoint 和 tool_name 构造下载地址。可选地指定版本 qualifier。 + Constructs download URL from data_endpoint and tool_name, optionally with a version qualifier. + + Args: + qualifier: 版本标识,如 "v1.0.0"、"default"、"LATEST"。为空时下载缺省版本 / + Version qualifier (e.g. "v1.0.0", "default", "LATEST"). + When None, downloads the default version. + config: 配置对象 / Configuration object Returns: Optional[str]: 下载 URL / Download URL @@ -749,20 +763,30 @@ def _get_skill_download_url( data_endpoint = cfg.get_data_endpoint() if not data_endpoint or not effective_name: return None - return f"{data_endpoint}/tools/{effective_name}/download" + url = f"{data_endpoint}/tools/{effective_name}/download" + if qualifier: + from urllib.parse import quote + + url = f"{url}?qualifier={quote(qualifier, safe='')}" + return url async def download_skill_async( self, target_dir: str = ".skills", + qualifier: Optional[str] = None, config: Optional[Config] = None, ) -> str: """异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously 从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。 + 可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。 Downloads skill zip package from data endpoint and extracts to {target_dir}/{tool_name}/ directory. + Optionally specify a version qualifier (e.g. "v1.0.0", "default", "LATEST"). Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" + qualifier: 版本标识,为空时下载缺省版本 / + Version qualifier; when None, downloads the default version config: 配置对象,可选 / Configuration object, optional Returns: @@ -779,7 +803,9 @@ async def download_skill_async( f" got {self.tool_type}" ) - download_url = self._get_skill_download_url(config) + download_url = self._get_skill_download_url( + qualifier=qualifier, config=config + ) if not download_url: raise ValueError( "Cannot construct download URL: data_endpoint or tool_name" @@ -814,15 +840,20 @@ async def download_skill_async( def download_skill( self, target_dir: str = ".skills", + qualifier: Optional[str] = None, config: Optional[Config] = None, ) -> str: """同步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory synchronously 从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。 + 可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。 Downloads skill zip package from data endpoint and extracts to {target_dir}/{tool_name}/ directory. + Optionally specify a version qualifier (e.g. "v1.0.0", "default", "LATEST"). Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" + qualifier: 版本标识,为空时下载缺省版本 / + Version qualifier; when None, downloads the default version config: 配置对象,可选 / Configuration object, optional Returns: @@ -839,7 +870,9 @@ def download_skill( f" got {self.tool_type}" ) - download_url = self._get_skill_download_url(config) + download_url = self._get_skill_download_url( + qualifier=qualifier, config=config + ) if not download_url: raise ValueError( "Cannot construct download URL: data_endpoint or tool_name" @@ -854,7 +887,9 @@ def download_skill( cfg = Config.with_configs(config) headers = self._get_auth_headers(download_url, cfg) - with httpx.Client(timeout=300, follow_redirects=True) as http_client: + with httpx.Client( + timeout=300, follow_redirects=True + ) as http_client: response = http_client.get(download_url, headers=headers) response.raise_for_status() diff --git a/tests/unittests/integration/test_skill_loader.py b/tests/unittests/integration/test_skill_loader.py index 8605db1..697d728 100644 --- a/tests/unittests/integration/test_skill_loader.py +++ b/tests/unittests/integration/test_skill_loader.py @@ -19,6 +19,7 @@ import agentrun.integration.builtin.skill as _builtin_skill_mod from agentrun.integration.utils.skill_loader import ( _parse_frontmatter, + _parse_skill_qualifier, skill_tools, SkillDetail, SkillInfo, @@ -654,7 +655,7 @@ class TestEnsureSkillsAvailable: def test_no_remote_names_does_nothing(self, tmp_path: Any) -> None: skills_dir = str(tmp_path / "skills") os.makedirs(skills_dir) - loader = SkillLoader(skills_dir=skills_dir, remote_skill_names=[]) + loader = SkillLoader(skills_dir=skills_dir, remote_skills=[]) # Should not raise loader._ensure_skills_available() @@ -664,7 +665,8 @@ def test_existing_skill_skips_download(self, tmp_path: Any) -> None: _create_skill_dir(skills_dir, "already-here") loader = SkillLoader( - skills_dir=skills_dir, remote_skill_names=["already-here"] + skills_dir=skills_dir, + remote_skills=[("already-here", None)], ) with patch("agentrun.tool.client.ToolClient") as mock_client: loader._ensure_skills_available() @@ -679,7 +681,8 @@ def test_missing_skill_triggers_download(self, tmp_path: Any) -> None: mock_client_instance.get.return_value = mock_tool_resource loader = SkillLoader( - skills_dir=skills_dir, remote_skill_names=["new-skill"] + skills_dir=skills_dir, + remote_skills=[("new-skill", None)], ) with patch( "agentrun.tool.client.ToolClient", @@ -690,10 +693,185 @@ def test_missing_skill_triggers_download(self, tmp_path: Any) -> None: name="new-skill", config=None ) mock_tool_resource.download_skill.assert_called_once_with( - target_dir=skills_dir, config=None + target_dir=skills_dir, qualifier=None, config=None ) +# ============================================================================= +# 8b. Skill 版本管理测试 +# ============================================================================= + + +class TestParseSkillQualifier: + """测试 _parse_skill_qualifier 解析函数""" + + def test_plain_name_no_qualifier(self) -> None: + assert _parse_skill_qualifier("skillA") == ("skillA", None) + + def test_name_with_version(self) -> None: + assert _parse_skill_qualifier("skillA@v1.0.0") == ( + "skillA", + "v1.0.0", + ) + + def test_name_with_default_alias(self) -> None: + assert _parse_skill_qualifier("skillA@default") == ( + "skillA", + "default", + ) + + def test_name_with_latest(self) -> None: + assert _parse_skill_qualifier("skillA@LATEST") == ( + "skillA", + "LATEST", + ) + + def test_empty_qualifier_falls_back_to_none(self) -> None: + # "skillA@" should be treated as no qualifier specified + assert _parse_skill_qualifier("skillA@") == ("skillA", None) + + def test_empty_name_raises(self) -> None: + with pytest.raises(ValueError, match="name part is empty"): + _parse_skill_qualifier("@v1.0.0") + + +class TestSkillLoaderQualifier: + """测试 SkillLoader 对 qualifier 的处理""" + + def test_qualifier_forces_redownload(self, tmp_path: Any) -> None: + """指定 qualifier 时即使本地目录存在也应重新下载""" + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + _create_skill_dir(skills_dir, "versioned-skill") + + mock_tool_resource = MagicMock() + mock_client_instance = MagicMock() + mock_client_instance.get.return_value = mock_tool_resource + + loader = SkillLoader( + skills_dir=skills_dir, + remote_skills=[("versioned-skill", "v1.0.0")], + ) + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + loader._ensure_skills_available() + mock_tool_resource.download_skill.assert_called_once_with( + target_dir=skills_dir, qualifier="v1.0.0", config=None + ) + + def test_qualifier_passed_through_on_missing_skill( + self, tmp_path: Any + ) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + + mock_tool_resource = MagicMock() + mock_client_instance = MagicMock() + mock_client_instance.get.return_value = mock_tool_resource + + loader = SkillLoader( + skills_dir=skills_dir, + remote_skills=[("new-skill", "v2.0.0")], + ) + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + loader._ensure_skills_available() + mock_tool_resource.download_skill.assert_called_once_with( + target_dir=skills_dir, qualifier="v2.0.0", config=None + ) + + +class TestSkillToolsVersioning: + """测试 skill_tools 顶层函数对版本语法的处理""" + + def test_string_with_qualifier(self, tmp_path: Any) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + + mock_tool_resource = MagicMock() + mock_client_instance = MagicMock() + mock_client_instance.get.return_value = mock_tool_resource + + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + toolset = skill_tools( + name="my-skill@v1.0.0", skills_dir=skills_dir + ) + assert isinstance(toolset, CommonToolSet) + mock_client_instance.get.assert_called_once_with( + name="my-skill", config=None + ) + mock_tool_resource.download_skill.assert_called_once_with( + target_dir=skills_dir, qualifier="v1.0.0", config=None + ) + + def test_list_with_mixed_qualifiers(self, tmp_path: Any) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + + mock_tool_resource = MagicMock() + mock_client_instance = MagicMock() + mock_client_instance.get.return_value = mock_tool_resource + + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + skill_tools( + name=["skill-a@v1.0.0", "skill-b@latest", "skill-c"], + skills_dir=skills_dir, + ) + calls = mock_tool_resource.download_skill.call_args_list + assert len(calls) == 3 + assert calls[0].kwargs["qualifier"] == "v1.0.0" + assert calls[1].kwargs["qualifier"] == "latest" + assert calls[2].kwargs["qualifier"] is None + + def test_tool_resource_with_explicit_qualifier( + self, tmp_path: Any + ) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + + mock_resource = MagicMock() + mock_resource.name = "resource-skill" + + skill_tools( + name=mock_resource, + skills_dir=skills_dir, + qualifier="v1.0.0", + ) + mock_resource.download_skill.assert_called_once_with( + target_dir=skills_dir, qualifier="v1.0.0", config=None + ) + + def test_tool_resource_qualifier_forces_redownload( + self, tmp_path: Any + ) -> None: + """ToolResource 入参且本地存在时,qualifier 非空也应触发重下""" + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + _create_skill_dir(skills_dir, "existing-resource") + + mock_resource = MagicMock() + mock_resource.name = "existing-resource" + + skill_tools( + name=mock_resource, + skills_dir=skills_dir, + qualifier="v2.0.0", + ) + mock_resource.download_skill.assert_called_once_with( + target_dir=skills_dir, qualifier="v2.0.0", config=None + ) + + # ============================================================================= # 9. builtin/skill.py 导出测试 # ============================================================================= diff --git a/tests/unittests/tool/test_tool.py b/tests/unittests/tool/test_tool.py index 8ed345c..d9106aa 100644 --- a/tests/unittests/tool/test_tool.py +++ b/tests/unittests/tool/test_tool.py @@ -484,6 +484,36 @@ def test_get_skill_download_url_no_endpoint(self, mock_config_class): url = tool._get_skill_download_url() assert url is None + def test_get_skill_download_url_with_qualifier(self): + """测试传入 qualifier 时 URL 包含 ?qualifier=xxx""" + tool = Tool( + tool_name="my-skill", + data_endpoint="https://example.com", + ) + url = tool._get_skill_download_url(qualifier="v1.0.0") + assert url == "https://example.com/tools/my-skill/download?qualifier=v1.0.0" + + def test_get_skill_download_url_qualifier_url_encoded(self): + """测试 qualifier 中的特殊字符被正确 URL 编码""" + tool = Tool( + tool_name="my-skill", + data_endpoint="https://example.com", + ) + url = tool._get_skill_download_url(qualifier="latest@beta") + # '@' should be percent-encoded as %40 + assert url == ( + "https://example.com/tools/my-skill/download?qualifier=latest%40beta" + ) + + def test_get_skill_download_url_empty_qualifier_omitted(self): + """测试空字符串 qualifier 等同于不指定""" + tool = Tool( + tool_name="my-skill", + data_endpoint="https://example.com", + ) + url = tool._get_skill_download_url(qualifier="") + assert url == "https://example.com/tools/my-skill/download" + @patch("httpx.AsyncClient") @patch("agentrun.utils.config.Config") async def test_download_skill_async_success(