From e7e2f0484e3e3eb7fd0d900ad366689742b3e145 Mon Sep 17 00:00:00 2001 From: yuyushui66 <779910032@qq.com> Date: Tue, 30 Jun 2026 10:47:30 +0800 Subject: [PATCH 1/3] feat: support skill version qualifier in download Add `qualifier` parameter to `Tool.download_skill()` and `download_skill_async()` so callers can request a specific skill version (e.g. "v1.0.0", "default", "LATEST") from the data plane download endpoint. `skill_tools()` accepts the same version semantics via the "name@qualifier" string syntax (for str/list inputs) or the `qualifier=` keyword (for ToolResource inputs); each list element can specify its own version independently. Specifying a qualifier forces re-download to avoid serving a stale local copy. `SkillLoader` is refactored internally to carry (name, qualifier) tuples; the legacy `remote_skill_names` parameter is replaced by `remote_skills`. Code scans confirm no external caller used the legacy parameter, so this is backward compatible in practice. Signed-off-by: daoxun.yds --- agentrun/integration/utils/skill_loader.py | 159 ++++++++++++--- agentrun/tool/__tool_async_template.py | 30 ++- agentrun/tool/tool.py | 55 +++++- .../integration/test_skill_loader.py | 186 +++++++++++++++++- tests/unittests/tool/test_tool.py | 30 +++ 5 files changed, 417 insertions(+), 43 deletions(-) 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( From 1548b1d634495c653563d693b06dc865e552c939 Mon Sep 17 00:00:00 2001 From: yuyushui66 <779910032@qq.com> Date: Wed, 15 Jul 2026 15:38:14 +0800 Subject: [PATCH 2/3] feat: validate skill version qualifier and add per-skill version marker - Add _validate_skill_qualifier: client-side fail-fast for skill version qualifiers (LATEST case-insensitive or strict SemVer vX.Y.Z), rejecting retired values like "default" instead of round-tripping to the backend. Uses \Z anchor to match backend Go's $ (rejects trailing newline). - Add .skill_version marker so version switches (pinned<->LATEST, pinned version changes) trigger re-download while matching versions reuse the local copy; dotfile is hidden from skill scanning. - Make skill parsing and download per-entry tolerant: one invalid qualifier or failed download is logged and skipped without aborting the whole batch. - Update docstrings from "default" to "LATEST" wording and add tests. Co-Authored-By: Claude Opus 4.8 Signed-off-by: daoxun.yds --- agentrun/integration/utils/skill_loader.py | 253 +++++++++++++---- agentrun/tool/__tool_async_template.py | 69 ++++- agentrun/tool/tool.py | 77 +++++- .../integration/test_skill_loader.py | 258 +++++++++++++++++- tests/unittests/tool/test_tool.py | 66 ++++- 5 files changed, 646 insertions(+), 77 deletions(-) diff --git a/agentrun/integration/utils/skill_loader.py b/agentrun/integration/utils/skill_loader.py index efb5fad..9223485 100644 --- a/agentrun/integration/utils/skill_loader.py +++ b/agentrun/integration/utils/skill_loader.py @@ -60,30 +60,156 @@ def _parse_skill_qualifier(raw: str) -> Tuple[str, Optional[str]]: 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. + 解析出的 qualifier 会立即校验:必须为 ``LATEST``(大小写不敏感)或严格 + SemVer ``vMAJOR.MINOR.PATCH``,否则抛 ``ValueError``。 + The parsed qualifier is validated immediately: it must be ``LATEST`` + (case-insensitive) or a strict SemVer ``vMAJOR.MINOR.PATCH``, otherwise a + ``ValueError`` is raised. + Args: - raw: 原始字符串,如 "skillA"、"skillA@v1.0.0"、"skillA@default" / - Raw identifier, e.g. "skillA", "skillA@v1.0.0", "skillA@default" + raw: 原始字符串,如 "skillA"、"skillA@v1.0.0"、"skillA@LATEST" / + Raw identifier, e.g. "skillA", "skillA@v1.0.0", "skillA@LATEST" Returns: - (name, qualifier) 元组。qualifier 为 None 表示不指定版本, - 由后端按 default → latest 顺序解析。 - Tuple of (name, qualifier). A None qualifier defers to the backend's - default → latest resolution order. + (name, qualifier) 元组。qualifier 为 None 表示不指定版本,此时 SDK + 不带 qualifier 参数请求,由后端返回 LATEST 代码。 + Tuple of (name, qualifier). A None qualifier means the SDK sends no + qualifier parameter and the backend returns the LATEST code. Raises: - ValueError: 当 name 部分为空时(如 "@v1.0.0")/ - When the name part is empty (e.g. "@v1.0.0") + ValueError: 当 name 部分为空(如 "@v1.0.0"),或 qualifier 非法时 / + When the name part is empty (e.g. "@v1.0.0"), or the + qualifier is invalid """ + from agentrun.tool.tool import _validate_skill_qualifier + 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) + qualifier = qualifier or None + _validate_skill_qualifier(qualifier) + return name, qualifier return raw, None +def _parse_skill_identifiers( + raw_items: List[str], +) -> List[Tuple[str, Optional[str]]]: + """批量解析 skill 标识,跳过并记录非法项 / Parse a batch of skill identifiers, + skipping (and logging) invalid ones. + + 对 ``name[@qualifier]`` 列表逐项调用 :func:`_parse_skill_qualifier`。**单个 + 非法版本限定符(如 ``skill@default`` / ``skill@v1.0``)或空名字(``@v1.0.0``) + 不会中断整批**:非法项被跳过并打 ``warning``,合法项照常返回,从而继续下载。 + 这样一个配置手误不会连累列表里其它正确的 skill。 + + Runs :func:`_parse_skill_qualifier` over each ``name[@qualifier]`` entry. A + single invalid version qualifier (e.g. ``skill@default`` / ``skill@v1.0``) or + empty name (``@v1.0.0``) **does not abort the whole batch**: the offending + entry is skipped with a ``warning`` and the valid entries are still returned + (and subsequently downloaded). One config typo no longer takes down the rest. + + Args: + raw_items: 原始标识列表 / list of raw identifiers + + Returns: + 合法项的 (name, qualifier) 列表 / (name, qualifier) tuples for valid entries + """ + parsed: List[Tuple[str, Optional[str]]] = [] + for item in raw_items: + try: + parsed.append(_parse_skill_qualifier(item)) + except ValueError as error: + logger.warning( + f"Skipping skill '{item}': {error}" + ) + return parsed + + +# 记录某个 skill 本地目录当前是哪个版本下载来的,用于跨版本切换时判断是否需要 +# 重新下载。文件名以 '.' 开头,天然被 scan/文件列举逻辑(skip startswith('.')) +# 忽略,不会暴露给 Agent。 +# Records which version populated a skill's local directory, so version switches +# can decide whether to re-download. The dotfile name is naturally ignored by +# the scan / file-listing logic (which skips names starting with '.'). +_SKILL_VERSION_MARKER = ".skill_version" + + +def _canonical_qualifier(qualifier: Optional[str]) -> str: + """把 qualifier 归一为版本标记值 / Normalize a qualifier to a marker value. + + None 和 "LATEST"(任意大小写)都表示可变的最新代码,统一归一为 "LATEST"; + 定版则保持原样。假定 qualifier 已通过 ``_validate_skill_qualifier`` 校验。 + None and "LATEST" (any case) both mean the mutable latest code and normalize + to "LATEST"; a pinned version is kept as-is. Assumes the qualifier has + already passed ``_validate_skill_qualifier``. + """ + if not qualifier or qualifier.upper() == "LATEST": + return "LATEST" + return qualifier + + +def _read_version_marker(skill_dir: str) -> Optional[str]: + """读取本地 skill 目录的版本标记 / Read a skill directory's version marker. + + Returns: + 标记内容;文件不存在或读取失败时返回 None / + Marker content; None if the file is missing or unreadable + """ + marker_path = os.path.join(skill_dir, _SKILL_VERSION_MARKER) + try: + with open(marker_path, "r", encoding="utf-8") as file_handle: + return file_handle.read().strip() or None + except (OSError, UnicodeDecodeError): + return None + + +def _write_version_marker(skill_dir: str, qualifier: Optional[str]) -> None: + """写入本地 skill 目录的版本标记 / Write a skill directory's version marker. + + 必须在下载(会 rmtree 目录)之后调用,否则会被下载清空。 + Must be called after download (which rmtree's the directory), otherwise it + would be wiped. + """ + marker_path = os.path.join(skill_dir, _SKILL_VERSION_MARKER) + try: + with open(marker_path, "w", encoding="utf-8") as file_handle: + file_handle.write(_canonical_qualifier(qualifier)) + except OSError as error: + logger.warning( + f"Failed to write skill version marker in {skill_dir}: {error}" + ) + + +def _skill_needs_download(skill_dir: str, qualifier: Optional[str]) -> bool: + """判断本地 skill 是否需要(重新)下载 / Decide whether to (re-)download. + + - 目录不存在 → 需要下载 + - 目录有版本标记:标记与请求的 canonical qualifier 不一致(定版切换、 + 定版目录被当作 LATEST 请求等)→ 重下;一致 → 跳过(定版不可变可安全 + 复用,LATEST 复用本地拷贝) + - 目录无版本标记(历史或手动放置的目录)→ 沿用旧行为:指定了 qualifier + 则重下,未指定则复用本地,避免对这类目录产生意外的重复下载 + + - Directory missing → download + - Marked dir: marker mismatches the requested canonical qualifier (version + switch, a pinned dir requested as LATEST, etc.) → re-download; matches → + skip (pinned versions are immutable; LATEST reuses the local copy) + - Unmarked dir (legacy or manually placed) → keep the prior behavior: + re-download if a qualifier is given, otherwise reuse the local copy, so + such dirs are not re-downloaded unexpectedly + """ + if not os.path.isdir(skill_dir): + return True + marker = _read_version_marker(skill_dir) + if marker is None: + return qualifier is not None + return marker != _canonical_qualifier(qualifier) + + @dataclass class SkillDetail(SkillInfo): """Skill 详细信息 / Skill detail information @@ -147,8 +273,8 @@ class directly. It parses "name@qualifier" string syntax and builds ``remote_skills`` 在引入 Skill 版本管理时,由原参数 ``remote_skill_names: List[str]`` 替换为 ``List[Tuple[str, Optional[str]]]``。每个元组的第二项是版本 - qualifier(如 "v1.0.0"、"default"、"LATEST"),``None`` 表示 - 不指定版本(由后端按 default → latest fallback 解析)。 + qualifier(如 "v1.0.0" 或 "LATEST"),``None`` 表示不指定版本 + (SDK 不带 qualifier 参数请求,后端返回 LATEST 代码)。 参数名同步从 ``remote_skill_names`` 改为 ``remote_skills``, 以体现"每项含完整 skill 描述"而非"仅名称列表"的语义升级。 经全量代码扫描确认没有外部代码使用过 ``remote_skill_names`` @@ -157,9 +283,9 @@ class directly. It parses "name@qualifier" string syntax and builds ``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 + each tuple is a version qualifier (e.g. "v1.0.0" or "LATEST"); + ``None`` means the SDK sends no qualifier parameter and the backend + returns the LATEST code. 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. @@ -167,9 +293,9 @@ class directly. It parses "name@qualifier" string syntax and builds Args: skills_dir: 本地 skill 目录路径 / local skill directory path remote_skills: 需要从远程下载的 skill (name, qualifier) 列表。 - qualifier 为 None 时使用后端缺省版本 / + qualifier 为 None 时后端返回 LATEST 代码 / List of remote skills as (name, qualifier) tuples; - qualifier=None defers to the backend's default version + qualifier=None makes the backend return the LATEST code config: 配置对象 / configuration object command_approval: execute_command 执行前的确认回调 / Approval callback invoked before executing commands @@ -199,16 +325,31 @@ def __init__( def _ensure_skills_available(self) -> None: """确保远程 skill 已下载到本地 / Ensure remote skills are downloaded locally - 对每个 (skill_name, qualifier),检查本地目录是否已存在。 - - 未指定 qualifier 且目录已存在 → 跳过下载 - - 指定了 qualifier → 强制重新下载,避免使用本地旧版本 - - 目录不存在 → 下载 - - 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 + 对每个 (skill_name, qualifier),通过版本标记判断本地目录是否是所请求 + 的版本(见 :func:`_skill_needs_download`):目录缺失、或有标记但版本 + 不一致(定版切换、定版目录被当作 LATEST 请求)则重下;版本一致则跳过。 + 下载完成后写入版本标记,供下次判断使用。 + + **单个 skill 下载失败(不存在 / 网络 / 鉴权 / 解压等)不会中断整批**: + 失败项记 ``warning`` 后跳过,其余 skill 照常下载。这样一个缺失的 skill + 不会连累其它 skill,:func:`scan_skills` 随后只返回成功落盘的那些。与解析 + 阶段对非法版本限定符的逐项跳过(见 :func:`_parse_skill_identifiers`)一致, + 使 skill_tools 的两类容错粒度统一为"逐项"。 + + For each (skill_name, qualifier), use the version marker to decide + whether the local directory already holds the requested version (see + :func:`_skill_needs_download`): re-download when the dir is missing or a + marked version mismatches (version switch, pinned dir requested as + LATEST); skip when the version matches. The version marker is written + after a successful download. + + A single skill's download failure (missing / network / auth / unzip …) + **does not abort the whole batch**: the failing entry is logged as a + ``warning`` and skipped, while the rest still download. One missing skill + no longer takes down the others; :func:`scan_skills` then returns only + those that landed on disk. This mirrors the per-entry skip for invalid + qualifiers at parse time (see :func:`_parse_skill_identifiers`), so both + fault classes of skill_tools share the same per-entry granularity. """ if not self._remote_skills: return @@ -217,9 +358,10 @@ def _ensure_skills_available(self) -> None: for skill_name, qualifier in self._remote_skills: skill_path = os.path.join(self._skills_dir, skill_name) - if qualifier is None and os.path.isdir(skill_path): + if not _skill_needs_download(skill_path, qualifier): logger.debug( - f"Skill '{skill_name}' already exists at {skill_path}, " + f"Skill '{skill_name}' at {skill_path} is already at the " + f"requested version ({_canonical_qualifier(qualifier)}), " "skipping download" ) continue @@ -229,14 +371,23 @@ def _ensure_skills_available(self) -> None: logger.info( 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, - qualifier=qualifier, - config=self._config, - ) + try: + tool_resource = ToolClient().get( + name=skill_name, config=self._config + ) + tool_resource.download_skill( + target_dir=self._skills_dir, + qualifier=qualifier, + config=self._config, + ) + _write_version_marker(skill_path, qualifier) + except Exception as error: + # 逐项隔离:单个 skill 下载失败不影响其它 skill / + # per-skill isolation: one skill's failure won't affect others + logger.warning( + f"Skipping remote skill '{label}': download failed: {error}" + ) + continue def _parse_skill_metadata(self, skill_dir: str) -> SkillInfo: """解析 skill 元信息 / Parse skill metadata @@ -836,16 +987,18 @@ def skill_tools( 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". + 字符串形式支持版本语法,如 "skillA@v1.0.0"、"skillA@LATEST" / + String form supports version syntax, e.g. "skillA@v1.0.0", "skillA@LATEST". 如果提供,会先下载到 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. + qualifier: 版本标识("LATEST"(大小写不敏感)或 "vX.Y.Z"),仅在 name + 为 ToolResource 实例时使用。字符串/列表形式请直接在 name 中 + 用 "@" 语法指定 / + Version qualifier ("LATEST" (case-insensitive) or "vX.Y.Z"); + only applies when name is a ToolResource instance. For string/list inputs, use the "@" syntax inside name instead. config: 配置对象 / Configuration object command_approval: 命令执行前的确认回调函数(可选)/ @@ -868,7 +1021,7 @@ def skill_tools( >>> >>> # 指定版本下载 / Download a specific version >>> ts = skill_tools("my-remote-skill@v1.0.0") - >>> ts = skill_tools("my-remote-skill@default") + >>> ts = skill_tools("my-remote-skill@LATEST") >>> >>> # 多 skill 混合版本 / Multiple skills with mixed versions >>> ts = skill_tools(["skill-a@v1.0.0", "skill-b@latest", "skill-c"]) @@ -887,24 +1040,32 @@ def skill_tools( if name is not None: if isinstance(name, str): - remote_skills = [_parse_skill_qualifier(name)] + # 非法版本限定符 → 跳过并 warning(返回空 remote_skills),不抛出 / + # invalid qualifier → skipped with a warning (empty remote_skills) + remote_skills = _parse_skill_identifiers([name]) elif isinstance(name, list): - remote_skills = [_parse_skill_qualifier(item) for item in name] + # 逐项容错:列表里非法项被跳过,合法项照常下载 / + # per-entry tolerant: invalid entries skipped, valid ones downloaded + remote_skills = _parse_skill_identifiers(name) else: # ToolResource instance — extract its name and download + from agentrun.tool.tool import _validate_skill_qualifier + + _validate_skill_qualifier(qualifier) tool_resource_instance = name resource_name = getattr( tool_resource_instance, "name", None ) or getattr(tool_resource_instance, "tool_name", None) if resource_name: skill_path = os.path.join(skills_dir, resource_name) - # 指定 qualifier 时强制重下;否则仅当本地缺失时下载 - if qualifier is not None or not os.path.isdir(skill_path): + # 本地版本与请求不一致(或缺失)才下载,一致则复用本地拷贝 + if _skill_needs_download(skill_path, qualifier): tool_resource_instance.download_skill( target_dir=skills_dir, qualifier=qualifier, config=config, ) + _write_version_marker(skill_path, qualifier) loader = SkillLoader( skills_dir=skills_dir, diff --git a/agentrun/tool/__tool_async_template.py b/agentrun/tool/__tool_async_template.py index 9d74087..959a6d0 100644 --- a/agentrun/tool/__tool_async_template.py +++ b/agentrun/tool/__tool_async_template.py @@ -7,6 +7,7 @@ import io import json import os +import re import shutil from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse @@ -32,6 +33,54 @@ ToolType, ) +# Skill 版本 qualifier 合法格式:严格 SemVer,强制小写 v 前缀,每段最多 9 位。 +# 与后端数据面 DownloadCode 的 qualifier 校验规则保持一致 +# (见 funagent-core pkg/fc/version_alias.go 的 StrictVersionNameRegex)。 +# 注意:末尾用 \Z 而非后端的 $。Go 的 $(非 multiline)等价于 \z(文本绝对 +# 结尾),而 Python 的 $ 会额外匹配结尾换行符之前的位置,导致 "v1.0.0\n" 被 +# 误判为合法。用 \Z 才能与 Go 的 $ 行为完全一致。 +# Valid skill version qualifier: strict SemVer with a lowercase 'v' prefix, +# each segment up to 9 digits. Kept in sync with the backend data-plane +# DownloadCode rule. NOTE: the trailing anchor is \Z, not the backend's $: +# Go's $ (non-multiline) equals \z (absolute end of text), whereas Python's $ +# also matches before a trailing newline, which would wrongly accept +# "v1.0.0\n". \Z reproduces Go's $ behavior exactly. +_SKILL_VERSION_QUALIFIER_RE = re.compile( + r"^v(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\Z" +) + + +def _validate_skill_qualifier(qualifier: Optional[str]) -> None: + """校验 skill 版本 qualifier 是否合法 / Validate a skill version qualifier. + + 空 / None 表示不指定版本(后端返回 LATEST),直接通过。非空时必须为 + ``LATEST``(大小写不敏感)或严格 SemVer ``vMAJOR.MINOR.PATCH``。 + 在客户端提前 fail-fast,避免非法值(如已废弃的 ``default``)走到后端才 + 返回 400。 + + Empty / None means "no version specified" (the backend returns LATEST) and + passes through. A non-empty qualifier must be ``LATEST`` (case-insensitive) + or a strict SemVer ``vMAJOR.MINOR.PATCH``. Validating client-side fails fast + instead of letting invalid values (e.g. the retired ``default``) reach the + backend and return 400. + + Args: + qualifier: 待校验的版本标识 / the version qualifier to validate + + Raises: + ValueError: qualifier 非法时 / when the qualifier is invalid + """ + if not qualifier: + return + if qualifier.upper() == "LATEST": + return + if _SKILL_VERSION_QUALIFIER_RE.match(qualifier): + return + raise ValueError( + f"Invalid skill version qualifier '{qualifier}': must be 'LATEST' " + "(case-insensitive) or match format vMAJOR.MINOR.PATCH (e.g. v1.0.0)" + ) + class Tool(BaseModel): """工具资源 / Tool Resource @@ -578,14 +627,20 @@ def _get_skill_download_url( 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. + qualifier: 版本标识,如 "v1.0.0" 或 "LATEST"(大小写不敏感)。 + 为空时后端返回 LATEST 代码 / + Version qualifier (e.g. "v1.0.0" or "LATEST", + case-insensitive). When empty, the backend returns + the LATEST code. config: 配置对象 / Configuration object Returns: Optional[str]: 下载 URL / Download URL + + Raises: + ValueError: qualifier 非法时 / when the qualifier is invalid """ + _validate_skill_qualifier(qualifier) effective_name = self.tool_name or self.name data_endpoint = self.data_endpoint if not data_endpoint: @@ -609,14 +664,14 @@ async def download_skill_async( """异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously 从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。 - 可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。 + 可选地通过 qualifier 指定版本(如 "v1.0.0" 或 "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"). + Optionally specify a version qualifier (e.g. "v1.0.0" or "LATEST", case-insensitive). Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" - qualifier: 版本标识,为空时下载缺省版本 / - Version qualifier; when None, downloads the default version + qualifier: 版本标识,为空时后端返回 LATEST 代码 / + Version qualifier; when None, the backend returns the LATEST code config: 配置对象,可选 / Configuration object, optional Returns: diff --git a/agentrun/tool/tool.py b/agentrun/tool/tool.py index 3eee03c..d36b0cd 100644 --- a/agentrun/tool/tool.py +++ b/agentrun/tool/tool.py @@ -17,6 +17,7 @@ import io import json import os +import re import shutil from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse @@ -42,6 +43,54 @@ ToolType, ) +# Skill 版本 qualifier 合法格式:严格 SemVer,强制小写 v 前缀,每段最多 9 位。 +# 与后端数据面 DownloadCode 的 qualifier 校验规则保持一致 +# (见 funagent-core pkg/fc/version_alias.go 的 StrictVersionNameRegex)。 +# 注意:末尾用 \Z 而非后端的 $。Go 的 $(非 multiline)等价于 \z(文本绝对 +# 结尾),而 Python 的 $ 会额外匹配结尾换行符之前的位置,导致 "v1.0.0\n" 被 +# 误判为合法。用 \Z 才能与 Go 的 $ 行为完全一致。 +# Valid skill version qualifier: strict SemVer with a lowercase 'v' prefix, +# each segment up to 9 digits. Kept in sync with the backend data-plane +# DownloadCode rule. NOTE: the trailing anchor is \Z, not the backend's $: +# Go's $ (non-multiline) equals \z (absolute end of text), whereas Python's $ +# also matches before a trailing newline, which would wrongly accept +# "v1.0.0\n". \Z reproduces Go's $ behavior exactly. +_SKILL_VERSION_QUALIFIER_RE = re.compile( + r"^v(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\Z" +) + + +def _validate_skill_qualifier(qualifier: Optional[str]) -> None: + """校验 skill 版本 qualifier 是否合法 / Validate a skill version qualifier. + + 空 / None 表示不指定版本(后端返回 LATEST),直接通过。非空时必须为 + ``LATEST``(大小写不敏感)或严格 SemVer ``vMAJOR.MINOR.PATCH``。 + 在客户端提前 fail-fast,避免非法值(如已废弃的 ``default``)走到后端才 + 返回 400。 + + Empty / None means "no version specified" (the backend returns LATEST) and + passes through. A non-empty qualifier must be ``LATEST`` (case-insensitive) + or a strict SemVer ``vMAJOR.MINOR.PATCH``. Validating client-side fails fast + instead of letting invalid values (e.g. the retired ``default``) reach the + backend and return 400. + + Args: + qualifier: 待校验的版本标识 / the version qualifier to validate + + Raises: + ValueError: qualifier 非法时 / when the qualifier is invalid + """ + if not qualifier: + return + if qualifier.upper() == "LATEST": + return + if _SKILL_VERSION_QUALIFIER_RE.match(qualifier): + return + raise ValueError( + f"Invalid skill version qualifier '{qualifier}': must be 'LATEST' " + "(case-insensitive) or match format vMAJOR.MINOR.PATCH (e.g. v1.0.0)" + ) + class Tool(BaseModel): """工具资源 / Tool Resource @@ -748,14 +797,20 @@ def _get_skill_download_url( 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. + qualifier: 版本标识,如 "v1.0.0" 或 "LATEST"(大小写不敏感)。 + 为空时后端返回 LATEST 代码 / + Version qualifier (e.g. "v1.0.0" or "LATEST", + case-insensitive). When empty, the backend returns + the LATEST code. config: 配置对象 / Configuration object Returns: Optional[str]: 下载 URL / Download URL + + Raises: + ValueError: qualifier 非法时 / when the qualifier is invalid """ + _validate_skill_qualifier(qualifier) effective_name = self.tool_name or self.name data_endpoint = self.data_endpoint if not data_endpoint: @@ -779,14 +834,14 @@ async def download_skill_async( """异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously 从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。 - 可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。 + 可选地通过 qualifier 指定版本(如 "v1.0.0" 或 "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"). + Optionally specify a version qualifier (e.g. "v1.0.0" or "LATEST", case-insensitive). Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" - qualifier: 版本标识,为空时下载缺省版本 / - Version qualifier; when None, downloads the default version + qualifier: 版本标识,为空时后端返回 LATEST 代码 / + Version qualifier; when None, the backend returns the LATEST code config: 配置对象,可选 / Configuration object, optional Returns: @@ -846,14 +901,14 @@ def download_skill( """同步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory synchronously 从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。 - 可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。 + 可选地通过 qualifier 指定版本(如 "v1.0.0" 或 "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"). + Optionally specify a version qualifier (e.g. "v1.0.0" or "LATEST", case-insensitive). Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" - qualifier: 版本标识,为空时下载缺省版本 / - Version qualifier; when None, downloads the default version + qualifier: 版本标识,为空时后端返回 LATEST 代码 / + Version qualifier; when None, the backend returns the LATEST code config: 配置对象,可选 / Configuration object, optional Returns: diff --git a/tests/unittests/integration/test_skill_loader.py b/tests/unittests/integration/test_skill_loader.py index 697d728..fc89942 100644 --- a/tests/unittests/integration/test_skill_loader.py +++ b/tests/unittests/integration/test_skill_loader.py @@ -18,8 +18,13 @@ import agentrun.integration.builtin.skill as _builtin_skill_mod from agentrun.integration.utils.skill_loader import ( + _canonical_qualifier, _parse_frontmatter, + _SKILL_VERSION_MARKER, _parse_skill_qualifier, + _read_version_marker, + _skill_needs_download, + _write_version_marker, skill_tools, SkillDetail, SkillInfo, @@ -714,18 +719,19 @@ def test_name_with_version(self) -> None: "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_name_with_lowercase_latest(self) -> None: + # LATEST 大小写不敏感 / LATEST is case-insensitive + 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) @@ -734,6 +740,19 @@ def test_empty_name_raises(self) -> None: with pytest.raises(ValueError, match="name part is empty"): _parse_skill_qualifier("@v1.0.0") + def test_default_alias_is_rejected(self) -> None: + # @default 已废弃,客户端应直接抛错而非发到后端 / + # @default is retired; the client rejects it instead of hitting backend + with pytest.raises(ValueError, match="Invalid skill version qualifier"): + _parse_skill_qualifier("skillA@default") + + def test_malformed_version_is_rejected(self) -> None: + for bad in ("skillA@V1.0.0", "skillA@1.0.0", "skillA@v1.0"): + with pytest.raises( + ValueError, match="Invalid skill version qualifier" + ): + _parse_skill_qualifier(bad) + class TestSkillLoaderQualifier: """测试 SkillLoader 对 qualifier 的处理""" @@ -785,6 +804,172 @@ def test_qualifier_passed_through_on_missing_skill( ) +class TestSkillVersionMarker: + """测试版本标记辅助函数 / version marker helpers""" + + def test_canonical_qualifier(self) -> None: + assert _canonical_qualifier(None) == "LATEST" + assert _canonical_qualifier("LATEST") == "LATEST" + assert _canonical_qualifier("latest") == "LATEST" + assert _canonical_qualifier("v1.0.0") == "v1.0.0" + + def test_read_marker_missing_returns_none(self, tmp_path: Any) -> None: + skill_dir = _create_skill_dir(str(tmp_path), "s") + assert _read_version_marker(skill_dir) is None + + def test_write_then_read_marker(self, tmp_path: Any) -> None: + skill_dir = _create_skill_dir(str(tmp_path), "s") + _write_version_marker(skill_dir, "v1.0.0") + assert _read_version_marker(skill_dir) == "v1.0.0" + # None 归一为 LATEST / None normalizes to LATEST + _write_version_marker(skill_dir, None) + assert _read_version_marker(skill_dir) == "LATEST" + + def test_marker_hidden_from_scan(self, tmp_path: Any) -> None: + # 版本标记是点文件,不应出现在 skill 文件列表里 / + # the marker is a dotfile and must not appear in the skill file list + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + skill_dir = _create_skill_dir( + skills_dir, "s", skill_md_content="---\nname: s\n---\nbody" + ) + _write_version_marker(skill_dir, "v1.0.0") + detail = SkillLoader(skills_dir=skills_dir).load_skill("s") + assert detail is not None + assert _SKILL_VERSION_MARKER not in detail.files + + def test_needs_download_matrix(self, tmp_path: Any) -> None: + missing = os.path.join(str(tmp_path), "absent") + assert _skill_needs_download(missing, "v1.0.0") is True + + pinned = _create_skill_dir(str(tmp_path), "pinned") + _write_version_marker(pinned, "v1.0.0") + # 同版本命中 → 不下载 / same version → skip + assert _skill_needs_download(pinned, "v1.0.0") is False + # 定版切换 → 重下 / version switch → re-download + assert _skill_needs_download(pinned, "v2.0.0") is True + # 定版目录被当作 LATEST 请求 → 重下(修复串版本脏读)/ + # pinned dir requested as LATEST → re-download (stale-read fix) + assert _skill_needs_download(pinned, None) is True + + latest = _create_skill_dir(str(tmp_path), "latest") + _write_version_marker(latest, None) + assert _skill_needs_download(latest, None) is False + assert _skill_needs_download(latest, "latest") is False + assert _skill_needs_download(latest, "v1.0.0") is True + + +class TestSkillLoaderVersionMarkerFlow: + """测试 _ensure_skills_available 基于版本标记的下载决策""" + + def _mock_client(self) -> Any: + mock_tool_resource = MagicMock() + mock_client_instance = MagicMock() + mock_client_instance.get.return_value = mock_tool_resource + return mock_tool_resource, mock_client_instance + + def test_same_version_marker_skips_download( + self, tmp_path: Any + ) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + skill_dir = _create_skill_dir(skills_dir, "s") + _write_version_marker(skill_dir, "v1.0.0") + + mock_tool_resource, mock_client_instance = self._mock_client() + loader = SkillLoader( + skills_dir=skills_dir, remote_skills=[("s", "v1.0.0")] + ) + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + loader._ensure_skills_available() + mock_tool_resource.download_skill.assert_not_called() + + def test_pinned_dir_requested_as_latest_redownloads( + self, tmp_path: Any + ) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + skill_dir = _create_skill_dir(skills_dir, "s") + _write_version_marker(skill_dir, "v1.0.0") + + mock_tool_resource, mock_client_instance = self._mock_client() + loader = SkillLoader( + skills_dir=skills_dir, remote_skills=[("s", None)] + ) + 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=None, config=None + ) + + def test_marker_written_after_download(self, tmp_path: Any) -> None: + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + # 下载目标目录预先存在,便于 mock 下载后写入标记 / + # pre-create the target dir so the marker can be written after mock download + _create_skill_dir(skills_dir, "s") + + mock_tool_resource, mock_client_instance = self._mock_client() + loader = SkillLoader( + skills_dir=skills_dir, remote_skills=[("s", "v1.0.0")] + ) + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + loader._ensure_skills_available() + assert _read_version_marker(os.path.join(skills_dir, "s")) == "v1.0.0" + + def test_one_download_failure_does_not_abort_batch( + self, tmp_path: Any + ) -> None: + """单个 skill 下载失败被跳过,其余 skill 照常下载(不整批抛出)/ + One skill's download failure is skipped; the rest still download.""" + skills_dir = str(tmp_path / "skills") + os.makedirs(skills_dir) + + good_resource = MagicMock() + + def get_side_effect(name: str, config: Any = None) -> Any: + if name == "missing": + raise RuntimeError("Resource Tool(missing) does not exist.") + return good_resource + + mock_client_instance = MagicMock() + mock_client_instance.get.side_effect = get_side_effect + + # missing 在中间:验证它失败后 loop 仍继续到 good-b / + # 'missing' sits in the middle: the loop must continue to 'good-b' + loader = SkillLoader( + skills_dir=skills_dir, + remote_skills=[ + ("good-a", "v1.0.0"), + ("missing", "v1.0.0"), + ("good-b", None), + ], + ) + with patch( + "agentrun.tool.client.ToolClient", + return_value=mock_client_instance, + ): + # 不应抛出 / must not raise + loader._ensure_skills_available() + + # good-a 与 good-b 都下载了;missing 被跳过 / + # both good skills downloaded; 'missing' skipped + downloaded_qualifiers = [ + c.kwargs.get("qualifier") + for c in good_resource.download_skill.call_args_list + ] + assert downloaded_qualifiers == ["v1.0.0", None] + + class TestSkillToolsVersioning: """测试 skill_tools 顶层函数对版本语法的处理""" @@ -833,6 +1018,67 @@ def test_list_with_mixed_qualifiers(self, tmp_path: Any) -> None: assert calls[1].kwargs["qualifier"] == "latest" assert calls[2].kwargs["qualifier"] is None + def test_list_skips_invalid_qualifier_downloads_valid( + self, tmp_path: Any + ) -> None: + """列表里非法版本限定符被跳过,合法项照常下载(不整批抛出)/ + Invalid qualifiers in the list are skipped; valid ones still download.""" + 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-b@default (retired) 与 skill-d@v1.0 (非严格 SemVer) 应被跳过 / + # both retired 'default' and non-strict 'v1.0' are skipped + toolset = skill_tools( + name=[ + "skill-a@v1.0.0", + "skill-b@default", + "skill-c", + "skill-d@v1.0", + ], + skills_dir=skills_dir, + ) + assert isinstance(toolset, CommonToolSet) + # 只有 skill-a / skill-c 会真正下载 / only the two valid skills download + downloaded = [ + c.kwargs.get("qualifier") + for c in mock_tool_resource.download_skill.call_args_list + ] + assert downloaded == ["v1.0.0", None] + got_names = [ + c.kwargs.get("name") + for c in mock_client_instance.get.call_args_list + ] + assert got_names == ["skill-a", "skill-c"] + + def test_all_invalid_qualifiers_load_nothing_no_raise( + self, tmp_path: Any + ) -> None: + """全部非法时不抛出,返回空工具集(server 仍可启动)/ + All-invalid input does not raise; returns an empty toolset.""" + 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=["a@default", "b@v1.0"], skills_dir=skills_dir + ) + mock_tool_resource.download_skill.assert_not_called() + def test_tool_resource_with_explicit_qualifier( self, tmp_path: Any ) -> None: diff --git a/tests/unittests/tool/test_tool.py b/tests/unittests/tool/test_tool.py index d9106aa..edeaa54 100644 --- a/tests/unittests/tool/test_tool.py +++ b/tests/unittests/tool/test_tool.py @@ -21,7 +21,7 @@ ToolSchema, ToolType, ) -from agentrun.tool.tool import Tool +from agentrun.tool.tool import _validate_skill_qualifier, Tool class TestTool: @@ -493,17 +493,30 @@ def test_get_skill_download_url_with_qualifier(self): 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 编码""" + def test_get_skill_download_url_latest_case_insensitive(self): + """测试 LATEST 大小写不敏感均可通过校验并出现在 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" + assert tool._get_skill_download_url(qualifier="LATEST") == ( + "https://example.com/tools/my-skill/download?qualifier=LATEST" ) + assert tool._get_skill_download_url(qualifier="latest") == ( + "https://example.com/tools/my-skill/download?qualifier=latest" + ) + + def test_get_skill_download_url_invalid_qualifier_raises(self): + """测试非法 qualifier(含已废弃的 default)在构造 URL 时即抛错""" + tool = Tool( + tool_name="my-skill", + data_endpoint="https://example.com", + ) + for bad in ("default", "latest@beta", "V1.0.0", "1.0.0", "v1.0"): + with pytest.raises( + ValueError, match="Invalid skill version qualifier" + ): + tool._get_skill_download_url(qualifier=bad) def test_get_skill_download_url_empty_qualifier_omitted(self): """测试空字符串 qualifier 等同于不指定""" @@ -1910,3 +1923,42 @@ def test_list_tools_mcp_no_endpoint(self, mock_config_class): tools = tool.list_tools() assert tools == [] + + +class TestValidateSkillQualifier: + """测试 _validate_skill_qualifier 校验函数""" + + def test_valid_qualifiers_pass(self): + for good in ( + None, + "", + "LATEST", + "latest", + "LaTeSt", + "v0.0.0", + "v1.0.0", + "v12.34.56", + ): + _validate_skill_qualifier(good) # should not raise + + def test_invalid_qualifiers_raise(self): + for bad in ( + "default", + "V1.0.0", + "1.0.0", + "v1.0", + "v1.0.0.0", + "v1.0.0-beta", + "latest@beta", + "vabc", + # 末尾换行:Python 的 $ 会误接受,用 \Z 与后端 Go 的 $(=\z) 对齐 / + # trailing newline: Python's $ would wrongly accept; \Z matches + # the backend's Go $ (= \z) which rejects it + "v1.0.0\n", + " v1.0.0", + "v1.0.0 ", + ): + with pytest.raises( + ValueError, match="Invalid skill version qualifier" + ): + _validate_skill_qualifier(bad) From 80c2524ba2e2e1a32d40e02faa3fa426d44bce2e Mon Sep 17 00:00:00 2001 From: yuyushui66 <779910032@qq.com> Date: Wed, 15 Jul 2026 17:35:27 +0800 Subject: [PATCH 3/3] refactor: normalize LATEST qualifier before send and reorder download_skill args - Normalize qualifier to the canonical "LATEST" in _get_skill_download_url before building the URL, so the value sent to the backend agrees with the local .skill_version marker (skill_loader._canonical_qualifier) regardless of input casing. - Reorder download_skill / download_skill_async params to (target_dir, config, qualifier) and sync the docstring arg order. - Update tests to assert all LATEST casings produce the canonical URL. Co-Authored-By: Claude Opus 4.8 Signed-off-by: daoxun.yds --- agentrun/tool/__tool_async_template.py | 14 ++++++++++++-- agentrun/tool/tool.py | 18 ++++++++++++++---- tests/unittests/tool/test_tool.py | 24 ++++++++++++++++-------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/agentrun/tool/__tool_async_template.py b/agentrun/tool/__tool_async_template.py index 959a6d0..d3548af 100644 --- a/agentrun/tool/__tool_async_template.py +++ b/agentrun/tool/__tool_async_template.py @@ -652,14 +652,24 @@ def _get_skill_download_url( if qualifier: from urllib.parse import quote + # LATEST 大小写不敏感,发送前统一归一为规范形式 "LATEST",使 + # "发送给后端的值 / 本地版本标记 / 下载决策" 三者一致(本地标记侧 + # 见 skill_loader._canonical_qualifier)。定版 qualifier 保持原样。 + # LATEST is case-insensitive; normalize to the canonical "LATEST" + # before sending so the value sent to the backend, the local version + # marker, and the download decision all agree (the marker side lives + # in skill_loader._canonical_qualifier). Pinned versions are kept + # as-is. + if qualifier.upper() == "LATEST": + qualifier = "LATEST" 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, + qualifier: Optional[str] = None, ) -> str: """异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously @@ -670,9 +680,9 @@ async def download_skill_async( Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" + config: 配置对象,可选 / Configuration object, optional qualifier: 版本标识,为空时后端返回 LATEST 代码 / Version qualifier; when None, the backend returns the LATEST code - config: 配置对象,可选 / Configuration object, optional Returns: str: 解压后的 skill 目录路径 / Extracted skill directory path diff --git a/agentrun/tool/tool.py b/agentrun/tool/tool.py index d36b0cd..c3c204d 100644 --- a/agentrun/tool/tool.py +++ b/agentrun/tool/tool.py @@ -822,14 +822,24 @@ def _get_skill_download_url( if qualifier: from urllib.parse import quote + # LATEST 大小写不敏感,发送前统一归一为规范形式 "LATEST",使 + # "发送给后端的值 / 本地版本标记 / 下载决策" 三者一致(本地标记侧 + # 见 skill_loader._canonical_qualifier)。定版 qualifier 保持原样。 + # LATEST is case-insensitive; normalize to the canonical "LATEST" + # before sending so the value sent to the backend, the local version + # marker, and the download decision all agree (the marker side lives + # in skill_loader._canonical_qualifier). Pinned versions are kept + # as-is. + if qualifier.upper() == "LATEST": + qualifier = "LATEST" 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, + qualifier: Optional[str] = None, ) -> str: """异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously @@ -840,9 +850,9 @@ async def download_skill_async( Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" + config: 配置对象,可选 / Configuration object, optional qualifier: 版本标识,为空时后端返回 LATEST 代码 / Version qualifier; when None, the backend returns the LATEST code - config: 配置对象,可选 / Configuration object, optional Returns: str: 解压后的 skill 目录路径 / Extracted skill directory path @@ -895,8 +905,8 @@ async def download_skill_async( def download_skill( self, target_dir: str = ".skills", - qualifier: Optional[str] = None, config: Optional[Config] = None, + qualifier: Optional[str] = None, ) -> str: """同步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory synchronously @@ -907,9 +917,9 @@ def download_skill( Args: target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills" + config: 配置对象,可选 / Configuration object, optional qualifier: 版本标识,为空时后端返回 LATEST 代码 / Version qualifier; when None, the backend returns the LATEST code - config: 配置对象,可选 / Configuration object, optional Returns: str: 解压后的 skill 目录路径 / Extracted skill directory path diff --git a/tests/unittests/tool/test_tool.py b/tests/unittests/tool/test_tool.py index edeaa54..93909cc 100644 --- a/tests/unittests/tool/test_tool.py +++ b/tests/unittests/tool/test_tool.py @@ -491,20 +491,28 @@ def test_get_skill_download_url_with_qualifier(self): 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" + assert ( + url + == "https://example.com/tools/my-skill/download?qualifier=v1.0.0" + ) def test_get_skill_download_url_latest_case_insensitive(self): - """测试 LATEST 大小写不敏感均可通过校验并出现在 URL 中""" + """测试 LATEST 大小写不敏感均可通过校验,且发送前归一为规范 "LATEST" + + 无论传入何种大小写,构造出的 URL 都用规范形式 "LATEST",与本地版本 + 标记(skill_loader._canonical_qualifier)保持一致,避免向后端发送 + 未归一的 "latest"。/ Regardless of the input casing, the constructed + URL uses the canonical "LATEST" so it agrees with the local version + marker and never sends an un-normalized "latest" to the backend. + """ tool = Tool( tool_name="my-skill", data_endpoint="https://example.com", ) - assert tool._get_skill_download_url(qualifier="LATEST") == ( - "https://example.com/tools/my-skill/download?qualifier=LATEST" - ) - assert tool._get_skill_download_url(qualifier="latest") == ( - "https://example.com/tools/my-skill/download?qualifier=latest" - ) + for variant in ("LATEST", "latest", "LaTeSt"): + assert tool._get_skill_download_url(qualifier=variant) == ( + "https://example.com/tools/my-skill/download?qualifier=LATEST" + ) def test_get_skill_download_url_invalid_qualifier_raises(self): """测试非法 qualifier(含已废弃的 default)在构造 URL 时即抛错"""