From b76659aa576b450555d41371eabde8d8e7aa2711 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 16:50:56 +0800 Subject: [PATCH 01/13] feat(skill): hot-refresh skill catalogs --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 6 + .../2026-07-27-skill-catalog-hot-refresh.md | 46 ++ ...2026-07-27-skill-catalog-hot-refresh.zh.md | 46 ++ docs/config-catalog.md | 20 +- docs/cordis-catalog/events.md | 19 + docs/cordis-catalog/services.md | 21 +- docs/core-data-structures/skills.i18n.yaml | 6 +- docs/core-data-structures/skills.md | 28 +- docs/core-data-structures/skills.zh.md | 28 +- docs/event-producer-consumer.md | 5 +- docs/tool-catalog.md | 2 +- examples/tui-agent/tests/pty-harness.ts | 38 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 30 ++ .../cordis/tool-cordis/src/api-catalog.ts | 19 + .../examples/agent-spine-demo/package.json | 1 + .../agent-spine-demo/tests/agent-core.spec.ts | 145 ++++- packages/skill/README.i18n.yaml | 6 +- packages/skill/README.md | 6 +- packages/skill/README.zh.md | 6 +- packages/skill/skill-local/README.i18n.yaml | 6 +- packages/skill/skill-local/README.md | 27 +- packages/skill/skill-local/README.zh.md | 27 +- packages/skill/skill-local/package.json | 1 + packages/skill/skill-local/src/index.ts | 498 +++++++++++++++++- .../tests/skill-local-watcher.spec.ts | 220 ++++++++ .../skill-local/tests/skill-local.spec.ts | 340 +++++++++++- packages/skill/skill/README.i18n.yaml | 6 +- packages/skill/skill/README.md | 18 +- packages/skill/skill/README.zh.md | 18 +- packages/skill/skill/src/index.ts | 82 ++- packages/skill/skill/tests/skill.spec.ts | 154 +++++- packages/skill/tool-skill/README.i18n.yaml | 6 +- packages/skill/tool-skill/README.md | 22 +- packages/skill/tool-skill/README.zh.md | 22 +- packages/skill/tool-skill/package.json | 1 + packages/skill/tool-skill/src/index.ts | 109 +++- .../skill/tool-skill/tests/tool-skill.spec.ts | 272 +++++++++- packages/ui/tui/README.i18n.yaml | 6 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 34 +- packages/ui/tui/tests/tui.spec.ts | 140 ++++- pnpm-lock.yaml | 23 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 5 + 46 files changed, 2372 insertions(+), 153 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md create mode 100644 packages/skill/skill-local/tests/skill-local-watcher.spec.ts 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 new file mode 100644 index 0000000000..ec6e7fd572 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: f818766eb55f237e21aa3da9586887e493b9de75 +2026-07-27-skill-catalog-hot-refresh.zh.md: 3f0be2e760f4a18504e18803bcb8a8e47acffa5d 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 new file mode 100644 index 0000000000..f818766eb5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -0,0 +1,46 @@ +# Agent Note: Skill catalog hot refresh + +Status: implemented + +English | [中文](2026-07-27-skill-catalog-hot-refresh.zh.md) + +## Problem + +Skill summaries are model routing input, but local skills can appear, disappear, or be renamed after a session starts. IDEs, Git operations, shell commands, and other processes can all mutate `.agents/skills` without going through the harness filesystem tools. A startup-only catalog leaves the model unaware of new skills and able to call deleted names. Treating every instruction-body edit as a catalog revision would instead couple progressive loading to unnecessary prompt churn. + +Filesystem updates are also non-atomic from the observer's perspective. An editor or Git operation may briefly remove a file, a watched root may not exist at startup, and discovery may fail transiently. Publishing those intermediate observations as authoritative empty catalogs would be worse than retaining the last complete view. + +## Decision + +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit, while `ctx.skills.invalidateProvider(provider)` dirties only the exact registered provider and discards completed catalog caches. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A stale provider callback after disposal or replacement is a no-op because invalidation uses object identity. + +`@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. Deleting a root re-establishes ancestor observation. 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. + +`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. + +The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. + +Instruction bodies keep progressive disclosure. Every `skill(name)` call asks the provider to reread and parse the current file; there is no body cache, hash, revision, or proactive notification. Previously logged tool results remain unchanged. If the loaded frontmatter name no longer matches the selected candidate, the registry rejects the stale name and invalidates that provider so a later catalog observation can publish the new name. + +## Verification + +Registry tests pin exact invalidation, 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, 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. + +## Alternatives considered + +- **Put the live catalog in World State** — rejected because catalog replacements are model-visible session inputs and must be reconstructable from the event log. Durable injected history already provides replay, resume, fork, and compaction semantics without another mutable state plane. +- **Rely only on `fs/observed`** — rejected because IDEs, Git, shell commands, and external processes do not cross that seam. The event remains a latency fast path for first-party tools, while host watching supplies coverage. +- **Hash or version every `SKILL.md` body** — rejected because the model initially sees only names and descriptions, and the provider already rereads the body on each tool call. Body revisions would create catalog traffic without changing routing and would not justify rewriting historical tool results. +- **Watch every bundle resource** — rejected because references, scripts, and assets are loaded on demand and do not affect the category list. Broad recursive watching would add invalidations, descriptor pressure, and platform variability without improving routing. +- **Publish partial or failed discovery as the new catalog** — rejected because a transient read failure is not evidence of deletion. The completeness bit lets the model-facing consumer preserve its last-good catalog until a full observation succeeds. + +## Consequences + +- New, deleted, and renamed local skills become visible at model-step boundaries without restarting the agent, including when the skills root did not exist at startup. +- The TUI's `/skill:` completions converge on the same complete catalog without blocking each keystroke on discovery; an open slash-name draft refreshes when the catalog arrives. +- Catalog updates are append-only, logged, and whole-list replacements. They preserve the stable initial prefix and retire stale names explicitly, at token cost proportional to the current catalog on each actual digest change. +- Body-only edits produce no catalog message. A subsequent tool call sees current content, while prior tool results remain an accurate record of what the model previously loaded. +- Missing-root polling and Chokidar add one maintained runtime dependency, host watcher resources, bounded detection latency, and deployment tunables. The bounded project set and teardown contract contain those costs. +- Remote or future mutable providers remain responsible for calling `invalidateProvider()` from their own observation mechanism; the registry does not impose a universal watcher or TTL. 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 new file mode 100644 index 0000000000..3f0be2e760 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -0,0 +1,46 @@ +# Agent Note: Skill 目录热刷新 + +Status: implemented + +[English](2026-07-27-skill-catalog-hot-refresh.md) | 中文 + +## 问题 + +skill(技能)摘要是模型的路由输入,但本地 skill 可在会话启动后新增、消失或重命名。IDE、Git 操作、shell 命令和其他进程都可以修改 `.agents/skills`,而不经过 harness 文件系统工具。仅在启动时构建目录,会让模型无法获知新 skill,并且仍能调用已删除的名称。反之,如果把每次指令正文编辑都视为目录修订,就会让渐进式加载与不必要的提示词频繁变化耦合。 + +从观察方来看,文件系统更新也不是原子完成的。编辑器或 Git 操作可能会短暂移除文件,受监视的根目录在启动时可能不存在,发现也可能暂时失败。把这些中间观察结果发布为权威空目录,比保留最后一个完整视图更糟。 + +## 决策 + +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位;`ctx.skills.invalidateProvider(provider)` 只会将精确的已注册提供方标记为脏,并丢弃已经完成的目录缓存。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。提供方在资源释放或被替换后到达的陈旧回调不会执行任何操作,因为失效操作使用对象身份。 + +`@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 + +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 + +`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线;如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 + +TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 + +指令正文继续采用渐进式披露。每次调用 `skill(name)` 时,系统都会要求提供方重新读取并解析当前文件;不存在正文缓存、哈希、修订或主动通知。先前记录的工具结果保持不变。如果加载后的 frontmatter 名称不再匹配所选候选项,注册表会拒绝这个陈旧名称,并使该提供方失效,以便后续目录观察发布新名称。 + +## 验证 + +注册表测试固定了精确失效、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 + +## 考虑过的替代方案 + +- **将实时目录放入 World State**:不予采纳,因为目录替换是模型可见的会话输入,必须能够从事件日志重建。持久注入历史已经提供回放、恢复、fork 和压缩语义,无需再引入一套可变状态层。 +- **只依赖 `fs/observed`**:不予采纳,因为 IDE、Git、shell 命令和外部进程都不会经过该 seam。该事件仍作为第一方工具的低延迟快速路径,宿主监视则补齐覆盖。 +- **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。 +- **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 +- **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 + +## 影响 + +- 新增、删除和重命名的本地 skill 会在模型步骤边界变得可见,无需重启 agent(智能体),即使 skill 根目录在启动时不存在也一样。 +- TUI 的 `/skill:` 补全会收敛到同一份完整目录,而不会让每次按键都阻塞于发现;打开的斜杠命令名称草稿会在目录到达时刷新。 +- 目录更新采用仅追加、日志记录和全量列表替换。它们会保持稳定的初始前缀,并显式停用陈旧名称;每次 digest 实际变化时,token 成本与当前目录大小成正比。 +- 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 +- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 +- 远程或未来的可变提供方仍有责任通过自身观察机制调用 `invalidateProvider()`;注册表不会强制采用通用 watcher 或 TTL。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..6082b41412 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1149,7 +1149,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:113`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:121`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1164,10 +1164,22 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Whether host-local skill roots are watched for catalog changes. */ + watch?: boolean + /** Whether Chokidar uses polling instead of native filesystem events. */ + watchUsePolling?: boolean + /** Milliseconds a changed skill entry must remain stable before it is observed. */ + watchStabilityThresholdMs?: number + /** Milliseconds between Chokidar stability or polling probes. */ + watchPollIntervalMs?: number + /** Maximum distinct project roots whose skill directories remain watched. */ + watchMaxProjects?: number + /** Whether watched symbolic links follow their target files. */ + watchFollowSymlinks?: boolean } ``` -Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:45`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1567,7 +1579,7 @@ Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../package ## `@deepseek-ai/dsh-tool-skill` -Requires: `tools` · `skills` +Requires: `agents` · `tools` · `skills` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -1577,7 +1589,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:19`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:24`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ccae4c9f9b..96c37db8b3 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -708,6 +708,25 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +## `skills/*` + +### `skills/change` — emit + +A skill provider, runtime contribution, or provider-backed catalog may have changed. This is an unfiltered invalidation notification; consumers refetch the catalog for their own lookup options. Listener failures are contained and cannot veto the registry mutation. + +```ts cordis-catalog +/** + * A skill provider, runtime contribution, or provider-backed catalog may + * have changed. This is an unfiltered invalidation notification; consumers + * refetch the catalog for their own lookup options. Listener failures are + * contained and cannot veto the registry mutation. + * @mode emit + */ +'skills/change'(): void +``` + +Source: [`packages/skill/skill/src/index.ts:139`](../../packages/skill/skill/src/index.ts) + ## `slash/*` ### `slash/input-begin-command` — bail diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..44cc25b433 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1393,6 +1393,14 @@ Registry of skill providers. It merges provider catalogs with stable first-wins */ registerProvider(provider: SkillProvider): () => void +/** + * Invalidate catalogs contributed by one currently registered provider. Exact object identity + * prevents a late callback from an old provider instance from invalidating its replacement. + * Calls for an already-unregistered provider are harmless. + * @param provider - exact provider instance whose external source changed. + */ +invalidateProvider(provider: SkillProvider): void + /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and @@ -1411,6 +1419,15 @@ register(skill: SkillRegistration): () => void */ async list(options: SkillLookupOptions = {}): Promise +/** + * Observe the current model-invocable catalog and whether all providers completed discovery. + * Incomplete observations are never cached, allowing consumers to retain last-good state and + * retry on their next request boundary. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries plus provider-completeness state. + */ +async snapshot(options: SkillLookupOptions = {}): Promise + /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against @@ -1422,9 +1439,9 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Types: [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) +Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) -Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:160`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 2f67387224..df0b1746fa 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb +# pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md +skills.md: fca33adb794a0d448249a2e7d518f03d8c36bc8a +skills.zh.md: a4bb20229a2c7e02cb549091ef9023d16e401f48 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index fc9599713d..fca33adb79 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -2,7 +2,7 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the session-prefix catalog and model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans and watches project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). @@ -10,7 +10,7 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation without caching it, while malformed candidates fail fast. `invalidateProvider()` clears completed catalogs only for the exact live provider object, and an in-flight discovery retries when its provider generation changes. Provider and runtime membership mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -50,6 +50,8 @@ 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. + ## Skill identity Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`/SKILL.md`) and flat Markdown files (`.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1. @@ -83,6 +85,18 @@ interface SkillSummary { } ``` +`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure. `skills` contains the sorted summaries collected in that observation; `complete` is true only when every registered provider completed. Incomplete snapshots are not cached, allowing a consumer to retain its last-good model catalog and retry. + +```ts type-equiv +/** One catalog observation plus whether every registered provider completed discovery. */ +interface SkillCatalogSnapshot { + /** Sorted model-invocable summaries from providers that completed. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed discovery for this observation. */ + readonly complete: boolean +} +``` + `SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`. ```ts type-equiv @@ -132,6 +146,8 @@ type SkillRegistration = Omit & { readonly provider Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. +Full definitions are not cached by the registry. Each `get()` calls the winning provider with the selected candidate, so the local provider rereads the current body. A definition whose name no longer matches that candidate is rejected and invalidates the exact provider for rediscovery. + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -142,7 +158,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`) plus watcher enablement, polling, stability, symlink, and project-capacity controls. The consumer owns its catalog description bound. Exact defaults and validation are in the generated [config catalog](../config-catalog.md). ```ts type-equiv /** Skill registry configuration. */ @@ -154,6 +170,8 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` contributes a user-role `` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md). +`dsh-tool-skill` contributes the initial user-role `` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md). -The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions. +Before each later model step, the consumer digests exact tool visibility plus the rendered names and descriptions from a complete snapshot. A changed digest appends a durable full replacement through `agent.inject()` with `{ kind: 'skill-catalog', version: 1, digest }` metadata; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. Visible metadata supplies the replay baseline, while a replacement shadowed by compaction is re-established against the loop's initial-prefix baseline when necessary. These updates are session history, not World State. + +The model-facing `skill({ name })` tool validates the kebab-case name, rereads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 0eb4c0aa69..a4bb20229a 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 @@ -10,7 +10,7 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。`invalidateProvider()` 只针对传入的活动提供方对象清除已完成目录;若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时的成员关系变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -50,6 +50,8 @@ interface SkillProvider { 项目根目录为包含 `.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。 + ## Skill 身份 skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`/SKILL.md`)和扁平 Markdown 文件(`.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 @@ -83,6 +85,18 @@ interface SkillSummary { } ``` +`SkillCatalogSnapshot` 用于区分已确定的不存在和提供方的瞬时失败。`skills` 包含该次观测中收集并排序的摘要;只有每个已注册提供方都已完成发现,`complete` 才为 true。不完整快照不会缓存,因此消费方可以保留上一份可用模型目录并重试。 + +```ts type-equiv +/** One catalog observation plus whether every registered provider completed discovery. */ +interface SkillCatalogSnapshot { + /** Sorted model-invocable summaries from providers that completed. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed discovery for this observation. */ + readonly complete: boolean +} +``` + `SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 ```ts type-equiv @@ -132,6 +146,8 @@ type SkillRegistration = Omit & { readonly provider skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -142,7 +158,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`),以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)。 ```ts type-equiv /** Skill registry configuration. */ @@ -154,6 +170,8 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 +`dsh-tool-skill` 通过 `agent/session-prefix` 贡献初始的 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 +在后续每个模型步骤之前,消费方都会对精确的工具可见性以及完整快照中已渲染的名称和描述计算 digest。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换,并携带 `{ kind: 'skill-catalog', version: 1, digest }` 元数据;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。可见元数据提供回放基线;如果替换被压缩(compaction)遮蔽,必要时会根据 loop 的初始前缀基线重新建立该替换。这些更新属于会话历史,而非 World State。 + +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 重新读取完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ced431bf51..9254d0d56a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | @@ -38,6 +38,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../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-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../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), [`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-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`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:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:139`](../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:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 07956b019b..3ccf014042 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -26,7 +26,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | -| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..7a4be3a21a 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,8 @@ import { spawn } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -36,7 +37,16 @@ while time.monotonic() < deadline: if chunk: output.extend(chunk) while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: - os.write(fd, actions[action_index]["send"].encode()) + action = actions[action_index] + if "writeFile" in action: + target = os.path.join(cwd, action["writeFile"]["path"]) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(action["writeFile"]["content"]) + if "send" in action: + os.write(fd, action["send"].encode()) + else: + os.write(fd, action["send"].encode()) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -56,11 +66,14 @@ if actual_exit != int(expected_exit): sys.exit(125) ` -/** One terminal action sent after its marker has rendered. */ -interface TuiPtyAction { - readonly waitFor: string - readonly send: string -} +/** One terminal input or workspace mutation performed after its marker renders. */ +type TuiPtyAction = + | { readonly waitFor: string; readonly send: string } + | { + readonly waitFor: string + readonly writeFile: { readonly path: string; readonly content: string } + readonly send?: string + } /** Inputs for a keyless real-Loader TUI process smoke. */ export interface TuiPtySmokeOptions { @@ -160,7 +173,16 @@ async function runWindowsPtySmoke( terminal.onData((chunk) => { output += chunk while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { - terminal.write(actions[actionIndex]!.send) + const action = actions[actionIndex]! + if ('writeFile' in action) { + const target = join(cwd, action.writeFile.path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, action.writeFile.content) + const input = action.send + if (input !== undefined) terminal.write(input) + } else { + terminal.write(action.send) + } actionIndex += 1 } }) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 5967366c12..18d0001926 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -211,6 +211,36 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('adds a watched local skill to live /skill: autocomplete without restarting', async () => { + const skill = [ + '---', + 'name: hot-added-skill', + 'description: HOT_ADDED_COMPLETION_MARKER', + '---', + '', + 'Hot-added body.', + '', + ].join('\n') + const output = await smoke({ + label: 'tui-agent hot-added skill autocomplete', + tempDirPrefix: 'tui-agent-hot-skill-', + configPath: scriptedConfigPath, + actions: [ + { + waitFor: 'scripted TUI ready.', + writeFile: { + path: '.agents/skills/hot-added-skill/SKILL.md', + content: skill, + }, + send: '/skill:hot', + }, + { waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('HOT_ADDED_COMPLETION_MARKER') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ label: 'tui-agent file autocomplete', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..1b6c903450 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -666,6 +666,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'registerProvider(provider: SkillProvider): () => void', jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param provider - the provider to register by `provider.name`.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', }, + { + signature: 'invalidateProvider(provider: SkillProvider): void', + jsDoc: '/**\n * Invalidate catalogs contributed by one currently registered provider. Exact object identity\n * prevents a late callback from an old provider instance from invalidating its replacement.\n * Calls for an already-unregistered provider are harmless.\n * @param provider - exact provider instance whose external source changed.\n */', + }, { signature: 'register(skill: SkillRegistration): () => void', jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', @@ -674,6 +678,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async list(options: SkillLookupOptions = {}): Promise', jsDoc: '/**\n * List model-invocable skill summaries for a workspace. Lookup options and\n * provider candidates are readonly same-process values borrowed throughout\n * discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries, excluding skills disabled for model invocation.\n */', }, + { + signature: 'async snapshot(options: SkillLookupOptions = {}): Promise', + jsDoc: '/**\n * Observe the current model-invocable catalog and whether all providers completed discovery.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus provider-completeness state.\n */', + }, { signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', @@ -1169,6 +1177,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, + { + name: 'skills/change', + mode: 'emit', + signature: '\'skills/change\'(): void', + jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */', + summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.', + }, { name: 'slash/input-begin-command', mode: 'bail', @@ -2151,6 +2166,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, + { + name: 'SkillCatalogSnapshot', + declaration: 'export interface SkillCatalogSnapshot {\n readonly skills: SkillSummary[];\n readonly complete: boolean;\n}', + }, { name: 'SkillDefinition', declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 923a9aace6..165c7688e4 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -55,6 +55,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index e00bf2016d..04e95120f3 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -8,8 +8,10 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -399,6 +401,147 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('snapshots a created project skill through catalog refresh and progressive loading', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-')) + try { + await mkdir(join(root, '.git'), { recursive: true }) + const skillPath = '.agents/skills/hot-skill/SKILL.md' + const skillSource = '---\nname: hot-skill\ndescription: Hot-added skill\n---\n\nUse the freshly loaded body.\n' + const adapter = new MockAdapter([ + toolCallResponse('mkdir-skill', 'bash', { + command: 'mkdir -p .agents/skills/hot-skill', + description: 'Create the project skill directory', + }), + toolCallResponse('write-skill', 'write', { + file_path: skillPath, + content: skillSource, + }), + toolCallResponse('load-skill', 'skill', { name: 'hot-skill' }), + textResponse('SKILL_REFRESH_OK'), + ]) + const ctx = await mount({ + workspaceContext: false, + skills: { + local: { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }, + }, + }) + await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(ToolFs) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('skill-refresh-session'), + meta: { cwd: root }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + + handle.agent.followup([{ type: 'text', text: 'Create and load the project skill.' }]) + await waitForIdle(ctx, handle.agent) + + expect(adapter.requests).toHaveLength(4) + expect(adapter.requests.slice(0, 2).map(request => request.messages.map(messageText).join('\n'))) + .toEqual([ + expect.not.stringContaining('hot-skill'), + expect.not.stringContaining('hot-skill'), + ]) + const catalogRequest = adapter.requests[2]?.messages.map(messageText).join('\n') + expect(catalogRequest).toContain('The available skill catalog changed.') + expect(catalogRequest).toContain('- `hot-skill`: Hot-added skill') + const loadedRequest = JSON.stringify(adapter.requests[3]?.messages) + expect(loadedRequest).toContain('') + expect(loadedRequest).toContain('Use the freshly loaded body.') + + const transcript = handle.agent.session.events.flatMap>((event) => { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tool-skill') { + return [{ + type: event.type, + source: event.data.source, + meta: { + kind: (event.data.meta as { kind?: unknown } | undefined)?.kind, + version: (event.data.meta as { version?: unknown } | undefined)?.version, + digest: typeof (event.data.meta as { digest?: unknown } | undefined)?.digest, + }, + text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n'), + }] + } + if (event.type === 'tool/result' && ['write-skill', 'load-skill'].includes(event.data.callId)) { + return [{ + type: event.type, + callId: event.data.callId, + isError: event.data.isError, + text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n') + .replaceAll(root, '{{cwd}}'), + }] + } + return [] + }) + expect(transcript).toMatchInlineSnapshot(` + [ + { + "callId": "write-skill", + "isError": false, + "text": "{{cwd}}/.agents/skills/hot-skill/SKILL.md + file + + Created file + ", + "type": "tool/result", + }, + { + "meta": { + "digest": "string", + "kind": "skill-catalog", + "version": 1, + }, + "source": { + "kind": "plugin", + "plugin": "tool-skill", + }, + "text": " + The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session: + + + - \`hot-skill\`: Hot-added skill + + + Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the \`skill\` tool with the exact name before acting. + ", + "type": "user/message", + }, + { + "callId": "load-skill", + "isError": false, + "text": " + + Base directory for this skill: {{cwd}}/.agents/skills/hot-skill + Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. + + + + Use the freshly loaded body. + + ", + "type": "tool/result", + }, + ] + `) + + await handle.dispose() + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('shares top-level dshHome between local skills and the managed bash environment', async () => { const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-')) const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-')) diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index 5843c4fa86..9c4a5fafe3 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f -README.zh.md: d219f710c0185298af89ba2e074d9e3b2e093896 +# pnpm run verify-translation-pairing --write packages/skill/README.md +README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822 +README.zh.md: 6f10c3e907e9cc97bf742fa6155db1442cb44edf diff --git a/packages/skill/README.md b/packages/skill/README.md index 5c75661de1..4fb41dda5d 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -6,8 +6,8 @@ The canonical three-package capability seam for reusable agent instructions: a p | Package | Role | ctx key | |---|---|---| -| `skill/` | Provider registry, precedence resolution, stable catalog snapshots, and full-definition lookup | `ctx.skills` | -| `skill-local/` | Project/custom/user filesystem provider | (registers on `ctx.skills`) | -| `tool-skill/` | Session-prefix catalog and model-facing `skill` loader | (registers on `ctx.tools`) | +| `skill/` | Provider registry, precedence resolution, complete/incomplete catalog snapshots, and full-definition lookup | `ctx.skills` | +| `skill-local/` | Project/custom/user filesystem provider with membership watching | (registers on `ctx.skills`) | +| `tool-skill/` | Initial and replacement catalogs plus the model-facing `skill` loader | (registers on `ctx.tools`) | The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md). diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index d219f710c0..6f10c3e907 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| `skill/` | 提供方注册表、优先级解析、稳定目录快照和完整定义查找 | `ctx.skills` | -| `skill-local/` | 项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills`) | -| `tool-skill/` | 会话前缀目录和面向模型的 `skill` 加载器 | (注册到 `ctx.tools`) | +| `skill/` | 提供方注册表、优先级解析、完整/不完整目录快照和完整定义查找 | `ctx.skills` | +| `skill-local/` | 带目录成员关系监视的项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills`) | +| `tool-skill/` | 初始目录和替换目录,以及面向模型的 `skill` loader | (注册到 `ctx.tools`) | 接口位于 `skill/skill/`。提供方同步注册,并通过 `ctx.skills` 执行异步发现;`tool-skill` 只消费该接口,因此嵌入式或远程提供方可替换或补充 `skill-local`,无需改变面向模型的契约。`agent-core` 默认加载该家族,但它仍然是核心控制主干之外的功能,与 [`bash/`](../bash/README.md)、[`fs/`](../fs/README.md)、[`web/`](../web/README.md) 和 [`subagent/`](../subagent/README.md) 并列。 diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 603c1e0c90..79ff24a116 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: c488fdc4b1d97b5aa1113e41a470484063526ded -README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601 +# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md +README.md: 0e25e5a964902d84edbd2ca53f4e63b5f9c21065 +README.zh.md: 19b1091ec08eec9f844f339cdd0226309735ca09 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index c488fdc4b1..0e25e5a964 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -17,6 +17,12 @@ Requires `ctx.skills` (`inject: ['skills']`). | `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | +| `watch` | `true` | Watch host-local roots and invalidate the local provider when catalog membership or frontmatter may have changed. | +| `watchUsePolling` | `false` | Use Chokidar polling instead of native events for existing skill roots. | +| `watchStabilityThresholdMs` | `200` | Stable-write window for Chokidar `add` and `change` events. | +| `watchPollIntervalMs` | `100` | Chokidar polling/stability interval and missing-path probe interval. | +| `watchMaxProjects` | `128` | Maximum distinct project roots retained in the watcher LRU. | +| `watchFollowSymlinks` | `true` | Follow symbolic links while watching existing roots. | ## Discovery @@ -32,23 +38,34 @@ Default roots are resolved in this provider's rank order: The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. -When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request. +When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. + +## Catalog Change Detection + +Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. + +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. ## Skill Format Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case. +The catalog and body have separate lifecycles. Discovery parses frontmatter to produce the summary. Every `skill(name)` load rereads and reparses the current file, so body edits need no hash, revision, cache invalidation, or proactive model notification. A frontmatter rename between discovery and loading rejects the stale name and invalidates the provider; the next catalog observation publishes the new name. + ## Model Experience -Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the session-prefix catalog and a selected instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. +Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the initial or replacement catalog and a selected current instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +Watcher invalidation can cause the named consumer to append a replacement catalog after the reusable session prefix. Body-only edits leave the catalog digest unchanged. ## Known Limitations and Deferred Work - **Discovery is one level deep** — only `//SKILL.md` and `/.md` are recognized; nested skill trees and package manifests are ignored. - **Project scope is the nearest `.git` ancestor** — workspaces without that marker fall back to the supplied cwd, with no alternate project-root marker or monorepo subproject selection. -- **Unreadable or malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from a skipped one. -- **No filesystem watching** — edits rely on the registry cache being evicted or invalidated by provider reload before a previously collected cwd is rediscovered. +- **Malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one; unexpected I/O failures preserve the last-good catalog instead. +- **Missing-root observation polls one path segment** — roots absent at startup use `fs.watchFile` at `watchPollIntervalMs` until Chokidar can attach, trading bounded detection latency for reliable creation detection across IDE, Git, and shell workflows. +- **No body revision protocol** — a loaded body is ordinary retained tool history; later file edits affect later calls but neither rewrite old results nor announce that the body changed. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 796c814a3c..19b1091ec0 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -17,6 +17,12 @@ | `dshHome` | `$DSH_HOME` or `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | 为兼容 skill 扫描的共享 agent 配置根。 | | `customSkillDirs` | `[]` | 在项目根之后、用户根之前扫描的其他本地 skill 根。 | +| `watch` | `true` | 监视宿主本地根,并在目录成员或 frontmatter 可能发生变化时使本地提供方失效。 | +| `watchUsePolling` | `false` | 对现有 skill 根使用 Chokidar 轮询,而不是原生事件。 | +| `watchStabilityThresholdMs` | `200` | Chokidar `add` 和 `change` 事件的稳定写入窗口。 | +| `watchPollIntervalMs` | `100` | Chokidar 轮询/稳定性间隔和缺失路径探测间隔。 | +| `watchMaxProjects` | `128` | watcher LRU 中保留的不同项目根数量上限。 | +| `watchFollowSymlinks` | `true` | 监视现有根时跟随符号链接。 | ## 发现 @@ -32,23 +38,34 @@ 项目根是包含 `.git` 的最近祖先;如果不存在,则使用当前 cwd。用户 DSH 根会跳过其 `.system` 子级,因此系统所有目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 -当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。缺失、不可读或格式错误的 skill 文件会警告并跳过,而不会使整个请求失败。 +当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 + +## 目录变更检测 + +现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 + +不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 + +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录,使提供方的当前观察不完整,并触发重试;effect 释放会关闭所有 watcher,并收束延迟回调。 ## Skill 格式 Skill 可以是单层目录 bundle(`/SKILL.md`),也可以是平铺 Markdown 文件(`.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name` 和 `description`,而 `whenToUse`、`disableModelInvocation` 和 `metadata` 可选。名称必须使用 kebab-case。 +目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。 + ## 模型体验 -通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 +通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到初始目录或替换目录中,并将所选当前指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 #### KV 缓存影响 -不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 +watcher 触发的失效可促使指定的消费方在可复用会话前缀之后追加替换目录。仅涉及正文的编辑不会改变目录 digest。 ## 已知限制与待完成工作 - **发现深度为一层**:只识别 `//SKILL.md` 和 `/.md`;忽略嵌套 skill 树和包 manifest。 - **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。 -- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。 -- **无文件系统 watcher**:先前已收集 cwd 重新发现之前,编辑操作依赖注册表缓存被驱逐,或因提供方重新加载而失效。 +- **格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与无效的 skill;意外 I/O 失败则会保留最后一份可用目录。 +- **缺失根观察每次轮询一个路径段**:启动时不存在的根会使用 `fs.watchFile` 按 `watchPollIntervalMs` 轮询,直至 Chokidar 可以附加;这以有界检测延迟换取跨 IDE、Git 和 shell 工作流的可靠创建检测。 +- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。 diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 1bc655fb39..8331a94a97 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -34,6 +34,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "chokidar": "^5.0.0", "schemastery": "^3.18.0", "yaml": "^2.4.2" }, diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 2c5e480e2a..a20f42b1b5 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -10,9 +10,11 @@ */ import { access, readdir, readFile, stat } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { unwatchFile, watchFile, type Stats } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' import type { Context } from 'cordis' +import chokidar from 'chokidar' import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' @@ -32,6 +34,9 @@ const PROJECT_AGENTS_RANK = 200 const CUSTOM_RANK = 300 const USER_DSH_RANK = 400 const USER_AGENTS_RANK = 500 +const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200 +const DEFAULT_WATCH_POLL_INTERVAL_MS = 100 +const DEFAULT_WATCH_MAX_PROJECTS = 128 export const name = 'skill-local' export const inject = ['skills'] @@ -44,12 +49,30 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Whether host-local skill roots are watched for catalog changes. */ + watch?: boolean + /** Whether Chokidar uses polling instead of native filesystem events. */ + watchUsePolling?: boolean + /** Milliseconds a changed skill entry must remain stable before it is observed. */ + watchStabilityThresholdMs?: number + /** Milliseconds between Chokidar stability or polling probes. */ + watchPollIntervalMs?: number + /** Maximum distinct project roots whose skill directories remain watched. */ + watchMaxProjects?: number + /** Whether watched symbolic links follow their target files. */ + watchFollowSymlinks?: boolean } export const Config: Schema = z.object({ dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), + watch: z.boolean().default(true), + watchUsePolling: z.boolean().default(false), + watchStabilityThresholdMs: z.number().default(DEFAULT_WATCH_STABILITY_THRESHOLD_MS), + watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS), + watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS), + watchFollowSymlinks: z.boolean().default(true), }) interface SkillRoot { @@ -57,6 +80,7 @@ interface SkillRoot { source: SkillSource rank: number skipSystem?: boolean + projectRoot?: string } interface SkillRootEntry { @@ -79,10 +103,26 @@ interface LocalLocator { directory: string } +interface ResolvedWatchConfig { + enabled: boolean + usePolling: boolean + stabilityThresholdMs: number + pollIntervalMs: number + maxProjects: number + followSymlinks: boolean +} + /** Register the local filesystem skill provider on `ctx.skills`. */ export function apply(ctx: Context, config: Config = {}): void { const provider = new LocalSkillProvider(ctx, config) ctx.skills.registerProvider(provider) + ctx.effect(function* () { + yield async () => { await provider.dispose() } + }, 'skill-local watcher') + ctx.on('fs/observed', (target, _version, actor) => { + if (mutationToolName(actor) === undefined) return + provider.observeHostMutation(target.displayPath) + }) } /** Provider that maps local project/user skill roots into `ctx.skills`. */ @@ -91,11 +131,13 @@ export class LocalSkillProvider implements SkillProvider { private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] + private readonly watchManager: SkillWatchManager constructor(private readonly ctx: Context, config: Config = {}) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) + this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config)) } /** @@ -105,6 +147,7 @@ export class LocalSkillProvider implements SkillProvider { */ async list(options: SkillLookupOptions): Promise { const roots = await this.roots(options.cwd) + await this.watchManager.observeRoots(roots) const candidates: SkillCandidate[] = [] for (const root of roots) { for (const skill of await discoverRoot(root, this.ctx)) { @@ -138,13 +181,26 @@ export class LocalSkillProvider implements SkillProvider { } } + /** + * Invalidate this provider synchronously after a first-party filesystem mutation. + * @param path - host display path observed after a model-facing write or edit. + */ + observeHostMutation(path: string): void { + this.watchManager.observeHostMutation(path) + } + + /** Close every host watcher and contain late filesystem callbacks. */ + async dispose(): Promise { + await this.watchManager.dispose() + } + private async roots(cwd: string | undefined): Promise { const roots: SkillRoot[] = [] if (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 }, - { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK }, + { 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( @@ -156,6 +212,405 @@ export class LocalSkillProvider implements SkillProvider { } } +type SkillWatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' + +type RootWatchMode = + | { kind: 'root'; anchor: string } + | { kind: 'ancestor'; anchor: string; nextPath: string } + +interface RootWatchState { + root: SkillRoot + owners: Set + watcher: WatchHandle | undefined + opening: Promise | undefined + unhealthy: boolean +} + +interface WatchHandle { + close(): Promise | void +} + +/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */ +class SkillWatchManager { + private readonly roots = new Map() + private readonly projects = new Map>() + private closing = false + private invalidationQueued = false + + constructor( + private readonly ctx: Context, + private readonly provider: SkillProvider, + private readonly config: ResolvedWatchConfig, + ) {} + + async observeRoots(roots: readonly SkillRoot[]): Promise { + if (this.closing) return + const projectRoots = new Map() + const pending: Promise[] = [] + for (const root of roots) { + if (root.projectRoot === undefined) { + pending.push(this.retainRoot(root, `shared:${root.path}`)) + continue + } + const grouped = projectRoots.get(root.projectRoot) ?? [] + grouped.push(root) + projectRoots.set(root.projectRoot, grouped) + } + for (const [projectRoot, grouped] of projectRoots) { + const owner = `project:${projectRoot}` + this.projects.delete(projectRoot) + const paths = new Set(grouped.map(root => root.path)) + this.projects.set(projectRoot, paths) + for (const root of grouped) pending.push(this.retainRoot(root, owner)) + } + let evictedProject = false + while (this.projects.size > this.config.maxProjects) { + const oldest = this.projects.entries().next() + /* v8 ignore next -- the loop condition proves one project exists. */ + if (oldest.done) break + const [projectRoot, paths] = oldest.value + this.projects.delete(projectRoot) + const owner = `project:${projectRoot}` + for (const path of paths) pending.push(this.releaseRoot(path, owner)) + evictedProject = true + } + await Promise.all(pending) + if (evictedProject) this.ctx.skills.invalidateProvider(this.provider) + } + + observeHostMutation(path: string): void { + if (this.closing) return + const normalized = resolve(path) + if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return + this.ctx.skills.invalidateProvider(this.provider) + } + + async dispose(): Promise { + if (this.closing) return + this.closing = true + const states = [...this.roots.values()] + this.roots.clear() + this.projects.clear() + await Promise.all(states.map(async (state) => { + await settleWatcherOpening(state.opening) + const watcher = state.watcher + state.watcher = undefined + if (watcher !== undefined) await this.closeWatcher(watcher) + })) + } + + private async retainRoot(root: SkillRoot, owner: string): Promise { + let state = this.roots.get(root.path) + if (state === undefined) { + state = { root, owners: new Set(), watcher: undefined, opening: undefined, unhealthy: true } + this.roots.set(root.path, state) + } + state.owners.add(owner) + if (this.config.enabled) await this.ensureWatcher(state) + } + + private async releaseRoot(path: string, owner: string): Promise { + const state = this.roots.get(path) + /* v8 ignore next -- Concurrent cwd observations can evict the same shared root before this release settles. */ + if (state === undefined) return + state.owners.delete(owner) + if (state.owners.size > 0) return + this.roots.delete(path) + await settleWatcherOpening(state.opening) + const watcher = state.watcher + state.watcher = undefined + if (watcher !== undefined) await this.closeWatcher(watcher) + } + + private ensureWatcher(state: RootWatchState): Promise { + if (this.closing || !this.config.enabled) return Promise.resolve() + if (state.watcher !== undefined && !state.unhealthy) return Promise.resolve() + if (state.opening !== undefined) return state.opening + const opening = this.replaceWatcher(state) + state.opening = opening + void opening.then( + () => { + state.opening = undefined + }, + () => { + state.opening = undefined + }, + ) + return opening + } + + private async replaceWatcher(state: RootWatchState): Promise { + const previous = state.watcher + state.watcher = undefined + if (previous !== undefined) await this.closeWatcher(previous) + /* v8 ignore next -- Teardown can win while an unhealthy watcher is still closing. */ + if (this.closing || state.owners.size === 0) return + try { + const watcher = await this.openStableWatcher(state) + /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */ + if (watcher === undefined) return + /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup + if (this.closing || state.owners.size === 0) { + await this.closeWatcher(watcher) + return + } + /* v8 ignore stop */ + state.watcher = watcher + state.unhealthy = false + } catch (error) { + state.unhealthy = true + this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) + throw error + } + } + + private async openStableWatcher(state: RootWatchState): Promise { + while (!this.closing && state.owners.size > 0) { + const mode = await resolveRootWatchMode(state.root.path) + const watcher = mode.kind === 'ancestor' + ? this.openAncestorWatcher(state, mode) + : await this.openRootWatcher(state, mode) + const current = await resolveRootWatchMode(state.root.path) + /* v8 ignore else -- A host path transition between the two probes is timing-dependent. */ + if (sameWatchMode(mode, current)) return watcher + /* v8 ignore next -- Covered by the same host path transition guard. */ + await this.closeWatcher(watcher) + } + /* v8 ignore next -- The loop exits only when teardown wins between awaited probes. */ + return undefined + } + + private openAncestorWatcher(state: RootWatchState, mode: Extract): WatchHandle { + const listener = (_current: Stats, _previous: Stats): void => { + this.handleWatchEvent(state, mode, 'change', mode.nextPath) + } + watchFile(mode.nextPath, { + persistent: false, + interval: this.config.pollIntervalMs, + }, listener) + return { + close() { + unwatchFile(mode.nextPath, listener) + }, + } + } + + private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { + const watcher = chokidar.watch(mode.anchor, { + persistent: false, + ignoreInitial: true, + depth: 1, + followSymlinks: this.config.followSymlinks, + atomic: true, + awaitWriteFinish: { + stabilityThreshold: this.config.stabilityThresholdMs, + pollInterval: this.config.pollIntervalMs, + }, + usePolling: this.config.usePolling, + interval: this.config.pollIntervalMs, + }) + let ready = false + const readiness = Promise.withResolvers() + const onError = (error: unknown): void => { + if (!ready) { + readiness.reject(error) + return + } + this.handleWatcherError(state, error) + } + watcher.on('error', onError) + watcher.once('ready', () => { + ready = true + readiness.resolve(undefined) + }) + for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) { + watcher.on(event, (path) => { this.handleWatchEvent(state, mode, event, path) }) + } + try { + await readiness.promise + } catch (error) { + await this.closeWatcher(watcher) + throw error + } + return watcher + } + + private handleWatchEvent( + state: RootWatchState, + mode: RootWatchMode, + event: SkillWatchEvent, + path: string, + ): void { + if (this.closing || !isRelevantWatchEvent(state.root, mode, event, resolve(path))) return + this.queueInvalidation() + if (mode.kind === 'ancestor' || (resolve(path) === state.root.path && event === 'unlinkDir')) { + state.unhealthy = true + this.scheduleRewatch(state) + } + } + + private handleWatcherError(state: RootWatchState, error: unknown): void { + if (this.closing) return + this.ctx.logger.warn(`skill-local: watcher for ${state.root.path} failed: ${errorMessage(error)}`) + state.unhealthy = true + this.queueInvalidation() + this.scheduleRewatch(state) + } + + private scheduleRewatch(state: RootWatchState): void { + const currentOpening = state.opening ?? Promise.resolve() + void (async () => { + await settleWatcherOpening(currentOpening) + try { + await this.ensureWatcher(state) + } catch { + // Watch startup logged the retry failure; the next incomplete discovery retries it again. + return + } + this.queueInvalidation() + })() + } + + private queueInvalidation(): void { + if (this.closing || this.invalidationQueued) return + this.invalidationQueued = true + queueMicrotask(() => { + this.invalidationQueued = false + if (this.closing) return + this.ctx.skills.invalidateProvider(this.provider) + }) + } + + private async closeWatcher(watcher: WatchHandle): Promise { + try { + await watcher.close() + } catch (error) { + this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`) + } + } +} + +async function settleWatcherOpening(opening: Promise | undefined): Promise { + if (opening === undefined) return + try { + await opening + } catch { + // Watch startup already logged the underlying failure; teardown only contains it. + } +} + +function resolveWatchConfig(config: Config): ResolvedWatchConfig { + const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS + const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS + const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS + assertPositiveInteger('watchStabilityThresholdMs', stabilityThresholdMs) + assertPositiveInteger('watchPollIntervalMs', pollIntervalMs) + assertPositiveInteger('watchMaxProjects', maxProjects) + return { + enabled: config.watch ?? true, + usePolling: config.watchUsePolling ?? false, + stabilityThresholdMs, + pollIntervalMs, + maxProjects, + followSymlinks: config.watchFollowSymlinks ?? true, + } +} + +async function resolveRootWatchMode(root: string): Promise { + let candidate = root + while (true) { + try { + const info = await stat(candidate) + if (info.isDirectory()) { + if (candidate === root) return { kind: 'root', anchor: root } + const firstSegment = relative(candidate, root).split(sep)[0] + /* v8 ignore next -- candidate is a strict ancestor of root. */ + if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root } + return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) } + } + } catch (error) { + /* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */ + if (!isAbsentPathError(error)) throw error + } + const parent = dirname(candidate) + /* v8 ignore next -- Traversal reaches the existing filesystem root before this fallback. */ + if (parent === candidate) return { kind: 'ancestor', anchor: candidate, nextPath: root } + candidate = parent + } +} + +function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean { + return left.kind === right.kind + && left.anchor === right.anchor + && (left.kind === 'root' || (right.kind === 'ancestor' && left.nextPath === right.nextPath)) +} + +function isRelevantWatchEvent( + root: SkillRoot, + mode: RootWatchMode, + event: SkillWatchEvent, + path: string, +): boolean { + if (mode.kind === 'ancestor') { + return path === mode.nextPath + } + const segments = containedSegments(root.path, path) + if (segments === undefined) return false + if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir' + if (root.skipSystem === true && segments[0] === '.system') return false + if (segments.length === 1) { + if (event === 'addDir' || event === 'unlinkDir') return true + return segments[0]?.endsWith('.md') === true + } + return segments.length === 2 + && segments[1] === 'SKILL.md' + && event !== 'addDir' + && event !== 'unlinkDir' +} + +function isPotentialSkillPath(root: SkillRoot, path: string): boolean { + const segments = containedSegments(root.path, path) + if (segments === undefined || segments.length === 0 || segments.length > 2) return false + if (root.skipSystem === true && segments[0] === '.system') return false + return segments.length === 1 + ? segments[0]?.endsWith('.md') === true + : segments[1] === 'SKILL.md' +} + +function containedSegments(root: string, path: string): string[] | undefined { + const child = relative(root, path) + if (child.length === 0) return [] + if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) return undefined + return child.split(sep) +} + +function mutationToolName(actor: object | undefined): 'edit' | 'write' | undefined { + if (actor === undefined || !('name' in actor)) return undefined + const value = actor.name + return value === 'edit' || value === 'write' ? value : undefined +} + +function assertPositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new TypeError(`skill-local: ${field} must be a positive integer`) + } +} + +function isAbsentPathError(error: unknown): boolean { + return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR') +} + +function isAbsentSkillPathError(error: unknown): boolean { + return isAbsentPathError(error) + || hasErrorCode(error, 'FS_NOT_FOUND') + || hasErrorCode(error, 'FS_NOT_DIRECTORY') +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + async function discoverRoot(root: SkillRoot, ctx: Context): Promise { const skills: SkillCandidate[] = [] const entries = await listSkillRootEntries(root, ctx) @@ -193,9 +648,12 @@ async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise { - // Skill roots are optional; an absent or unlistable root contributes no skills. - const entries = await fsListDir(fs, root.path).catch(() => undefined) - return entries === undefined ? [] : entries.map(entryFromFs) + try { + return (await fsListDir(fs, root.path)).map(entryFromFs) + } catch (error) { + if (isAbsentSkillPathError(error)) return [] + throw error + } } async function fsListDir(fs: FileSystem, path: string): Promise { @@ -211,9 +669,11 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom let entries try { entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' }) - } catch { - // Missing or unreadable local skill roots are expected in most deployments. - return [] + } catch (error) { + /* v8 ignore else -- Native non-absence directory failures are provider-dependent; the ctx.fs path pins incomplete discovery. */ + if (isAbsentSkillPathError(error)) return [] + /* v8 ignore next -- Same native error branch as above. */ + throw error } const result: SkillRootEntry[] = [] @@ -274,31 +734,39 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): } try { return await readFile(path, { encoding: 'utf8', signal }) - } catch { + } catch (error) { signal?.throwIfAborted() - return undefined + if (isAbsentSkillPathError(error)) return undefined + throw error } } async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise { // A missing or temporarily inaccessible skill file is not fatal to discovery. signal?.throwIfAborted() - const target = await fs.resolve(path).catch(() => undefined) + let target + try { + target = await fs.resolve(path) + } catch (error) { + if (isAbsentSkillPathError(error)) return undefined + throw error + } signal?.throwIfAborted() - if (target === undefined) return undefined let info try { info = await fs.stat(target, signal) } catch (error) { signal?.throwIfAborted() - ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`) - return undefined + if (isAbsentSkillPathError(error)) return undefined + throw error } if (info === undefined || info.type !== 'file') return undefined try { return await fs.readText(target, signal) } catch (error) { signal?.throwIfAborted() + if (isAbsentSkillPathError(error)) return undefined + if (!hasErrorCode(error, 'FS_NOT_TEXT')) throw error ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`) return undefined } diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts new file mode 100644 index 0000000000..5cf5de8460 --- /dev/null +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -0,0 +1,220 @@ +import { EventEmitter } from 'node:events' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import SkillService from '@deepseek-ai/dsh-skill' + +interface FakeWatcherControl { + emitter: EventEmitter + closeCalls: number + options: Record +} + +const watcherHarness = vi.hoisted(() => ({ + watchers: [] as FakeWatcherControl[], + startupErrors: [] as Error[], + closeErrors: 0, + deferredReady: 0, +})) + +vi.mock('chokidar', () => ({ + default: { + watch(_path: unknown, options: Record) { + const emitter = new EventEmitter() as EventEmitter & { close(): Promise } + const control: FakeWatcherControl = { emitter, closeCalls: 0, options } + emitter.close = async () => { + control.closeCalls += 1 + if (watcherHarness.closeErrors > 0) { + watcherHarness.closeErrors -= 1 + throw new Error('close failed') + } + } + watcherHarness.watchers.push(control) + queueMicrotask(() => { + if (watcherHarness.deferredReady > 0) { + watcherHarness.deferredReady -= 1 + return + } + const error = watcherHarness.startupErrors.shift() + if (error === undefined) emitter.emit('ready') + else emitter.emit('error', error) + }) + return emitter + }, + }, +})) + +const SkillLocal = await import('../src/index.ts') + +async function tempDir(name: string): Promise { + return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) +} + +async function writeSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n\nBody.\n`) +} + +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +beforeEach(() => { + watcherHarness.watchers.length = 0 + watcherHarness.startupErrors.length = 0 + watcherHarness.closeErrors = 0 + watcherHarness.deferredReady = 0 +}) + +describe('skill-local watcher failures', () => { + it('marks a startup failure incomplete and retries discovery without caching it', 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.closeErrors = 1 + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchUsePolling: true, + watchFollowSymlinks: false, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'retry-skill' }], + complete: true, + }) + expect(watcherHarness.watchers).toHaveLength(2) + expect(watcherHarness.watchers[1]?.options).toMatchObject({ + atomic: true, + depth: 1, + followSymlinks: false, + usePolling: true, + interval: 10, + awaitWriteFinish: { + stabilityThreshold: 20, + pollInterval: 10, + }, + }) + + await fiber.dispose() + }) + + it('filters events, coalesces invalidation, recovers runtime errors, and contains late callbacks', async () => { + const home = await tempDir('skill-watch-runtime-error') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'watched-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill']) + const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) + let invalidations = 0 + ctx.skills.invalidateProvider = (provider) => { + invalidations += 1 + invalidateProvider(provider) + } + const first = watcherHarness.watchers[0] + if (first === undefined) throw new Error('expected a root watcher') + + first.emitter.emit('change', join(root, 'notes.txt')) + first.emitter.emit('change', join(home, 'outside.md')) + first.emitter.emit('change', join(root, 'watched-skill/references.md')) + first.emitter.emit('change', join(root, '.system/SKILL.md')) + await settle() + expect(invalidations).toBe(0) + + first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + await settle() + expect(invalidations).toBe(1) + + watcherHarness.closeErrors = 1 + watcherHarness.startupErrors.push(new Error('runtime rewatch failed')) + first.emitter.emit('error', new Error('runtime watch failed')) + await settle() + await settle() + expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) + expect(invalidations).toBeGreaterThanOrEqual(2) + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'watched-skill' }], + complete: true, + }) + + await fiber.dispose() + first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('error', new Error('late error')) + await settle() + }) + + it('settles an opening watcher when plugin disposal races its ready event', async () => { + const home = await tempDir('skill-watch-opening-dispose') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'racing-skill') + watcherHarness.deferredReady = 1 + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new SkillLocal.LocalSkillProvider(ctx, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + ctx.skills.registerProvider(provider) + + const discovery = provider.list({}) + await settle() + const first = watcherHarness.watchers[0] + if (first === undefined) throw new Error('expected an opening root watcher') + first.emitter.emit('unlinkDir', root) + const disposal = provider.dispose() + first.emitter.emit('ready') + + await Promise.all([discovery, disposal]) + await settle() + expect(first.closeCalls).toBeGreaterThan(0) + }) + + it('contains an opening watcher rejection during provider teardown', async () => { + const home = await tempDir('skill-watch-opening-reject') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'rejected-skill') + watcherHarness.deferredReady = 1 + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new SkillLocal.LocalSkillProvider(ctx, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + ctx.skills.registerProvider(provider) + + const discovery = provider.list({}) + await settle() + const first = watcherHarness.watchers[0] + if (first === undefined) throw new Error('expected an opening root watcher') + const disposal = provider.dispose() + first.emitter.emit('error', new Error('opening failed during disposal')) + + await expect(discovery).rejects.toThrow('opening failed during disposal') + await disposal + }) +}) diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 0c7d473b13..470d39b630 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import SkillService from '@deepseek-ai/dsh-skill' -import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillLocal from '../src/index.ts' async function tempDir(name: string): Promise { @@ -26,19 +26,26 @@ class TestFileSystem extends FileSystem { listDirCalls = 0 failResolvePaths = new Set() failStatPaths = new Set() + failListDirPaths = new Set() + errorResolvePaths = new Set() + errorStatPaths = new Set() + errorReadPaths = new Set() + missingReadPaths = new Set() statOverrides = new Map() statSignals: Array = [] readTextSignals: Array = [] readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise override async resolve(path: string): Promise { - if (this.failResolvePaths.has(path)) throw new Error('resolve failed') + if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND') + if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed') return { targetKey: path as never, displayPath: path } } override async stat(target: FsTarget, signal?: AbortSignal): Promise { this.statSignals.push(signal) - if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed') + if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND') + if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily failed') if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath) try { const fs = await import('node:fs/promises') @@ -70,8 +77,10 @@ class TestFileSystem extends FileSystem { override async readText(target: FsTarget, signal?: AbortSignal): Promise { this.readTextSignals.push(signal) if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal) + if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND') + if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed') const text = await readFile(target.displayPath, 'utf8') - if (text.includes('\uFFFD')) throw new Error('not text') + if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT') return text } @@ -81,6 +90,7 @@ class TestFileSystem extends FileSystem { override async listDir(target: FsTarget): Promise { this.listDirCalls += 1 + if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed') const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' }) const result: FsDirEntry[] = [] for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { @@ -122,11 +132,22 @@ async function setupLocal(home: string, config: Partial = {}) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), + watch: false, ...config, }) return ctx } +async function waitFor(read: () => Promise, accept: (value: T) => boolean): Promise { + const deadline = Date.now() + 5000 + while (true) { + const value = await read() + if (accept(value)) return value + if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state') + await new Promise(resolve => setTimeout(resolve, 20)) + } +} + describe('dsh-skill-local plugin exports', () => { it('declares stable plugin metadata', () => { expect(SkillLocal.name).toBe('skill-local') @@ -224,7 +245,7 @@ describe('LocalSkillProvider', () => { const listedBeforeDelete = await ctx.skills.list() const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill') if (flatSummary === undefined) throw new Error('expected flat-skill') - await writeFile(join(root, 'flat-skill.md'), '') + await rm(join(root, 'flat-skill.md')) expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill']) expect(await ctx.skills.get('flat-skill')).toBeUndefined() @@ -327,7 +348,7 @@ describe('LocalSkillProvider', () => { size: 0, }) await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([ ['backend-root', 'project-agents'], @@ -337,6 +358,92 @@ describe('LocalSkillProvider', () => { expect(await ctx.skills.get('binary-skill')).toBeUndefined() }) + it('reports transient root reads as incomplete without caching an empty catalog', async () => { + const home = await tempDir('skill-transient-root') + const root = join(home, '.agents/skills') + await writeSkill(root, 'stable-skill', 'Stable skill') + const ctx = new Context() + await ctx.plugin(TestFileSystem) + const fs = ctx.fs as TestFileSystem + await ctx.plugin(SkillService) + await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: false, + }) + + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'stable-skill' }], + complete: true, + }) + fs.failListDirPaths.add(root) + const path = join(root, 'stable-skill/SKILL.md') + ctx.emit( + 'fs/observed', + { targetKey: path as never, displayPath: path }, + FsVersion('failed-read'), + { name: 'edit' }, + ) + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) + + fs.failListDirPaths.clear() + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'stable-skill' }], + complete: true, + }) + }) + + it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => { + const home = await tempDir('skill-transient-entry') + const root = join(home, '.agents/skills') + const path = join(root, 'stable-skill/SKILL.md') + await writeSkill(root, 'stable-skill', 'Stable skill') + const ctx = new Context() + await ctx.plugin(TestFileSystem) + const fs = ctx.fs as TestFileSystem + await ctx.plugin(SkillService) + await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: false, + }) + const invalidate = (): void => { + ctx.emit( + 'fs/observed', + { targetKey: path as never, displayPath: path }, + FsVersion('entry-failure'), + { name: 'write' }, + ) + } + + expect((await ctx.skills.snapshot()).complete).toBe(true) + for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) { + failures.add(path) + invalidate() + expect((await ctx.skills.snapshot()).complete).toBe(false) + failures.clear() + } + + fs.missingReadPaths.add(path) + invalidate() + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + fs.missingReadPaths.clear() + invalidate() + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'stable-skill' }], + complete: true, + }) + }) + + it('marks an unexpected native skill-file read failure incomplete', async () => { + const home = await tempDir('skill-native-read-failure') + const root = join(home, '.agents/skills') + await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true }) + const ctx = await setupLocal(home) + + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) + }) + it('forwards cancellation to filesystem reads while loading a skill', async () => { const home = await tempDir('skill-read-abort') await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill') @@ -345,7 +452,7 @@ describe('LocalSkillProvider', () => { await ctx.plugin(TestFileSystem) const fs = ctx.fs as TestFileSystem await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill']) fs.statSignals = [] @@ -372,6 +479,219 @@ describe('LocalSkillProvider', () => { expect(fs.readTextSignals).toEqual([controller.signal]) }) + it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => { + const home = await tempDir('skill-watch-home') + const agentsRoot = join(home, '.agents/skills') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + try { + expect(await ctx.skills.list()).toEqual([]) + + await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.') + const added = await waitFor( + async () => await ctx.skills.list(), + skills => skills.some(skill => skill.name === 'watched-skill'), + ) + expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description') + + await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.') + const changed = await waitFor( + async () => await ctx.skills.list(), + skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description', + ) + expect(changed).toHaveLength(1) + expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.') + + await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added') + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => names.includes('flat-added'), + )).toEqual(['flat-added', 'watched-skill']) + + await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill')) + await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill') + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => names.includes('renamed-skill') && !names.includes('watched-skill'), + )).toEqual(['flat-added', 'renamed-skill']) + + await rm(join(agentsRoot, 'renamed-skill'), { recursive: true }) + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => !names.includes('renamed-skill'), + )).toEqual(['flat-added']) + + await rm(join(home, '.agents'), { recursive: true }) + expect(await waitFor( + async () => await ctx.skills.list(), + skills => skills.length === 0, + )).toEqual([]) + + await writeSkill(agentsRoot, 'recreated-skill', 'Recreated') + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => names.includes('recreated-skill'), + )).toEqual(['recreated-skill']) + } finally { + await fiber.dispose() + } + + }) + + it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => { + const home = await tempDir('skill-observed-home') + const root = join(home, '.agents/skills') + const ctx = await setupLocal(home) + expect(await ctx.skills.list()).toEqual([]) + const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) + let invalidations = 0 + ctx.skills.invalidateProvider = (provider) => { + invalidations += 1 + invalidateProvider(provider) + } + + await writeSkill(root, 'observed-skill', 'Observed skill') + const path = join(root, 'observed-skill/SKILL.md') + const emitObserved = (displayPath: string, actor?: object): void => { + ctx.emit( + 'fs/observed', + { targetKey: displayPath as never, displayPath }, + FsVersion('observed'), + actor, + ) + } + emitObserved(path) + emitObserved(path, {}) + emitObserved(path, { name: 'read' }) + emitObserved(join(home, 'outside.md'), { name: 'write' }) + emitObserved(root, { name: 'write' }) + emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' }) + emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' }) + emitObserved(join(root, 'flat-skill.md'), { name: 'write' }) + ctx.emit( + 'fs/observed', + { targetKey: path as never, displayPath: path }, + FsVersion('observed'), + { name: 'edit' }, + ) + + expect(invalidations).toBe(2) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill']) + }) + + it('bounds project watchers and re-observes an evicted project on its next lookup', async () => { + const home = await tempDir('skill-watch-lru-home') + const first = await tempDir('skill-watch-lru-first') + const second = await tempDir('skill-watch-lru-second') + await mkdir(join(first, '.git'), { recursive: true }) + await mkdir(join(second, '.git'), { recursive: true }) + await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project') + await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + customSkillDirs: [join(first, '.agents/skills')], + watch: true, + watchMaxProjects: 1, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + try { + expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project') + expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project') + await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed') + + expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description) + .toBe('First project refreshed') + } finally { + await fiber.dispose() + } + + const noWatch = new Context() + await noWatch.plugin(SkillService) + await noWatch.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: false, + watchMaxProjects: 1, + }) + await noWatch.skills.list({ cwd: first }) + await noWatch.skills.list({ cwd: second }) + }) + + it('contains repeated disposal and late first-party observations', async () => { + const home = await tempDir('skill-watch-dispose') + const nonDirectoryRoot = join(home, 'not-a-directory') + await writeFile(nonDirectoryRoot, 'not a skill root') + await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new SkillLocal.LocalSkillProvider(ctx, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + customSkillDirs: [nonDirectoryRoot], + watch: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + ctx.skills.registerProvider(provider) + expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + + await provider.dispose() + await provider.dispose() + provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) + + expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + }) + + it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => { + const home = await tempDir('skill-watch-symlink-home') + const external = await tempDir('skill-watch-symlink-external') + const root = join(home, '.dsh/skills') + await writeSkill(external, 'linked-skill', 'First linked description') + await mkdir(root, { recursive: true }) + await symlink(join(external, 'linked-skill'), join(root, 'linked-skill')) + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchFollowSymlinks: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + try { + expect((await ctx.skills.list())[0]?.description).toBe('First linked description') + await writeSkill(external, 'linked-skill', 'Second linked description') + const refreshed = await waitFor( + async () => await ctx.skills.list(), + skills => skills[0]?.description === 'Second linked description', + ) + expect(refreshed[0]?.name).toBe('linked-skill') + } finally { + await fiber.dispose() + } + }) + + it('validates watcher tunables at plugin load', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + + await expect(ctx.plugin(SkillLocal, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects') + await expect(ctx.plugin(SkillLocal, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs') + await expect(ctx.plugin(SkillLocal, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs') + }) + it('uses default home root resolution without exposing builtin skills', async () => { const previousDshHome = process.env.DSH_HOME const previousAgentsHome = process.env.DSH_AGENTS_HOME @@ -382,14 +702,14 @@ describe('LocalSkillProvider', () => { await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill') const ctx = new Context() await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal) + await ctx.plugin(SkillLocal, { watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill']) process.env.DSH_HOME = join(envHome, 'empty-dsh') process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') const empty = new Context() await empty.plugin(SkillService) - SkillLocal.apply(empty, {}) + SkillLocal.apply(empty, { watch: false }) expect(await empty.skills.list()).toEqual([]) delete process.env.DSH_AGENTS_HOME diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index d9e7e42df7..f9adb2f558 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 639616d0b75f960e9ccd48546d44db841372bbe2 -README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b +# pnpm run verify-translation-pairing --write packages/skill/skill/README.md +README.md: f4f933576c60c8d750e3bd8eb0183ccb4b37e2da +README.zh.md: baa0d0ca43a8da3e3177d5c05437501a1fbe1735 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 639616d0b7..f4f933576c 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -11,10 +11,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. +- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement. +- `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.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. +### Events + +- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. + ### Config | Field | Default | Meaning | @@ -27,7 +33,9 @@ A provider registers synchronously and performs remote setup, authentication, an 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 `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. 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: 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. + +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 that exact provider is invalidated so the next snapshot rediscovers its catalog. ## Runtime Skills @@ -39,15 +47,15 @@ The registry does not render model guidance or register model-facing tools. [`@d ## Model Experience -Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results. +Indirectly, through `dsh-tool-skill`, which renders provider summaries into the initial session prefix or durable replacement catalog messages and loaded instructions into retained tool results. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct prompt effect. The named consumer owns initial prefix composition and append-only catalog replacements after invalidation. ## Known Limitations and Deferred Work -- **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload. +- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` 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. -- **A provider-list failure removes that whole source for the request** — the registry logs and skips it, with no model-visible diagnostic or partial-catalog recovery contract. +- **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. - **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 3afdd41539..baa0d0ca43 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -11,10 +11,16 @@ ### 公开 API - `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。 +- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。 +- `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,以供有序组合拆卸。 +### 事件 + +- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 + ### 配置 | 字段 | 默认值 | 含义 | @@ -27,7 +33,9 @@ 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -契约违反会快速失败。被拒绝的 `list()` 视为瞬时来源失败:系统记录它、跳过它,并且不缓存。只缓存已完成目录;提供方或运行时修订变更会丢弃正在进行的结果并重试。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 +契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 + +定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。 ## 运行时 Skill @@ -39,15 +47,15 @@ ## 模型体验 -通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。 +通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到初始会话前缀或持久的替换目录消息中,并将已加载指令渲染到已保留工具结果中。 #### KV 缓存影响 -不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 +不直接影响提示词。指定的消费方负责初始前缀组装,以及失效后的仅追加式目录替换。 ## 已知限制与待完成工作 -- **已完成目录没有 TTL 或 watcher 失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存 cwd 会保持陈旧,直到被驱逐或重新加载提供方/运行时。 +- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 -- **提供方列表失败会移除该请求的整个来源**:注册表会记录并跳过它,不提供模型可见诊断或部分目录恢复契约。 +- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f736fc8d0f..aa078280a8 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -87,6 +87,14 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } +/** One catalog observation plus whether every registered provider completed discovery. */ +export interface SkillCatalogSnapshot { + /** Sorted model-invocable summaries from providers that completed. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed discovery for this observation. */ + 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. */ @@ -119,6 +127,17 @@ declare module 'cordis' { interface Context { skills: SkillService } + + interface Events { + /** + * A skill provider, runtime contribution, or provider-backed catalog may + * have changed. This is an unfiltered invalidation notification; consumers + * refetch the catalog for their own lookup options. Listener failures are + * contained and cannot veto the registry mutation. + * @mode emit + */ + 'skills/change'(): void + } } interface IndexedCandidate { @@ -189,6 +208,17 @@ export class SkillService extends Service { return dispose } + /** + * Invalidate catalogs contributed by one currently registered provider. Exact object identity + * prevents a late callback from an old provider instance from invalidating its replacement. + * Calls for an already-unregistered provider are harmless. + * @param provider - exact provider instance whose external source changed. + */ + invalidateProvider(provider: SkillProvider): void { + if (this.providers.get(provider.name)?.provider !== provider) return + this.invalidateCache() + } + /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and @@ -228,11 +258,26 @@ export class SkillService extends Service { * @returns sorted summaries, excluding skills disabled for model invocation. */ async list(options: SkillLookupOptions = {}): Promise { - return (await this.collect(options)) - .map(entry => entry.candidate) - .filter(skill => skill.disableModelInvocation !== true) - .map(toSummary) - .sort(compareSkillSummary) + return (await this.snapshot(options)).skills + } + + /** + * Observe the current model-invocable catalog and whether all providers completed discovery. + * Incomplete observations are never cached, allowing consumers to retain last-good state and + * retry on their next request boundary. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries plus provider-completeness state. + */ + async snapshot(options: SkillLookupOptions = {}): Promise { + const collected = await this.collect(options) + return { + skills: collected.entries + .map(entry => entry.candidate) + .filter(skill => skill.disableModelInvocation !== true) + .map(toSummary) + .sort(compareSkillSummary), + complete: collected.cacheable, + } } /** @@ -247,7 +292,7 @@ export class SkillService extends Service { if (!isSkillName(name)) return undefined const collected = await this.collect(options) throwIfAborted(options.signal) - const match = collected.find(entry => entry.candidate.name === name) + const match = collected.entries.find(entry => entry.candidate.name === name) if (match === undefined) return undefined const definition = await waitWithAbort( match.provider.get(match.candidate, options), @@ -255,17 +300,21 @@ export class SkillService extends Service { ) if (definition === undefined) return undefined validateDefinition(definition) + if (definition.name !== match.candidate.name) { + this.invalidateProvider(match.provider) + return undefined + } return definition } - private async collect(options: SkillLookupOptions): Promise { + private async collect(options: SkillLookupOptions): Promise { throwIfAborted(options.signal) while (true) { const providerRevision = this.providerRevision const runtimeRevision = this.runtimeRevision const key = collectCacheKey(options, providerRevision, runtimeRevision) const cached = this.collectCache.get(key) - if (cached !== undefined) return cached + if (cached !== undefined) return { entries: cached, cacheable: true } const result = await this.collectFresh(options) throwIfAborted(options.signal) @@ -277,7 +326,7 @@ export class SkillService extends Service { this.collectCache.delete(oldest.value) } } - return result.entries + return result } } @@ -339,6 +388,21 @@ export class SkillService extends Service { private invalidateCache(): void { this.providerRevision += 1 this.collectCache.clear() + this.notifyChange() + } + + /** Notify catalog observers without making their refresh work load-bearing. */ + private notifyChange(): void { + for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) { + try { + const returned: unknown = callback() + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`) + } + } } } diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 8195d418cb..2f0134eab7 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -555,7 +555,9 @@ describe('SkillService registry', () => { return undefined }, }) - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) + const incomplete = await ctx.skills.snapshot() + expect(incomplete.skills.map(skill => skill.name)).toEqual(['second-skill']) + expect(incomplete.complete).toBe(false) expect(flakyCalls).toBe(1) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) expect(flakyCalls).toBe(2) @@ -566,6 +568,156 @@ describe('SkillService registry', () => { expect(flakyCalls).toBe(3) }) + it('invalidates only the exact registered provider and ignores its late callbacks', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) + const dispose = ctx.skills.registerProvider(provider) + + expect((await ctx.skills.snapshot()).complete).toBe(true) + provider.replace([memorySkill('second-skill', 'Second', 10)]) + ctx.skills.invalidateProvider(new MemoryProvider([])) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) + + ctx.skills.invalidateProvider(provider) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) + dispose() + + const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)]) + ctx.skills.registerProvider(replacement) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) + ctx.skills.invalidateProvider(provider) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) + expect(replacement.listCalls).toBe(1) + }) + + it('emits catalog invalidations for live provider and runtime mutations', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new MemoryProvider([memorySkill('provider-skill', 'Provider', 10)]) + let changes = 0 + ctx.on('skills/change', () => { changes += 1 }) + + const disposeProvider = ctx.skills.registerProvider(provider) + expect(changes).toBe(1) + ctx.skills.invalidateProvider(new MemoryProvider([])) + expect(changes).toBe(1) + ctx.skills.invalidateProvider(provider) + expect(changes).toBe(2) + + const disposeRuntime = ctx.skills.register({ + name: 'runtime-skill', + description: 'Runtime', + source: 'runtime', + content: 'Runtime body.', + }) + expect(changes).toBe(3) + disposeRuntime() + expect(changes).toBe(4) + disposeProvider() + expect(changes).toBe(5) + ctx.skills.invalidateProvider(provider) + expect(changes).toBe(5) + }) + + it('contains synchronous and asynchronous catalog observer failures', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment + const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected'))) + let observed = 0 + const disposeObserver = ctx.on('skills/change', () => { observed += 1 }) + + const provider = new MemoryProvider([]) + expect(() => ctx.skills.registerProvider(provider)).not.toThrow() + await Promise.resolve() + expect(observed).toBe(1) + expect(warnings).toEqual([ + 'skills/change listener threw: Error: observer threw', + 'skills/change listener rejected: Error: observer rejected', + ]) + + disposeThrowing() + disposeRejecting() + disposeObserver() + }) + + it('retries an in-flight catalog invalidated by its provider', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let release: (() => void) | undefined + const started = Promise.withResolvers() + const gate = new Promise((resolve) => { release = resolve }) + const provider = new MemoryProvider([memorySkill('stale-skill', 'Stale', 10)]) + const originalList = provider.list.bind(provider) + provider.list = async (options) => { + if (provider.listCalls === 0) { + provider.listCalls += 1 + started.resolve(undefined) + await gate + return [memorySkill('stale-skill', 'Stale', 10)] + } + return await originalList(options) + } + ctx.skills.registerProvider(provider) + + const pending = ctx.skills.list() + await started.promise + provider.replace([memorySkill('fresh-skill', 'Fresh', 10)]) + ctx.skills.invalidateProvider(provider) + release?.() + + expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill']) + expect(provider.listCalls).toBe(2) + }) + + it('invalidates a provider whose loaded definition changed identity', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let listCalls = 0 + const provider: SkillProvider = { + name: 'renamed', + async list() { + listCalls += 1 + return [{ + name: 'old-name', + description: 'Old name', + provider: 'renamed', + source: 'test', + rank: 1, + locator: 'old-name', + }] + }, + async get(candidate) { + return { ...candidate, name: 'new-name', content: 'Fresh body.' } + }, + } + ctx.skills.registerProvider(provider) + + expect(await ctx.skills.get('old-name')).toBeUndefined() + await ctx.skills.list() + expect(listCalls).toBe(2) + }) + + it('returns undefined when a discovered candidate disappears before loading', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + ctx.skills.registerProvider({ + name: 'vanished-body', + async list() { + return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }] + }, + async get() { + return undefined + }, + }) + + await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined() + }) + it('contains a provider rejection whose string coercion throws', async () => { const ctx = new Context() await ctx.plugin(SkillService) diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 868bdd0344..ccfbdc59fe 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 50a0e06ac06ca8a3b89c3d5ac604dcf2dc423533 -README.zh.md: 56fb2b87adcf072cf2b8b6670864fa274ed5f66f +# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md +README.md: 7cbb2bef77bd32f188a4d3068a287fbee31b9371 +README.zh.md: fe17e2e7080151e9bd41d4e0b96764719b27baa5 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 50a0e06ac0..7cbb2bef77 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -4,13 +4,17 @@ English | [中文](README.zh.md) The model-facing skill catalog and `skill` tool. -Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`). +Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools', 'skills']`). -## Session-prefix catalog +## Catalog lifecycle -The plugin contributes one user-role `` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. This exact-definition check keeps prompt guidance, the model-visible schema, and executable dispatch aligned. +The plugin contributes the initial user-role `` catalog through `agent/session-prefix`. Before every later model step it observes `ctx.skills.snapshot()` and computes a digest over exact `skill` tool visibility plus the ordered rendered `name` and `description` entries. It resolves skills for the calling session's cwd and lists only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message. +When that digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog and metadata `{ kind: 'skill-catalog', version: 1, digest }`. An empty replacement explicitly retires names from earlier catalogs. The latest still-visible metadata supplies the comparison baseline across replay or plugin reload. If compaction shadows that replacement, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. + +The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. + +`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of the initial message; the [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns durable replacements. ## Tool: `skill` @@ -24,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results. -The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context. +Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries. ## Model Experience @@ -32,7 +36,7 @@ The tool does not call `agent.inject()` in v1. Its result is already recorded as #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is a frozen user-role session prefix. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The initial catalog is a user-role session prefix. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. ##### Skill catalog template @@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca #### Token effect -Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed. +Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no initial catalog tokens are sent when the list is empty or the tool is hidden or shadowed. Each actual catalog change adds one retained complete replacement message. #### KV Cache effect -Prefix-stable within a loop instance once the session prefix is composed. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may invalidate reuse from the first changed catalog token. +The initial catalog remains prefix-stable. Dynamic changes are append-only history after that prefix, so existing reusable tokens stay intact while the replacement and later turns form a new suffix. ### Tool schema @@ -146,3 +150,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Loaded instruction bodies have no size cap** — a provider can return a skill large enough to consume substantial next-step context; only catalog descriptions are truncated. - **Resources are guidance, not attachments** — the tool reports a base directory/URL/opaque hint but neither enumerates nor fetches referenced files for the model. - **Loading is one-shot text** — there is no partial, streaming, or cached-content handle when a remote provider is slow or a skill body is large. +- **Catalog replacement is whole-list** — one changed name or description appends every currently visible summary; this keeps stale-name retirement explicit but costs tokens proportional to the catalog. +- **Bodies are not versioned** — body-only edits do not change the catalog digest or notify the model; a later tool call reads the current provider content while earlier tool results remain historical facts. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 56fb2b87ad..fe17e2e708 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -4,13 +4,17 @@ 面向模型的 skill 目录和 `skill` 工具。 -需要 `ctx.tools` 和 `ctx.skills` (`inject: ['tools', 'skills']`)。 +需要 `ctx.agents`、`ctx.tools` 和 `ctx.skills`(`inject: ['agents', 'tools', 'skills']`)。 -## 会话前缀目录 +## 目录生命周期 -该插件贡献一个用户角色 `` 目录,并通过 `agent/session-prefix` 提供它。它为调用会话的 cwd 解析 skill,将前缀中止信号转发到发现,并只列出已排序的 `name` 和 `description` 条目;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill,则省略目录;如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。这项精确定义检查使提示词指引、模型可见 schema 和可执行分派保持对齐。 +该插件通过 `agent/session-prefix` 提供初始的用户角色 `` 目录。之后每个模型步骤开始前,它都会观察 `ctx.skills.snapshot()`,并针对 `skill` 工具的精确可见性,以及按顺序渲染的 `name` 和 `description` 条目计算 digest。它根据调用会话的 cwd 解析 skill,且只列出这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了该消息仅存在于请求中、记录于 header 的生命周期。 +该 digest 变化时,`agent.inject()` 会记录一条持久的用户角色消息,其中包含完整替换目录和元数据 `{ kind: 'skill-catalog', version: 1, digest }`。空替换会显式停用较早目录中的名称。恢复后,最新且仍可见的元数据充当比较基线;若压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以会话前缀为基线,并在必要时重新发布当前完整目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 + +如果最初没有模型可调用 skill,则省略目录;如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 + +`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了初始消息仅存在于请求中、记录于 header 的生命周期;[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久替换。 ## 工具:`skill` @@ -24,7 +28,7 @@ 无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。 -该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。 +工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。 ## 模型体验 @@ -32,7 +36,7 @@ #### 模型所见 -如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。该目录是冻结的用户角色会话前缀。 +如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。初始目录是用户角色会话前缀。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。 ##### Skill 目录模板 @@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca #### Token 影响 -重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送目录 token。 +重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送初始目录 token。每次实际目录变更都会添加一条保留的完整替换消息。 #### KV 缓存影响 -会话前缀组合完成后,在一个循环实例内前缀稳定。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从第一个变更目录 token 起使重用失效。 +初始目录保持前缀稳定。动态变更作为该前缀之后的仅追加历史,因此现有可重用 token 保持不变,替换消息和后续轮次则形成新的后缀。 ### 工具 schema @@ -146,3 +150,5 @@ Load referenced resources only as needed. - **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill;只有目录描述会被截断。 - **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。 - **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分、流式或缓存内容句柄。 +- **目录替换采用全量列表**:一个名称或描述发生变化,就会追加当前所有可见摘要;这样能显式停用陈旧名称,但 token 成本与目录大小成正比。 +- **正文不做版本化**:仅修改正文不会改变目录 digest,也不会通知模型;后续工具调用会读取提供方的当前内容,而先前工具结果仍是历史事实。 diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 47421bf19c..9d86da58dd 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index f5fbd1dff5..1fe078d75f 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -4,16 +4,21 @@ * @module @deepseek-ai/dsh-tool-skill */ +import { createHash } from 'node:crypto' import type { Context } from 'cordis' import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever, type Message } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['tools', 'skills'] +export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 +const CATALOG_META_KIND = 'skill-catalog' +const CATALOG_META_VERSION = 1 +const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const /** Model-facing skill catalog configuration. */ export interface Config { @@ -35,6 +40,7 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) + const baselineBySession = new WeakMap() const skillTool = defineTool({ name: 'skill', @@ -116,11 +122,40 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { - if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() - const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) + const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const snapshot = toolVisible + ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) + : { skills: [], complete: true } const rest = await next() - if (skills.length === 0) return rest - return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest] + signal.throwIfAborted() + if (!snapshot.complete) return rest + const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength) + baselineBySession.set(agent.session, digest) + if (!toolVisible || snapshot.skills.length === 0) return rest + return [renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength), ...rest] + }) + + ctx.on('agent/pre-step', async (agent, _turn, _step, signal) => { + const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const snapshot = toolVisible + ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) + : { skills: [], complete: true } + signal.throwIfAborted() + if (!snapshot.complete) return + const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength) + const effective = latestVisibleCatalogDigest(agent) ?? baselineBySession.get(agent.session) + if (effective === digest) return + if (effective === undefined && snapshot.skills.length === 0) { + baselineBySession.set(agent.session, digest) + return + } + agent.inject( + renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength).content, + { + source: PLUGIN_SOURCE, + meta: { kind: CATALOG_META_KIND, version: CATALOG_META_VERSION, digest }, + }, + ) }) } @@ -171,7 +206,7 @@ function renderResourceHint(skill: Pick `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) + const entries = renderCatalogEntries(skills, descriptionMaxLength) return { role: 'user', content: [{ @@ -191,6 +226,68 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb } } +function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): Message { + const entries = renderCatalogEntries(skills, descriptionMaxLength) + const availability = skills.length === 0 + ? [ + 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', + ] + : [ + 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', + ] + return { + role: 'user', + content: [{ + type: 'text', + text: [ + '', + 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:', + '', + '', + ...entries, + '', + '', + ...availability, + '', + ].join('\n'), + }], + } +} + +function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { + return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +} + +function catalogDigest(toolVisible: boolean, skills: SkillSummary[], descriptionMaxLength: number): string { + return createHash('sha256') + .update(JSON.stringify({ + toolVisible, + entries: renderCatalogEntries(skills, descriptionMaxLength), + })) + .digest('hex') +} + +function latestVisibleCatalogDigest(agent: Agent): string | undefined { + const visible = new Set(agent.session.surface.nodes) + for (const event of [...agent.session.events].reverse()) { + if (!visible.has(event.seq) + || event.type !== 'user/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== name) continue + const meta = event.data.meta + if (!isRecord(meta) + || meta.kind !== CATALOG_META_KIND + || meta.version !== CATALOG_META_VERSION + || typeof meta.digest !== 'string') continue + return meta.digest + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() const truncated = normalized.length <= maxLength diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7fef1cbf22..3ef11a5e30 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -5,9 +5,10 @@ import { tmpdir } from 'node:os' import { Context } from 'cordis' import { CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -28,8 +29,9 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject(content, options) { + session.append('user/message', { + content, + source: options?.source ?? { kind: 'user' }, + ...(options?.meta === undefined ? {} : { meta: options.meta }), + }, { surfaceOp: 'append' }) + return AgentMessageId('stub') + }, + send: () => AgentMessageId('stub'), + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +function openMessageTurn(session: Session, turn = 1): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +async function firePreStep(ctx: Context, agent: Agent, turn: number, step: number): Promise { + await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, new AbortController().signal) +} + +function catalogUpdates(session: Session): Extract[] { + return session.events.filter((event): event is Extract => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tool-skill') +} + async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise { return await composePrefixForAgent(ctx, agentForCwd(cwd), signal) } @@ -50,8 +94,8 @@ async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new Ab ) } -async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> { - const agent = agentForCwd(cwd) +async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> { + const agent = typeof subject === 'string' ? agentForCwd(subject) : subject let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools'], @@ -64,9 +108,10 @@ describe('dsh-tool-skill', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' }) const fiber = await ctx.plugin(toolSkill) @@ -169,15 +214,227 @@ describe('dsh-tool-skill', () => { expect(await composePrefix(ctx, '/workspace')).toEqual([]) }) + it('omits an incomplete initial catalog and retries on a later request boundary', async () => { + const home = await tempDir('tool-incomplete-prefix') + const ctx = await setup(home) + let failing = true + const provider = { + name: 'recovering', + async list() { + if (failing) throw new Error('temporarily unavailable') + return [] + }, + async get() { + return undefined + }, + } + ctx.skills.registerProvider(provider) + const session = new Session(SessionId('incomplete-prefix')) + const agent = sessionAgent(session) + openMessageTurn(session) + + expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + failing = false + ctx.skills.invalidateProvider(provider) + await firePreStep(ctx, agent, 1, 1) + + expect(catalogUpdates(session)).toEqual([]) + }) + + it('records an empty baseline when pre-step runs before prefix composition', async () => { + const home = await tempDir('tool-empty-pre-step') + const ctx = await setup(home) + const session = new Session(SessionId('empty-pre-step')) + const agent = sessionAgent(session) + openMessageTurn(session) + + await firePreStep(ctx, agent, 1, 1) + await firePreStep(ctx, agent, 1, 2) + + expect(catalogUpdates(session)).toEqual([]) + }) + + it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => { + const home = await tempDir('tool-dynamic-catalog') + const ctx = await setup(home) + const disposeFirst = ctx.skills.register({ + name: 'first-skill', + description: 'First skill', + source: 'runtime', + content: 'First body.', + }) + const session = new Session(SessionId('dynamic-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') + await firePreStep(ctx, agent, 1, 1) + expect(catalogUpdates(session)).toEqual([]) + + const disposeSecond = ctx.skills.register({ + name: 'second-skill', + description: 'Second skill', + source: 'runtime', + content: 'Second body.', + }) + await firePreStep(ctx, agent, 1, 2) + + const addition = catalogUpdates(session)[0] + if (addition?.type !== 'user/message') throw new Error('expected catalog addition') + expect(addition.data.meta).toMatchObject({ kind: 'skill-catalog', version: 1 }) + expect(JSON.stringify(addition.data.content)).toContain('first-skill') + expect(JSON.stringify(addition.data.content)).toContain('second-skill') + + disposeSecond() + disposeFirst() + await firePreStep(ctx, agent, 1, 3) + + const removal = catalogUpdates(session)[1] + if (removal?.type !== 'user/message') throw new Error('expected catalog removal') + expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available') + expect(JSON.stringify(removal.data.content)).not.toContain('first-skill') + expect(JSON.stringify(removal.data.content)).not.toContain('second-skill') + }) + + it('resumes from the latest valid visible catalog metadata', async () => { + const home = await tempDir('tool-catalog-resume') + const ctx = await setup(home) + ctx.skills.register({ + name: 'resumed-skill', + description: 'Resumed skill', + source: 'runtime', + content: 'Resumed body.', + }) + const session = new Session(SessionId('catalog-resume')) + const agent = sessionAgent(session) + openMessageTurn(session) + session.append('user/message', { + content: [{ type: 'text', text: 'old catalog' }], + source: { kind: 'plugin', plugin: 'tool-skill' }, + meta: { kind: 'skill-catalog', version: 1, digest: 'old-digest' }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'text', text: 'malformed metadata' }], + source: { kind: 'plugin', plugin: 'tool-skill' }, + meta: { kind: 'skill-catalog', version: 1, digest: 42 }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'text', text: 'non-record metadata' }], + source: { kind: 'plugin', plugin: 'tool-skill' }, + meta: [], + }, { surfaceOp: 'append' }) + + await firePreStep(ctx, agent, 1, 1) + + expect(catalogUpdates(session)).toHaveLength(4) + expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('resumed-skill') + }) + + it('re-establishes a replacement catalog after compaction shadows its metadata', async () => { + const home = await tempDir('tool-catalog-compaction') + const ctx = await setup(home) + ctx.skills.register({ + name: 'first-skill', + description: 'First skill', + source: 'runtime', + content: 'First body.', + }) + const session = new Session(SessionId('catalog-compaction')) + const agent = sessionAgent(session) + openMessageTurn(session) + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') + ctx.skills.register({ + name: 'second-skill', + description: 'Second skill', + source: 'runtime', + content: 'Second body.', + }) + await firePreStep(ctx, agent, 1, 1) + const replacement = catalogUpdates(session)[0] + if (replacement === undefined) throw new Error('expected replacement catalog') + session.append('user/message', { + content: [{ type: 'text', text: 'compacted history' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start: replacement.seq, end: replacement.seq }, + sourceEventSeqs: [replacement.seq], + }) + + await firePreStep(ctx, agent, 1, 2) + + expect(catalogUpdates(session)).toHaveLength(2) + expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('second-skill') + }) + + it('keeps body-only edits out of the catalog and loads the latest body on demand', async () => { + const home = await tempDir('tool-body-refresh') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'body-skill', 'Stable description', 'First body.') + const ctx = await setup(home) + const session = new Session(SessionId('body-refresh')) + const agent = sessionAgent(session) + openMessageTurn(session) + + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description') + await writeSkill(root, 'body-skill', 'Stable description', 'Second body.') + await firePreStep(ctx, agent, 1, 1) + expect(catalogUpdates(session)).toEqual([]) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('body-refresh'), + name: 'skill', + arguments: { name: 'body-skill' }, + agent, + }) + expect(result.isError).toBe(false) + expect(JSON.stringify(result.content)).toContain('Second body.') + expect(JSON.stringify(result.content)).not.toContain('First body.') + }) + + it('retains the last-good catalog while any provider discovery is incomplete', async () => { + const home = await tempDir('tool-incomplete-catalog') + const ctx = await setup(home) + const disposeStable = ctx.skills.register({ + name: 'stable-skill', + description: 'Stable skill', + source: 'runtime', + content: 'Stable body.', + }) + const session = new Session(SessionId('incomplete-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') + + ctx.skills.registerProvider({ + name: 'failing', + async list() { + throw new Error('temporarily unavailable') + }, + async get() { + return undefined + }, + }) + disposeStable() + await firePreStep(ctx, agent, 1, 1) + + expect(catalogUpdates(session)).toEqual([]) + }) + it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => { const home = await tempDir('tool-restricted-catalog') const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) - const { agent, scope } = await mintAgentScope(ctx, '/workspace') + const session = new Session(SessionId('restricted-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + const { scope } = await mintAgentScope(ctx, agent) scope.ctx.tools.restrict({ deny: ['skill'] }) expect(ctx.tools.get('skill', agent)).toBeUndefined() expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + await firePreStep(ctx, agent, 1, 1) + expect(catalogUpdates(session)).toEqual([]) expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) await scope.dispose() }) @@ -207,8 +464,9 @@ describe('dsh-tool-skill', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3') }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a2a17d022d..8ba46590bc 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 3d64de9f0703838cad10f8e04665ec4b985d00dc -README.zh.md: 5cf41dd76c7c28cd2d605466c7c10cfe1c1dd958 +# pnpm run verify-translation-pairing --write packages/ui/tui/README.md +README.md: 632a2fd2c94b4d49c4a479b3c4f5477fe9246c8d +README.zh.md: 75c941b36033048f4122bf5bb1fc4ff26c76620e diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 3d64de9f07..632a2fd2c9 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -130,7 +130,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr #### What the model sees -A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. +A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 5cf41dd76c..75c941b360 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -130,7 +130,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。 +提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。 #### Token 影响 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index de0ef02f7e..0c34ec5382 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2668,10 +2668,12 @@ export function createTuiChat( } // Skill listing is async while `createTuiChat` is synchronous, so the - // completions rebuild once the catalog resolves. Disabled-for-model skills - // are absent from `list()`, so they never appear as completions; a user can + // TUI retains the last complete catalog for synchronous editor completion + // and refreshes it after registry invalidation. Disabled-for-model skills are + // absent from snapshots, so they never appear as completions; a user can // still invoke one by typing its exact name. let skillCommands: SlashCommand[] = [] + let skillCommandScan = 0 const refreshCommandAutocomplete = (): void => { const base = new CombinedAutocompleteProvider( [ @@ -2692,19 +2694,31 @@ export function createTuiChat( agent, )) } + const refreshVisibleSlashAutocomplete = (): void => { + const cursor = editor.getCursor() + const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col) + if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) { + // pi-tui's provider setter closes an existing menu but does not query + // the replacement for the current draft. Tab in a slash-name context + // only requests suggestions, so it refreshes without editing the text. + editor.handleInput('\t') + } + } const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) refreshCommandAutocomplete() - const loadSkillCommands = (service: SkillService): void => { - service.list({ cwd, signal: skillAbort.signal }).then( - (summaries) => { - if (disposed || summaries.length === 0) return - skillCommands = summaries.map(skill => ({ + const refreshSkillCommands = (service: SkillService): void => { + const scan = ++skillCommandScan + service.snapshot({ cwd, signal: skillAbort.signal }).then( + (snapshot) => { + if (disposed || scan !== skillCommandScan || !snapshot.complete) return + skillCommands = snapshot.skills.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, argumentHint: '[instructions]', })) refreshCommandAutocomplete() + refreshVisibleSlashAutocomplete() requestRender() }, () => { @@ -2713,7 +2727,10 @@ export function createTuiChat( }, ) } - if (skills !== undefined) loadSkillCommands(skills) + const disposeSkillChanges = skills === undefined + ? () => {} + : ctx.on('skills/change', () => { refreshSkillCommands(skills) }) + if (skills !== undefined) refreshSkillCommands(skills) // The agent scope is minted by agent-loop and intentionally inherits only // that core plugin's dependencies. A child command producer declares its own @@ -3202,6 +3219,7 @@ export function createTuiChat( fileSearch.dispose() removeInputListener() disposeCommandChanges() + disposeSkillChanges() stopBannerReveal() disposeSessionEvents() disposeQueued() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c7af95061a..59da1d37d4 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -10,7 +10,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionRecord } from '@deepseek-ai/dsh-session-query' -import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' +import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -2674,6 +2674,129 @@ describe('skill slash command', () => { await dispose(result) }) + it('refreshes slash completions after runtime skill additions and complete removals', async () => { + let skills: SkillService | undefined + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + await ctx.plugin(SkillService) + skills = ctx.get('skills') + }, + }) + if (skills === undefined) throw new Error('skills service not mounted') + + result.terminal.send('/skill:dynamic') + await tick() + result.terminal.output = '' + const disposeSkill = skills.register({ + name: 'dynamic-skill', + description: 'DYNAMIC_COMPLETION_MARKER', + source: 'runtime', + content: 'Dynamic body.', + }) + await tick() + expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER') + + result.terminal.send('\x03') + disposeSkill() + await tick() + result.terminal.output = '' + result.terminal.send('/skill:dynamic') + await tick() + expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER') + await dispose(result) + }) + + it('retains last-good slash completions across incomplete snapshots', async () => { + let skills: SkillService | undefined + let provider: SkillProvider | undefined + let fail = false + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + await ctx.plugin(SkillService) + skills = ctx.get('skills') + provider = { + name: 'flaky-completion', + async list() { + if (fail) throw new Error('transient completion failure') + return [{ + name: 'stable-skill', + description: 'STABLE_COMPLETION_MARKER', + source: 'test', + provider: 'flaky-completion', + rank: 1, + locator: 'stable', + }] + }, + async get() { + return undefined + }, + } + skills?.registerProvider(provider) + }, + }) + if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted') + + fail = true + skills.invalidateProvider(provider) + await tick() + result.terminal.output = '' + result.terminal.send('/skill:stable') + await tick() + expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER') + await dispose(result) + }) + + it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => { + const pendingSnapshots: Array> = [] + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + snapshot: () => { + const pending = Promise.withResolvers() + pendingSnapshots.push(pending) + return pending.promise + }, + get: () => Promise.resolve(undefined), + } as never) + }, + }) + expect(pendingSnapshots).toHaveLength(1) + + result.ctx.emit('skills/change') + result.ctx.emit('skills/change') + expect(pendingSnapshots).toHaveLength(3) + pendingSnapshots[2]?.resolve({ + skills: [{ + name: 'latest-skill', + description: 'LATEST_COMPLETION_MARKER', + source: 'runtime', + provider: 'runtime', + }], + complete: true, + }) + await tick() + pendingSnapshots[0]?.resolve({ + skills: [{ name: 'stale-first', description: 'STALE_FIRST', source: 'runtime', provider: 'runtime' }], + complete: true, + }) + pendingSnapshots[1]?.resolve({ + skills: [{ name: 'stale-second', description: 'STALE_SECOND', source: 'runtime', provider: 'runtime' }], + complete: true, + }) + await tick() + + result.terminal.output = '' + result.terminal.send('/skill:latest') + await tick() + expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER') + expect(result.terminal.output).not.toContain('STALE_FIRST') + expect(result.terminal.output).not.toContain('STALE_SECOND') + await dispose(result) + }) + it('loads a skill as a user turn, appending typed instructions', async () => { const result = await setup({ configureContext: withSkills }) result.terminal.send('/skill:demo-skill') @@ -2732,7 +2855,7 @@ describe('skill slash command', () => { configureContext: async (ctx) => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { - list: () => Promise.reject(new Error('list boom')), + snapshot: () => Promise.reject(new Error('list boom')), get: () => Promise.reject(new Error('get boom')), } as never) }, @@ -2746,13 +2869,13 @@ describe('skill slash command', () => { }) it('drops skill list and lookup results that settle after disposal', async () => { - const pendingList: Array<(value: SkillSummary[]) => void> = [] + const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = [] const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = [] const result = await setup({ configureContext: async (ctx) => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { - list: () => new Promise((resolve) => { pendingList.push(resolve) }), + snapshot: () => new Promise((resolve) => { pendingSnapshots.push(resolve) }), get: () => new Promise((resolve, reject) => { pendingGet.push({ resolve, reject }) }), } as never) }, @@ -2765,7 +2888,14 @@ describe('skill slash command', () => { await tick() await dispose(result) - for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }]) + result.ctx.emit('skills/change') + expect(pendingSnapshots).toHaveLength(1) + for (const resolve of pendingSnapshots) { + resolve({ + skills: [{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }], + complete: true, + }) + } pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' }) pendingGet[1]?.reject(new Error('late failure')) await tick() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..21f46c1cbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1881,6 +1881,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../bash/bash-sandbox @@ -3491,6 +3494,9 @@ importers: packages/skill/skill-local: dependencies: + chokidar: + specifier: ^5.0.0 + version: 5.0.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3532,6 +3538,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -7609,6 +7618,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -9304,6 +9317,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -12428,6 +12445,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -14470,6 +14491,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 693f0aab60..81be7a9b3a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -144,6 +144,7 @@ export const LINK_MAP: Record = { SessionTitleObservationResult: 'session-query.md', SessionTitleProvider: 'session-title.md', SessionTitleSnapshot: 'session-title.md', + SkillCatalogSnapshot: 'skills.md', SkillDefinition: 'skills.md', SkillLookupOptions: 'skills.md', SkillProvider: 'skills.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8f7902dc9b..dcf3cb1b98 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -308,9 +308,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', source: 'packages/skill/tool-skill/src/index.ts', - requires: ['ctx.tools', 'ctx.skills'], - writes: ['tool/call', 'tool/result'], + requires: ['ctx.tools', 'ctx.agents', 'ctx.skills'], + writes: ['tool/call', 'tool/result', 'user/message replacement catalogs via agent.inject()'], async mount(ctx) { + await ctx.plugin(AgentRegistry) await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: resolve(root, '.tmp/tool-catalog/.dsh'), diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 02665b6dce..2668ac7154 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -989,6 +989,11 @@ "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillCatalogSnapshot", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillCandidate", From fcbf0f0952d61634e0931fccba155463602e22ea Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 17:12:08 +0800 Subject: [PATCH 02/13] perf(skill): avoid catalog event copy --- .../feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 ++-- .../feature/2026-07-27-skill-catalog-hot-refresh.md | 2 +- .../feature/2026-07-27-skill-catalog-hot-refresh.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 6 +++++- 4 files changed, 9 insertions(+), 5 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 ec6e7fd572..3530f2c4f7 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: f818766eb55f237e21aa3da9586887e493b9de75 -2026-07-27-skill-catalog-hot-refresh.zh.md: 3f0be2e760f4a18504e18803bcb8a8e47acffa5d +2026-07-27-skill-catalog-hot-refresh.md: 7a63574a7a260489760f5ec376a6c1fdcd71e681 +2026-07-27-skill-catalog-hot-refresh.zh.md: 7cf6dcdbfe6e3540779bbe4cf48bdef037edf070 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 f818766eb5..7a63574a7a 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 @@ -18,7 +18,7 @@ The skill capability separates catalog membership from instruction-body loading. 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. Deleting a root re-establishes ancestor observation. 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. -`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. +`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. The lookup scans the read-only event view by descending index and stops at the newest visible replacement, avoiding a full event-array copy on every model step. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. 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 3f0be2e760..7cf6dcdbfe 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 @@ -18,7 +18,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps 系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 -`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线;如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 +`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线。查找会按索引降序扫描只读事件视图,在找到最新且仍可见的替换时停止,从而避免在每个模型步骤复制整个事件数组。如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 1fe078d75f..56292a80a0 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -269,7 +269,11 @@ function catalogDigest(toolVisible: boolean, skills: SkillSummary[], description function latestVisibleCatalogDigest(agent: Agent): string | undefined { const visible = new Set(agent.session.surface.nodes) - for (const event of [...agent.session.events].reverse()) { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + // The loop bounds prove the read-only event view contains this index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! if (!visible.has(event.seq) || event.type !== 'user/message' || event.data.source.kind !== 'plugin' From 979fa8ab33dac1f54ab6ac96c572e0f548ec0eab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:06:07 +0800 Subject: [PATCH 03/13] refactor(skill): scope provider invalidation --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 8 +- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 8 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 16 +-- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 12 +- docs/core-data-structures/skills.zh.md | 12 +- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 8 +- packages/skill/skill-local/src/index.ts | 39 ++++-- .../tests/skill-local-watcher.spec.ts | 42 +++--- .../skill-local/tests/skill-local.spec.ts | 31 ++-- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 11 +- packages/skill/skill/README.zh.md | 11 +- packages/skill/skill/src/index.ts | 84 ++++++----- packages/skill/skill/tests/skill.spec.ts | 132 ++++++++++++------ .../skill/tool-skill/tests/tool-skill.spec.ts | 16 ++- packages/ui/tui/tests/tui.spec.ts | 8 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + 24 files changed, 295 insertions(+), 181 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 a1a9780a3f..ba65c7d05d 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: 61f6690cb00546664e36d3916c98caf2dcc1b79f -2026-07-27-skill-catalog-hot-refresh.zh.md: f174d27f88c861cd3d951c1379f7ebaf72888f27 +2026-07-27-skill-catalog-hot-refresh.md: 8a53195dd8c4880c5cfa758ccf666ae88b2e1030 +2026-07-27-skill-catalog-hot-refresh.zh.md: 8cf55534460d2926be706353afd2019fad1bb45d 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 61f6690cb0..8a53195dd8 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,7 +12,7 @@ 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, while `ctx.skills.invalidateProvider(provider)` dirties only the exact registered provider and discards completed catalog caches. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A stale provider callback after disposal or replacement is a no-op because invalidation uses object identity. +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. `@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. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin exact invalidation, 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, 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 snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, 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. ## Alternatives considered @@ -35,6 +35,8 @@ Registry tests pin exact invalidation, contained observer failures, incomplete s - **Hash or version every `SKILL.md` body** — rejected because the model initially sees only names and descriptions, and the provider already rereads the body on each tool call. Body revisions would create catalog traffic without changing routing and would not justify rewriting historical tool results. - **Watch every bundle resource** — rejected because references, scripts, and assets are loaded on demand and do not affect the category list. Broad recursive watching would add invalidations, descriptor pressure, and platform variability without improving routing. - **Publish partial or failed discovery as the new catalog** — rejected because a transient read failure is not evidence of deletion. The completeness bit lets the model-facing consumer preserve its last-good catalog until a full observation succeeds. +- **Keep `invalidateProvider(provider)` public** — rejected because it exposes a registry mutation method and makes callers resupply an identity the registry already owns. The factory-issued closure binds invalidation to one registration and becomes inert on disposal, so observers need neither registry access nor provider identity. +- **Extract a generic Cordis file-watching service now** — deferred until another consumer establishes the reusable service contract. The local provider marks its Chokidar and missing-root observation boundary for that extraction; skill-path filtering and the call to the provider's invalidation closure remain skill-specific. ## Consequences @@ -43,4 +45,4 @@ Registry tests pin exact invalidation, contained observer failures, incomplete s - Catalog messages are append-only, logged, whole-list snapshots. They preserve earlier reusable tokens; replacements retire stale names explicitly, at token cost proportional to the current catalog on each actual digest change. - Body-only edits produce no catalog message. A subsequent tool call sees current content, while prior tool results remain an accurate record of what the model previously loaded. - Missing-root polling and Chokidar add one maintained runtime dependency, host watcher resources, bounded detection latency, and deployment tunables. The bounded project set and teardown contract contain those costs. -- Remote or future mutable providers remain responsible for calling `invalidateProvider()` from their own observation mechanism; the registry does not impose a universal watcher or TTL. +- Remote or future mutable providers retain their own registration-scoped invalidation closure and call it from their observation mechanism; the registry does not impose a universal watcher or TTL. 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 f174d27f88..8cf5553446 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,7 +12,7 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话 ## 决策 -skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位;`ctx.skills.invalidateProvider(provider)` 只会将精确的已注册提供方标记为脏,并丢弃已经完成的目录缓存。在发现期间,如果提供方或运行时 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 使用有界集合,并按最久未观察顺序淘汰。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了精确失效、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 @@ -35,6 +35,8 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills - **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。 - **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 - **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 +- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。 +- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。 ## 影响 @@ -43,4 +45,4 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills - 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。 - 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 - 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 -- 远程或未来的可变提供方仍有责任通过自身观察机制调用 `invalidateProvider()`;注册表不会强制采用通用 watcher 或 TTL。 +- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b4ac98544a..5e4d5414f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1187,7 +1187,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:121`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:129`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1219,7 +1219,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:46`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:47`](../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 885c98b806..3c20bb6ffc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -657,7 +657,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:139`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:147`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f87767af64..9a8196f06a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1397,19 +1397,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters * the provider and invalidates catalog caches. - * @param provider - the provider to register by `provider.name`. + * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ -registerProvider(provider: SkillProvider): () => void - -/** - * Invalidate catalogs contributed by one currently registered provider. Exact object identity - * prevents a late callback from an old provider instance from invalidating its replacement. - * Calls for an already-unregistered provider are harmless. - * @param provider - exact provider instance whose external source changed. - */ -invalidateProvider(provider: SkillProvider): void +registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which @@ -1449,9 +1441,9 @@ async snapshot(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) +Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillProviderControl](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) -Source: [`packages/skill/skill/src/index.ts:160`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:168`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index a9040ffece..a89d2150a5 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.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 docs/core-data-structures/skills.md -skills.md: e1b2bfd5336c3cbce7f3c85bf5e440519efef36b -skills.zh.md: 3a80b6a93d37e945b7a2ad7ed4bccb45724d6b15 +skills.md: 247d8d71890a1624225091a7d53dd6cee977134a +skills.zh.md: 7d5c61dcf3efc3cc90cae2d5bf9f16e4df74bd79 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index e1b2bfd533..247d8d7189 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -10,7 +10,7 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation without caching it, while malformed candidates fail fast. `invalidateProvider()` clears completed catalogs only for the exact live provider object, and an in-flight discovery retries when its provider generation changes. Provider and runtime membership mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation without caching it, while malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries when its provider generation changes. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -36,6 +36,16 @@ interface SkillProvider { } ``` +```ts type-equiv +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} +``` + ## Local discovery priority The shipped local provider scans roots in rank order: diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 3a80b6a93d..7d5c61dcf3 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -10,7 +10,7 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。`invalidateProvider()` 只针对传入的活动提供方对象清除已完成目录;若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时的成员关系变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -36,6 +36,16 @@ interface SkillProvider { } ``` +```ts type-equiv +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} +``` + ## 本地发现优先级 内置的本地提供方按 rank 顺序扫描各根目录: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e6ea309d10..1942291a03 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:80`](../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-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../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), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`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:102`](../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:139`](../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:147`](../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 923c7c8747..12efd2e644 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -671,12 +671,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Registry of skill providers.', methods: [ { - signature: 'registerProvider(provider: SkillProvider): () => void', - jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param provider - the provider to register by `provider.name`.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', - }, - { - signature: 'invalidateProvider(provider: SkillProvider): void', - jsDoc: '/**\n * Invalidate catalogs contributed by one currently registered provider. Exact object identity\n * prevents a late callback from an old provider instance from invalidating its replacement.\n * Calls for an already-unregistered provider are harmless.\n * @param provider - exact provider instance whose external source changed.\n */', + signature: 'registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void', + jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', }, { signature: 'register(skill: SkillRegistration): () => void', @@ -2216,6 +2212,10 @@ 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}', }, + { + name: 'SkillProviderControl', + declaration: 'export interface SkillProviderControl {\n readonly signal: AbortSignal;\n readonly invalidate: () => void;\n}', + }, { name: 'SkillRegistration', declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 480f821b95..5df3e316cc 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -171,7 +171,7 @@ describe('skill.list', () => { it('lists skills for the session cwd taken from the header', async () => { const ctx = await harness() const seenCwds: (string | undefined)[] = [] - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'probe', list: (options) => { seenCwds.push(options.cwd) @@ -181,7 +181,7 @@ describe('skill.list', () => { }]) }, get: () => Promise.resolve(undefined), - }) + })) const api = createApiProxy(ctx, DEFAULTS) // No agent is registered for this session: header resolution must not // touch (or resume through) the Agent registry. @@ -210,11 +210,11 @@ describe('skill.list', () => { it('folds a provider failure into internal', async () => { const ctx = await harness() - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'broken', list: () => Promise.reject(new Error('directory exploded')), get: () => Promise.resolve(undefined), - }) + })) const api = createApiProxy(ctx, DEFAULTS) const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const response = await api.skills.list(request({ sessionId: session.id })) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 228aa700b7..dbf3a3c0b6 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -26,6 +26,7 @@ import { type SkillDefinition, type SkillLookupOptions, type SkillProvider, + type SkillProviderControl, type SkillSource, } from '@deepseek-ai/dsh-skill' @@ -119,8 +120,11 @@ interface ResolvedWatchConfig { /** Register the local filesystem skill provider on `ctx.skills`. */ export function apply(ctx: Context, config: Config = {}): void { - const provider = new LocalSkillProvider(ctx, config) - ctx.skills.registerProvider(provider) + let provider!: LocalSkillProvider + ctx.skills.registerProvider((control) => { + provider = new LocalSkillProvider(ctx, control, config) + return provider + }) ctx.effect(function* () { yield async () => { await provider.dispose() } }, 'skill-local watcher') @@ -138,12 +142,18 @@ export class LocalSkillProvider implements SkillProvider { private readonly customSkillDirs: string[] private readonly watchManager: SkillWatchManager private readonly bundledSkillDir: string | undefined + private disposal: Promise | undefined - constructor(private readonly ctx: Context, config: Config = {}) { + constructor( + private readonly ctx: Context, + control: SkillProviderControl, + config: Config = {}, + ) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) - this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config)) + this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) + control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } @@ -197,9 +207,13 @@ export class LocalSkillProvider implements SkillProvider { this.watchManager.observeHostMutation(path) } - /** Close every host watcher and contain late filesystem callbacks. */ - async dispose(): Promise { - await this.watchManager.dispose() + /** + * Close every host watcher and contain late filesystem callbacks. + * @returns a shared promise that settles when every watcher reaches quiescence. + */ + dispose(): Promise { + this.disposal ??= this.watchManager.dispose() + return this.disposal } private async roots(cwd: string | undefined): Promise { @@ -250,7 +264,7 @@ class SkillWatchManager { constructor( private readonly ctx: Context, - private readonly provider: SkillProvider, + private readonly invalidate: () => void, private readonly config: ResolvedWatchConfig, ) {} @@ -286,18 +300,17 @@ class SkillWatchManager { evictedProject = true } await Promise.all(pending) - if (evictedProject) this.ctx.skills.invalidateProvider(this.provider) + if (evictedProject) this.invalidate() } observeHostMutation(path: string): void { if (this.closing) return const normalized = resolve(path) if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return - this.ctx.skills.invalidateProvider(this.provider) + this.invalidate() } async dispose(): Promise { - if (this.closing) return this.closing = true const states = [...this.roots.values()] this.roots.clear() @@ -376,6 +389,8 @@ class SkillWatchManager { } } + // FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis + // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { const mode = await resolveRootWatchMode(state.root.path) @@ -489,7 +504,7 @@ class SkillWatchManager { queueMicrotask(() => { this.invalidationQueued = false if (this.closing) return - this.ctx.skills.invalidateProvider(this.provider) + this.invalidate() }) } 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 5cf5de8460..c13f4f1542 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -123,12 +123,8 @@ describe('skill-local watcher failures', () => { watchStabilityThresholdMs: 20, }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill']) - const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) let invalidations = 0 - ctx.skills.invalidateProvider = (provider) => { - invalidations += 1 - invalidateProvider(provider) - } + ctx.on('skills/change', () => { invalidations += 1 }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected a root watcher') @@ -169,14 +165,17 @@ describe('skill-local watcher failures', () => { watcherHarness.deferredReady = 1 const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - watch: true, - watchPollIntervalMs: 10, - watchStabilityThresholdMs: 20, + let provider!: InstanceType + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + return provider }) - ctx.skills.registerProvider(provider) const discovery = provider.list({}) await settle() @@ -187,6 +186,7 @@ describe('skill-local watcher failures', () => { first.emitter.emit('ready') await Promise.all([discovery, disposal]) + disposeProvider() await settle() expect(first.closeCalls).toBeGreaterThan(0) }) @@ -198,14 +198,17 @@ describe('skill-local watcher failures', () => { watcherHarness.deferredReady = 1 const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - watch: true, - watchPollIntervalMs: 10, - watchStabilityThresholdMs: 20, + let provider!: InstanceType + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + return provider }) - ctx.skills.registerProvider(provider) const discovery = provider.list({}) await settle() @@ -216,5 +219,6 @@ describe('skill-local watcher failures', () => { await expect(discovery).rejects.toThrow('opening failed during disposal') await disposal + disposeProvider() }) }) diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 6c218f3f61..27bd0904b1 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -572,12 +572,8 @@ describe('LocalSkillProvider', () => { const root = join(home, '.agents/skills') const ctx = await setupLocal(home) expect(await ctx.skills.list()).toEqual([]) - const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) let invalidations = 0 - ctx.skills.invalidateProvider = (provider) => { - invalidations += 1 - invalidateProvider(provider) - } + ctx.on('skills/change', () => { invalidations += 1 }) await writeSkill(root, 'observed-skill', 'Observed skill') const path = join(root, 'observed-skill/SKILL.md') @@ -657,15 +653,18 @@ describe('LocalSkillProvider', () => { await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill') const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - customSkillDirs: [nonDirectoryRoot], - watch: true, - watchStabilityThresholdMs: 20, - watchPollIntervalMs: 10, + let provider!: SkillLocal.LocalSkillProvider + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + customSkillDirs: [nonDirectoryRoot], + watch: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + return provider }) - ctx.skills.registerProvider(provider) expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) await provider.dispose() @@ -673,6 +672,7 @@ describe('LocalSkillProvider', () => { provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + disposeProvider() }) it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => { @@ -740,7 +740,10 @@ describe('LocalSkillProvider', () => { expect(await empty.skills.list()).toEqual([]) delete process.env.DSH_AGENTS_HOME - expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local') + expect(new SkillLocal.LocalSkillProvider(empty, { + signal: new AbortController().signal, + invalidate() {}, + }, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local') } finally { if (previousDshHome === undefined) { delete process.env.DSH_HOME diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index d38296e354..09a8788486 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: 54362bd3a0b8bcbf8161ce45f13535b49eab18a1 -README.zh.md: 8f15a44c815ffa687d01f8fc8f6070a8f1d28195 +README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56 +README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 54362bd3a0..66b240c3a6 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -10,8 +10,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API -- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. -- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement. +- `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.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. @@ -19,7 +18,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Events -- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. +- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after an active provider's registration control invalidates. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. ### Config @@ -29,13 +28,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider registers synchronously and performs remote setup, authentication, and discovery in its 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. 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. -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 that exact provider is invalidated so the next snapshot rediscovers its catalog. +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. ## Runtime Skills @@ -55,7 +54,7 @@ No direct prompt effect. The named consumer owns the durable initial catalog and ## Known Limitations and Deferred Work -- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism. +- **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. - **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 8f15a44c81..0ebbab0899 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -10,8 +10,7 @@ ### 公开 API -- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。 -- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。 +- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 - `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 @@ -19,7 +18,7 @@ ### 事件 -- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 +- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 ### 配置 @@ -29,13 +28,13 @@ ## 提供方契约 -提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 +提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 -定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。 +定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 ## 运行时 Skill @@ -55,7 +54,7 @@ ## 已知限制与待完成工作 -- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。 +- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 - **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index e3a041eb7d..f103f828a0 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -117,6 +117,14 @@ export interface SkillProvider { readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise } +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +export interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} + /** Skill registry configuration. */ export interface Config { /** Maximum number of completed cwd/provider catalogs kept in memory. */ @@ -180,43 +188,50 @@ export class SkillService extends Service { * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters * the provider and invalidates catalog caches. - * @param provider - the provider to register by `provider.name`. + * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ - registerProvider(provider: SkillProvider): () => void { - const name = provider.name - if (name === RUNTIME_PROVIDER) { - throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) + registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void { + const lifecycle = new AbortController() + let active = false + let provider: SkillProvider + const control: SkillProviderControl = { + signal: lifecycle.signal, + invalidate: () => { + if (active) this.invalidateProvider(provider) + }, } - if (this.providers.has(name)) { - throw new Error(`a skill provider named "${name}" is already registered`) - } - const providers = this.providers - const order = this.nextProviderOrder - const invalidateCache = (): void => { this.invalidateCache() } - this.nextProviderOrder += 1 - const dispose = this.ctx.effect(function* () { - providers.set(name, { provider, order }) - invalidateCache() - yield () => { - providers.delete(name) - invalidateCache() + try { + provider = create(control) + const name = provider.name + if (name === RUNTIME_PROVIDER) { + throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) } - }, 'skills.registerProvider()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose - } - - /** - * Invalidate catalogs contributed by one currently registered provider. Exact object identity - * prevents a late callback from an old provider instance from invalidating its replacement. - * Calls for an already-unregistered provider are harmless. - * @param provider - exact provider instance whose external source changed. - */ - invalidateProvider(provider: SkillProvider): void { - if (this.providers.get(provider.name)?.provider !== provider) return - this.invalidateCache() + if (this.providers.has(name)) { + throw new Error(`a skill provider named "${name}" is already registered`) + } + const providers = this.providers + const order = this.nextProviderOrder + const invalidateCache = (): void => { this.invalidateCache() } + this.nextProviderOrder += 1 + const dispose = this.ctx.effect(function* () { + active = true + providers.set(name, { provider, order }) + invalidateCache() + yield () => { + active = false + providers.delete(name) + lifecycle.abort(new Error(`skill provider "${name}" disposed`)) + invalidateCache() + } + }, 'skills.registerProvider()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity + return dispose + } catch (error) { + lifecycle.abort(error) + throw error + } } /** @@ -391,6 +406,11 @@ export class SkillService extends Service { this.notifyChange() } + private invalidateProvider(provider: SkillProvider): void { + /* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */ + if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache() + } + /** Notify catalog observers without making their refresh work load-bearing. */ private notifyChange(): void { for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) { diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 2f0134eab7..fdf9c40abc 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -34,6 +34,10 @@ class MemoryProvider implements SkillProvider { } } +function registerProvider(ctx: Context, provider: SkillProvider): () => void { + return ctx.skills.registerProvider(() => provider) +} + describe('SkillService registry', () => { it('registers providers, resolves duplicates first-wins, and disposes providers', async () => { const ctx = new Context() @@ -59,8 +63,8 @@ describe('SkillService registry', () => { return { ...candidate, content: (candidate.locator as { content: string }).content } }, } - const disposeMemory = ctx.skills.registerProvider(provider) - ctx.skills.registerProvider(overrideProvider) + const disposeMemory = registerProvider(ctx, provider) + registerProvider(ctx, overrideProvider) expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([ ['a-skill', 'A skill', 'memory'], @@ -84,24 +88,52 @@ describe('SkillService registry', () => { return { ...candidate, content: (candidate.locator as { content: string }).content } }, } - ctx.skills.registerProvider(sameRankProvider) + registerProvider(ctx, sameRankProvider) expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank') await expect(ctx.plugin({ name: 'duplicate-memory', inject: ['skills'], apply(pluginCtx: Context) { - pluginCtx.skills.registerProvider(new MemoryProvider([])) + registerProvider(pluginCtx, new MemoryProvider([])) }, })).rejects.toThrow('already registered') - expect(() => ctx.skills.registerProvider({ - name: 'runtime', - async list() { - return [] - }, - async get() { - return undefined - }, + let rejectedSignal: AbortSignal | undefined + expect(() => ctx.skills.registerProvider((control) => { + rejectedSignal = control.signal + return { + name: 'runtime', + async list() { + return [] + }, + async get() { + return undefined + }, + } })).toThrow('reserved') + expect(rejectedSignal?.aborted).toBe(true) + + const factoryFailure = new Error('factory failed') + let failedSignal: AbortSignal | undefined + expect(() => ctx.skills.registerProvider((control) => { + failedSignal = control.signal + throw factoryFailure + })).toThrow(factoryFailure) + expect(failedSignal?.reason).toBe(factoryFailure) + + const effectContext = new Context() + const effectService = new SkillService(effectContext) + const effectFailure = new Error('effect registration failed') + vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure }) + let effectSignal: AbortSignal | undefined + expect(() => effectService.registerProvider((control) => { + effectSignal = control.signal + return { + name: 'effect-provider', + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + } + })).toThrow(effectFailure) + expect(effectSignal?.reason).toBe(effectFailure) disposeMemory() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed']) @@ -111,7 +143,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) const badDescription = { value: 'object-description' } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'bad-candidate', list: () => Promise.resolve([{ ...memorySkill('bad-candidate', 'placeholder', 1), @@ -125,7 +157,7 @@ describe('SkillService registry', () => { const badBoolean = new Context() await badBoolean.plugin(SkillService) - badBoolean.skills.registerProvider({ + registerProvider(badBoolean, { name: 'bad-boolean', list: () => Promise.resolve([{ ...memorySkill('bad-boolean', 'Bad boolean', 1), @@ -140,7 +172,7 @@ describe('SkillService registry', () => { it('rejects non-array provider results and every malformed candidate scalar', async () => { const badList = new Context() await badList.plugin(SkillService) - badList.skills.registerProvider({ + registerProvider(badList, { name: 'non-array-list', list: () => Promise.resolve({} as unknown as SkillCandidate[]), get: () => Promise.resolve(undefined), @@ -171,7 +203,7 @@ describe('SkillService registry', () => { path: '/skills/candidate/SKILL.md', ...patch, } as SkillCandidate - ctx.skills.registerProvider({ + registerProvider(ctx, { name: providerName, list: () => Promise.resolve([candidate]), get: () => Promise.resolve(undefined), @@ -195,7 +227,7 @@ describe('SkillService registry', () => { rank: 1, locator: 'skill-a', } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'contextual', async list(received) { listedWith = received @@ -218,7 +250,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) let getCalls = 0 - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'cached', async list() { return [{ @@ -267,7 +299,7 @@ describe('SkillService registry', () => { }) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'held', async list() { return [{ @@ -347,7 +379,7 @@ describe('SkillService registry', () => { } let listCalls = 0 let received: SkillCandidate | undefined - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'detached', async list() { listCalls += 1 @@ -422,7 +454,7 @@ describe('SkillService registry', () => { await ctx.plugin(SkillService) const providerName = `definition-provider-${index}` const skillName = `definition-${index}` - ctx.skills.registerProvider({ + registerProvider(ctx, { name: providerName, list: () => Promise.resolve([{ name: skillName, @@ -455,7 +487,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'bad', async list() { return [memorySkill('Bad_Name', 'bad', 1)] @@ -474,7 +506,7 @@ describe('SkillService registry', () => { for (const candidate of invalidCandidates) { const invalid = new Context() await invalid.plugin(SkillService) - invalid.skills.registerProvider({ + registerProvider(invalid, { name: candidate.name, async list() { return [candidate] @@ -492,7 +524,7 @@ describe('SkillService registry', () => { it('sorts model-visible summaries without locale-sensitive collation', async () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider(new MemoryProvider([ + registerProvider(ctx, new MemoryProvider([ memorySkill('z-skill', 'Z skill', 10), memorySkill('a-skill', 'A skill', 10), ])) @@ -517,7 +549,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 }) const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) - ctx.skills.registerProvider(provider) + registerProvider(ctx, provider) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) provider.replace([memorySkill('second-skill', 'Second', 10)]) @@ -544,7 +576,7 @@ describe('SkillService registry', () => { let fail = true let flakyCalls = 0 - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'flaky', async list() { flakyCalls += 1 @@ -572,21 +604,27 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) - const dispose = ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + let signal: AbortSignal | undefined + const dispose = ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + signal = control.signal + return provider + }) expect((await ctx.skills.snapshot()).complete).toBe(true) provider.replace([memorySkill('second-skill', 'Second', 10)]) - ctx.skills.invalidateProvider(new MemoryProvider([])) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) - ctx.skills.invalidateProvider(provider) + invalidate() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) dispose() + expect(signal?.aborted).toBe(true) const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)]) - ctx.skills.registerProvider(replacement) + registerProvider(ctx, replacement) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) - ctx.skills.invalidateProvider(provider) + invalidate() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) expect(replacement.listCalls).toBe(1) }) @@ -598,11 +636,13 @@ describe('SkillService registry', () => { let changes = 0 ctx.on('skills/change', () => { changes += 1 }) - const disposeProvider = ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + const disposeProvider = ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) expect(changes).toBe(1) - ctx.skills.invalidateProvider(new MemoryProvider([])) - expect(changes).toBe(1) - ctx.skills.invalidateProvider(provider) + invalidate() expect(changes).toBe(2) const disposeRuntime = ctx.skills.register({ @@ -616,7 +656,7 @@ describe('SkillService registry', () => { expect(changes).toBe(4) disposeProvider() expect(changes).toBe(5) - ctx.skills.invalidateProvider(provider) + invalidate() expect(changes).toBe(5) }) @@ -632,7 +672,7 @@ describe('SkillService registry', () => { const disposeObserver = ctx.on('skills/change', () => { observed += 1 }) const provider = new MemoryProvider([]) - expect(() => ctx.skills.registerProvider(provider)).not.toThrow() + expect(() => registerProvider(ctx, provider)).not.toThrow() await Promise.resolve() expect(observed).toBe(1) expect(warnings).toEqual([ @@ -662,12 +702,16 @@ describe('SkillService registry', () => { } return await originalList(options) } - ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) const pending = ctx.skills.list() await started.promise provider.replace([memorySkill('fresh-skill', 'Fresh', 10)]) - ctx.skills.invalidateProvider(provider) + invalidate() release?.() expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill']) @@ -695,7 +739,7 @@ describe('SkillService registry', () => { return { ...candidate, name: 'new-name', content: 'Fresh body.' } }, } - ctx.skills.registerProvider(provider) + registerProvider(ctx, provider) expect(await ctx.skills.get('old-name')).toBeUndefined() await ctx.skills.list() @@ -705,7 +749,7 @@ describe('SkillService registry', () => { it('returns undefined when a discovered candidate disappears before loading', async () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'vanished-body', async list() { return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }] @@ -728,7 +772,7 @@ describe('SkillService registry', () => { throw new Error('provider failure coercion failed') }, } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'hostile-failure', list() { // Deliberately violate the provider contract to prove containment is total. @@ -753,7 +797,7 @@ describe('SkillService registry', () => { let release: (() => void) | undefined const started = new Promise((resolve) => { markStarted = resolve }) const gate = new Promise((resolve) => { release = resolve }) - const dispose = ctx.skills.registerProvider({ + const dispose = registerProvider(ctx, { name: 'delayed', async list() { markStarted?.() @@ -783,7 +827,7 @@ describe('SkillService registry', () => { const held = new Promise((resolve) => { release = () => { resolve([]) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'uncooperative', list(options) { seenSignal = options.signal diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index c08df2f984..028f0f7869 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -153,7 +153,7 @@ describe('dsh-tool-skill', () => { const home = await tempDir('tool-prefix-signal') const ctx = await setup(home) let seenSignal: AbortSignal | undefined - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'signal-probe', async list(options) { seenSignal = options.signal @@ -162,7 +162,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) const controller = new AbortController() await composePrefix(ctx, '/workspace', controller.signal) @@ -245,7 +245,11 @@ describe('dsh-tool-skill', () => { return undefined }, } - ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) const session = new Session(SessionId('incomplete-prefix')) const agent = sessionAgent(session) openMessageTurn(session) @@ -253,7 +257,7 @@ describe('dsh-tool-skill', () => { await composePrefixForAgent(ctx, agent) expect(catalogMessages(session)).toEqual([]) failing = false - ctx.skills.invalidateProvider(provider) + invalidate() await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toEqual([]) @@ -421,7 +425,7 @@ describe('dsh-tool-skill', () => { openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'failing', async list() { throw new Error('temporarily unavailable') @@ -429,7 +433,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) disposeStable() await fireStep(ctx, agent, 1, 1) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 501aa6c5c5..bc11ff68b8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3596,6 +3596,7 @@ describe('skill slash command', () => { it('retains last-good slash completions across incomplete snapshots', async () => { let skills: SkillService | undefined let provider: SkillProvider | undefined + let invalidate = (): void => {} let fail = false const result = await setup({ configureContext: async (ctx) => { @@ -3619,13 +3620,16 @@ describe('skill slash command', () => { return undefined }, } - skills?.registerProvider(provider) + skills?.registerProvider((control) => { + invalidate = control.invalidate + return provider as SkillProvider + }) }, }) if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted') fail = true - skills.invalidateProvider(provider) + invalidate() await tick() result.terminal.output = '' result.terminal.send('/skill:stable') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 23b114276d..f16711ab1a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -105,6 +105,7 @@ export const LINK_MAP: Record = { PreparedLlmCall: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', + SkillProviderControl: 'skills.md', CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 160f3c33c2..53fb6b4a33 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1009,6 +1009,11 @@ "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillProviderControl", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "Config", From 9e45736298c96ea4122632b353e83326b8adb33c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:52:08 -0700 Subject: [PATCH 04/13] fix(gui): default todo panel to collapsed --- apps/web/tests/todo-display.snapshot.ts | 25 +++++++------------ .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/skeleton/TodoPanel.tsx | 2 +- .../ui-conversation/tests/todo-panel.spec.tsx | 14 ++++++++--- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 2ae5f0e402..8d6f7833b2 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -147,27 +147,14 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn }).toMatchInlineSnapshot(` { "panelHeader": "To-dos1/3 tasks · 1 in progress", - "panelItems": [ - { - "status": "completed", - "text": "梳理需求", - }, - { - "status": "in_progress", - "text": "实现 fixture 样本", - }, - { - "status": "pending", - "text": "浏览器验收", - }, - ], + "panelItems": [], "row": "更新任务清单1/3 已完成 · 实现 fixture 样本", "rowState": "ok", } `) }) -it('collapses the plan strip to the count summary and restores it', async () => { +it('expands the default-collapsed plan strip and restores its folded state', async () => { boot() await openFixtureSession() @@ -176,19 +163,25 @@ it('collapses the plan strip to the count summary and restores it', async () => const header = panel.querySelector('button') if (header === null) throw new Error('todo panel header missing') - fireEvent.click(header) expect({ collapsedHeader: visibleText(header), + expanded: header.getAttribute('aria-expanded'), listGone: panel.querySelector('ul') === null, }).toMatchInlineSnapshot(` { "collapsedHeader": "To-dos1/3 tasks · 1 in progress", + "expanded": "false", "listGone": true, } `) fireEvent.click(header) expect(panel.querySelectorAll('li')).toHaveLength(3) + expect(header.getAttribute('aria-expanded')).toBe('true') + + fireEvent.click(header) + expect(panel.querySelector('ul')).toBeNull() + expect(header.getAttribute('aria-expanded')).toBe('false') }) it('hides the plan strip when the next turn starts', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 49e43861f3..9efd57ee1b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051 -README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d +README.md: 908b8c4136cd7823b7d8fdf2da3749c26f9f1563 +README.zh.md: 8ddf04255629d44226f4a99b8174c46665f932cc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 85cf040a48..908b8c4136 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 305258e286..8ddf042556 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index dd116ed3c8..22f5786ab1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -83,7 +83,7 @@ function progressLabel(todos: readonly TodoItem[]): string { } export function TodoPanel({ todos }: TodoPanelProps) { - const [collapsed, setCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(true) if (todos.length === 0) return null return ( diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 8cf1f45f52..65146160da 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -31,11 +31,18 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('shows progress, one row per item with its status glyph', () => { + it('starts collapsed with the progress summary visible', () => { render() expect(screen.getByTestId('todo-panel')).toBeTruthy() expect(screen.getByText('To-dos')).toBeTruthy() expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() + expect(screen.getByRole('button', { expanded: false })).toBeTruthy() + expect(screen.queryByRole('list')).toBeNull() + }) + + it('expands to show one row per item with its status glyph', () => { + render() + fireEvent.click(screen.getByRole('button', { expanded: false })) const items = screen.getAllByRole('listitem') expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() @@ -44,8 +51,9 @@ describe('TodoPanel', () => { expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) }) - it('collapse hides the list; expand restores; header keeps the count summary', () => { + it('collapse hides an expanded list; expand restores; header keeps the count summary', () => { render() + fireEvent.click(screen.getByRole('button', { expanded: false })) const header = screen.getByRole('button', { expanded: true }) fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() @@ -58,7 +66,7 @@ describe('TodoPanel', () => { it('collapsed header still shows zero in-progress when nothing is active', () => { render() - fireEvent.click(screen.getByRole('button', { expanded: true })) + expect(screen.getByRole('button', { expanded: false })).toBeTruthy() expect(screen.queryByText('都完了')).toBeNull() expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy() }) From 709d545e7c60185f1084a95835a5a3e4076bf928 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:53:47 +0800 Subject: [PATCH 05/13] fix(skill): re-probe retained root watchers --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 4 +- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 4 +- packages/skill/skill-local/src/index.ts | 27 ++++++-- .../tests/skill-local-watcher.spec.ts | 62 ++++++++++++++++++- 5 files changed, 87 insertions(+), 14 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 ba65c7d05d..c89c716b13 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: 8a53195dd8c4880c5cfa758ccf666ae88b2e1030 -2026-07-27-skill-catalog-hot-refresh.zh.md: 8cf55534460d2926be706353afd2019fad1bb45d +2026-07-27-skill-catalog-hot-refresh.md: e7cff2cb53a044ed0c4789cef3550652903f3586 +2026-07-27-skill-catalog-hot-refresh.zh.md: 86519c93880f94b1b9d3bdc011aa09b04ca10686 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 8a53195dd8..e7cff2cb53 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 @@ -16,7 +16,7 @@ The skill capability separates catalog membership from instruction-body loading. `@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. Deleting a root re-establishes ancestor observation. 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 make discovery incomplete and retry; 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, 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 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. ## 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 8cf5553446..86519c9388 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 @@ -16,7 +16,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 -系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。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 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、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/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index dbf3a3c0b6..4ac5f101cd 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -252,6 +252,7 @@ interface RootWatchState { } interface WatchHandle { + mode: RootWatchMode close(): Promise | void } @@ -348,9 +349,8 @@ class SkillWatchManager { private ensureWatcher(state: RootWatchState): Promise { if (this.closing || !this.config.enabled) return Promise.resolve() - if (state.watcher !== undefined && !state.unhealthy) return Promise.resolve() if (state.opening !== undefined) return state.opening - const opening = this.replaceWatcher(state) + const opening = this.ensureCurrentWatcher(state) state.opening = opening void opening.then( () => { @@ -363,6 +363,18 @@ class SkillWatchManager { return opening } + private async ensureCurrentWatcher(state: RootWatchState): Promise { + const watcher = state.watcher + if (watcher !== undefined && !state.unhealthy) { + const current = await resolveRootWatchMode(state.root.path) + // A child unlink can publish an empty catalog before root unlinkDir arrives. + // Discovery therefore revalidates the retained handle independently. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits + if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return + } + await this.replaceWatcher(state) + } + private async replaceWatcher(state: RootWatchState): Promise { const previous = state.watcher state.watcher = undefined @@ -389,7 +401,7 @@ class SkillWatchManager { } } - // FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis + // TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { @@ -416,6 +428,7 @@ class SkillWatchManager { interval: this.config.pollIntervalMs, }, listener) return { + mode, close() { unwatchFile(mode.nextPath, listener) }, @@ -436,6 +449,10 @@ class SkillWatchManager { usePolling: this.config.usePolling, interval: this.config.pollIntervalMs, }) + const handle: WatchHandle = { + mode, + close: () => watcher.close(), + } let ready = false const readiness = Promise.withResolvers() const onError = (error: unknown): void => { @@ -456,10 +473,10 @@ class SkillWatchManager { try { await readiness.promise } catch (error) { - await this.closeWatcher(watcher) + await this.closeWatcher(handle) throw error } - return watcher + return handle } private handleWatchEvent( 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 c13f4f1542..97ab8c9ee7 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' -import { mkdir, writeFile } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +13,33 @@ interface FakeWatcherControl { options: Record } +interface FakeWatchFileControl { + path: string + listener(current: Stats, previous: Stats): void +} + const watcherHarness = vi.hoisted(() => ({ watchers: [] as FakeWatcherControl[], startupErrors: [] as Error[], closeErrors: 0, deferredReady: 0, + watchFiles: [] as FakeWatchFileControl[], })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) { + watcherHarness.watchFiles.push({ path, listener }) + }, + unwatchFile(path: string, listener: FakeWatchFileControl['listener']) { + const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener) + if (index !== -1) watcherHarness.watchFiles.splice(index, 1) + }, + } +}) + vi.mock('chokidar', () => ({ default: { watch(_path: unknown, options: Record) { @@ -67,6 +88,7 @@ beforeEach(() => { watcherHarness.startupErrors.length = 0 watcherHarness.closeErrors = 0 watcherHarness.deferredReady = 0 + watcherHarness.watchFiles.length = 0 }) describe('skill-local watcher failures', () => { @@ -158,6 +180,40 @@ describe('skill-local watcher failures', () => { await settle() }) + it('re-probes a retained root after child unlink and observes immediate recreation', async () => { + const home = await tempDir('skill-watch-root-reprobe') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'old-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['old-skill']) + const original = watcherHarness.watchers[0] + if (original === undefined) throw new Error('expected a root watcher') + + await rm(root, { recursive: true }) + original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md')) + await settle() + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + + const missingRoot = watcherHarness.watchFiles.find(control => control.path === root) + expect(missingRoot).toBeDefined() + await writeSkill(root, 'recreated-skill') + missingRoot!.listener({} as Stats, {} as Stats) + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) }) + await settle() + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill']) + await fiber.dispose() + }) + it('settles an opening watcher when plugin disposal races its ready event', async () => { const home = await tempDir('skill-watch-opening-dispose') const root = join(home, '.dsh/skills') @@ -178,7 +234,7 @@ describe('skill-local watcher failures', () => { }) const discovery = provider.list({}) - await settle() + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') first.emitter.emit('unlinkDir', root) @@ -211,7 +267,7 @@ describe('skill-local watcher failures', () => { }) const discovery = provider.list({}) - await settle() + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') const disposal = provider.dispose() From cf390cc6fd62c5e007b2cb31ab9f3dd695716938 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:44:06 +0800 Subject: [PATCH 06/13] fix(skill): recognize empty catalog tombstones --- packages/skill/tool-skill/src/index.ts | 6 ++++-- packages/skill/tool-skill/tests/tool-skill.spec.ts | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index bd635c9893..f6f45b0316 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -18,7 +18,7 @@ export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 const CATALOG_ENTRIES_START = '\n' -const CATALOG_ENTRIES_END = '\n' +const CATALOG_ENTRIES_END = '' const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const /** Model-facing skill catalog configuration. */ @@ -275,7 +275,9 @@ function catalogContentDigest(content: UserMessage['content']): string | undefin const entriesStart = start + CATALOG_ENTRIES_START.length const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart) if (end === -1) return undefined - return digestCatalogEntries(text.slice(entriesStart, end)) + const renderedEntries = text.slice(entriesStart, end) + const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries + return digestCatalogEntries(entries) } function catalogDescription(value: string, maxLength: number): string { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 0390504565..3962b98f28 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -320,6 +320,9 @@ describe('dsh-tool-skill', () => { expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available') expect(JSON.stringify(removal.data.content)).not.toContain('first-skill') expect(JSON.stringify(removal.data.content)).not.toContain('second-skill') + + await fireStep(ctx, agent, 1, 4) + expect(catalogMessages(session)).toHaveLength(3) }) it('resumes from the latest valid visible catalog content', async () => { From cf7f14948c7ec525d0fa59489a5355cf7c3cc1a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:45:00 +0800 Subject: [PATCH 07/13] test(tui): match durable skill catalog source --- examples/tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 40076c97e3..139c8a1900 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -105,7 +105,7 @@ async function readLoggedRequestContext(cwd: string): Promise Date: Wed, 29 Jul 2026 21:12:01 +0800 Subject: [PATCH 08/13] fix(skill): ignore unchanged missing-root probes --- packages/skill/skill-local/src/index.ts | 30 ++++++++++++++----- .../tests/skill-local-watcher.spec.ts | 25 ++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 4ac5f101cd..ed68e84bcb 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -421,7 +421,7 @@ class SkillWatchManager { private openAncestorWatcher(state: RootWatchState, mode: Extract): WatchHandle { const listener = (_current: Stats, _previous: Stats): void => { - this.handleWatchEvent(state, mode, 'change', mode.nextPath) + void this.handleAncestorWatchEvent(state, mode) } watchFile(mode.nextPath, { persistent: false, @@ -435,6 +435,25 @@ class SkillWatchManager { } } + private async handleAncestorWatchEvent( + state: RootWatchState, + mode: Extract, + ): Promise { + let current: RootWatchMode + try { + current = await resolveRootWatchMode(state.root.path) + } catch (error) { + /* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */ + if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error) + return + /* v8 ignore stop */ + } + if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return + this.queueInvalidation() + state.unhealthy = true + this.scheduleRewatch(state) + } + private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { const watcher = chokidar.watch(mode.anchor, { persistent: false, @@ -481,13 +500,13 @@ class SkillWatchManager { private handleWatchEvent( state: RootWatchState, - mode: RootWatchMode, + mode: Extract, event: SkillWatchEvent, path: string, ): void { if (this.closing || !isRelevantWatchEvent(state.root, mode, event, resolve(path))) return this.queueInvalidation() - if (mode.kind === 'ancestor' || (resolve(path) === state.root.path && event === 'unlinkDir')) { + if (resolve(path) === state.root.path && event === 'unlinkDir') { state.unhealthy = true this.scheduleRewatch(state) } @@ -591,13 +610,10 @@ function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean { function isRelevantWatchEvent( root: SkillRoot, - mode: RootWatchMode, + mode: Extract, event: SkillWatchEvent, path: string, ): boolean { - if (mode.kind === 'ancestor') { - return path === mode.nextPath - } const segments = containedSegments(root.path, path) if (segments === undefined) return false if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir' 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 97ab8c9ee7..2a7a3e5f86 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -92,6 +92,31 @@ beforeEach(() => { }) describe('skill-local watcher failures', () => { + it('ignores missing-path probes until the observed path actually changes', async () => { + const home = await tempDir('skill-watch-missing-stable') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + }) + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + expect(watcherHarness.watchFiles).toHaveLength(2) + let invalidations = 0 + ctx.on('skills/change', () => { invalidations += 1 }) + + for (const control of watcherHarness.watchFiles) { + control.listener({} as Stats, {} as Stats) + } + await settle() + + expect(invalidations).toBe(0) + expect(watcherHarness.watchFiles).toHaveLength(2) + await fiber.dispose() + }) + it('marks a startup failure incomplete and retries discovery without caching it', async () => { const home = await tempDir('skill-watch-start-error') const root = join(home, '.dsh/skills') From 48cf50fc558a92bbc7a73eb7e39c2844b3b8ac63 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:17:04 +0800 Subject: [PATCH 09/13] fix(skill): cancel opening watchers on dispose --- packages/skill/skill-local/src/index.ts | 16 ++++++++++++++-- .../tests/skill-local-watcher.spec.ts | 7 +++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index ed68e84bcb..907c15a8ab 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -260,6 +260,7 @@ interface WatchHandle { class SkillWatchManager { private readonly roots = new Map() private readonly projects = new Map>() + private readonly lifecycle = new AbortController() private closing = false private invalidationQueued = false @@ -313,6 +314,7 @@ class SkillWatchManager { async dispose(): Promise { this.closing = true + this.lifecycle.abort(new Error('skill-local watcher disposed')) const states = [...this.roots.values()] this.roots.clear() this.projects.clear() @@ -348,6 +350,7 @@ class SkillWatchManager { } private ensureWatcher(state: RootWatchState): Promise { + /* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */ if (this.closing || !this.config.enabled) return Promise.resolve() if (state.opening !== undefined) return state.opening const opening = this.ensureCurrentWatcher(state) @@ -395,8 +398,11 @@ class SkillWatchManager { state.watcher = watcher state.unhealthy = false } catch (error) { - state.unhealthy = true - this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup + if (!this.closing) { + state.unhealthy = true + this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) + } throw error } } @@ -474,6 +480,9 @@ class SkillWatchManager { } let ready = false const readiness = Promise.withResolvers() + const signal = this.lifecycle.signal + const onAbort = (): void => { readiness.reject(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) const onError = (error: unknown): void => { if (!ready) { readiness.reject(error) @@ -494,6 +503,8 @@ class SkillWatchManager { } catch (error) { await this.closeWatcher(handle) throw error + } finally { + signal.removeEventListener('abort', onAbort) } return handle } @@ -539,6 +550,7 @@ class SkillWatchManager { this.invalidationQueued = true queueMicrotask(() => { this.invalidationQueued = false + /* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */ if (this.closing) return this.invalidate() }) 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 2a7a3e5f86..a1df287fbe 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -262,11 +262,10 @@ describe('skill-local watcher failures', () => { await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') - first.emitter.emit('unlinkDir', root) const disposal = provider.dispose() - first.emitter.emit('ready') - await Promise.all([discovery, disposal]) + await expect(discovery).rejects.toThrow('skill-local watcher disposed') + await disposal disposeProvider() await settle() expect(first.closeCalls).toBeGreaterThan(0) @@ -295,8 +294,8 @@ describe('skill-local watcher failures', () => { await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') - const disposal = provider.dispose() first.emitter.emit('error', new Error('opening failed during disposal')) + const disposal = provider.dispose() await expect(discovery).rejects.toThrow('opening failed during disposal') await disposal 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 10/13] 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", From c10c72837b0d50446ac72b4a2f5df3883a21169a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:13:57 +0800 Subject: [PATCH 11/13] fix(skill): close watchers after probe disposal --- packages/skill/skill-local/src/index.ts | 4 ++ .../tests/skill-local-watcher.spec.ts | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index f124a10f62..add2f8792c 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -489,6 +489,10 @@ class SkillWatchManager { let ready = false const readiness = Promise.withResolvers() const signal = this.lifecycle.signal + if (signal.aborted) { + await this.closeWatcher(handle) + signal.throwIfAborted() + } const onAbort = (): void => { readiness.reject(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) const onError = (error: unknown): void => { 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 4f9cfc59ef..14e3c07e41 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -18,12 +18,18 @@ interface FakeWatchFileControl { listener(current: Stats, previous: Stats): void } +interface FakeStatGate { + started: PromiseWithResolvers + release: PromiseWithResolvers +} + const watcherHarness = vi.hoisted(() => ({ watchers: [] as FakeWatcherControl[], startupErrors: [] as Error[], closeErrors: 0, deferredReady: 0, watchFiles: [] as FakeWatchFileControl[], + statGates: [] as FakeStatGate[], })) vi.mock('node:fs', async (importOriginal) => { @@ -40,6 +46,21 @@ vi.mock('node:fs', async (importOriginal) => { } }) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async stat(...args: Parameters) { + const gate = watcherHarness.statGates.shift() + if (gate !== undefined) { + gate.started.resolve(undefined) + await gate.release.promise + } + return await actual.stat(...args) + }, + } +}) + vi.mock('chokidar', () => ({ default: { watch(_path: unknown, options: Record) { @@ -89,6 +110,7 @@ beforeEach(() => { watcherHarness.closeErrors = 0 watcherHarness.deferredReady = 0 watcherHarness.watchFiles.length = 0 + watcherHarness.statGates.length = 0 }) describe('skill-local watcher failures', () => { @@ -279,6 +301,42 @@ describe('skill-local watcher failures', () => { expect(first.closeCalls).toBeGreaterThan(0) }) + it('closes an opening watcher when disposal wins the mode probe', async () => { + const home = await tempDir('skill-watch-probe-dispose') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'racing-skill') + watcherHarness.deferredReady = 1 + const statGate: FakeStatGate = { + started: Promise.withResolvers(), + release: Promise.withResolvers(), + } + watcherHarness.statGates.push(statGate) + const ctx = new Context() + await ctx.plugin(SkillService) + let provider!: InstanceType + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + return provider + }) + + const discovery = provider.list({}) + await statGate.started.promise + const disposal = provider.dispose() + statGate.release.resolve(undefined) + + await expect(discovery).rejects.toThrow('skill-local watcher disposed') + await disposal + expect(watcherHarness.watchers).toHaveLength(1) + expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0) + disposeProvider() + }) + it('contains an opening watcher rejection during provider teardown', async () => { const home = await tempDir('skill-watch-opening-reject') const root = join(home, '.dsh/skills') From db543ffa859ec6cf3b3fdfde1809b3bb49232bb3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:27:49 +0800 Subject: [PATCH 12/13] fix(skill): bound catalog discovery retries --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +-- .../2026-07-27-skill-catalog-hot-refresh.md | 4 +-- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 4 +-- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 6 ++-- docs/core-data-structures/skills.i18n.yaml | 4 +-- docs/core-data-structures/skills.md | 8 ++--- docs/core-data-structures/skills.zh.md | 8 ++--- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/skill/skill/README.i18n.yaml | 4 +-- packages/skill/skill/README.md | 4 +-- packages/skill/skill/README.zh.md | 4 +-- packages/skill/skill/src/index.ts | 18 +++++++--- packages/skill/skill/tests/skill.spec.ts | 34 +++++++++++++++++++ 16 files changed, 76 insertions(+), 34 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 d3ecfe624c..9a3df405be 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: 7c81b287cecde60c42f7e1e3ec171244ffc18aa6 -2026-07-27-skill-catalog-hot-refresh.zh.md: ca570a3c6a0402e6764824e3101e15054769b85d +2026-07-27-skill-catalog-hot-refresh.md: 8e70fb10e7da4292325b72f3a0392bef2271738c +2026-07-27-skill-catalog-hot-refresh.zh.md: 9a6b6d944baa4a9cc4dddb5158fdcbac2b05f5db 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 7c81b287ce..8e70fb10e7 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,7 +12,7 @@ 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. 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. +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 once; if the retry is also superseded, the latest candidates return as an incomplete, uncached observation. 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. @@ -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 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. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, bounded 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 ca570a3c6a..9a6b6d944b 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,7 +12,7 @@ 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 使用有界集合,并按最久未观察顺序淘汰。 @@ -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 74c0a43a7b..3ff1e3c028 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:138`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f5b1f3d70d..07d4d52ddb 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:156`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 50b0fd5768..d6166f1c8d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1660,11 +1660,11 @@ register(skill: SkillRegistration): () => void async list(options: SkillLookupOptions = {}): Promise /** - * Observe the current model-invocable catalog and whether all providers completed discovery. + * Observe the current model-invocable catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries plus provider-completeness state. + * @returns sorted summaries plus discovery-completeness state. */ async snapshot(options: SkillLookupOptions = {}): Promise @@ -1681,7 +1681,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * Observe the current model-invocable catalog and whether all providers completed discovery.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus provider-completeness state.\n */', + jsDoc: '/**\n * Observe the current model-invocable catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', }, { signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index f5af86ea54..d39e7a1505 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: 9813338dcec82fc2db7e149be1bd80ec5239684d -README.zh.md: 70558875a2d223367e2b35f326fba4b5d966fae3 +README.md: 65ff110999ea416648f3d676eb813dd4ceb194f8 +README.zh.md: 79e77d2a2846489125ba0ecaafc138172ba2fd86 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 9813338dce..65ff110999 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 rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached. +- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that 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. @@ -32,7 +32,7 @@ A provider factory runs synchronously and receives one registration-scoped contr 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 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. +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 once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. 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. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 70558875a2..79e77d2a28 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,以供有序组合拆卸。 @@ -32,7 +32,7 @@ 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并在返回前重试。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 +违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 6c12503bd9..2cc37a6683 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -15,6 +15,7 @@ import type Schema from 'schemastery' const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const DEFAULT_COLLECT_CACHE_ENTRIES = 128 +const MAX_COLLECT_ATTEMPTS = 2 const RUNTIME_PROVIDER = 'runtime' const RUNTIME_RANK = 250 @@ -87,11 +88,11 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } -/** One catalog observation plus whether every registered provider completed discovery. */ +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] - /** Whether every registered provider completed discovery for this observation. */ + /** Whether every registered provider completed without a concurrent catalog revision. */ readonly complete: boolean } @@ -286,11 +287,11 @@ export class SkillService extends Service { } /** - * Observe the current model-invocable catalog and whether all providers completed discovery. + * Observe the current model-invocable catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries plus provider-completeness state. + * @returns sorted summaries plus discovery-completeness state. */ async snapshot(options: SkillLookupOptions = {}): Promise { const collected = await this.collect(options) @@ -333,6 +334,7 @@ export class SkillService extends Service { private async collect(options: SkillLookupOptions): Promise { throwIfAborted(options.signal) + let attempt = 1 while (true) { const providerRevision = this.providerRevision const runtimeRevision = this.runtimeRevision @@ -342,7 +344,13 @@ export class SkillService extends Service { const result = await this.collectFresh(options) throwIfAborted(options.signal) - if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue + if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) { + 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) { diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index cb9fc8e0c9..ac3df3aa35 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -748,6 +748,40 @@ describe('SkillService registry', () => { expect(provider.listCalls).toBe(2) }) + it('bounds repeated in-flight invalidation and leaves the result uncached', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let listCalls = 0 + ctx.skills.registerProvider(control => ({ + name: 'self-invalidating', + async list() { + listCalls += 1 + control.invalidate() + return [{ + ...memorySkill('bounded-skill', `Attempt ${listCalls}`, 10), + provider: 'self-invalidating', + }] + }, + async get() { + return undefined + }, + })) + + expect(await ctx.skills.snapshot()).toEqual({ + skills: [{ + name: 'bounded-skill', + description: 'Attempt 2', + provider: 'self-invalidating', + source: 'memory', + }], + complete: false, + }) + expect(listCalls).toBe(2) + + expect((await ctx.skills.snapshot()).skills[0]?.description).toBe('Attempt 4') + expect(listCalls).toBe(4) + }) + it('invalidates a provider whose loaded definition changed identity', async () => { const ctx = new Context() await ctx.plugin(SkillService) From 8165518561cc44cc053f7e0b815e9da09888c94a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:32:46 +0800 Subject: [PATCH 13/13] fix(web): query the composer by the zh default placeholder in image-display snapshot --- apps/web/tests/image-display.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 40501dced1..5e5aa1c3dd 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -167,7 +167,7 @@ it('accepts a pasted image into the composer rail and removes it', async () => { // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: {