夜雨聆风学习资料网

ARTICLE · 995943

DeepSeek Harness 源码-Skill 系统与 Catalog

DeepSeek Harness 源码-Skill 系统与 Catalog

DeepSeek Harness 源码解析系列

第 17 讲:Skill 系统与 Catalog

基于 DeepSeek Harness 源码 · 2026-09-01

💡 本讲一句话:Skill(技能)是"可复用的任务指令包"——模型先看到一份只含名字+描述的目录,需要时才用 skill 工具把正文拉进来。读完整篇你能带走四件事:分层注册表如何用 rank + scope 链裁决重名技能、"摘要/候选/定义"三层形状为什么必须分开、会话目录靠 digest 去重而不是每步重发,以及用户 /name 手势为什么是绕过 model-invocation 的唯一入口。

一、能力缝:先定义"技能"长什么样

第 15/16 讲我们拆过 fs 策略和沙箱,都是"能力缝 + provider"的形态。Skill 是同一套范式的又一次落地:packages/skill/skill/src/index.ts 只拥有 Service Definition(ctx.skills)——它不决定技能从哪来,只负责合并各 provider 的目录、裁决重名、按需加载正文。先看它对"名字"和"来源桶"下的定义:

📄 packages/skill/skill/src/index.ts(第 20-39 行)

const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/const DEFAULT_COLLECT_CACHE_ENTRIES = 128const MAX_COLLECT_ATTEMPTS = 2const RUNTIME_PROVIDER = 'runtime'const RUNTIME_RANK = 250/** Standard precedence rank for packaged skill providers and local bundled roots. */export const BUNDLED_SKILL_RANK = 600/** Return whether a string is a valid kebab-case skill name. */export function isSkillName(name: string): boolean {  return SKILL_NAME.test(name)}/** Origin bucket for a skill contribution — prompt-visible metadata, not precedence by itself. */export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})

为什么这样设计:名字语法被锁死成 kebab-case(^[a-z0-9]+(?:-[a-z0-9]+)*$)——小写字母数字 + 连字符,不允许大写、下划线、点。这不是审美偏好:技能名会原样出现在模型可见的目录和 /name 手势里,一个不含可转义字符的名字才能安全地嵌进伪 XML 标签而不破坏结构。注释特意强调 SkillSource "prompt-visible metadata, not precedence by itself"——来源桶只是给模型看的元数据,真正的优先级由 rank 决定(见下文)。末尾的 (string & {}) 是 TypeScript 惯用法:既保留字面量联合的类型提示,又允许 provider 塞入自定义来源字符串。

🔑 三层形状是这条缝的核心抽象:SkillSummary(摘要,模型目录用)→ SkillCandidate(候选,provider 到注册表)→ SkillDefinition(完整定义,含正文)。三者层层包含、职责分离——目录永远不碰正文。

先看最外层的"调用策略"和摘要形状:

📄 packages/skill/skill/src/index.ts(第 47-71 行)

/** Invocation controls shared by skill discovery consumers. */export interface SkillInvocationPolicy {  /** Whether model-facing catalogs and loaders include this skill. */  readonly modelInvocable: boolean  /** Whether human-facing command catalogs and loaders include this skill. */  readonly userInvocable: boolean}/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */export interface SkillSummary {  readonly name: string  readonly description: string  readonly whenToUse?: string  readonly invocation: SkillInvocationPolicy  readonly source: SkillSource  readonly provider: string  readonly resourceBase?: SkillResourceBase}

为什么这样设计:SkillInvocationPolicy 把"谁能调这个技能"拆成两个独立的正向布尔值:modelInvocable(模型能不能在目录里看到并加载它)和 userInvocable(用户能不能用 /name 直接调它)。两者正交,于是有四种组合:都开、仅模型、仅用户、都关。注释反复强调摘要是 "invocation-neutral"(调用中立)的——注册表本身不替消费者做策略判断,它把四个组合全保留下来,由 isModelInvocable()/isUserInvocable() 在各自的边界上读取。这样"仅供用户调用的技能"(modelInvocable:false)能存在于注册表里、却永远不进模型目录。

再看候选和完整定义——它们比摘要多了 rank、locator 和正文:

📄 packages/skill/skill/src/index.ts(第 73-93 行)

