第三部分 · 能力接缝
第 10 章:技能系统
SKILL.md 是数据、provider 是发现、`skill` 工具是入口——技能不是工具,是内容
技能不是工具,是内容
「技能」(skill)与「工具」(tool)在 dsh 里是两种完全不同的东西。工具是可执行的代码(第 5 章),技能是可加载的内容:一份 SKILL.md,带 YAML frontmatter,里面是「做什么、怎么做」的指令文本。模型通过一个名叫 skill 的工具把它加载进上下文,然后按里面的指示行动。技能包是四个包组成的能力族:
| 包 | 角色 | 内容 |
|---|---|---|
skill/skill | Service Definition | ctx.skills:分层注册表(register)、list/get 两段式查询、renderSkillContent 唯一渲染器 |
skill/skill-filesystem | Provider | 本地文件系统发现:扫描 .dsh/skills、.agents/skills 等 6 级 rank 根,解析 frontmatter,经 ctx.fs 加载正文,digest 驱动热刷新 |
skill/skill-badge | Provider 范例 | 打包 provider:把一个技能目录发布成 npm 包(仓库自己的 .agents/skills/ 就是目录束约定的使用者) |
skill/tool-skill | Consumer | skill 工具 + 目录注入 + 用户手势(/name)注入 |
技能的表示很轻:目录束 <name>/SKILL.md 或平铺 <name>.md,仅一层(不支持递归 **/SKILL.md),<root>/.system 子目录被跳过。frontmatter 必填 name(kebab-case,文法即 SKILL_NAME 常量:/^[a-z0-9]+(?:-[a-z0-9]+)*$/)与 description,可选 whenToUse、metadata、disable-model-invocation、user-invocable。校验是 fail-closed 的:无效 frontmatter 会忽略并告警——但不隐藏有效的兄弟技能(坏文件不影响好文件);而驼峰旧拼写(disableModelInvocation 等)会直接抛错拒绝整条技能,绝不悄悄读错。发现与加载的生命周期分离:发现只解析 frontmatter,每次 skill 调用重读重解析当前文件——body 编辑零协调,「改文件即生效」。
分层注册表与两段式查询
注册表(skill/src/index.ts)建在第 3 章讲过的 ScopedLayers 上:host 层 + per-scope 覆盖层。同一个技能名可以在全局注册,也可以在某个 preset 的 scope 里注册同名覆盖——chainLayers 的「最近者胜」语义在这里再次出现(preset 可以给某个 agent 换掉一个技能的版本)。
register(skill: SkillRegistration): () => void {
// ...校验 name/description...
return this.layers.effect(
this.ctx,
layer => layer.skills.insert(skill.name, skill),
{ label: 'skills.register()' },
)
}
查询是两段式:list(lookup) 返回轻量摘要(供目录展示与技能工具的参数校验),get(name, lookup) 返回完整定义(含 content 正文与 resourceBase)。同名的多个 provider 来源按 rank → providerOrder → localOrder 决胜。provider 接口本身也是两段式(list 发现 + get 加载),候选里的 locator 是 provider 私有的不透明句柄——registry 只转交不解析;一个「可用但不权威」的发现结果用 {candidates, complete: false} 表达,快照不缓存、Consumer 保留 last-good 目录。渲染只有一个出口 renderSkillContent(skill)——它生成固定的三件套形状:
<skill_content name="skill-name"> <skill_resources> … resourceBase 三种形态(directory / url / opaque)的资源提示 … </skill_resources> <skill_instructions> … SKILL.md 正文原样 … </skill_instructions> </skill_content>
工具结果与手势注入共用这同一个渲染器,模型两条路径看到同一形状。本地 provider 的发现根是 6 级 rank(rank 越小优先级越高):
| rank | 根 | 说明 |
|---|---|---|
100 | 项目 .dsh/skills | 项目根 = 最近含 .git 的祖先,找不到用 cwd |
200 | 项目 .agents/skills | 仓库自带技能(dsh 自己的 .agents/skills/ 就是) |
300 | customSkillDirs | 配置的自定义目录 |
400 | ~/.dsh/skills | 用户级(跳过 .system) |
500 | ~/.agents/skills | 用户级 agents 目录 |
600 | $DSH_BUNDLED_SKILL_DIR | 打包内置根 |
skill 工具:模型侧的入口
模型侧只有一个入口工具 skill(tool-skill/src/index.ts):
const skillTool = defineTool({
name: 'skill',
description: 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.',
parameters: {
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', required: true },
provider: { type: 'string', required: true },
resourceBase: { /* directory | url | opaque */ },
content: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }],
},
async execute(args, exec) {
if (!isSkillName(args.name)) {
throw new Error(`invalid skill name "${args.name}"`)
}
// The agent is its own scope key, so the lookup resolves the layered
// registry exactly as this agent's composition sees it.
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent }
const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
if (!summary) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
}
if (!isModelInvocable(summary)) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
const skill = await ctx.skills.get(args.name, lookup)
// ...返回 { name, provider, resourceBase, content }
},
注意 lookup 的 scope: exec.agent——「agent 是自己的 scope key」,技能查询按这个 agent 的组合视角解析分层注册表。目录注入由另一个 agent/pre-step 监听器负责:它不是独立工具,而是一条持久的 user-role <system-reminder> 消息——内含 <available_skills> 列表(每条 - \`name\`: description,description 截断 500 字并 XML 转义),用 sha256 digest 对比(对 entries 而非渲染文本)判变,变化时整表替换。「什么变了」与「怎么展示」分离,让目录对 KV-cache 前缀稳定。目录只含 modelInvocable 技能的 name + description——绝不含 body/path/source,模型「知道有这些技能」但不占工具 schema 的位置。
用户手势:/name 注入
技能还有第二条注入路径——用户手势。一个以 /<name> 开头的用户消息,若命名了一个 user-invocable 的技能,就是一次确定性的加载手势:
ctx.on('agent/pre-step', async (
{ agent, messages, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
const names = invokedSkillNames(messages)
if (names.length === 0) return decision
// ...
for (const name of names) {
const skill = await ctx.skills.get(name, lookup)
// Unknown names and user-disabled skills stay plain prose: the
// gesture was never a claim this boundary recognizes. ...
if (skill === undefined || !isUserInvocable(skill)) continue
const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' }
injections.push(createUserMessage({
content: [{ type: 'text', text: renderSkillContent(skill) }],
source,
}))
}
if (injections.length === 0) return decision
return { kind: 'enter', messages: [...decision.messages, ...injections] }
})
几个细节:只有 source.kind === 'user' 的消息被扫描——外部注入的文本无法伪造手势;命名不存在的技能或用户禁用的技能时,那一行只是普通文本(手势从未被认领);这是 disable-model-invocation 技能的唯一入口——目录与 skill 工具永远看不到它们。注入的位置也有讲究:正文追加在所有其他注入之后——背景在前(工作区规则、运行时策略、目录),要模型行动的素材在最末、离答案最近。
在《Pi Agent 源码解析》里,技能是 markdown 指令,通过扩展系统挂进核心;在《Prime Agent 源码解析》里,技能升级成可安装的 Python 包,模型 import 后直接调用函数。dsh 走的是第三条路:技能保持「内容」的本质(SKILL.md + frontmatter),但发现机制 provider 化——本地目录、npm 包、甚至未来的远程源都能成为技能来源,而消费端(工具、目录、手势)只认 ctx.skills 一个接口。技能从「核心内置的某个东西」变成了「一个内容接缝」。
调用策略:双正交表面
SkillInvocationPolicy 把「谁能调用」拆成两个正交维度:模型(disable-model-invocation 关掉模型侧)与用户(user-invocable 打开用户侧)。默认组合是「模型可调用、用户不可手势」;技能作者可以用 frontmatter 调整任意组合,非法取值(非布尔、旧驼峰拼写)整条技能丢弃——fail-closed。策略在边界执行、registry 中立:ctx.skills.get() 本身不查策略,isModelInvocable/isUserInvocable 由 Consumer 在各自边界调用,杜绝「目录里有但工具被拒」的错位。技能加载本身没有独立的审批环节——它只是「读内容注入上下文」,与读文件同级(skill 工具是 kind: 'read' 的 generic 卡片),modelInvocable 即其全部准入策略;写操作才进审批管线(第 16 章的 ctx.approval)。
实践应用
- 技能是内容不是代码:把「能力文档」与「能力执行」分开——技能只提供指令文本,执行仍走工具。这让技能可以热更新、可以来自任何 provider、可以不经编译就被模型消费。
- 两段式查询:
list给摘要(目录、参数校验)、get给全文(注入)——大内容不必随目录全量加载。 - 手势注入是确定性的:只有真实用户消息能触发
/name;未知名字回到普通文本——「边界不认领不是错误,是普通文本」。 - 双正交调用策略:模型可调与用户可手势是两个独立旋钮,技能作者按需组合——比单一「可见/不可见」二元更精确。
总结
这一章拆完了技能系统:SKILL.md 内容模型、provider 化的发现机制、skill 工具与 /name 手势两条注入路径、双正交调用策略。第三部分(能力接缝)到此收尾——fs/shell/sandbox/llm/skill,五个接缝展示了同一种组织哲学:能力是一等概念,替换是配置,消费是接口。第四部分把镜头转向「一个 agent 不够时」:子代理、workflow 编排与目标/任务体系。