技能系统
skills.ts 实现 Agent Skills 规范(见 https://agentskills.io/integrate-skills)。技能是 markdown 文件,带 frontmatter 描述技能用途,LLM 在系统提示里看到技能列表后,通过 read 工具读取对应 SKILL.md 获取详细指令。这个文件负责技能的发现、加载、校验、去重、格式化为 prompt 段落。
职责
- 发现规则:
loadSkillsFromDirInternal递归扫目录——遇到SKILL.md视为技能根不再递归,否则扫子目录和根.md文件。见packages/coding-agent/src/core/skills.ts:173-280。 - frontmatter 校验:
validateName检查名称(a-z0-9-、不超 64 字符、不连字符开头/结尾、无--、与父目录名一致),validateDescription检查描述必填且不超 1024 字符。见packages/coding-agent/src/core/skills.ts:93-117、packages/coding-agent/src/core/skills.ts:122-132。 - 多源加载:
loadSkills从用户全局(~/.pi/agent/skills)、项目(./.pi/skills)、显式skillPaths三个来源加载,冲突时保留先注册的并生成collision诊断。见packages/coding-agent/src/core/skills.ts:405-504。 - 去重:用
realpath解析符号链接,同一文件通过不同路径注册只算一次;同名技能先到先得。见packages/coding-agent/src/core/skills.ts:416-445。 - prompt 格式化:
formatSkillsForPrompt生成<available_skills>XML 段落,每个技能<name>/<description>/<location>三元组,disableModelInvocation: true的技能不进 prompt(只能通过/skill:name显式调用)。见packages/coding-agent/src/core/skills.ts:340-366。 - ignore 规则:尊重
.gitignore/.ignore/.fdignore,递归扫描时按目录前缀拼路径模式。见packages/coding-agent/src/core/skills.ts:48-66、packages/coding-agent/src/core/skills.ts:25-46。
设计动机
为什么用 SKILL.md 而不是直接扫所有 .md?因为一个技能可能包含多个文件(脚本、子文档),SKILL.md 是入口标志。遇到 SKILL.md 后停止递归,把整个目录当成技能包,让技能作者可以组织多个文件而不被当作多个独立技能。
为什么用 frontmatter 而不是注释?因为 frontmatter 是 YAML,结构化、可解析、可校验。名称、描述、disable-model-invocation 这些字段需要 LLM 和程序都读得懂。validateName 强制名称与父目录名一致(name "x" does not match parent directory "y"),避免技能文件被移动后名称漂移。
为什么需要 disableModelInvocation?有些技能是用户自用的提示模板,不应该被 LLM 自动触发(比如「写代码时用这个风格」),只在用户显式 /skill:name 时才加载。formatSkillsForPrompt 过滤掉这类技能,prompt 里只暴露允许 LLM 自动调用的。
为什么用 realpath 去重?因为用户可能通过 --skills ./my-skills 同时又把 ./my-skills 软链到 ~/.pi/agent/skills/my-skills,两次扫描会拿到同一文件的不同路径。canonicalizePath 解析符号链接后比较,避免重复加载同一技能。
关键文件
packages/coding-agent/src/core/skills.ts:11-19— 常量:MAX_NAME_LENGTH64、MAX_DESCRIPTION_LENGTH1024、IGNORE_FILE_NAMES。packages/coding-agent/src/core/skills.ts:21-46—toPosixPath和prefixIgnorePattern,把子目录的 ignore 规则加上目录前缀。packages/coding-agent/src/core/skills.ts:75-87—Skill接口:name、description、filePath、baseDir、sourceInfo、disableModelInvocation。packages/coding-agent/src/core/skills.ts:93-117—validateName,5 条规则校验。packages/coding-agent/src/core/skills.ts:173-280—loadSkillsFromDirInternal,递归扫描与 ignore 过滤。packages/coding-agent/src/core/skills.ts:282-330—loadSkillFromFile,frontmatter 解析与校验。packages/coding-agent/src/core/skills.ts:340-366—formatSkillsForPrompt,XML 段落生成。packages/coding-agent/src/core/skills.ts:405-504—loadSkills,多源加载与去重。
formatSkillsForPrompt 用 XML 标签包裹技能列表,转义 XML 特殊字符:
// packages/coding-agent/src/core/skills.ts:340-366
export function formatSkillsForPrompt(skills: Skill[]): string {
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
if (visibleSkills.length === 0) {
return "";
}
const lines = [
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
];
for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}loadSkillsFromDirInternal 遇到 SKILL.md 立即返回,不再递归:
// packages/coding-agent/src/core/skills.ts:199-226
for (const entry of entries) {
if (entry.name !== "SKILL.md") {
continue;
}
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
} catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
if (!isFile || ig.ignores(relPath)) {
continue;
}
const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
diagnostics.push(...result.diagnostics);
return { skills, diagnostics };
}数据流
技能从磁盘到 prompt:
边界与失败
- 无描述则拒绝加载:
frontmatter.description为空或 trim 后为空时返回{ skill: null },不进 skillMap,见packages/coding-agent/src/core/skills.ts:310-312。 - 名称不符仍加载:名称校验只产生 warning 诊断,不阻止加载,让用户能看到问题但技能仍可用,见
packages/coding-agent/src/core/skills.ts:300-307。 - 损坏符号链接:
statSync抛错时continue跳过,不中断整个扫描,见packages/coding-agent/src/core/skills.ts:207-213、packages/coding-agent/src/core/skills.ts:241-252。 - node_modules 跳过:递归时显式跳过
node_modules,避免扫依赖里的.md,见packages/coding-agent/src/core/skills.ts:233-236。 - path 类型解析:显式
skillPaths可以是文件或目录,目录走loadSkillsFromDirInternal,文件必须是.md,见packages/coding-agent/src/core/skills.ts:478-498。 - source 识别:显式 path 在
includeDefaults: false时仍尝试识别为 user/project 源(基于路径前缀),否则标为path,见packages/coding-agent/src/core/skills.ts:464-470。
小结
skills.ts 实现 Agent Skills 规范,三源加载(用户全局、项目、显式路径),frontmatter 校验,realpath 去重,XML 格式化注入 prompt。技能被系统提示引用后通过 read 工具读取,看 系统提示构建 和 工具集 read/bash/edit/write/grep/find/ls。装配技能路径的入口看 CLI 入口与分发。