/** Provider catalog entry used by the registry to merge and later load skills. */export interface SkillCandidate extends SkillSummary {  /** Lower ranks win duplicate skill names before provider registration order is considered. */  readonly rank: number  /** Opaque provider-owned handle passed back to `provider.get()`. */  readonly locator: unknown  readonly path?: string  readonly metadata?: Readonly<Record<string, unknown>>}/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */export interface SkillDefinition extends SkillSummary {  /** Markdown instruction body after any provider-specific metadata removal. */  readonly content: string  readonly path?: string  readonly metadata?: Readonly<Record<string, unknown>>}

为什么这样设计:候选比摘要多两个关键字段。rank(数字,越小越优先)是重名裁决的第一依据;注释明说 "Lower ranks win ... before provider registration order is considered"。而 locator 被刻意声明成 unknown——这是不透明句柄:注册表只负责存下它、在调用胜出 provider 的 get() 时原样传回,从不解读它的内部结构。本地 provider 放的是文件路径,远程 provider 可能放一个 token 或 URL。把 locator 抽象成 unknown,注册表就彻底与"技能从哪来"解耦了。SkillDefinition 再补上 content(正文)——这是唯一带正文的形状,只有 get() 会返回它。

二、Provider 接口:list() 与 get() 的契约

注册表对外的核心是 SkillProvider 接口——任何"技能来源"(本地目录、远程 registry、随包资产)都实现它:

📄 packages/skill/skill/src/index.ts(第 247-268 行)

/** Provider interface for one source of skills, such as local directories or a remote registry. */export interface SkillProvider {  /** Unique provider name in the `ctx.skills` registry. */  readonly name: string  /**   * List available skill candidates for the current lookup context.   * @returns provider candidates as a complete-array shorthand, or an explicit   *   observation when usable candidates came from incomplete discovery.   */  readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>  /**   * Load a complete skill body for a previously listed candidate.   * @returns the full skill body, or `undefined` if it is no longer loadable.   */  readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>}

为什么这样设计:注意 list() 的返回类型是个联合:要么直接返回候选数组("完整发现"的简写),要么返回一个显式的 SkillProviderObservation(带 complete 标志)。这个区分至关重要——它让 provider 能表达"我有一批还能加载的候选,但这次发现不具权威性"(比如 watcher 启动失败、远程鉴权超时)。注册表据此决定结果能不能进缓存:complete:false 的观测永不缓存,消费者可以保留上一份可用目录并在下个请求边界重试。而 get() 接收的是当初那个候选(含不透明 locator),返回完整正文或 undefined(文件已消失)。

🧭 list/get 分离 = 懒加载:目录阶段只读 frontmatter(name/description),正文在模型真正调用时才读。一个技能目录里可能有几十个技能,但一次会话通常只用一两个——把"列目录"和"读正文"拆成两次 provider 调用,避免为展示目录而把所有正文都拉进内存。

三、分层注册表:rank + scope 链裁决重名

SkillRegistry 是"宿主 + 按 scope"的分层结构(工具注册表确立的形态)。一次 registerProvider() 会落入调用方上下文 scope 对应的层:宿主行和仓库插件进全局层,agent preset 常驻挂载的插件进该 preset 的层。看它的核心实现:

📄 packages/skill/skill/src/index.ts(第 391-428 行)

registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {    const lifecycle = new AbortController()    let registration: { layer: SkillLayer; name: string } | undefined    let provider: SkillProvider    const control: SkillProviderControl = {      signal: lifecycle.signal,      invalidate: () => {        const active = registration        if (active !== undefined && active.layer.providers.get(active.name)?.provider === provider) {          this.invalidateCache()        }      },    }    try {      provider = create(control)      const name = provider.name      if (name === RUNTIME_PROVIDER) {        throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)      }      const order = this.nextProviderOrder      this.nextProviderOrder += 1      return this.layers.effect(        this.ctx,        (layer) => {          const undo = layer.providers.insert(name, { provider, order })          registration = { layer, name }          return () => {            registration = undefined            undo()            lifecycle.abort(new Error(`skill provider "${name}" disposed`))          }        },        { label: 'skills.registerProvider()' },      )    } catch (error) {      lifecycle.abort(error)      throw error    }  }

