From 52d68a538316241d9f91668de224b02911f66d29 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:08:10 +0800 Subject: [PATCH] fix(skill): retain candidates across watcher failures --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 6 +-- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 6 +-- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 23 +++++++-- docs/core-data-structures/skills.zh.md | 23 +++++++-- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 ++- packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 16 ++++-- .../tests/skill-local-watcher.spec.ts | 20 +++++--- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 8 +-- packages/skill/skill/README.zh.md | 8 +-- packages/skill/skill/src/index.ts | 41 ++++++++++++--- packages/skill/skill/tests/skill.spec.ts | 50 +++++++++++++++---- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 23 files changed, 175 insertions(+), 68 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index c89c716b13..d3ecfe624c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: e7cff2cb53a044ed0c4789cef3550652903f3586 -2026-07-27-skill-catalog-hot-refresh.zh.md: 86519c93880f94b1b9d3bdc011aa09b04ca10686 +2026-07-27-skill-catalog-hot-refresh.md: 7c81b287cecde60c42f7e1e3ec171244ffc18aa6 +2026-07-27-skill-catalog-hot-refresh.zh.md: ca570a3c6a0402e6764824e3101e15054769b85d diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index e7cff2cb53..7c81b287ce 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -12,11 +12,11 @@ Filesystem updates are also non-atomic from the observer's perspective. An edito ## Decision -The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. Provider arrays are complete-discovery shorthand; an explicit incomplete observation can retain readable candidates for direct loads without becoming cacheable or authoritative for model-facing consumers. A provider or runtime generation change during discovery retries before returning. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. `@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. -A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. +A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures are logged and retried; discovery still returns readable candidates for direct loads but reports an incomplete observation. Teardown closes watchers and ignores late callbacks. `@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, persistent watcher failures with loadable candidates, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 86519c9388..ca570a3c6a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -12,11 +12,11 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话 ## 决策 -skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 -系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。 `@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0f297397f5..0e4cdd1b5f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1241,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:129`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1273,7 +1273,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:47`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:48`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 470a331db1..f5b1f3d70d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -659,7 +659,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:147`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:156`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 048385a013..50b0fd5768 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1681,7 +1681,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise Promise + readonly list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -61,7 +74,7 @@ The shipped local provider scans roots in rank order: The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. -Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete; project-scoped watchers use a configured bounded LRU. +Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU. ## Skill identity @@ -101,7 +114,7 @@ interface SkillSummary { ```ts type-equiv /** One catalog observation plus whether every registered provider completed discovery. */ interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries from providers that completed. */ + /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed discovery for this observation. */ readonly complete: boolean diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 7d5c61dcf3..2fd2f73f04 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -10,7 +10,19 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 + +`SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。 + +```ts type-equiv +/** Provider candidates plus whether the current discovery is authoritative. */ +interface SkillProviderObservation { + /** Candidates available from the current provider discovery. */ + readonly candidates: readonly SkillCandidate[] + /** Whether discovery completed and these candidates may be cached. */ + readonly complete: boolean +} +``` ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -23,9 +35,10 @@ interface SkillProvider { * authentication, and discovery are awaited inside this method. Implementations * should settle promptly when `options.signal` aborts. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. - * @returns provider candidates with precedence ranks and opaque locators. + * @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 list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -61,7 +74,7 @@ interface SkillProviderControl { 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 -Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整;项目作用域 watcher 使用按配置设限的 LRU。 +Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 ## Skill 身份 @@ -101,7 +114,7 @@ interface SkillSummary { ```ts type-equiv /** One catalog observation plus whether every registered provider completed discovery. */ interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries from providers that completed. */ + /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed discovery for this observation. */ readonly complete: boolean diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f63d34ea1d..8f46e59f05 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:147`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:156`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index eb497f5074..2cfc926ef1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2360,12 +2360,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillProvider', - declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', + declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', }, { name: 'SkillProviderControl', declaration: 'export interface SkillProviderControl {\n readonly signal: AbortSignal;\n readonly invalidate: () => void;\n}', }, + { + name: 'SkillProviderObservation', + declaration: 'export interface SkillProviderObservation {\n readonly candidates: readonly SkillCandidate[];\n readonly complete: boolean;\n}', + }, { name: 'SkillRegistration', declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 633ae1260c..56835ba955 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md -README.md: 1fe545f15c12a6b540b1feaa7b612ae27c1f4f56 -README.zh.md: 15911b45c229f41ed2ebca4db22a2050b2d71431 +README.md: d4b6253c6667f4786d53ad6c291f9e96f82546c3 +README.zh.md: 7cede0ea64064ca4f170d81303085b40b5751f15 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 1fe545f15c..d4b6253c66 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -46,7 +46,7 @@ Existing skill roots are watched with Chokidar. The provider observes direct bun A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. -The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged, make the current provider observation incomplete, and are retried; effect teardown closes every watcher and contains late callbacks. +The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. ## Skill Format diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 15911b45c2..7cede0ea64 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -46,7 +46,7 @@ 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 -如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录,使提供方的当前观察不完整,并触发重试;effect 释放会关闭所有 watcher,并收束延迟回调。 +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 ## Skill 格式 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 907c15a8ab..8bc02dbb31 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -27,6 +27,7 @@ import { type SkillLookupOptions, type SkillProvider, type SkillProviderControl, + type SkillProviderObservation, type SkillSource, } from '@deepseek-ai/dsh-skill' @@ -161,18 +162,25 @@ export class LocalSkillProvider implements SkillProvider { /** * Discover local skill summaries for a cwd-sensitive workspace. * @param options - lookup options; `cwd` selects the project roots to scan. - * @returns local provider candidates with stable root ranks. + * @returns local provider candidates with stable root ranks; watcher startup + * failure returns readable candidates as an incomplete observation. */ - async list(options: SkillLookupOptions): Promise { + async list(options: SkillLookupOptions): Promise { const roots = await this.roots(options.cwd) - await this.watchManager.observeRoots(roots) + let complete = true + try { + await this.watchManager.observeRoots(roots) + } catch (error) { + if (this.disposal !== undefined) throw error + complete = false + } const candidates: SkillCandidate[] = [] for (const root of roots) { for (const skill of await discoverRoot(root, this.ctx)) { candidates.push(skill) } } - return candidates + return complete ? candidates : { candidates, complete } } /** diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index a1df287fbe..4f9cfc59ef 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -117,11 +117,15 @@ describe('skill-local watcher failures', () => { await fiber.dispose() }) - it('marks a startup failure incomplete and retries discovery without caching it', async () => { + it('keeps skills loadable across persistent watcher startup failures without caching them', async () => { const home = await tempDir('skill-watch-start-error') const root = join(home, '.dsh/skills') await writeSkill(root, 'retry-skill') - watcherHarness.startupErrors.push(new Error('watch failed')) + watcherHarness.startupErrors.push( + new Error('watch failed once'), + new Error('watch failed twice'), + new Error('watch failed three times'), + ) watcherHarness.closeErrors = 1 const ctx = new Context() await ctx.plugin(SkillService) @@ -135,13 +139,17 @@ describe('skill-local watcher failures', () => { watchStabilityThresholdMs: 20, }) - expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'retry-skill' }], - complete: true, + complete: false, }) - expect(watcherHarness.watchers).toHaveLength(2) - expect(watcherHarness.watchers[1]?.options).toMatchObject({ + expect((await ctx.skills.get('retry-skill'))?.content).toBe('Body.') + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'retry-skill' }], + complete: false, + }) + expect(watcherHarness.watchers).toHaveLength(3) + expect(watcherHarness.watchers[0]?.options).toMatchObject({ atomic: true, depth: 1, followSymlinks: false, diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 09a8788486..df1e9b8e29 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill/README.md -README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56 -README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200 +README.md: 9813338dcec82fc2db7e149be1bd80ec5239684d +README.zh.md: abc637c1793b31158d015468941fd38ae06254c6 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 66b240c3a6..9813338dce 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -11,7 +11,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. -- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary. +- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. - `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. @@ -28,11 +28,11 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. +A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. +Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog. @@ -56,5 +56,5 @@ No direct prompt effect. The named consumer owns the durable initial catalog and - **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism. - **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. -- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state. +- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics. - **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 0ebbab0899..abc637c179 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -11,7 +11,7 @@ ### 公开 API - `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 -- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。 +- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整时,`complete` 为 false;不完整观测提供的候选项仍保留在该结果中,但该结果绝不缓存。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 - `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 @@ -28,11 +28,11 @@ ## 提供方契约 -提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 +提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 +契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 @@ -56,5 +56,5 @@ - **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 -- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 +- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f103f828a0..6c12503bd9 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -89,12 +89,20 @@ export interface SkillLookupOptions { /** One catalog observation plus whether every registered provider completed discovery. */ export interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries from providers that completed. */ + /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed discovery for this observation. */ readonly complete: boolean } +/** Provider candidates plus whether the current discovery is authoritative. */ +export interface SkillProviderObservation { + /** Candidates available from the current provider discovery. */ + readonly candidates: readonly SkillCandidate[] + /** Whether discovery completed and these candidates may be cached. */ + readonly complete: boolean +} + /** 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. */ @@ -105,9 +113,10 @@ export interface SkillProvider { * authentication, and discovery are awaited inside this method. Implementations * should settle promptly when `options.signal` aborts. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. - * @returns provider candidates with precedence ranks and opaque locators. + * @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 list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -387,11 +396,9 @@ export class SkillService extends Service { this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`) } if (output === undefined) continue - if (!Array.isArray(output)) { - throw new TypeError(`skill provider "${provider.name}" list() must return an array`) - } - const listed = output as readonly SkillCandidate[] - for (const candidate of listed) { + 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 }) localOrder += 1 @@ -426,6 +433,24 @@ export class SkillService extends Service { } } +function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation { + if (Array.isArray(output)) { + return { candidates: output as readonly SkillCandidate[], complete: true } + } + if (output === null || typeof output !== 'object') { + throw invalidProviderObservation(providerName) + } + const observation = output as Partial + if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') { + throw invalidProviderObservation(providerName) + } + return observation as SkillProviderObservation +} + +function invalidProviderObservation(providerName: string): TypeError { + return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`) +} + const RUNTIME_SKILL_PROVIDER: SkillProvider = { name: RUNTIME_PROVIDER, /* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */ diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index fdf9c40abc..cb9fc8e0c9 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill' +import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderObservation } from '@deepseek-ai/dsh-skill' function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate { return { @@ -169,15 +169,18 @@ describe('SkillService registry', () => { await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation') }) - it('rejects non-array provider results and every malformed candidate scalar', async () => { - const badList = new Context() - await badList.plugin(SkillService) - registerProvider(badList, { - name: 'non-array-list', - list: () => Promise.resolve({} as unknown as SkillCandidate[]), - get: () => Promise.resolve(undefined), - }) - await expect(badList.skills.list()).rejects.toThrow('list() must return an array') + it('rejects malformed provider results and every malformed candidate scalar', async () => { + const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }] + for (const [index, output] of malformedOutputs.entries()) { + const badList = new Context() + await badList.plugin(SkillService) + registerProvider(badList, { + name: `malformed-list-${index}`, + list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation), + get: () => Promise.resolve(undefined), + }) + await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation') + } const cases: { patch: Partial; expected: string }[] = [ { patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' }, @@ -600,6 +603,33 @@ describe('SkillService registry', () => { expect(flakyCalls).toBe(3) }) + it('keeps candidates from incomplete provider observations loadable without caching them', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let listCalls = 0 + registerProvider(ctx, { + name: 'incomplete-candidates', + async list() { + listCalls += 1 + return { + candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }], + complete: false, + } + }, + async get(candidate) { + return { ...candidate, content: (candidate.locator as { content: string }).content } + }, + }) + + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'available-skill' }], + complete: false, + }) + expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.') + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill']) + expect(listCalls).toBe(3) + }) + it('invalidates only the exact registered provider and ignores its late callbacks', async () => { const ctx = new Context() await ctx.plugin(SkillService) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c5a92ed3cd..fa2fbd4533 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -159,6 +159,7 @@ export const LINK_MAP: Record = { SkillDefinition: 'skills.md', SkillLookupOptions: 'skills.md', SkillProvider: 'skills.md', + SkillProviderObservation: 'skills.md', SkillRegistration: 'skills.md', SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8fd9c127af..6ac7cd8863 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -994,6 +994,11 @@ "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillProviderObservation", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillProvider",