DeepSeek Harness Skill 目录热刷新:从宿主文件事件到模型可见目录的完整链路
DeepSeek Harness Skill 目录热刷新:从宿主文件事件到模型可见目录的完整链路
skill(技能)摘要是模型的路由输入,但本地 skill 目录 .agents/skills 完全可能被 IDE、Git 操作、shell 命令或外部进程在会话启动后修改,而这些修改并不经过 harness 的文件系统工具。DeepSeek Harness 通过一条「Agent Note」设计并实现了 skill 目录热刷新能力:@deepseek-ai/dsh-skill 注册表、@deepseek-ai/dsh-skill-filesystem 本地提供方、@deepseek-ai/dsh-tool-skill 模型消费者与 TUI 四层协作,让新增、删除、重命名的本地 skill 在模型步骤边界变得可见,且无需重启 agent——即使 skill 根目录在启动时并不存在。
读完本文,你将掌握:目录成员关系与指令正文加载为何必须分离、snapshot() 完整性位与注册作用域失效闭包的运作机理、Chokidar 与缺失根目录观察的具体实现、目录消息的 digest 与空 tombstone 语义,以及 TUI 如何把同一失效通知当作界面状态消费。
问题:路由输入与宿主目录之间的时间差
skill 摘要是模型的路由输入——模型先看到每个 skill 的名称和描述,再决定是否调用 skill 工具加载完整正文。但本地 skill 存在三类动态变化:
- 新增:会话启动后新建
.agents/skills/foo/SKILL.md,模型却不知道foo存在; - 删除:skill 被移除后,模型仍会调用已删除的名称,得到报错;
- 重命名:名称变化后,旧目录与磁盘实际内容脱节。
仅靠启动时构建一次目录无法覆盖以上任何一类变化。反过来,如果「每次编辑指令正文」都视为目录修订,就会把渐进式加载(正文按需读取)与不必要的提示词频繁变化耦合在一起——正文变了不代表路由信息变了。
文件系统更新对观察方来说也不是原子操作:编辑器或 Git 操作可能短暂移除文件;受监视的根目录在启动时可能不存在;发现(discovery)可能瞬时失败。把这些中间观察结果发布为权威空目录,比保留最后一个完整视图更糟——一个瞬时读取失败不能证明文件已被删除。
核心决策:目录成员关系与指令正文加载分离
整个热刷新能力建立在一条设计分界线上:目录成员关系(catalog membership)与指令正文加载(instruction-body loading)完全分离。该决策体现在 skill 注册表源码 中,具体由两个公开 API 承载。
ctx.skills.snapshot():摘要 + 完整性位
snapshot(options) 返回 SkillCatalogSnapshot:
export interface SkillCatalogSnapshot {
/** 本次观察收集到的、按名称排序的调用中立摘要 */
readonly skills: SkillSummary[]
/** 是否每个注册提供方都在稳定目录修订内完成了发现 */
readonly complete: boolean
}
complete 位是这篇文章的核心概念:不完整的观察永远不会被缓存,消费方(模型目录、TUI)据此保留上一次完整视图(last-good),并在下一次请求边界重试。这直接回应了「瞬时失败不等于删除」的问题——只有完整观察成功,面向模型的消费方才会更新自己的权威目录。
ctx.skills.registerProvider(factory):注册作用域的失效闭包
registerProvider(create) 接收一个同步工厂,工厂拿到一项注册作用域内的控制能力 { signal, invalidate }:
export interface SkillProviderControl {
/** 注册失败或该精确注册被释放时中止 */
readonly signal: AbortSignal
/** 仅当该精确注册仍活跃时,失效已完成的目录缓存并通知消费方 */
readonly invalidate: () => void
}
invalidate() 只把该精确活动注册标记为脏:实现中它先校验 registration 仍指向同一层中的同一个 provider 对象(active.layer.providers.get(active.name)?.provider === provider),成立才调用 invalidateCache()。这意味着释放或替换后的延迟失效是 no-op——能力已被撤销。相比公开 invalidateProvider(provider) 的替代方案,发给工厂的闭包把失效绑定到单个注册,观察方既不需要访问注册表,也不需要重复提供 provider 身份。
注册表本身没有 TTL,也不做通用 watcher 假设(见 Known Limitations):远程或未来的可变提供方保留各自的失效闭包,从自己的观察机制里调用它。每次失效都会递增 revision、清空 collectCache,并触发无过滤的 skills/change 事件(invalidateCache/notifyChange)。
提供方观测协议:数组简写与显式不完整观测
提供方的 list() 返回值有两种形态(normalizeProviderObservation):
- 返回数组:完整发现的简写形式,等价于
{ candidates, complete: true },可缓存; - 返回
{ candidates, complete }对象:显式声明不完整。可读候选项仍保留下来供直接加载(skill(name)仍能读到正文),但不缓存、不作为面向模型消费方的权威结果。
发现期间如果提供方或运行时 generation 发生变化(revision 被 bump),collect 会重试一次(MAX_COLLECT_ATTEMPTS = 2);若重试又被后续修订取代,则把最新候选项作为不完整且不缓存的观察返回。单次不完整提供方的失败(provider.list() 抛错)会被记录并跳过,该层被标记为不可缓存——消费方继续保留 last-good。
本地提供方:@deepseek-ai/dsh-skill-filesystem
@deepseek-ai/dsh-skill-filesystem 直接依赖 Chokidar,是热刷新的文件系统侧实现。它由 apply() 通过 ctx.skills.registerProvider(...) 注册,同时订阅 fs/observed 事件(L130-L143)。
监视范围:有界、扁平、路由相关
已有根目录的监视对象被严格限定在与目录路由相关的路径上(isRelevantWatchEvent):
- 直属 skill bundle 目录(一层深度,如
foo/SKILL.md); - 平铺的 Markdown 条目(根目录下
*.md文件); - 直属
SKILL.md条目文件。
add / addDir / change / unlink / unlinkDir 五类事件会触发成员关系失效;文件变更还支持刷新 frontmatter 中的 name 与 description。bundle 内更深层的资源文件(参考资料、脚本、产物)被显式忽略——它们是按需加载的,不影响类别列表,宽泛递归监视只会增加失效、描述符压力和平台差异。
同一微任务批次中的事件会合并为一次失效:queueInvalidation() 用布尔位去重,queueMicrotask 内统一调用 invalidate()(L579-L588)。
项目 watcher 使用有界集合:projects Map 按最久未观察顺序淘汰,超过 watchMaxProjects(默认 128)时驱逐最旧项目并释放其 watcher(observeRoots)。
缺失根目录:fs.watchFile 逐段跟随
skill 根目录在启动时可能不存在(例如项目刚 clone 下来、.agents/skills 尚未创建)。resolveRootWatchMode 从根目录向上找最近的现有祖先:
- 若根目录存在 → 返回
root模式,交给 Chokidar; - 若缺失 → 返回
ancestor模式,用fs.watchFile观察「祖先到根目录之间的下一段缺失路径」,每次stat到该段出现后,再跟进下一层,直到真实根目录出现。
每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式(ensureCurrentWatcher 中的 resolveRootWatchMode)。即使子项移除在根目录 unlinkDir 事件之前就触发了失效并发布了权威空目录,或者该事件根本没有到达,这个独立探测也会在删除后重新建立祖先观察——这是「删除后还能重新监视」的关键保障。
Chokidar 配置:全部可调
openRootWatcher 公开了完整的 Chokidar 选项,对应 Config schema:
| 配置项 | 默认值 | 含义 |
|---|---|---|
watch |
true |
是否监视宿主 skill 根目录的目录变化 |
watchUsePolling |
false |
Chokidar 使用原生事件还是轮询模式 |
watchStabilityThresholdMs |
200 |
变更条目需保持稳定多久才被观测(awaitWriteFinish.stabilityThreshold) |
watchPollIntervalMs |
100 |
稳定性或轮询探测的时间间隔 |
watchMaxProjects |
128 |
保持监视的不同项目根目录数量上限 |
watchFollowSymlinks |
true |
是否跟随符号链接(关闭时保留根链接自身) |
另有 providerName(默认 filesystem)、includeDefaultRoots(默认 true,包含项目与用户根目录)、dshHome($DSH_HOME 或 ~/.dsh)、agentsHome($DSH_AGENTS_HOME 或 ~/.agents)、customSkillDirs(自定义根目录)与 bundledSkillDir。根目录扫描顺序及 rank 定义在 roots():项目 .dsh/skills(100)→ 项目 .agents/skills(200)→ 自定义目录(300)→ 用户 .dsh/skills(400)→ 用户 .agents/skills(500)→ bundled(600)。
第一方工具快速路径与失败兜底
IDE、Git、shell 不会经过 harness 的文件系统 seam,这是引入宿主 watcher 的原因;但第一方 write 和 edit 工具会触发 fs/observed 事件,observeHostMutation 同步失效相关提供方——下一个模型步骤无需等待宿主事件投递就能看到自身改动。
watcher 启动或运行失败会被记录(skill-filesystem: failed to watch ...)并触发重试(scheduleRewatch);发现过程仍返回可读候选项供直接加载,但报告不完整观测。资源销毁(dispose)会关闭全部 watcher,并通过 closing 标志与生命周期 AbortController 忽略延迟回调。
模型消费者:@deepseek-ai/dsh-tool-skill
@deepseek-ai/dsh-tool-skill 负责把注册表目录转成持久、仅追加、可重建的会话输入,并注册面向模型的 skill 加载工具。
目录消息:首次发布与完整替换
在返回 enter 的 agent/pre-step 中,插件在首次观察到非空完整目录时贡献一条带来源(skill-catalog)的 UserMessage(renderCatalogMessage),内容为 <system-reminder> 包裹的 <available_skills> 列表。目录以 catalog 形式持久化,SkillCatalogSource.entries 精确记录本次发布条目,消费方展示列表时不必重新解析面向模型的伪 XML 帧。
每次 pre-step 的流程(L213-L251):
- 先调用
next(),若决策为 reject 则不做任何事; - 应用
skill工具的精确可见性(ctx.tools.get(skillTool.name, agent) === skillTool,防止同名 scoped 影子继承目录); - 调
ctx.skills.snapshot({ cwd, signal, scope: agent }),不完整则保留 last-good; - 对
<available_skills>标签间精确渲染的文本计算 digest(digestCatalogEntries:对每个条目JSON.stringify([name, description])后做 SHA-256——分隔符本身就是合法描述字符,只有引号化才能让边界精确); - 反向扫描只读会话事件(不复制),查找本插件发布的最新一条可识别且仍可见的目录(
catalogHistory)。
digest 变化时,向 enter 决策贡献完整替换目录(renderCatalogUpdate),声明「This complete catalog replaces every earlier available-skills list」;所有 skill 消失时也包含显式空目录——文本明确指示模型「Do not use names from earlier skill catalogs」,这就是空 tombstone。reject 或监听器失败则什么都不记录。
压缩(compaction)与反向扫描
反向扫描通常在最新且仍可见的目录处停止。若没有目录仍然可见、但历史事件中存在可识别目录,则说明压缩已将其遮蔽,插件付出一次 O(session-events) 全量扫描确认该事实,下一次完整且进入步骤的观察会重新建立当前目录(含空 tombstone)。当前目录为空且历史上从未发布目录时,不发送任何内容;不完整快照则保留最后一次完整模型视图。
渐进式披露与陈旧名称拒绝
指令正文继续渐进式披露:每次 skill(name) 调用都要求提供方重新读取并解析当前文件——没有正文缓存、哈希、修订或主动通知(get() 中 collect 命中缓存后仍把 provider 的 get() 与 abort signal 竞速执行)。先前记录的工具结果保持不变——只改正文不会产生目录消息,后续工具调用看到的是当前内容,历史工具结果仍然准确记录了模型当时加载的内容。
若加载后的 frontmatter 名称不再匹配所选候选项,注册表拒绝这个陈旧名称(definition.name !== match.candidate.name),并调用 invalidateEntry 使该提供方失效(仅在精确注册仍存活时),后续目录观察便能发布新名称。加载返回的正文经 renderSkillContent 渲染为统一的 <skill_content> 块,同时覆盖 skill 工具结果与用户显式 /name 调用注入两条路径。
TUI:同一失效通知,不同的消费方式
TUI 把同一失效通知当作界面状态而非会话历史消费:skills/change 不携带 diff,TUI 为活动会话的 cwd 重新获取 snapshot(),只应用最新的完整结果,观测不完整时保留先前命令;完整的空结果会清除陈旧补全项。由于 pi-tui 在提供方被替换时会关闭自动补全,若目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查——已打开的斜杠草稿竞态因此被覆盖。这保证了 /skill: 补全收敛到同一份完整目录,而不会让每次按键都阻塞于发现。
验证与测试矩阵
该设计的正确性由四层测试固定(对应 Agent Note 验证章节):
- 注册表测试(skill.spec.ts):注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、有界 generation 重试、陈旧名称拒绝;
- 本地提供方测试(skill-filesystem.spec.ts 与 skill-filesystem-watcher.spec.ts):bundle 与平铺文件的创建/移除/重命名、根目录创建/删除/重建(含未观测到根
unlinkDir的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、持续 watcher 失败但仍可加载候选项、事件合并、项目容量上限、资源销毁与暂时读取; - 工具测试(tool-skill.spec.ts):完整替换消息、空 tombstone、仅改正文时 digest 稳定、不完整状态保留、可见性与恢复元数据;
- TUI 测试:last-complete 保留、权威空结果清除、刷新以最新结果为准、资源销毁与已打开斜杠草稿的竞态;一项真实 Loader/PTY smoke 测试在启动后添加本地 skill,无需重启即可观察到补全项出现。
此外还有一个无密钥、装配完成的 agent-spine 快照测试:通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并用真实 skill 工具加载当前正文——端到端覆盖「第一方写入 → 同步失效 → 目录替换 → 正文加载」链路。
考虑过的替代方案(决策依据)
| 方案 | 结论 | 理由 |
|---|---|---|
| 将实时目录放入 World State | 不予采纳 | 目录替换是模型可见的会话输入,必须能从事件日志重建;持久注入历史已提供回放、恢复、fork 与压缩语义 |
只依赖 fs/observed |
不予采纳 | IDE、Git、shell 与外部进程不经过该 seam;事件仅作为第一方工具的低延迟快速路径,宿主监视补齐覆盖 |
为每个 SKILL.md 正文计算哈希/版本 |
不予采纳 | 模型最初只见名称与描述,提供方已在每次工具调用时重读正文;正文修订产生目录流量却不改变路由 |
| 监视每个 bundle 资源 | 不予采纳 | 资源按需加载,不影响类别列表;宽泛递归监视徒增失效与描述符压力 |
| 将部分/失败发现发布为新目录 | 不予采纳 | 瞬时读取失败不是删除证据;完整性位让消费方保留 last-good 直到完整观察成功 |
保留公开 invalidateProvider(provider) |
不予采纳 | 暴露注册表变更方法且要求调用方重复提供身份;工厂闭包将失效绑定到单个注册并在释放后失效 |
| 现在提取通用 Cordis 文件监视服务 | 暂缓 | 等另一个消费方确立可复用的服务约定;本地提供方已在 TODO(file-watch-service) 处标出 Chokidar 与缺失根目录观测边界,skill 路径过滤与失效闭包调用仍属 skill 专用逻辑 |
影响与部署权衡
- 新增、删除、重命名的本地 skill 在模型步骤边界变得可见,无需重启 agent,包括 skill 根目录启动时不存在的情况;
- TUI
/skill:补全收敛到同一份完整目录,按键不阻塞于发现; - 目录消息采用仅追加、日志记录、全量列表快照:保留较早可复用 token,替换目录显式停用陈旧名称,每次 digest 实际变化时 token 成本与当前目录大小成正比;
- 仅修改正文不产生目录消息,历史工具结果保持准确;
- 缺失根目录轮询与 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟与部署可调参数——有界项目集合与资源销毁约定把这些成本限制住;
- 远程或未来可变提供方保留自身注册作用域的失效闭包,注册表不强加通用 watcher 或 TTL。
若需在部署中调整监视行为,直接修改 dsh-skill-filesystem 的 watch* 系列配置即可;完整的字段声明可查阅 配置目录文档 与 skill 子系统参考。