为什么这样设计:三个细节值得逐行看。其一,control.invalidate() 里有个精确身份校验:active.layer.providers.get(active.name)?.provider === provider——只有当"这个层里、这个名字下、当前挂着的 provider 实例恰好就是我注册的那个"时才失效缓存。这是为了防止一个已被替换/移除的旧注册误伤新注册的目录(stale invalidation)。其二,'runtime' 是保留名——provider 不能占用它,因为它是注册表自己用来承载 register()(运行时注入技能)的。其三,整个注册走 this.layers.effect(...),返回一个 disposer——Cordis 的"注册即 effect、dispose 即回滚"约定:fiber 销毁时自动 undo() + lifecycle.abort(),provider 的 watcher 随之关闭。

🔒 重名裁决是两级:跨层时"最近层的同名条目直接胜出"(scope 链 shadowing);同一层内才用 rank → providerOrder → localOrder 三级排序。rank 只在单层内有意义,因为不同层的优先级由 scope 距离决定。

四、collect():缓存键带 scope 链 + revision 重试

读取路径的核心是 collect()——它带缓存、带并发安全。这是整条缝里最精巧的一段:

📄 packages/skill/skill/src/index.ts(第 520-550 行)

private async collect(options: SkillViewOptions): Promise<CollectResult> {    throwIfAborted(options.signal)    let attempt = 1    while (true) {      const revision = this.revision      // The chain is part of the key rather than assumed stable: a blank-session      // recompose re-parents an existing scope without touching this registry,      // and only a chain-bearing key makes the next read see the new preset.      const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision)      const cached = this.collectCache.get(key)      if (cached !== undefined) return { entries: cached, cacheable: true }      const result = await this.collectFresh(options)      throwIfAborted(options.signal)      if (revision !== this.revision) {        if (attempt < MAX_COLLECT_ATTEMPTS) {          attempt += 1          continue        }        return { entries: result.entries, cacheable: false }      }      if (result.cacheable) {        this.collectCache.set(key, result.entries)        if (this.collectCache.size > this.collectCacheMaxEntries) {          const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>          this.collectCache.delete(oldest.value)        }      }      return result    }  }

为什么这样设计:三个并发安全机制叠在一起。第一,缓存键包含 scope 链 + revision(注释解释得很清楚):空会话重组会"重新挂父级"一个已存在的 scope,却不触碰注册表本身——只有把链带进 key,下一次读取才能看到新 preset;revision 则保证任何 invalidateCache()(revision+1)都会让旧缓存键失配。第二,发现期间 revision 变了就重试:进入 collectFresh() 前记下 revision,返回后若已变化(说明有 provider 注册/注销发生了),最多重试一次(MAX_COLLECT_ATTEMPTS=2);再变就返回最新候选但标记 cacheable:false——绝不缓存一份可能过期的目录。第三,LRU 上限:缓存超过 collectCacheMaxEntries(默认 128)就淘汰最老的键。这三层保证了"读到的一定是稳定的、且不会无限膨胀"。

🔬 "不完整不缓存"是铁律:任何 provider 失败、watcher 启动失败、或发现期间目录被改动,都会让结果 cacheable:false。消费者因此能保留上一份可用视图并自行重试——注册表从不把"半成品"固化成缓存。

再看 collectFresh() 怎么把各层合并——这就是 shadowing 规则落地处:

📄 packages/skill/skill/src/index.ts(第 552-566 行)

private async collectFresh(options: SkillViewOptions): Promise<CollectResult> {    // Global first, then existing chain overlays farthest ancestor first and    // the exact scope last, so the nearest layer's same-name entry replaces    // the farther ones — the tools registry's shadowing rule. Rank decides    // duplicates only within one layer.    const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)]    const merged = new Map<string, IndexedCandidate>()    let cacheable = true    for (const layer of layers) {      const collected = await this.collectLayer(layer, options)      if (!collected.cacheable) cacheable = false      for (const entry of collected.entries) merged.set(entry.candidate.name, entry)    }    return { entries: merged, cacheable }  }

为什么这样设计:合并顺序是"全局层 → scope 链从最远祖先到最近 scope",然后后写的覆盖先写的(merged.set(name, entry))。因为最近的层最后写入,它的同名条目就自然覆盖了更远的——这正是注释说的 "the nearest layer's same-name entry replaces the farther ones"。注意 rank 不参与跨层裁决(rank 只在单层内排序),跨层只看 scope 距离。任何一层不完整都会把整个结果标成不可缓存。

五、单层内:runtime 优先 + provider 错误隔离

listLayerCandidates() 负责收集单层内的所有候选。它先放 runtime 技能(rank=250,介于 project 和 user 之间),再逐个 provider 拉取——关键是一个 provider 挂了不能拖垮整层:

📄 packages/skill/skill/src/index.ts(第 585-620 行)

private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {    throwIfAborted(options.signal)    const candidates: IndexedCandidate[] = []    let cacheable = true    for (const { provider, order } of [...layer.providers.values()]) {      let localOrder = 0      let output: unknown      try {        output = await waitWithAbort(provider.list(options), options.signal)      } catch (error) {        if (options.signal?.aborted === true) throw toError(options.signal.reason)        cacheable = false        this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)      }      if (output === undefined) continue      const observation = normalizeProviderObservation(output, provider.name)      if (!observation.complete) cacheable = false      for (const candidate of observation.candidates) {        validateCandidate(candidate, provider.name)        candidates.push({ candidate, provider, providerOrder: order, localOrder, layer })        localOrder += 1      }    }    return { entries: candidates, cacheable }  }

为什么这样设计:provider 的 list() 被包在 try/catch 里,且用 waitWithAbort() 与调用方的 signal 竞争——一个不合作的 provider(挂死、抛错)既不能拖垮整层,也不能让调用方永远等下去。失败时只把 cacheable 置 false + 记一条 warn,其余 provider 的候选照常收集("从不完整观测中省略该提供方的结果")。注意区分两种失败:调用方主动 abort(signal.aborted===true)要重新抛出,让上层感知取消;provider 自身出错则被隔离。每个候选都过 validateCandidate()——格式错误的候选快速失败(抛错),而不是静默塞进目录。

🧯 错误隔离 vs 快速失败:provider 级失败(网络、watcher)→ 降级为不完整观测,继续服务;候选级格式错误(非法名字、缺描述)→ 直接抛错。前者是"环境抖动",后者是"契约违反"——两种故障用不同策略处理。

六、get():加载正文 + 名字一致性校验

get() 是"摘要 → 正文"的跨越。它不缓存完整定义(每次重新读),并做一道微妙的名字一致性检查:

📄 packages/skill/skill/src/index.ts(第 501-518 行)

async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> {    if (!isSkillName(name)) return undefined    const collected = await this.collect(options)    throwIfAborted(options.signal)    const match = collected.entries.get(name)    if (match === undefined) return undefined    const definition = await waitWithAbort(      match.provider.get(match.candidate, options),      options.signal,    )    if (definition === undefined) return undefined    validateDefinition(definition)    if (definition.name !== match.candidate.name) {      this.invalidateEntry(match)      return undefined    }    return definition  }

为什么这样设计:流程是"选候选 → 传回 locator 给 provider 加载 → 校验定义 → 核对名字是否还匹配"。最后那步 definition.name !== match.candidate.name 是防"陈旧定义":如果 provider 在加载期间把文件改名了(目录里的名字和正文 frontmatter 里的名字对不上),就拒绝返回并失效该条目,触发重新发现。这避免了"模型按旧名字调用、却拿到一个已经改名的技能正文"的错位。waitWithAbort() 再次出现——加载阶段同样与 signal 竞争,provider 挂死不会卡住调用方。

七、本地 provider:六档 rank + 根目录发现

具体实现 FileSystemSkillProvider(packages/skill/skill-filesystem/src/index.ts)按 rank 顺序扫描各根目录。rank 常量定义在文件头部:

📄 packages/skill/skill-filesystem/src/index.ts(第 36-43 行)

const PROJECT_DSH_RANK = 100const PROJECT_AGENTS_RANK = 200const CUSTOM_RANK = 300const USER_DSH_RANK = 400const USER_AGENTS_RANK = 500const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200const DEFAULT_WATCH_POLL_INTERVAL_MS = 100const DEFAULT_WATCH_MAX_PROJECTS = 128

为什么这样设计:rank 是严格递增的十进制阶梯(100/200/.../600),每档之间留了 100 的空隙——这是刻意的:留出插入空间,将来要在两档之间加新来源时不必重排所有常量。优先级语义是"越靠近当前工作区、越具体,越优先":项目级 > 自定义 > 用户级 > 随包。runtime 技能(rank=250)插在 project-agents(200) 和 custom(300) 之间——比项目技能低、比用户技能高,符合"运行时注入的内置能力应被项目覆盖、但优先于个人配置"的直觉。

📄 packages/skill/skill-filesystem/src/index.ts(第 241-261 行)

private async roots(cwd: string | undefined): Promise<SkillRoot[]> {    const roots: SkillRoot[] = []    if (this.includeDefaultRoots && cwd !== undefined) {      const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))      roots.push(        { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot },        { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot },      )    }    roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })))    if (this.includeDefaultRoots) {      roots.push(        { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },        { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },      )    }    if (this.bundledSkillDir !== undefined) {      roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_SKILL_RANK, trustedHost: true })    }    return roots  }

为什么这样设计:根目录的组装顺序就是 rank 顺序。注意三个标志位:projectRoot(项目级根要记录所属 git 仓库,供 watcher 按项目分组 + LRU 淘汰);skipSystem:true(user-dsh 根跳过 .system 子目录,避免把系统内部技能暴露给用户);trustedHost:true(bundled 根是宿主可信资产,读取时绕过 ctx.fs 直接用 node fs——因为随包技能属于应用自身,不该被沙箱文件系统策略拦截)。项目根通过 findProjectRoot() 向上找最近的 .git,找不到就用 cwd 本身。

Rank
Source
根目录
特殊标志
100
project-dsh
<projectRoot>/.dsh/skills
记录 projectRoot(watcher 分组)
200
project-agents
<projectRoot>/.agents/skills
记录 projectRoot(watcher 分组)
250
runtime
ctx.skills.register() 注入
保留名,provider 不可占用
300
custom
Config.customSkillDirs
—
400
user-dsh
<dshHome>/skills
skipSystem(跳过 .system)
500
user-agents
<agentsHome>/skills
—
600
bundled
Config.bundledSkillDir / $DSH_BUNDLED_SKILL_DIR
trustedHost(绕过 ctx.fs)

八、frontmatter 解析 + invocation 策略规范化

