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",