ARTICLE · 995943
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 本身。
八、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('&', '&').replaceAll('<', '<').replaceAll('>', '>')}为什么这样设计:这行代码本身就是一个"转义链"——把 provider 提供的文本里的 &/</> 转成实体,防止 provider 文本里夹带的 </skill_resources> 之类标签提前闭合框架。注意替换顺序:先转 &(否则后两步产生的 < 里的 & 会被二次转义成 &lt;)。这是典型的"先转义最基础的字符"原则。配合前面 name 的 kebab-case 锁死,整个伪 XML 框架对模型是稳定可解析的。
🛡️ 信任边界清晰:技能正文 = 可信(作者指令,原样嵌入);provider 元数据 = 半可信(escapeText 转义后嵌入);用户输入 = 不可信(永远在包裹之外)。三层信任、三种处理——这是整条缝的安全模型。
十四、总览:一次技能加载的完整数据流
把整讲压成一张对照表 + 一条数据流:
一次"模型加载技能"的完整数据流:
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技术推荐官」获取更多源码解析内容