本地技能是 Markdown 文件,头部带 YAML frontmatter。解析器要求首行必须是 ---:

📄 packages/skill/skill-filesystem/src/index.ts(第 909-921 行)

function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {  const firstLineEnd = raw.indexOf('\n')  if (firstLineEnd < 0) return undefined  const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')  if (firstLine !== '---') return undefined  const start = firstLineEnd + 1  const closing = findClosingFrontmatter(raw, start)  if (closing === undefined) return undefined  const yaml = raw.slice(start, closing.start)  const parsed = parseYaml(yaml) as unknown  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined  return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }}

为什么这样设计:解析是严格且防御性的:首行不是 ---、找不到闭合的 ---、YAML 解析出来不是对象(是数组/null/标量)——任何一条不满足都返回 undefined,上层记 warn 并跳过这个文件而不是抛错。一个写坏的 SKILL.md 不该让整个目录发现失败。body 是 frontmatter 之后的正文(trim 后成为 content)。frontmatter 里的 name/description 是必填,whenToUse/metadata 可选。

📄 packages/skill/skill-filesystem/src/index.ts(第 992-1002 行)

function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy {  rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation')  rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation')  rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable')  const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation')  const userInvocable = frontmatterBoolean(data, 'user-invocable')  return {    modelInvocable: disableModelInvocation !== true,    userInvocable: userInvocable !== false,  }}

为什么这样设计:这里有个刻意的语义反转。frontmatter 用的是"否定式"键 disable-model-invocation(默认 false,即默认可被模型调用),而领域模型里是"肯定式"的 modelInvocable。转换规则:modelInvocable = disableModelInvocation !== true(没显式禁用就为真)、userInvocable = userInvocable !== false(没显式关闭就为真)。省略即默认开启——符合"技能默认可用,除非作者明确限制"的直觉。而 rejectLegacyInvocationKey() 三连是向后兼容的硬拒绝:旧版用的驼峰键(disableModelInvocation/modelInvocable/userInvocable)现在直接抛错并提示改用 kebab-case——宁可让旧配置显式失败,也不静默接受两套语义。

