DeepSeek Harness 架构解读:宿主持有、按 Scope 分层的 Skill 注册表
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
本文基于 2026-08-09-layered-skill-registry.zh.md 这一已落地的架构笔记展开。核心主题是 DeepSeek Harness(@deepseek-ai/dsh-skill)如何把 skill 注册表从"每个 preset 私有 realm"重构为"宿主持有的单例 + 按 scope 分层",从而区分"部署供给哪些 skill"与"agent 是否消费它们"两个正交问题。读完本文,你将理解分层注册表的遮蔽(shadowing)规则、scope 链与发现缓存的关系、组合(composition)如何随之调整,以及网关在冷会话下如何仍能列出 skill 目录。
背景:一次混淆引发的三类故障
在引入分层设计之前,agent-preset stack 曾把整套 skill 能力——注册表、本地提供方(local provider)和skill工具——搬进每个 preset 自己的isolaterealm,理由是"agent 拥有哪些 skill"属于 agent 平面的选择。
这个框架混淆了两个不同的问题:
- 部署(deployment)供给哪些 skill:这是宿主平面(host plane)关心的事;
- agent 是否消费(consume)它们:这是 agent 平面(agent plane)关心的事。
把两者绑死在同一个 realm 里,直接导致了三类可复现的故障:
- repository-plugin e2e 挂死。repository 插件的 prepared wrapper 声明
inject: ['skills'],并把它的 skill 根目录挂载为宿主平面的提供方。但 web 与 headless profile 不再组合宿主注册表后,这个 wrapper 永远等不到skills服务,e2e 因此挂起;当时的绕行方案是直接删掉 fixture 的 skill 根目录,属于掩盖症状而非修复根因。 - 冷会话(cold session)没有任何注册表可读。按 preset 的 realm 注册表让网关的 skill 列表依赖"存在一个存活 agent"。当会话尚未启动、没有任何 agent 存活时,
/弹窗根本找不到注册表可读,也就无法列出 skill。 - 配置在每个 preset 里重复。发现配置、提供方挂载散落在各个 preset 组合中,既重复又难以保持一致。
值得注意的是,工具注册表(tools registry)从未有过这个问题。它是一个宿主单例,基于dsh-scope按 scope 分层:部署级工具(MCP 服务器、插件 entry)注册进全局层,preset 的行注册进该 preset 的层。skill 注册表要做的,就是采用同一形态。
核心决策:SkillRegistry 采用与工具注册表相同的分层形态
本次重构的核心决策一句话概括:SkillRegistry持有ScopedLayers<SkillLayer>,所有注册按调用方上下文的 scope 落入对应层,读取时把全局层与观察 scope 的链合并。
相关实现位于 packages/skill/skill/src/index.ts。注册表本身只负责合并提供方目录、按名称解析出胜出者并对外暴露摘要与完整定义;具体 skill 从哪里来(本地目录、打包插件、远程服务)由各提供方决定——这正是该包"Service Definition 角色"的边界。
注册:按调用方上下文 scope 落层
registerProvider()与register()都通过this.layers.effect(this.ctx, ...)写入——Cordis 的 effect 机制会读取当前上下文携带的 scope,因此:
- 宿主行(host rows)与 repository 插件落在全局层;
- preset 的
skill-filesystem(由常驻组合挂载,其上下文携带该 preset 的 scope key)落在该 preset 的层。
这一点在SkillLayer构造器里有直接体现:它用NamedEntries保存插入有序的提供方,重复名称的报错信息会根据 scope 是否存在区分"已全局注册"与"已在该 scope 注册"(见 store.ts 中NamedEntries的插入语义:insert()遇到重名直接抛错,成功插入则返回幂等的撤销函数,供 effect 卸载时精确删除)。
// SkillLayer 构造器中的重复诊断(packages/skill/skill/src/index.ts) this.providers = new NamedEntries(name => new Error(scope === undefined ? `a skill provider named "${name}" is already registered` : `a skill provider named "${name}" is already registered in this scope`))提供方名称:层内唯一,而非进程级唯一
这是一个容易被忽略但关键的前提:提供方名称在每层内唯一,而不是进程级唯一。正因为如此,每个 preset 才能各自挂载自己的local提供方——两个不同 preset 层里各有一个名叫local的提供方是合法的,它们在各自的层内互不干扰。
读取:SkillViewOptions 携带观察 scope
读取路径通过SkillViewOptions携带观察 scope——调用中的 agent 本身就是一个 scope key。该接口扩展了SkillLookupOptions:
// packages/skill/skill/src/index.ts export interface SkillViewOptions extends SkillLookupOptions { /** Viewing scope (the calling agent); omitted reads the global layer alone. */ readonly scope?: ScopeKey | undefined }注意scope缺省时只读全局层——宿主视图天然就是"仅全局"。这一借用的实现细节会在后文"影响"一节展开。
在消费者端,tool-skill的skill工具执行时正是这样构造查找选项的:const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent }——agent 就是自己的 scope key,因此解析出的分层视图与"该 agent 的组合所见"完全一致(见 packages/skill/tool-skill/src/index.ts)。
遮蔽规则:最近层直接赢,rank 只在单层内裁决
读取时,注册表将全局层与该 scope 的 scope 链合并,合并顺序是"全局层优先,然后链上从最远祖先到最近层逐层覆盖"。核心裁决规则:
- 最近层(nearest layer)直接赢得重名——较近层中同名的条目直接替换较远层中的条目;
- rank 只在单层内裁决重名——即工具注册表已经确立的遮蔽规则。
这正是collectFresh的实现逻辑(见 packages/skill/skill/src/index.ts):先收集全局层,再按chainLayers(options.scope)依次覆盖,后写者覆盖先写者:
// collectFresh:全局层优先,链上最近层覆盖较远层 const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)] const merged = new Map<string, IndexedCandidate>() for (const layer of layers) { const collected = await this.collectLayer(layer, options) for (const entry of collected.entries) merged.set(entry.candidate.name, entry) }而单层内的排序由compareIndexedCandidates决定(见 index.ts):
return left.candidate.rank - right.candidate.rank // 先比 rank(小者胜) || left.providerOrder - right.providerOrder // 再比提供方注册顺序 || left.localOrder - right.localOrder // 最后比层内本地顺序即:层内重名按 rank → 提供方注册顺序 → 本地顺序裁决;跨层重名则直接由层距决定。
跨层 rank 合池的方案曾被考虑并否决:rank 的设计前提是"各来源彼此知情",而在全局合池下,后安装的 repository 插件可能凭注册顺序的平手规则,静默顶掉 preset 自带的同名 skill——等于远程改变 preset 的行为。最近层优先则让组合的行为始终由其作者决定。
发现缓存:scope 链 + 修订计数作键
发现(discovery)缓存以解析后的 scope 链 + 一个修订计数为键,这是空会话重组得以立即可见的关键。
// packages/skill/skill/src/index.ts private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string { return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision }) }这里有两个精心设计的细节:
- scope 链本身参与键计算,而不是假定它稳定。空会话重组(blank-session recompose)会重新指定 agent scope key 的父级,但不触碰注册表——只有把链放进键里,下一次读取才会看到新的 preset 归属(源码注释明确记录了这一点:"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")。
- scope key 用
WeakMap映射到稳定的整数 id,因为 scope key 是不透明的按身份比较对象;缓存因此不会把对象直接序列化进键字符串。 - 修订计数(revision)在每次缓存失效时递增,见
invalidateCache()(index.ts):revision += 1、清空缓存、然后派发skills/change事件。
此外,发现过程对"中途变更"是防御性的:collect会记录开始时的 revision,若完成后 revision 已变化且重试次数少于MAX_COLLECT_ATTEMPTS(值为 2),就重试一次;连续第二次变更则返回最新候选但标记为complete: false且不缓存。收集缓存本身是一个受collectCacheMaxEntries(默认 128)约束的 LRU,见Config与构造函数(index.ts)。
组合随之调整:注册表归宿主,消费仍归 preset
注册表归宿主后,预设组合做了两处调整:
- web-app bundle 重新启用 base 的
skill注册表行——只有skill-filesystem与tool-skill仍归 preset 所有; - preset 组合拆掉
isolate: skillsrealm,改为直接落在宿主注册表上的平铺行。
网关(gateway)的 skills 域则以presenter scope读取宿主注册表——presenter 是存活 agent 就以其为 scope,否则用记录在案的 preset 的 standing key。于是冷会话也能列出其组合真正供给的目录,而不再报错;serviceFor分支保留,用于兼容仍然以 realm 自挂注册表的组合。
层内与运行时注册的补充语义
分层是主干,层内还有一些值得了解的规则:
- 运行时 skill 注册:
register()接受SkillRegistration,省略的invocation默认{ modelInvocable: true, userInvocable: true },省略的provider使用保留名runtime(RUNTIME_PROVIDER,rank 固定为 250,见 index.ts)。同名运行时条目在同一层内首胜,重复注册只记录警告并返回 no-op 的 disposer,避免二次卸载误删胜出者。 - "runtime" 是保留提供方名:
registerProvider()会拒绝任何名为runtime的提供方(index.ts)。 - skill 命名文法:
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,即 kebab-case(index.ts),list()、get()与消费者都会据此校验。 - 发现失败不拖垮整体:单个提供方的
list()抛错会被捕获,记录警告并标记本次观察不可缓存,其余提供方照常贡献候选。 skills/change事件:任何注册变更都会派发该事件(无 diff,消费者自行用各自的查找选项重新snapshot()),且监听器失败被包含,不能否决注册表的变更(见 index.ts)。
影响与行为变化
重构落地后的行为边界,笔记给出了明确的五点结论:
部署级 skill 会到达每个挂载
tool-skill的 preset 会话。repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)能汇入 standard preset agent 的目录,而宿主视图保持仅全局——"部署级供给"与"preset 会话消费"通过tool-skill的挂载与否解耦。层可见性与消费仍是两个独立选择。
minimalagent 原则上可以读到全局层,但它不组合skill工具——"agent 到底有没有 skill"依旧由 preset 通过挂载或省略tool-skill决定。可见(visibility)不等于可消费(consumption)。提供方选项仍是借用的调用方对象。
SkillViewOptions扩展SkillLookupOptions;注册表只消费其中的scope,提供方则从同一个只读对象中读取自己的契约(cwd、signal),保持既有的借用恒等保证——对象被借用而非复制。TUI profile 不受影响。所有行都在宿主时,只有一个(全局)层,合并视图等价于旧的单注册表视图,rank 行为不变。
跨层遮蔽是静默的。层内败者照旧记录日志;较近层顶替较远层的名称沿用工具注册表的惯例,不记录。注册表也不提供检查被遮蔽定义的 API。
曾考虑的替代方案
设计过程中否决了两个候选方案,理由值得记录:
- 跨全部可见层的 rank 合池:忠实于单注册表的优先级,但跨层平手会按注册顺序裁决(启动期提供方永远赢过常驻挂载),preset 自带的 skill 可能被它看不见的部署变更顶掉。因组合稳定性被否决。
- 保留按 preset 的 realm 注册表,把 repository skill 作为目录交给 preset 的提供方扫描:wrapper 的
inject: ['skills']契约仍然破损(或者需要按 profile 分叉 wrapper),发现配置在每个 preset 里重复,冷会话依旧无处可读。被否决。
两个方案最终都败给同一个原则:组合(composition)的可见行为必须由组合的作者决定,而不是由部署时序或全局注册顺序远程改写。
小结
分层 skill 注册表是"Everything is a Plugin"哲学在 skill 能力面上的又一次落地:注册表作为宿主单例被所有 preset 共享,层结构由dsh-scope的 scope 链天然承载,而每个 preset 通过组合(挂载skill-filesystem、tool-skill)决定自己消费什么。理解这一设计,有助于在扩展 preset 组合、接入新 skill 提供方或排查 skill 目录"多一个/少一个"问题时,快速定位是"部署供给"还是"agent 消费"的哪一层出了问题。相关延伸资料可继续阅读 packages/skill/README.md(skill 能力包族总览)、docs/subsystems/skills.md(子系统参考,含本地发现优先级表)以及 packages/skill/skill/tests/skill.spec.ts(注册表行为测试)。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考