7.2 Skill 系统
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:3.7 给过一句口诀——「Extension 是代码,Skill 是知识」。这句话在源码层面究竟体现为什么?一个
SKILL.md文件从磁盘走到模型眼前,中间经过哪几个函数?为什么 Skill 明明是「能力」却不是一个工具(Tool)?disable-model-invocation这个开关又切断了哪条路?本章把这条链路从磁盘扫描一路走到系统提示词(System Prompt),再走到/skill:name那条旁路。前置知识:3.7 Compaction、Extension 与 Skill(代码 vs 知识、渐进式披露)、6.4 工具系统(read 工具是什么)、6.8 配置系统(
~/.pi/agent与.pi、项目信任)、4.4 从哪里开始读源码(主链路轮廓)。学习目标:读完本章后你能
- 用「Pi 进程读不读这个文件的正文」这一条标准,把 Skill 和 Extension 的区别讲到源码级;
- 逐字段说清
SKILL.md的 frontmatter,并说出唯一会导致 skill 被拒绝加载的条件;- 复述发现规则的三条:
SKILL.md短路、根层.md、递归子目录,并指出两种发现模式("pi"/"agents")的差异;- 画出「磁盘 →
loadSkills→ 系统提示词 XML → 模型 read」这条注入链,并说出read工具没开时会发生什么;- 解释
disable-model-invocation为什么只影响系统提示词、不影响/skill:name;- 在没有任何 API Key 的前提下,自己造一个最小 skill、用
--skill加载、在启动清单里看到它。
建立直觉:Skill 是货架上的说明书,不是货架上的机器
3.7 已经把两者的分工说清楚了。这里把那句口诀往源码方向再推一步,给出一条可以直接用来判断的硬标准:
Pi 这个进程,会不会执行或解释这个文件的正文? Extension 是
.ts文件,Pi 把它 import 进自己的进程、注册它的钩子——Pi 执行它。 Skill 是.md文件,Pi 从头到尾只读它 frontmatter 里的两个字段(name、description),正文一个字都不看——读正文的是模型,用的是 read 工具。
这不是比喻,是源码事实。coding-agent 侧表示一个 skill 的数据结构里,压根没有存放正文的字段:
SkillFrontmatter// packages/coding-agent/src/core/skills.ts:67-81(行末中文为本书添加的批注,源码中没有)
export interface SkillFrontmatter {
name?: string;
description?: string;
"disable-model-invocation"?: boolean;
[key: string]: unknown;
}
export interface Skill {
name: string;
description: string;
filePath: string; // ← SKILL.md 的绝对路径
baseDir: string; // ← skill 目录,即 filePath 的父目录
sourceInfo: SourceInfo;
disableModelInvocation: boolean;
}Skill 里没有 content,只有 filePath。这一个缺失的字段就是整章的钥匙:Pi 交给模型的不是内容,是一张写着「有这么一本书、讲的是这个、放在这个书架上」的目录卡片。要不要把书取下来,由模型自己决定。这就是 3.7 里提到的**渐进式披露(Progressive Disclosure)**在 Pi 里的具体形态。
packages/coding-agent/docs/skills.md:68 括号里写着「模型并不总会这么做,可用提示词或 /skill:name 强制」;同文件 71 行把这套机制直接命名为 progressive disclosure)。/skill:name 存在的理由,就是给这个概率性行为一个确定性兜底。 <available_skills> XML 文本;模型「使用」skill 的动作,物理上就是调用 read 工具去读一个 .md 文件。因此 skill 段落是否注入,直接取决于 read 工具在不在——下文的 hasRead 门控就是干这件事的。 最小示例:三行 frontmatter 就是一个完整的 Skill
一个能被加载的 skill,最少只需要一个文件。建一个目录 hello-skill/,里面放 SKILL.md:
---
name: hello-skill
description: Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.
---
# Hello Skill
1. Say hello.
2. Tell the user which file you read this from.把它交给 Pi 之后,进入系统提示词的只有下面这段——正文(# Hello Skill 以下)完全没有出现。这是本书用仓库自带的 tsx 直接调用 loadSkills() + formatSkillsForPrompt() 得到的真实输出(跑法见本章实践任务步骤 5):
The 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>
<skill>
<name>hello-skill</name>
<description>Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.</description>
<location>/tmp/pi-skill-lab/hello-skill/SKILL.md</location>
</skill>
</available_skills>三条信息:叫什么、什么时候用、在哪里。第三条 <location> 是绝对路径——它就是给模型调用 read 工具时用的实参。
frontmatter 逐字段
| 字段 | 是否必需 | 源码行为 |
|---|---|---|
description | 是 | 缺失或全空白 → 不加载(skills.ts:305-307)。这是唯一的硬性拒绝条件 |
name | 否 | 缺失时回退为父目录名(skills.ts:296)。校验失败只产生 warning,skill 照常加载 |
disable-model-invocation | 否 | 等于 true 时从系统提示词中剔除(skills.ts:316 解析、skills.ts:336 过滤),但 /skill:name 仍可用 |
| 其他任意字段 | 否 | 落入索引签名 [key: string]: unknown,coding-agent 的加载路径不消费 |
校验规则(全部只产生 warning,不阻止加载):name 必须是小写字母、数字或连字符,≤64 字符,不以连字符开头或结尾,无连续连字符(skills.ts:92-112);description ≤1024 字符(skills.ts:117-127)。两个上限常量在 skills.ts:11 与 skills.ts:14,注释写着 "per spec"——指的是 Agent Skills 标准。
description 是模型判断「这个 skill 和当前任务有没有关系」的唯一依据。没有它,这个 skill 在系统提示词里就是一条永远不会被选中的死条目,加载了也没有意义。名字写错顶多难看,描述没了则完全失效——所以前者告警、后者拒绝。这两段逻辑写在同一个函数里,看一眼先后顺序就清楚。 // packages/coding-agent/src/core/skills.ts:295-307
// Use name from frontmatter, or fall back to parent directory name
const name = frontmatter.name || parentDirName;
// Validate name
const nameErrors = validateName(name);
for (const error of nameErrors) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
// Still load the skill even with warnings (unless description is completely missing)
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, diagnostics };
}frontmatter.name || parentDirName回到 Pi 源码:从磁盘到系统提示词
第一步:skill 目录从哪里来
在正常的 CLI(命令行界面,Command-Line Interface)流程下,skills.ts 自己不负责决定扫哪些目录。目录清单由 PackageManager.resolve() 汇总,一次性把用户级、项目级、.agents/ 三套来源合并成一个路径列表:
userAgentsSkillsDir归纳成表(CONFIG_DIR_NAME 默认 .pi、agentDir 默认 ~/.pi/agent,见 6.8):
| 来源 | 路径 | 需要项目信任? | 发现模式 |
|---|---|---|---|
| 用户级(pi 约定) | ~/.pi/agent/skills/ | 否 | "pi" |
| 用户级(跨 harness 约定) | ~/.agents/skills/ | 否 | "agents" |
| 项目级(pi 约定) | <cwd>/.pi/skills/ | 是 | "pi" |
| 项目级(跨 harness 约定) | <cwd> 及祖先目录的 .agents/skills/ | 是 | "agents" |
settings.json 的 skills 数组 | 任意 | 项目侧需信任 | 按落点判定 |
命令行 --skill <path> | 任意 | 否 | 显式路径 |
两种发现模式的差别只有一行代码:只有 "pi" 模式允许「根目录下的直接 .md 文件」各自算一个 skill;.agents/ 那套只认 SKILL.md 目录。
// packages/coding-agent/src/core/package-manager.ts:405-409
const relPath = toPosixPath(relative(root, fullPath));
if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) {
entries.push(fullPath);
continue;
}dir === rootdir === root 这个条件值得注意:只有根层的 .md 算数,递归进子目录之后就不再收集散装 .md 了。
第二步:扫描与解析
拿到目录列表后才轮到 skills.ts。它的发现规则写在函数注释里,且注释与实现一致:
loadSkillsFromDir「不再递归」在实现里表现为一个 return——第一轮循环只找 SKILL.md,一旦命中就带着结果直接返回,连同级的其他条目都不再看(skills.ts:194-221)。这条短路规则决定了:把一个 skill 目录嵌套在另一个 skill 目录里,外层赢、内层根本不会被发现。对应测试是 packages/coding-agent/test/skills.test.ts:108 的 "should prefer a directory's root SKILL.md over nested SKILL.md files"。
第二轮循环负责递归,同时做两件卫生工作:跳过 . 开头的目录和 node_modules(skills.ts:223-231),并按 .gitignore / .ignore / .fdignore 过滤(skills.ts:16 定义文件名,skills.ts:47-65 加载规则)。这解释了一个容易困惑的现象:放在被 gitignore 命中的目录里的 skill 不会被加载,而且没有任何报错。
汇总阶段用 Map<name, Skill> 去重,先到者胜,同名冲突记一条 type: "collision" 诊断(skills.ts:410-426);符号链接指向同一物理文件时用 canonicalizePath 静默跳过(skills.ts:403-408)。冲突的优先级顺序(project > user > package)有专门的回归测试:packages/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts。
第三步:--skill 与 --no-skills 的关系
命令行这两个开关的说明写得很克制(真实采集,research/cli-captures/pi-help.txt:43-44):
--skill <path> Load a skill file or directory (can be used multiple times)
--no-skills, -ns Disable skills discovery and loading但「disable ... loading」并不等于「一个都不加载」。源码里 --no-skills 只砍掉自动发现的那部分,显式 --skill 路径照样合并进去:
// packages/coding-agent/src/core/resource-loader.ts:467-472
const skillPaths = this.noSkills
? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
: this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);updateSkillsFromPaths再往下一层,updateSkillsFromPaths 调用 loadSkills 时把 includeDefaults 固定写死为 false(resource-loader.ts:676-681)——默认目录已经由 PackageManager 汇总过了,不能再扫一遍。据此推断(尚未在源码中直接证实):loadSkills 里那个 includeDefaults: true 分支(skills.ts:430-433)是留给把 Pi 当库用的 SDK 调用者的,正常 CLI 流程恒为 false;依据是仓库内对该参数只有这一个传入点。
第四步:注入系统提示词
skill 数据结构最终在重建系统提示词时被消费:
_rebuildSystemPrompt注意 _rebuildSystemPrompt 的入参是工具名列表——这不是巧合。skills 段落有一道门:
// packages/coding-agent/src/core/system-prompt.ts:154-157
// Append skills section (only if read tool is available)
if (hasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}formatSkillsForPrompt(skills)这道门是「Skill 不是 tool」这个设计的直接后果:既然模型只能靠 read 工具取正文,那么在 pi --exclude-tools read 或 --no-tools 之后,把 skill 清单塞进上下文就纯属浪费 token。一种看法是,这属于难得的「连提示词也要做死代码消除」的工程细节;代价是它让系统提示词的内容依赖于工具集,调试时容易忽略。
最后一环是格式化:
formatSkillsForPrompt// packages/coding-agent/src/core/skills.ts:335-348
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.",
// …(省略:第三句,说明 skill 正文里的相对路径应按 skill 目录解析)
"",
"<available_skills>",
];
// …(省略 350-356:为每个 skill push 出 <skill><name/><description/><location/></skill>)第 336 行那个 filter 就是 disable-model-invocation 的全部作用范围——它只影响系统提示词,不影响 skill 是否被加载。加了这个开关的 skill 依然在 session.skills 里、依然出现在 /skill: 自动补全里,只是模型「不知道」它存在。对应测试是 packages/coding-agent/test/skills.test.ts:308-330 的 "should exclude skills with disableModelInvocation from prompt",它断言 <name>hidden-skill</name> 不出现,且 <skill> 只出现一次。
第五步:/skill:name 那条旁路
模型自主加载是概率性的。Pi 另给了一条确定性通道:用户直接输入 /skill:name,Pi 在消息发出去之前就把正文塞进用户消息。
_expandSkillCommand// packages/coding-agent/src/core/agent-session.ts:1311-1315
try {
const content = readFileSync(skill.filePath, "utf-8");
const body = stripFrontmatter(content).trim();
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
return args ? `${skillBlock}\n\n${args}` : skillBlock;这五行里有三个值得记住的细节:
- 读盘的是 Pi 自己,不是模型——这是全章唯一一处 Pi 进程真正读 skill 正文的地方,而且是用户显式要求的结果;
baseDir被写进提示语("References are relative to ..."),这样 skill 正文里的相对路径(例如scripts/process.sh)对模型才有意义;- 命令后面跟的参数用
\n\n直接接在</skill>之后,没有任何前缀——这一点下文与官方文档对照时还会再提。
交互模式把每个 skill 注册成一个自动补全命令 skill:${skill.name}(interactive-mode.ts:641),受 settings.json 的 enableSkillCommands 开关控制(默认 true,settings-manager.ts:1049)。
图解
图 7.2-1 Skill 的发现与注入链路
从上到下读。上半部分是「哪些目录会被扫」,注意 --skill 直接汇入 updateSkillsFromPaths,绕过了 PackageManager——这就是 --no-skills 砍不掉它的原因。下半部分是「元数据怎么变成提示词文本」,菱形节点是本章最容易被忽略的一道门:read 工具不在,整段 skills 就不会出现。每个节点都标了真实源码位置,可以用 grep -rn 逐个复核。
这张图里没有任何一条线通向「工具注册表」。这正是 Skill 与 7.3 自定义工具 的分水岭:后者真的会往工具列表里加一项,前者只往提示词里加一段文本。
下面这张图对比两条「让模型真正拿到正文」的路径:
图 7.2-2 两条加载路径:模型自主 read 与 /skill 强制展开
关注两处差异。第一,谁去读文件:路径 A 是模型发起 toolCall、Pi 代为执行 read 工具;路径 B 是 Pi 在 _expandSkillCommand(agent-session.ts:1301)里直接 readFileSync。第二,正文落在哪种消息里:路径 A 落在工具结果里,路径 B 落在用户消息里。disable-model-invocation 切断的只有路径 A——因为它删掉的正是路径 A 起点所依赖的那份目录。
把两张图串起来,本章的完整调用链是:
- 起点
createAgentSessionServices(packages/coding-agent/src/core/agent-session-services.ts:134) - →
resourceLoader.reload()(调用点agent-session-services.ts:152,定义resource-loader.ts:387) - →
packageManager.resolve()(resource-loader.ts:403,目录汇总package-manager.ts:2342-2358) - →
updateSkillsFromPaths(resource-loader.ts:472,定义resource-loader.ts:671) - →
loadSkills(resource-loader.ts:676,定义skills.ts:387) - →
loadSkillsFromDirInternal(skills.ts:173)→loadSkillFromFile(skills.ts:277)→parseFrontmatter(packages/coding-agent/src/utils/frontmatter.ts:28) - →
AgentSession.setActiveToolsByName(agent-session.ts:926)→_rebuildSystemPrompt(调用点agent-session.ts:939,定义agent-session.ts:1021) - →
buildSystemPrompt(agent-session.ts:1054,定义packages/coding-agent/src/core/system-prompt.ts:28) - → 终点
formatSkillsForPrompt(system-prompt.ts:156,定义skills.ts:335)
旁路那条:AgentSession.prompt(agent-session.ts:1114)→ _expandSkillCommand(调用点 agent-session.ts:1154,定义 agent-session.ts:1301)→ stripFrontmatter(utils/frontmatter.ts:39)。TUI(终端界面,Terminal User Interface)侧再用 parseSkillBlock(agent-session.ts:127)把这个 block 解析回来做折叠显示(interactive-mode.ts:3318-3336)。
真实启动清单里的那三个名字
本书采集的 TUI 启动画面里有这么一行(真实采集,research/cli-captures/pi-tui-main.txt:9-10,采集时的工作目录是 Pi 仓库根):
[Skills]
add-llm-provider, find-skills, ssh-skill这三个名字来自两个不同的地方,值得逐一落实(源码与文件位置经本次核对):
add-llm-provider来自 Pi 仓库自己的.pi/skills/add-llm-provider.md,是仓库里 git 追踪的文件。注意它是一个根层散装.md文件,不是SKILL.md目录——正好命中前面那条mode === "pi" && dir === root。它的description是一份给packages/ai添加新 LLM(大语言模型,Large Language Model)Provider(模型服务提供方)的清单,覆盖核心类型、Provider 实现、懒注册、模型生成、测试矩阵、coding-agent 接线与文档。find-skills与ssh-skill不在 Pi 仓库里。它们来自采集机器上的~/.agents/skills/——也就是那条跨 harness 的用户级约定目录。换一台机器跑,这两项很可能不出现。
第一条是 4.1 讲过的「Pi 用自己开发自己」的又一个实例,而且是最省事的那种:给自己加 Provider 这件事有固定套路,作者没有把它写成扩展、也没有塞进系统提示词,而是写成一份仓库内的 Markdown,让 Agent 在需要时自己去读。从源码结构看,这与 system-prompt.ts:131-138 那段「Pi documentation」是同一个思路的两个层次:后者告诉模型「Pi 的文档在这些绝对路径下」,前者告诉模型「这个仓库里有一份专门讲加 Provider 的操作手册」。
按 ctrl+o 展开后,清单会按来源分组并显示绝对路径(真实采集,本书实测,复现步骤见实践任务步骤 3):
[Skills]
user
~/.agents/skills/find-skills/SKILL.md
~/.agents/skills/ssh-skill/SKILL.md
path
/private/tmp/pi-skill-lab/hello-skill/SKILL.mduser / path 这两个分组名直接来自 createSkillSourceInfo(skills.ts:136-158)对 source 字符串的映射,而 source 由 getSource(skills.ts:447-453)判定:落在用户目录下算 "user"、项目目录下算 "project"、其余(例如 --skill 给的任意路径)算 "path"。同一套信息在 /skill: 自动补全里被压缩成一个字母前缀 [u] / [p] / [t](interactive-mode.ts:523-546)。
官方文档对照
官方文档 packages/coding-agent/docs/skills.md 是本章内容的权威说明,大部分与源码一致:
- 官方文档说明,Pi 实现了 Agent Skills 标准但「宽松」:多数违规只告警不拒绝,并明确允许 skill 名字与父目录不一致(理由是共享 skill 目录会被多个 harness 使用)。这与
skills.ts的validateName一致——它确实没有这条检查。 - 官方文档说明的目录清单(
docs/skills.md:24-41)与package-manager.ts:2342-2358一致,包括「.agents/skills里根层.md被忽略」这一条。 - 官方文档说明「缺 description 不加载;同名冲突告警并保留先发现者」(
docs/skills.md:186-188),与skills.ts:305-307、skills.ts:410-426一致。
有一处与源码不一致,读者按文档理解会走偏:
docs/skills.md:82写:命令后的参数会以User: <args>的形式追加到 skill 内容后面。 源码(agent-session.ts:1315)是`${skillBlock}\n\n${args}`——发给模型的文本里没有User:前缀。「User」只体现在 TUI 层:那段尾巴被单独渲染成一条用户消息组件(interactive-mode.ts:3328-3336)。教学与调试都应以源码为准。
另有一处尚未确认:docs/skills.md 的 frontmatter 表还列了 license、compatibility、metadata、allowed-tools 四个字段(其中 allowed-tools 标注为实验性)。在本章的调研范围内,没有看到 coding-agent 的 skill 加载路径消费它们——SkillFrontmatter 只显式声明三个字段,其余落入索引签名。它们可能由扩展或其他模块处理,尚未确认。
packages/agent(包名 @earendil-works/pi-agent-core)里也有一个 harness/skills.ts,与 coding-agent 的那份是平行实现而非复用(coding-agent 并没有 import 它)。差异之一很有意思:harness 版的 validateName 多一条检查——名字与父目录不一致时告警(packages/agent/src/harness/skills.ts:283),也就是说它走的是标准的严格版本,而 coding-agent 刻意不检查。差异之二是 harness 版的 Skill 结构带 content 正文(packages/agent/src/harness/types.ts:64-75),因为它面向的是「应用把资源注入进来」而不是「自己去磁盘上发现」。这一对差异本身就是 [6.3](/pi-modules/agent-core) 讲的「库层 vs 产品层」分工的注脚。 实践任务
目标:亲手走完「造文件 → 被发现 → 进清单 → 进系统提示词」全过程,并顺带验证三条规则:① --no-skills 砍不掉显式 --skill;② 缺 description 的 skill 不加载且会报诊断;③ disable-model-invocation 的 skill 会加载但不进提示词。全程不需要任何 API Key。
前置:本地有 Pi 源码仓库(本书锁定的 commit 即可)。下文用 $PI_REPO 表示仓库根目录绝对路径,先执行 export PI_REPO=/你的路径/pi。
步骤 1:造两个 skill,一个正常、一个故意缺 description:
mkdir -p /tmp/pi-skill-lab/hello-skill /tmp/pi-skill-lab/broken-skill
cat > /tmp/pi-skill-lab/hello-skill/SKILL.md <<'EOF'
---
name: hello-skill
description: Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.
---
# Hello Skill
1. Say hello.
2. Tell the user which file you read this from.
EOF
printf -- '---\nname: broken-skill\n---\n\n# Broken\n' > /tmp/pi-skill-lab/broken-skill/SKILL.md步骤 2:启动 Pi,只加载正常那个。用仓库自带的 pi-test.sh 直接跑源码(4.1 用过);--no-env 清掉环境里的 API Key,另两个开关的含义按帮助原文(真实采集,research/cli-captures/pi-help.txt:55-56)是 --offline「禁用启动期网络操作」、--no-approve, -na「本次运行忽略项目本地文件」:
cd /tmp/pi-skill-lab
$PI_REPO/pi-test.sh --no-env --offline --no-approve --skill ./hello-skill预期现象:启动画面里出现 [Skills] 一节,其中至少有 hello-skill。本书实测(macOS + tmux,采集机器的 ~/.agents/skills 里另有两个 skill)输出为:
[Skills]
find-skills, hello-skill, ssh-skill如果你的 ~/.agents/skills 和 ~/.pi/agent/skills 是空的,你只会看到 hello-skill——这同样算成功。
步骤 3:按 ctrl+o 展开,清单会按来源分组并给出绝对路径。本书实测输出:
[Skills]
user
~/.agents/skills/find-skills/SKILL.md
~/.agents/skills/ssh-skill/SKILL.md
path
/private/tmp/pi-skill-lab/hello-skill/SKILL.md确认你的 hello-skill 落在 path 分组下——它是通过 --skill 进来的,不属于任何默认目录。再输入 /skill:,自动补全列表会带上来源字母前缀(本书实测输出,右侧描述被终端宽度截断):
→ skill:find-skills [u] Helps users discover and install agent skills when they ask questions lik
skill:ssh-skill [u] Use this skill for SSH and remote server operations, including login, com
skill:hello-skill [t] Explains how this book verifies skill loading. Use when the user asks abo确认 hello-skill 那一行是 [t],与展开清单里的 path 分组对得上——这个字母由 getAutocompleteSourceTag(interactive-mode.ts:523-546)从 sourceInfo.scope 映射而来,加方括号在 prefixAutocompleteDescription(interactive-mode.ts:548-554)。看完按两次 ctrl+c 退出。
步骤 4:验证 --no-skills 与缺 description。这次把两个 skill 都显式传进去,同时打开 --no-skills:
cd /tmp/pi-skill-lab
$PI_REPO/pi-test.sh --no-env --offline --no-approve --no-skills --skill ./hello-skill --skill ./broken-skill本书实测输出(自动发现的那两个消失了;broken-skill 被拒绝并给出诊断):
[Skills]
hello-skill
[Skill conflicts]
/private/tmp/pi-skill-lab/broken-skill/SKILL.md
description is required步骤 5(看看进入提示词的到底是什么):写一个只读探针,直接调用本章讲的两个函数。存成 /tmp/pi-skill-lab/probe.ts,把第一行的路径换成你的 $PI_REPO 实际值:
import { formatSkillsForPrompt, loadSkills } from "/你的路径/pi/packages/coding-agent/src/core/skills.ts";
const { skills, diagnostics } = loadSkills({
cwd: "/tmp/pi-skill-lab",
agentDir: "/tmp/pi-skill-lab/agent",
skillPaths: process.argv.slice(2),
includeDefaults: false,
});
console.log("skills =", skills.map((s) => s.name));
console.log("diagnostics =", diagnostics.map((d) => `${d.type}: ${d.message}`));
console.log("--- system prompt fragment ---");
console.log(formatSkillsForPrompt(skills));用仓库自带的 tsx 跑(不需要 build,也不会改动仓库里任何文件):
cd $PI_REPO
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/pi-skill-lab/probe.ts /tmp/pi-skill-lab/hello-skill /tmp/pi-skill-lab/broken-skill本书实测输出(正文一个字都没进提示词,只有三元组):
skills = [ 'hello-skill' ]
diagnostics = [ 'warning: description is required' ]
--- system prompt fragment ---
The 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>
<skill>
<name>hello-skill</name>
<description>Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.</description>
<location>/tmp/pi-skill-lab/hello-skill/SKILL.md</location>
</skill>
</available_skills>步骤 6(验证 disable-model-invocation):再造一个带开关的 skill,然后重跑探针:
mkdir -p /tmp/pi-skill-lab/secret-skill
cat > /tmp/pi-skill-lab/secret-skill/SKILL.md <<'EOF'
---
name: secret-skill
description: Only reachable via /skill:secret-skill. Never advertised in the system prompt.
disable-model-invocation: true
---
# Secret Skill
EOF
cd $PI_REPO
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/pi-skill-lab/probe.ts /tmp/pi-skill-lab/hello-skill /tmp/pi-skill-lab/secret-skill本书实测输出的第一行是 skills = [ 'hello-skill', 'secret-skill' ]——两个都加载了;但下方的 XML 里只有 hello-skill。
如何判断成功:四个断言全部对上即通过。① 步骤 2 的清单里有 hello-skill;② 步骤 4 里自动发现的 skill 消失但 hello-skill 还在,且 broken-skill 报 description is required;③ 步骤 5 的 XML 里没有 Say hello 这类正文;④ 步骤 6 里 secret-skill 出现在 skills 数组但不出现在 XML 里。
常见错误:
- 把命令写成
... | tee out.txt:管道让标准输出不是 TTY(终端),resolveAppMode(main.ts:109-120,见 4.4)会自动切到 print 模式并立刻退出,你根本看不到 TUI 清单。这是本书实测踩过的坑。 - 文件名写成小写
skill.md:发现逻辑用的是精确比较entry.name !== "SKILL.md"(skills.ts:195)。放在~/.pi/agent/skills/my-skill/skill.md这种位置时它不会被发现——第一轮找不到SKILL.md,第二轮递归进子目录后dir === root不再成立,散装.md规则也不适用。统一用全大写最省事。 - 把 lab 目录放在被
.gitignore命中的路径下:skills.ts:47-65会按 ignore 规则过滤,skill 静默消失、没有任何报错。 - 关于
--no-approve:本书实测在空的 lab 目录下加不加它,[Skills]一节完全一样——它的作用是让本次运行忽略项目本地文件。如果你把 lab 目录建在一个已有.pi/或AGENTS.md的项目里,去掉它会连带把该项目的资源和信任流程一起牵进来,观察结果就不干净了。
对应源码位置:发现 packages/coding-agent/src/core/skills.ts:160-275;解析与拒绝 skills.ts:277-325;--skill 与 --no-skills 合并 packages/coding-agent/src/core/resource-loader.ts:467-472;注入门控 packages/coding-agent/src/core/system-prompt.ts:154-157;XML 格式化 skills.ts:335-361;来源分组 skills.ts:136-158 与 skills.ts:447-453。相关测试:packages/coding-agent/test/skills.test.ts(尤其第 308 行的 "should exclude skills with disableModelInvocation from prompt")与 packages/coding-agent/test/resource-loader.test.ts("should discover skills from agentDir")。
本章小结
- Skill 的本质是元数据加一个文件路径。coding-agent 的
Skill结构里没有content字段(skills.ts:74-81);Pi 唯一一次读 skill 正文,是用户敲/skill:name时(agent-session.ts:1312)。这就是「Extension 是代码、Skill 是知识」在源码里的样子。 - 格式极简、校验极宽:
description是唯一硬性要求,缺了就不加载;name可省(回退父目录名),写错只告警;未知字段被忽略。 - 发现规则三条:目录含
SKILL.md则短路、不再递归;"pi"模式下根层散装.md各算一个 skill;递归子目录找SKILL.md。.gitignore生效,node_modules与.开头目录被跳过。同名冲突先到者胜。 - 注入的是文本,不是工具:
formatSkillsForPrompt输出<available_skills>XML 三元组,且只在read工具存在时注入(system-prompt.ts:155)。disable-model-invocation唯一的作用就是把自己从这段 XML 里摘掉。 --no-skills关的是自动发现,不是显式--skill(resource-loader.ts:467-469)。- Pi 仓库自带的
.pi/skills/add-llm-provider.md是「拿 skill 记录自己怎么被扩展」的实例;启动清单里另外两个名字来自机器上的~/.agents/skills/,并不属于仓库。 - 关键术语:技能(Skill)、渐进式披露(Progressive Disclosure)、frontmatter、发现模式(SkillDiscoveryMode)、名字冲突(collision)、skill block。
- 关键源码索引:
- 数据结构与校验:
packages/coding-agent/src/core/skills.ts:67-127 - 发现与解析:
skills.ts:160-325 - XML 格式化:
skills.ts:335-361 - 汇总去重与冲突:
skills.ts:387-487 - 目录来源与发现模式:
packages/coding-agent/src/core/package-manager.ts:347-419、:2342-2472 - CLI 开关合并:
packages/coding-agent/src/core/resource-loader.ts:467-472、:671-692 - 系统提示词门控:
packages/coding-agent/src/core/system-prompt.ts:154-157(自定义提示词路径:63-67) - 会话侧组装与展开:
packages/coding-agent/src/core/agent-session.ts:1021-1054、:1301-1325 - TUI 命令注册:
packages/coding-agent/src/modes/interactive/interactive-mode.ts:636-648 - 测试:
packages/coding-agent/test/skills.test.ts、test/resource-loader.test.ts、test/sdk-skills.test.ts、test/suite/regressions/2781-skill-collision-precedence.test.ts - 官方文档:
packages/coding-agent/docs/skills.md
- 数据结构与校验:
- 自测问题:
- 一个
SKILL.md的 frontmatter 只写了name没写description,它会被加载吗?只写了description没写name呢?分别说出对应源码行。 - 用
pi --tools bash,edit --skill ./my-skill启动,my-skill会出现在启动清单里吗?会出现在系统提示词里吗?为什么? - 一个 skill 加了
disable-model-invocation: true,用户仍然想用它,有几种办法?它们分别走图 7.2-2 里的哪条路径? - 为什么
--no-skills --skill ./x这个看似矛盾的组合是有意义的?给出源码依据。
- 一个
- 下一章:7.3 自定义工具与斜杠命令——本章反复强调「Skill 不是 tool」,下一章讲真正往工具列表里加东西是怎么做的。
- 尚未展开的内容:Skill 的 npm / git 包分发(
PackageSource与package.json的pi.skills清单,位于package-manager.ts其余 2000 余行)留待深入;prompt-templates.ts的模板展开机制(纯客户端字符串替换,与 skill 的「模型自主加载」正好形成对照)见 6.7 系统提示词与 Prompt Templates;扩展在before_agent_start事件里整体覆盖系统提示词的能力(agent-session.ts:1246-1253)见 7.1 Extension 系统。