🔐 四种调用组合的落地:默认(两键都省略)→ 模型+用户都可调;disable-model-invocation:true → 仅用户可调(这是 /name 手势的唯一入口);user-invocable:false → 仅模型可调;两者都限制 → 只能由受信 get() 调用方获取。

九、watcher:双探测稳定性 + 祖先监视

技能目录要"活"——文件增删改后模型应能看到变化。本地 provider 用 chokidar 监视根目录,但有个难题:根目录可能还不存在(用户还没建 .dsh/skills)。openStableWatcher() 用"双探测"解决:

📄 packages/skill/skill-filesystem/src/index.ts(第 436-450 行)

private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {    while (!this.closing && state.owners.size > 0) {      const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)      const watcher = mode.kind === 'ancestor'        ? this.openAncestorWatcher(state, mode)        : await this.openRootWatcher(state, mode)      const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)      if (sameWatchMode(mode, current)) return watcher      await this.closeWatcher(watcher)    }    return undefined  }

为什么这样设计:核心是开 watcher 前后各探测一次根路径状态(resolveRootWatchMode())。如果两次探测结果一致(sameWatchMode),说明期间宿主路径没发生"存在↔缺失"的跳变,watcher 可信;不一致就关掉重来。这是经典的 TOCTOU(time-of-check-to-time-of-use)防护——避免在"根目录刚被创建/删除"的瞬间挂上一个失效的 watcher。resolveRootWatchMode() 会向上找最近的已存在祖先:根目录本身存在就返回 {kind:'root'}(直接 chokidar 监视它);不存在就返回 {kind:'ancestor', nextPath}——用 node 的 watchFile() 轮询那个祖先,等缺失的那一段出现后再切换成真正的 root watcher。

📄 packages/skill/skill-filesystem/src/index.ts(第 542-555 行)

private handleWatchEvent(state, mode, event, path): void {    const target = resolve(path)    if (this.closing || !isRelevantWatchEvent({ ...state.root, path: mode.anchor }, event, target)) return    this.queueInvalidation()    if (target === mode.anchor && event === 'unlinkDir') {      state.unhealthy = true      this.scheduleRewatch(state)    }  }

为什么这样设计:watcher 事件先过 isRelevantWatchEvent() 过滤——只有"技能相关"的变更(新增/删除 bundle 目录、改 *.md、动 SKILL.md)才触发失效,bundle 内部的资源文件改动不算目录变更。命中后走 queueInvalidation()——它用 queueMicrotask + invalidationQueued 标志做去抖合并:一批连续的文件事件只触发一次缓存失效,避免风暴。若被删的是根目录本身(unlinkDir on anchor),标记 unhealthy 并 scheduleRewatch()——切回祖先监视模式,等它重新出现。

📡 watcher 失败 ≠ 目录不可用:watcher 启动失败只让当前观测"不完整"(不缓存),但已发现的候选仍可直接加载。项目级 watcher 还有 LRU 上限(默认 128 个项目)防止多工作区场景下句柄泄漏。

十、Consumer:skill 工具 + 会话目录注入

注册表是"数据源",真正面向模型的是 packages/skill/tool-skill/src/index.ts。它注册一个 skill 工具——模型用它按名字加载正文:

📄 packages/skill/tool-skill/src/index.ts(第 127-156 行)

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)      if (!skill) {        throw new Error(`skill "${args.name}" is unknown or no longer available`)      }      return { name: skill.name, provider: skill.provider, content: skill.content }    },

为什么这样设计:注意 scope: exec.agent——agent 本身就是自己的 scope key,所以这次查找会按"这个 agent 的组合视角"解析分层注册表(它能看到自己 preset 层挂载的技能)。策略检查做了两次:加载前查摘要的 isModelInvocable(summary),加载后再查定义——因为"目录里可见"和"正文可加载"是两个边界,仅用户可调的技能(modelInvocable:false)在这里被明确拒绝。这正是前面说的:模型工具永远看不到 disable-model-invocation 技能。

