1212import os
1313import re
1414import 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
1726from agentrun .integration .utils .tool import CommonToolSet , Tool , ToolParameter
1827from 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
4788class 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 ,
0 commit comments