Skip to content

Commit 39a5fff

Browse files
committed
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.
1 parent 72e8479 commit 39a5fff

5 files changed

Lines changed: 417 additions & 43 deletions

File tree

agentrun/integration/utils/skill_loader.py

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,16 @@
1212
import os
1313
import re
1414
import subprocess
15-
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
15+
from typing import (
16+
Any,
17+
Callable,
18+
Dict,
19+
List,
20+
Optional,
21+
TYPE_CHECKING,
22+
Tuple,
23+
Union,
24+
)
1625

1726
from agentrun.integration.utils.tool import CommonToolSet, Tool, ToolParameter
1827
from agentrun.utils.log import logger
@@ -43,6 +52,38 @@ class SkillInfo:
4352
path: str = ""
4453

4554

55+
def _parse_skill_qualifier(raw: str) -> Tuple[str, Optional[str]]:
56+
"""解析 'skillName[@qualifier]' 语法 / Parse 'skillName[@qualifier]' syntax
57+
58+
工具名格式受后端正则 ^[_a-zA-Z][-_a-zA-Z0-9]*$ 约束,不允许包含 '@',
59+
因此用 '@' 作为分隔符不会有歧义。
60+
Tool names are constrained by the backend regex ^[_a-zA-Z][-_a-zA-Z0-9]*$
61+
and cannot contain '@', so using '@' as a separator is unambiguous.
62+
63+
Args:
64+
raw: 原始字符串,如 "skillA"、"skillA@v1.0.0"、"skillA@default" /
65+
Raw identifier, e.g. "skillA", "skillA@v1.0.0", "skillA@default"
66+
67+
Returns:
68+
(name, qualifier) 元组。qualifier 为 None 表示不指定版本,
69+
由后端按 default → latest 顺序解析。
70+
Tuple of (name, qualifier). A None qualifier defers to the backend's
71+
default → latest resolution order.
72+
73+
Raises:
74+
ValueError: 当 name 部分为空时(如 "@v1.0.0")/
75+
When the name part is empty (e.g. "@v1.0.0")
76+
"""
77+
if "@" in raw:
78+
name, qualifier = raw.rsplit("@", 1)
79+
if not name:
80+
raise ValueError(
81+
f"Invalid skill identifier '{raw}': name part is empty"
82+
)
83+
return name, (qualifier or None)
84+
return raw, None
85+
86+
4687
@dataclass
4788
class SkillDetail(SkillInfo):
4889
"""Skill 详细信息 / Skill detail information
@@ -94,22 +135,62 @@ class SkillLoader:
94135
reading skill instruction content, and constructing the load_skills tool
95136
for Agent runtime invocation.
96137
138+
使用方式说明 / Usage Note:
139+
推荐通过顶层 ``skill_tools()`` 函数使用本类,避免直接构造。
140+
``skill_tools()`` 会自动解析 "name@qualifier" 字符串语法并替你构造
141+
合适的 ``remote_skills`` 元组列表。
142+
Prefer the top-level ``skill_tools()`` helper over constructing this
143+
class directly. It parses "name@qualifier" string syntax and builds
144+
the ``remote_skills`` tuple list for you.
145+
146+
参数历史变更 / Parameter History:
147+
``remote_skills`` 在引入 Skill 版本管理时,由原参数
148+
``remote_skill_names: List[str]`` 替换为
149+
``List[Tuple[str, Optional[str]]]``。每个元组的第二项是版本
150+
qualifier(如 "v1.0.0"、"default"、"LATEST"),``None`` 表示
151+
不指定版本(由后端按 default → latest fallback 解析)。
152+
参数名同步从 ``remote_skill_names`` 改为 ``remote_skills``,
153+
以体现"每项含完整 skill 描述"而非"仅名称列表"的语义升级。
154+
经全量代码扫描确认没有外部代码使用过 ``remote_skill_names``
155+
参数(所有外部使用都是 ``SkillLoader(skills_dir=...).scan_skills()``
156+
形式仅用于本地扫描),所以参数变更对实际使用零影响。
157+
158+
``remote_skills`` was renamed from ``remote_skill_names: List[str]``
159+
when Skill version management was introduced. The second item of
160+
each tuple is a version qualifier (e.g. "v1.0.0", "default",
161+
"LATEST"); ``None`` defers to the backend's default → latest
162+
resolution. Code scans confirm no external caller used the legacy
163+
``remote_skill_names`` parameter (all external usage is of the
164+
form ``SkillLoader(skills_dir=...).scan_skills()`` for local
165+
scanning only), so this rename has zero practical impact.
166+
97167
Args:
98168
skills_dir: 本地 skill 目录路径 / local skill directory path
99-
remote_skill_names: 需要从远程下载的 skill 名称列表 / list of remote skill names to download
169+
remote_skills: 需要从远程下载的 skill (name, qualifier) 列表。
170+
qualifier 为 None 时使用后端缺省版本 /
171+
List of remote skills as (name, qualifier) tuples;
172+
qualifier=None defers to the backend's default version
100173
config: 配置对象 / configuration object
174+
command_approval: execute_command 执行前的确认回调 /
175+
Approval callback invoked before executing commands
176+
command_timeout: execute_command 默认超时秒数 /
177+
Default execute_command timeout in seconds
101178
"""
102179