🎯 目录只发摘要,正文按需拉:会话目录里只有 name + description(≤500 字),绝不含正文、路径、来源。模型看到"有个叫 git-commit 的技能,描述是……",需要时才调 skill({name:'git-commit'}) 把完整指令拉进来。这既省 token,又让模型不会"凭目录里的描述臆测技能行为"。

十一、目录去重:digest 而非每步重发

会话目录是一个持久的 user-role <system-reminder>。但每个模型步骤前都要判断"要不要重发"——靠的是对条目列表算 digest:

📄 packages/skill/tool-skill/src/index.ts(第 328-335 行)

function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {  // JSON per entry rather than a separator character: every separator is itself  // a legal description character, so only quoting makes the boundary exact.  const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')  return createHash('sha256').update(canonical).digest('hex')}

为什么这样设计:digest 是对条目列表(name+description)算的,不是对渲染后的 prose——注释点明"entries are what changes; the surrounding <system-reminder> framing is written for the model and must not decide whether a republish is needed"。用 JSON.stringify 逐条序列化再 join,是因为任何分隔符本身都是合法的描述字符(描述里可能有逗号、竖线),只有 JSON 引号能精确界定边界——避免 "a,b" + "c" 和 "a" + "b,c" 撞出同一个 digest。digest 不变就不重发,变了才追加一条"替换目录"。

📄 packages/skill/tool-skill/src/index.ts(第 213-251 行,核心判定)

ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise<PreStepDecision> => {    const decision = await next()    if (decision.kind === 'reject') return decision    signal.throwIfAborted()    const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool    const snapshot = toolVisible      ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal, scope: agent })      : { skills: [], complete: true }    if (!snapshot.complete) return decision    const skills = snapshot.skills.filter(isModelInvocable)    const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)    const digest = digestCatalogEntries(entries)    const history = catalogHistory(agent)    const existing = catalogMessage(decision.messages)    if (history.visibleDigest === digest) {      return existing === undefined ? decision : { kind: 'enter', messages: decision.messages.filter(m => m.id !== existing.message.id) }    }

为什么这样设计:这段是"目录生命周期管理"的核心。第一,toolVisible 用精确身份比较(=== skillTool)而非按名字查——防止一个恰好也叫 skill 的 scoped shadow 工具继承这份目录。第二,!snapshot.complete 时直接 return decision——不完整快照保留上一份可用视图,不重发。第三,digest 与历史可见目录一致时:若当前消息里已有该目录就移除(避免重复),没有就不动。只有 digest 真正变化才走"追加替换目录"路径。

📜 目录是会话历史,不是 World State:它作为 user-role 消息进入 transcript,compaction 可能隐藏旧目录——但下一份完整快照会重建当前目录。删除所有技能时会发一条"显式空替换"告诉模型"现在没有可用技能了"。

十二、/name 手势:用户绕过 model-invocation 的唯一入口

除了模型主动调 skill 工具,用户还能在消息里直接写 /git-commit 来"点名"一个技能。这个手势的识别很讲究:

📄 packages/skill/tool-skill/src/index.ts(第 409-431 行)

const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/gfunction invokedSkillNames(messages: readonly UserMessage[]): string[] {  const names: string[] = []  for (const message of messages) {    if ((message.source as { kind?: unknown }).kind !== 'user') continue    for (const block of message.content) {      if (block.type !== 'text') continue      for (const match of block.text.matchAll(SKILL_GESTURE)) {        const name = match[2]        if (name !== undefined && !names.includes(name)) names.push(name)      }    }  }  return names}

为什么这样设计:正则 /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g 要求 /name 前后都是空白边界(行首或空白 + 名字 + 空白或行尾)。注释解释这能"把文件路径(/usr/bin)和分数(5/8)排除在外"——因为 /usr/bin 的 bin 后面不是空白边界、5/8 的 8 前是数字不是空白。更关键的是安全边界:只扫描 source.kind === 'user' 的消息——外部文本(工具输出、注入内容)无法伪造这个手势。命中后还要过 isUserInvocable(skill),所以仅用户可调的技能只有走这条路径才能被加载。

