179 lines
7.8 KiB
Python
179 lines
7.8 KiB
Python
"""用户 skill 创作工具: save_skill / fork_skill。
|
|
|
|
为什么是 host-side typed tool(而非让 agent 用 fs/shell 写):
|
|
- fs/shell 的 base_dir 锚在进程 cwd(host)或容器 workdir(docker),**都不指向**
|
|
`user_root/.skills` —— 用相对路径写 `.skills` 只在 docker 下碰巧成立,host 下会
|
|
写错地方,跨 backend 不可靠。
|
|
- host-side 工具直接知道 `user_root/.skills`,一个落点两种 backend 通吃(与
|
|
seedream / DocumentDownload 直接 host 侧写 working_dir 完全一致的范式)。
|
|
- docker 下 user_root 整个 bind 到 /workspace,host 侧写进 .skills 的文件在容器内
|
|
`/workspace/.skills/...` 自动可见(fork 带过来的脚本随之可跑)。
|
|
|
|
这两个工具不在 `executor_docker.CONTAINER_TOOLS` 里,故恒在 host 侧执行。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from core.skills import SkillRegistry, parse_frontmatter
|
|
|
|
from .base import Tool
|
|
|
|
# skill 名:小写字母 / 数字开头,可含 _ -,≤64。挡住路径穿越(`../`、`/`)、空格、大写。
|
|
_SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
|
|
|
|
|
|
def _validate_skill_name(name: str) -> Optional[str]:
|
|
"""返回错误串(不合法)或 None(合法)。"""
|
|
if not name or not _SKILL_NAME_RE.match(name):
|
|
return (
|
|
f"skill 名 '{name}' 不合法:须小写字母/数字开头,只含小写字母、数字、_ 、-,"
|
|
"长度 ≤64(用于目录名,故挡空格 / 大写 / 路径分隔符)"
|
|
)
|
|
return None
|
|
|
|
|
|
def _set_frontmatter_name(text: str, new_name: str) -> str:
|
|
"""把 markdown frontmatter 里的 name 改成 new_name(只动 name 行,不重排其余字段)。
|
|
|
|
fork 后复制来的 SKILL.md 仍带原 skill 的 `name:`,不改的话注册时会用旧名 →
|
|
与被 fork 的内置同名、反而覆盖了内置。故 fork 落盘后必须把 name 对齐到 new_name。
|
|
"""
|
|
m = _FRONTMATTER_RE.match(text)
|
|
if not m:
|
|
return f"---\nname: {new_name}\n---\n\n" + text
|
|
fm = m.group(1)
|
|
if re.search(r"(?m)^name:", fm):
|
|
fm = re.sub(r"(?m)^name:.*$", f"name: {new_name}", fm, count=1)
|
|
else:
|
|
fm = f"name: {new_name}\n" + fm
|
|
return f"---\n{fm}\n---\n" + text[m.end():]
|
|
|
|
|
|
class SaveSkillTool(Tool):
|
|
name = "save_skill"
|
|
description = (
|
|
"Create or overwrite one of the USER's own skills at .skills/<name>/SKILL.md. "
|
|
"Use to author a skill from scratch or save an edited copy. The content must be a "
|
|
"full SKILL.md with YAML frontmatter containing `name` and `description` "
|
|
"(description is the routing blurb shown in the skill list — make it specific: "
|
|
"trigger words + when NOT to use). Takes effect from the user's NEXT message. "
|
|
"To copy a built-in skill that bundles scripts (e.g. ppt), use fork_skill instead "
|
|
"so the scripts come along."
|
|
)
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Skill name = directory name (lowercase, [a-z0-9_-], <=64). "
|
|
"Same name as a built-in => overrides it for this user; pick a new name to keep both.",
|
|
},
|
|
"content": {
|
|
"type": "string",
|
|
"description": "Full SKILL.md text including --- frontmatter --- (name + description).",
|
|
},
|
|
},
|
|
"required": ["name", "content"],
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
user_skills_dir: Path,
|
|
registry: SkillRegistry,
|
|
base_dir: Optional[Path] = None,
|
|
user_root: Optional[Path] = None,
|
|
) -> None:
|
|
super().__init__(base_dir, user_root=user_root)
|
|
self.user_skills_dir = Path(user_skills_dir)
|
|
self.registry = registry
|
|
|
|
def execute(self, name: str, content: str) -> str:
|
|
err = _validate_skill_name(name)
|
|
if err is not None:
|
|
return f"[Error] {err}"
|
|
# frontmatter 必须合法且有 description —— 写时就挡住"加载失败"黑洞
|
|
try:
|
|
meta, _ = parse_frontmatter(content)
|
|
except Exception as e: # yaml.YAMLError 等
|
|
return f"[Error] frontmatter YAML 非法,无法保存:{e}"
|
|
if not (meta.get("description") or "").strip():
|
|
return "[Error] frontmatter 缺 description —— 这是 skill 列表里的路由说明,必填(写清触发词 + 何时别用)"
|
|
fm_name = (meta.get("name") or "").strip()
|
|
if fm_name and fm_name != name:
|
|
return (
|
|
f"[Error] frontmatter 里 name='{fm_name}' 与目标名 '{name}' 不一致 —— "
|
|
"请改成一致(或省略 frontmatter 的 name,默认用目录名)"
|
|
)
|
|
skill_dir = self.user_skills_dir / name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
|
builtin = self.registry.get(name)
|
|
note = ""
|
|
if builtin is not None and builtin.source == "builtin":
|
|
note = f" (注意:与内置 skill '{name}' 同名,你这条将覆盖内置;想并存请改名)"
|
|
return f"[saved skill '{name}' to .skills/{name}/SKILL.md,下条消息生效]{note}"
|
|
|
|
|
|
class ForkSkillTool(Tool):
|
|
name = "fork_skill"
|
|
description = (
|
|
"Copy an existing skill (built-in or one of the user's own) — INCLUDING its bundled "
|
|
"scripts/assets — into the user's .skills/<new_name>/, so they can customize it. "
|
|
"This is the right way to 'copy zcbot's ppt skill and tweak it': fork first, then "
|
|
"edit the copied SKILL.md (with the edit tool or save_skill). The new copy's "
|
|
"frontmatter name is auto-set to new_name. Takes effect from the user's NEXT message."
|
|
)
|
|
parameters = {
|
|
"type": "object",
|
|
"properties": {
|
|
"src": {"type": "string", "description": "Name of the skill to copy (as listed in the skill discovery block)."},
|
|
"new_name": {
|
|
"type": "string",
|
|
"description": "New skill name (lowercase, [a-z0-9_-], <=64). Must not already exist under .skills/.",
|
|
},
|
|
},
|
|
"required": ["src", "new_name"],
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
user_skills_dir: Path,
|
|
registry: SkillRegistry,
|
|
base_dir: Optional[Path] = None,
|
|
user_root: Optional[Path] = None,
|
|
) -> None:
|
|
super().__init__(base_dir, user_root=user_root)
|
|
self.user_skills_dir = Path(user_skills_dir)
|
|
self.registry = registry
|
|
|
|
def execute(self, src: str, new_name: str) -> str:
|
|
err = _validate_skill_name(new_name)
|
|
if err is not None:
|
|
return f"[Error] {err}"
|
|
skill = self.registry.get(src)
|
|
if skill is None:
|
|
available = ", ".join(self.registry.skills.keys()) or "(none)"
|
|
return f"[Error] 源 skill '{src}' 不存在。可选:{available}"
|
|
dest = self.user_skills_dir / new_name
|
|
if dest.exists():
|
|
return f"[Error] .skills/{new_name}/ 已存在 —— 换个名字,或先删旧的"
|
|
self.user_skills_dir.mkdir(parents=True, exist_ok=True)
|
|
# copytree 整目录(SKILL.md + scripts/ + references/ + assets/ 一并带过来)
|
|
shutil.copytree(skill.skill_dir, dest)
|
|
md = dest / "SKILL.md"
|
|
n_files = sum(1 for _ in dest.rglob("*") if _.is_file())
|
|
if md.exists():
|
|
md.write_text(
|
|
_set_frontmatter_name(md.read_text(encoding="utf-8"), new_name),
|
|
encoding="utf-8",
|
|
)
|
|
return (
|
|
f"[forked '{src}' → .skills/{new_name}/ ({n_files} 个文件,frontmatter name 已设为 "
|
|
f"'{new_name}'),下条消息生效。现在可以编辑 .skills/{new_name}/SKILL.md 改造它]"
|
|
)
|