第三部分 · 能力接缝

第 10 章:技能系统

SKILL.md 是数据、provider 是发现、`skill` 工具是入口——技能不是工具,是内容

技能不是工具,是内容

「技能」(skill)与「工具」(tool)在 dsh 里是两种完全不同的东西。工具是可执行的代码(第 5 章),技能是可加载的内容:一份 SKILL.md,带 YAML frontmatter,里面是「做什么、怎么做」的指令文本。模型通过一个名叫 skill 的工具把它加载进上下文,然后按里面的指示行动。技能包是四个包组成的能力族:

角色内容
skill/skillService Definitionctx.skills:分层注册表(register)、list/get 两段式查询、renderSkillContent 唯一渲染器
skill/skill-filesystemProvider本地文件系统发现:扫描 .dsh/skills.agents/skills 等 6 级 rank 根,解析 frontmatter,经 ctx.fs 加载正文,digest 驱动热刷新
skill/skill-badgeProvider 范例打包 provider:把一个技能目录发布成 npm 包(仓库自己的 .agents/skills/ 就是目录束约定的使用者)
skill/tool-skillConsumerskill 工具 + 目录注入 + 用户手势(/name)注入

技能的表示很轻:目录束 <name>/SKILL.md 或平铺 <name>.md,仅一层(不支持递归 **/SKILL.md),<root>/.system 子目录被跳过。frontmatter 必填 name(kebab-case,文法即 SKILL_NAME 常量:/^[a-z0-9]+(?:-[a-z0-9]+)*$/)与 description,可选 whenToUsemetadatadisable-model-invocationuser-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/ 就是)
300customSkillDirs配置的自定义目录
400~/.dsh/skills用户级(跳过 .system
500~/.agents/skills用户级 agents 目录
600$DSH_BUNDLED_SKILL_DIR打包内置根

skill 工具:模型侧的入口

模型侧只有一个入口工具 skilltool-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 }
    },

注意 lookupscope: 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)。

实践应用

  1. 技能是内容不是代码:把「能力文档」与「能力执行」分开——技能只提供指令文本,执行仍走工具。这让技能可以热更新、可以来自任何 provider、可以不经编译就被模型消费。
  2. 两段式查询list 给摘要(目录、参数校验)、get 给全文(注入)——大内容不必随目录全量加载。
  3. 手势注入是确定性的:只有真实用户消息能触发 /name;未知名字回到普通文本——「边界不认领不是错误,是普通文本」。
  4. 双正交调用策略:模型可调与用户可手势是两个独立旋钮,技能作者按需组合——比单一「可见/不可见」二元更精确。

总结

这一章拆完了技能系统:SKILL.md 内容模型、provider 化的发现机制、skill 工具与 /name 手势两条注入路径、双正交调用策略。第三部分(能力接缝)到此收尾——fs/shell/sandbox/llm/skill,五个接缝展示了同一种组织哲学:能力是一等概念,替换是配置,消费是接口。第四部分把镜头转向「一个 agent 不够时」:子代理、workflow 编排与目标/任务体系。