🔀 两条加载路径,一个渲染器:模型走 skill 工具、用户走 /name 手势,但最终都调同一个 renderSkillContent()——模型在两条路径上看到完全一致的 <skill_content> 形状,不会因入口不同而行为漂移。

十三、renderSkillContent:伪 XML + 转义安全

两条路径最终都汇聚到 renderSkillContent()——它把技能渲染成模型可见的伪 XML。这里藏着本讲最微妙的一处安全设计:

📄 packages/skill/skill/src/index.ts(第 171-184 行)

export function renderSkillContent(skill): string {  const resourceHint = renderResourceHint(skill)  return [    `<skill_content name="${escapeAttr(skill.name)}">`,    '<skill_resources>',    ...resourceHint,    '</skill_resources>',    '',    '<skill_instructions>',    skill.content,    '</skill_instructions>',    '</skill_content>',  ].join('\n')}

为什么这样设计:输出结构是 <skill_content name> → <skill_resources>(资源基址提示)→ <skill_instructions>(正文)。注意转义策略是分层的:name 走 escapeAttr()(属性位置,转 & " <),资源提示里的 provider/path 走 escapeText()(文本位置,转 & < >),而 skill.content 正文是原样嵌入、不转义的——注释解释"skills are trusted local content, and user-supplied invocation text stays outside this wrapper"。技能正文被视为可信的本地内容(作者写的指令),而用户输入永远在 <skill_content> 包裹之外,不会被当成技能指令。

📄 packages/skill/skill/src/index.ts(第 227-229 行)

/** Escape model-facing prose embedded inside skill markup so provider-supplied text cannot open or close framing tags. */export function escapeText(value: string): string {  return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')}

为什么这样设计:这行代码本身就是一个"转义链"——把 provider 提供的文本里的 &/</> 转成实体,防止 provider 文本里夹带的 </skill_resources> 之类标签提前闭合框架。注意替换顺序:先转 &(否则后两步产生的 &lt; 里的 & 会被二次转义成 &amp;lt;)。这是典型的"先转义最基础的字符"原则。配合前面 name 的 kebab-case 锁死,整个伪 XML 框架对模型是稳定可解析的。

🛡️ 信任边界清晰:技能正文 = 可信(作者指令,原样嵌入);provider 元数据 = 半可信(escapeText 转义后嵌入);用户输入 = 不可信(永远在包裹之外)。三层信任、三种处理——这是整条缝的安全模型。

十四、总览:一次技能加载的完整数据流

把整讲压成一张对照表 + 一条数据流:

维度
SkillSummary(摘要)
SkillCandidate(候选)
SkillDefinition(定义)
包含字段
name/description/invocation/source/provider
+ rank + locator(unknown) + path/metadata
+ content(正文)
谁产生
注册表 toSummary() 投影
provider.list()
provider.get()
谁消费
会话目录(模型可见)
注册表合并/裁决
skill 工具 / /name 手势
是否含正文
否
否(只有 locator)
是
缓存策略
collectCache(complete 才存)
随摘要缓存
不缓存(每次重读)

一次"模型加载技能"的完整数据流:

skill({name}) / /name 手势 全链路

① tool-skill 的 pre-step 监听器:digest 比对,目录变化才注入/替换 <system-reminder>

② 模型看到目录摘要(name+description),决定调用 skill 工具 / 用户写 /name

③ execute():scope=agent,ctx.skills.list() → collect()(缓存键含 scope 链+revision)

④ collectFresh():全局层→scope 链合并,最近层同名胜出;单层内 rank→order→localOrder

⑤ isModelInvocable(summary) 检查(仅用户可调的技能在此被拒)

⑥ ctx.skills.get():传回 locator → provider.get() 重读正文(waitWithAbort 竞争 signal)

⑦ validateDefinition + 名字一致性校验(改名则失效重发现)

⑧ renderSkillContent():<skill_content>+<skill_resources>+<skill_instructions>,escapeText 防标签逃逸

⑨ 模型按正文指令执行;watcher 监听文件变更 → invalidateCache() → 下步 digest 变化重发目录

📚 系列导航

← 第 16 讲:沙箱隔离(bwrap / Landlock / Seatbelt / e2b)

→ 第 18 讲:Subagent 委托

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料