103180
def __init__(
104181
self,
105182
skills_dir: str = ".skills",
106-
remote_skill_names: Optional[List[str]] = None,
183+
remote_skills: Optional[List[Tuple[str, Optional[str]]]] = None,
107184
config: Optional["Config"] = None,
108185
command_approval: Optional[Callable[[str, str], bool]] = None,
109186
command_timeout: int = 300,
110187
):
111188
self._skills_dir = skills_dir
112-
self._remote_skill_names = remote_skill_names or []
189+
# remote_skills: List[Tuple[name, qualifier]]; qualifier=None means
190+
# "no version specified". 由 `skill_tools()` 顶层入口在解析
191+
# "name@qualifier" 语法后构造好后传入,下游 `_ensure_skills_available`
192+
# 直接元组解包消费。
193+
self._remote_skills = remote_skills or []
113194
self._config = config
114195
self._skills_cache: Optional[List[SkillInfo]] = None
115196
self._command_approval = command_approval
@@ -118,33 +199,43 @@ def __init__(
118199
def _ensure_skills_available(self) -> None:
119200
"""确保远程 skill 已下载到本地 / Ensure remote skills are downloaded locally
120201
121-
对每个 remote_skill_name,检查本地是否已存在对应目录,
122-
不存在则通过 ToolClient 下载。
202+
对每个 (skill_name, qualifier),检查本地目录是否已存在。
203+
- 未指定 qualifier 且目录已存在 → 跳过下载
204+
- 指定了 qualifier → 强制重新下载,避免使用本地旧版本
205+
- 目录不存在 → 下载
123206
124-
For each remote_skill_name, check if the local directory exists,
125-
download via ToolClient if not.
207+
For each (skill_name, qualifier) pair, check whether the local directory
208+
already exists.
209+
- No qualifier and directory exists → skip download
210+
- Qualifier specified → force re-download to avoid stale local version
211+
- Directory missing → download
126212
"""
127-
if not self._remote_skill_names:
213+
if not self._remote_skills:
128214
return
129215

130216
from agentrun.tool.client import ToolClient
131217

132-
for skill_name in self._remote_skill_names:
218+
for skill_name, qualifier in self._remote_skills:
133219
skill_path = os.path.join(self._skills_dir, skill_name)
134-
if os.path.isdir(skill_path):
220+
if qualifier is None and os.path.isdir(skill_path):
135221
logger.debug(
136222
f"Skill '{skill_name}' already exists at {skill_path}, "
137223
"skipping download"
138224
)
139225
continue
226+
label = (
227+
f"{skill_name}@{qualifier}" if qualifier else skill_name
228+
)
140229
logger.info(
141-
f"Downloading remote skill '{skill_name}' to {self._skills_dir}"
230+
f"Downloading remote skill '{label}' to {self._skills_dir}"
142231
)
143232
tool_resource = ToolClient().get(
144233
name=skill_name, config=self._config
145234
)
146235
tool_resource.download_skill(
147-
target_dir=self._skills_dir, config=self._config
236+
target_dir=self._skills_dir,
237+
qualifier=qualifier,
238+
config=self._config,
148239
)
149240

150241
def _parse_skill_metadata(self, skill_dir: str) -> SkillInfo:
@@ -730,30 +821,39 @@ def skill_tools(
730821
name: Optional[Union[str, List[str], "ToolResource"]] = None,
731822
*,
732823
skills_dir: str = ".skills",
824+
qualifier: Optional[str] = None,
733825
config: Optional["Config"] = None,
734826
command_approval: Optional[Callable[[str, str], bool]] = None,
735827
command_timeout: int = 300,
736828
) -> CommonToolSet:
737829
"""将 Skill 封装为通用工具集 / Wrap Skills as CommonToolSet
738830
739831
支持从工具名称、名称列表或 ToolResource 实例创建通用工具集。
832+
字符串入参支持 "skillName[@qualifier]" 语法以指定版本。
740833
Supports creating CommonToolSet from tool name, name list, or ToolResource instance.
834+
String inputs support "skillName[@qualifier]" syntax to specify a version.
741835
742836
Args:
743837
name: 远程 skill 名称、名称列表或 ToolResource 实例(可选)/
744838
Remote skill name, name list, or ToolResource instance (optional).
839+
字符串形式支持版本语法,如 "skillA@v1.0.0"、"skillA@default" /
840+
String form supports version syntax, e.g. "skillA@v1.0.0", "skillA@default".
745841
如果提供,会先下载到 skills_dir 再加载 /
746842
If provided, downloads to skills_dir before loading.
747843
如果不提供,仅从 skills_dir 加载本地已有的 skill /
748844
If not provided, only loads local skills from skills_dir.
749845
skills_dir: 本地 skill 目录,默认 ".skills" / Local skill directory, default ".skills"
846+
qualifier: 版本标识,仅在 name 为 ToolResource 实例时使用。
847+
字符串/列表形式请直接在 name 中用 "@" 语法指定 /
848+
Version qualifier; only applies when name is a ToolResource instance.
849+
For string/list inputs, use the "@" syntax inside name instead.
750850
config: 配置对象 / Configuration object
751851
command_approval: 命令执行前的确认回调函数(可选)/
752852
Optional approval callback invoked before executing commands.
753853
接收 (command, cwd) 参数,返回 True 允许执行,False 拒绝 /
754854
Receives (command, cwd), returns True to allow, False to reject.
755-
command_timeout: execute_command 的默认超时秒数,默认 30 /
756-
Default timeout in seconds for execute_command, default 30.
855+
command_timeout: execute_command 的默认超时秒数,默认 300 /
856+
Default timeout in seconds for execute_command, default 300.
757857
758858
Returns:
759859
CommonToolSet: 包含 load_skills、read_skill_file、execute_command 工具的通用工具集 /
@@ -766,22 +866,30 @@ def skill_tools(
766866
>>> # 下载远程 skill 后加载 / Download remote skill then load
767867
>>> ts = skill_tools("my-remote-skill")
768868
>>>
869+
>>> # 指定版本下载 / Download a specific version
870+
>>> ts = skill_tools("my-remote-skill@v1.0.0")
871+
>>> ts = skill_tools("my-remote-skill@default")
872+
>>>
873+
>>> # 多 skill 混合版本 / Multiple skills with mixed versions
874+
>>> ts = skill_tools(["skill-a@v1.0.0", "skill-b@latest", "skill-c"])
875+
>>>
876+
>>> # 通过 ToolResource 实例指定版本 / Specify version via ToolResource
877+
>>> tool = ToolClient().get("my-skill")
878+
>>> ts = skill_tools(tool, qualifier="v1.0.0")
879+
>>>
769880
>>> # 带命令确认回调 / With command approval callback
770881
>>> ts = skill_tools(
771882
... skills_dir=".skills",
772883
... command_approval=lambda cmd, cwd: input(f"Execute '{cmd}'? [y/N]: ").lower() == "y",
773884
... )
774-
>>>
775-
>>> # 自定义超时 / Custom timeout
776-
>>> ts = skill_tools(skills_dir=".skills", command_timeout=120)
777885
"""
778-
remote_names: List[str] = []
886+
remote_skills: List[Tuple[str, Optional[str]]] = []
779887

780888
if name is not None:
781889
if isinstance(name, str):
782-
remote_names = [name]
890+
remote_skills = [_parse_skill_qualifier(name)]
783891
elif isinstance(name, list):
784-
remote_names = name
892+
remote_skills = [_parse_skill_qualifier(item) for item in name]
785893
else:
786894
# ToolResource instance — extract its name and download
787895
tool_resource_instance = name
@@ -790,14 +898,17 @@ def skill_tools(
790898
) or getattr(tool_resource_instance, "tool_name", None)
791899
if resource_name:
792900
skill_path = os.path.join(skills_dir, resource_name)
793-
if not os.path.isdir(skill_path):
901+
# 指定 qualifier 时强制重下;否则仅当本地缺失时下载
902+
if qualifier is not None or not os.path.isdir(skill_path):
794903
tool_resource_instance.download_skill(
795-
target_dir=skills_dir, config=config
904+
target_dir=skills_dir,
905+
qualifier=qualifier,
906+
config=config,
796907
)
797908

798909
loader = SkillLoader(
799910
skills_dir=skills_dir,
800-
remote_skill_names=remote_names,
911+
remote_skills=remote_skills,
801912
config=config,
802913
command_approval=command_approval,
803914
command_timeout=command_timeout,

agentrun/tool/__tool_async_template.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -568,12 +568,20 @@ def _get_auth_headers(
568568
return headers
569569

570570
def _get_skill_download_url(
571-
self, config: Optional[Config] = None
571+
self,
572+
qualifier: Optional[str] = None,
573+
config: Optional[Config] = None,
572574
) -> Optional[str]:
573575
"""获取 Skill 工具的下载 URL / Get download URL for Skill tools
574576
575-
根据 data_endpoint 和 tool_name 构造下载地址。
576-
Constructs download URL from data_endpoint and tool_name.
577+
根据 data_endpoint 和 tool_name 构造下载地址。可选地指定版本 qualifier。
578+
Constructs download URL from data_endpoint and tool_name, optionally with a version qualifier.
579+
580+
Args:
581+
qualifier: 版本标识,如 "v1.0.0"、"default"、"LATEST"。为空时下载缺省版本 /
582+
Version qualifier (e.g. "v1.0.0", "default", "LATEST").
583+
When None, downloads the default version.
584+
config: 配置对象 / Configuration object
577585
578586
Returns:
579587
Optional[str]: 下载 URL / Download URL
@@ -585,20 +593,30 @@ def _get_skill_download_url(
585593
data_endpoint = cfg.get_data_endpoint()
586594
if not data_endpoint or not effective_name:
587595
return None
588-
return f"{data_endpoint}/tools/{effective_name}/download"
596+
url = f"{data_endpoint}/tools/{effective_name}/download"
597+
if qualifier:
598+
from urllib.parse import quote
599+
600+
url = f"{url}?qualifier={quote(qualifier, safe='')}"
601+
return url
589602

590603
async def download_skill_async(
591604
self,
592605
target_dir: str = ".skills",
606+
qualifier: Optional[str] = None,
593607
config: Optional[Config] = None,
594608
) -> str:
595609
"""异步下载 Skill 包并解压到本地目录 / Download skill package and extract to local directory asynchronously
596610
597611
从数据链路下载 skill 的 zip 包,并解压到 {target_dir}/{tool_name}/ 目录下。
612+
可选地通过 qualifier 指定版本(如 "v1.0.0"、"default"、"LATEST")。
598613
Downloads skill zip package from data endpoint and extracts to {target_dir}/{tool_name}/ directory.
614+
Optionally specify a version qualifier (e.g. "v1.0.0", "default", "LATEST").
599615
600616
Args:
601617
target_dir: 目标根目录,默认为 ".skills" / Target root directory, defaults to ".skills"
618+
qualifier: 版本标识,为空时下载缺省版本 /
619+
Version qualifier; when None, downloads the default version
602620
config: 配置对象,可选 / Configuration object, optional
603621
604622
Returns:
@@ -615,7 +633,9 @@ async def download_skill_async(
615633
f" got {self.tool_type}"
616634
)
617635

618-
download_url = self._get_skill_download_url(config)
636+
download_url = self._get_skill_download_url(
637+
qualifier=qualifier, config=config
638+
)
619639
if not download_url:
620640
raise ValueError(
621641
"Cannot construct download URL: data_endpoint or tool_name"

0 commit comments

Comments
 (0)