Merge remote-tracking branch 'origin/master' into worktree/pr823-retarget-latest-20260729
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/skills.i18n.yaml # docs/core-data-structures/skills.md # docs/core-data-structures/skills.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/skill/skill-local/README.i18n.yaml # packages/skill/skill/README.i18n.yaml # packages/skill/skill/README.md # packages/skill/skill/README.zh.md # packages/skill/skill/src/index.ts # packages/skill/skill/tests/skill.spec.ts # packages/skill/tool-skill/README.i18n.yaml # packages/skill/tool-skill/src/index.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/README.md
|
||||
README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f
|
||||
README.zh.md: 0db8933ecff2a8ab4767b29bc5319453ddfb87fa
|
||||
README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822
|
||||
README.zh.md: 5981173d05e74e0576e83f4f0ec42894050675cf
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) 并列。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
|
||||
README.md: 40666f8e56878260eeef13d378450273b950048d
|
||||
README.zh.md: edf3ef59539d85c903be788d063cfde74a5e16b2
|
||||
README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453
|
||||
README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Local filesystem provider for the `ctx.skills` registry.
|
||||
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the durable session catalogs and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
|
||||
## Plugin
|
||||
|
||||
@@ -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,7 +38,15 @@ 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 and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks.
|
||||
|
||||
## Skill Format
|
||||
|
||||
@@ -40,17 +54,20 @@ Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdow
|
||||
|
||||
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. A rejected camel-case spelling or a non-boolean invocation value drops the entire skill from discovery with a warning instead of discarding only that field or falling back to a permissive default. Invocation policy fails closed because ignoring invalid data could expose a skill on a disabled surface; wrong-typed optional `whenToUse` and `metadata` values are omitted because neither currently grants invocation.
|
||||
|
||||
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 to the existing request history. Body-only edits leave the catalog digest unchanged.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Discovery is one level deep** — only `<root>/<name>/SKILL.md` and `<root>/<name>.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.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
`ctx.skills` 注册表的本地文件系统提供方。
|
||||
|
||||
该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;会话前缀目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。
|
||||
该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 |
|
||||
| `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.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,7 +38,15 @@
|
||||
|
||||
项目根目录是包含 `.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 格式
|
||||
|
||||
@@ -40,17 +54,20 @@ Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 M
|
||||
|
||||
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。
|
||||
|
||||
目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
|
||||
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到初始目录或替换目录中,并将所选的当前指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
|
||||
watcher 触发的失效可促使指定的消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **发现深度为一层**:只识别 `<root>/<name>/SKILL.md` 和 `<root>/<name>.md`;忽略嵌套 skill 树和包 manifest(元数据清单)。
|
||||
- **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。
|
||||
- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。
|
||||
- **无文件系统监听**:在重新发现先前已收集的 cwd 之前,编辑内容能否生效取决于注册表缓存是否被淘汰,或是否因提供方重新加载而失效。
|
||||
- **格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与无效的 skill;意外 I/O 失败则会保留最后一份可用目录。
|
||||
- **缺失根观察每次轮询一个路径段**:启动时不存在的根会使用 `fs.watchFile` 按 `watchPollIntervalMs` 轮询,直至 Chokidar 可以附加;这以有界检测延迟换取跨 IDE、Git 和 shell 工作流的可靠创建检测。
|
||||
- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
type SkillInvocationPolicy,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillProviderControl,
|
||||
type SkillProviderObservation,
|
||||
type SkillSource,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
@@ -33,6 +37,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
|
||||
const BUNDLED_RANK = 600
|
||||
|
||||
export const name = 'skill-local'
|
||||
@@ -46,6 +53,18 @@ 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
|
||||
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
|
||||
bundledSkillDir?: string
|
||||
}
|
||||
@@ -54,6 +73,12 @@ export const Config: Schema<Config> = 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),
|
||||
bundledSkillDir: z.string(),
|
||||
})
|
||||
|
||||
@@ -62,6 +87,7 @@ interface SkillRoot {
|
||||
source: SkillSource
|
||||
rank: number
|
||||
skipSystem?: boolean
|
||||
projectRoot?: string
|
||||
trustedHost?: boolean
|
||||
}
|
||||
|
||||
@@ -85,10 +111,29 @@ 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)
|
||||
let provider!: LocalSkillProvider
|
||||
ctx.skills.registerProvider((control) => {
|
||||
provider = new LocalSkillProvider(ctx, control, config)
|
||||
return provider
|
||||
})
|
||||
ctx.effect(function* () {
|
||||
yield async () => { await provider.dispose() }
|
||||
}, 'skill-local watcher')
|
||||
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`. */
|
||||
@@ -97,12 +142,20 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly customSkillDirs: string[]
|
||||
private readonly watchManager: SkillWatchManager
|
||||
private readonly bundledSkillDir: string | undefined
|
||||
private disposal: Promise<void> | undefined
|
||||
|
||||
constructor(private readonly ctx: Context, config: Config = {}) {
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
control: SkillProviderControl,
|
||||
config: Config = {},
|
||||
) {
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
|
||||
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
|
||||
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
|
||||
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
|
||||
}
|
||||
@@ -110,17 +163,25 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
/**
|
||||
* Discover local skill summaries for a cwd-sensitive workspace.
|
||||
* @param options - lookup options; `cwd` selects the project roots to scan.
|
||||
* @returns local provider candidates with stable root ranks.
|
||||
* @returns local provider candidates with stable root ranks; watcher startup
|
||||
* failure returns readable candidates as an incomplete observation.
|
||||
*/
|
||||
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
|
||||
async list(options: SkillLookupOptions): Promise<SkillCandidate[] | SkillProviderObservation> {
|
||||
const roots = await this.roots(options.cwd)
|
||||
let complete = true
|
||||
try {
|
||||
await this.watchManager.observeRoots(roots)
|
||||
} catch (error) {
|
||||
if (this.disposal !== undefined) throw error
|
||||
complete = false
|
||||
}
|
||||
const candidates: SkillCandidate[] = []
|
||||
for (const root of roots) {
|
||||
for (const skill of await discoverRoot(root, this.ctx)) {
|
||||
candidates.push(skill)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
return complete ? candidates : { candidates, complete }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,13 +208,30 @@ 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.
|
||||
* @returns a shared promise that settles when every watcher reaches quiescence.
|
||||
*/
|
||||
dispose(): Promise<void> {
|
||||
this.disposal ??= this.watchManager.dispose()
|
||||
return this.disposal
|
||||
}
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
|
||||
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(
|
||||
@@ -168,6 +246,453 @@ 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<string>
|
||||
watcher: WatchHandle | undefined
|
||||
opening: Promise<void> | undefined
|
||||
unhealthy: boolean
|
||||
}
|
||||
|
||||
interface WatchHandle {
|
||||
mode: RootWatchMode
|
||||
close(): Promise<void> | void
|
||||
}
|
||||
|
||||
/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */
|
||||
class SkillWatchManager {
|
||||
private readonly roots = new Map<string, RootWatchState>()
|
||||
private readonly projects = new Map<string, Set<string>>()
|
||||
private readonly lifecycle = new AbortController()
|
||||
private closing = false
|
||||
private invalidationQueued = false
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly invalidate: () => void,
|
||||
private readonly config: ResolvedWatchConfig,
|
||||
) {}
|
||||
|
||||
async observeRoots(roots: readonly SkillRoot[]): Promise<void> {
|
||||
if (this.closing) return
|
||||
const projectRoots = new Map<string, SkillRoot[]>()
|
||||
const pending: Promise<void>[] = []
|
||||
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.invalidate()
|
||||
}
|
||||
|
||||
observeHostMutation(path: string): void {
|
||||
if (this.closing) return
|
||||
const normalized = resolve(path)
|
||||
if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.closing = true
|
||||
this.lifecycle.abort(new Error('skill-local watcher disposed'))
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
/* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */
|
||||
if (this.closing || !this.config.enabled) return Promise.resolve()
|
||||
if (state.opening !== undefined) return state.opening
|
||||
const opening = this.ensureCurrentWatcher(state)
|
||||
state.opening = opening
|
||||
void opening.then(
|
||||
() => {
|
||||
state.opening = undefined
|
||||
},
|
||||
() => {
|
||||
state.opening = undefined
|
||||
},
|
||||
)
|
||||
return opening
|
||||
}
|
||||
|
||||
private async ensureCurrentWatcher(state: RootWatchState): Promise<void> {
|
||||
const watcher = state.watcher
|
||||
if (watcher !== undefined && !state.unhealthy) {
|
||||
const current = await resolveRootWatchMode(state.root.path)
|
||||
// A child unlink can publish an empty catalog before root unlinkDir arrives.
|
||||
// Discovery therefore revalidates the retained handle independently.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
|
||||
if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return
|
||||
}
|
||||
await this.replaceWatcher(state)
|
||||
}
|
||||
|
||||
private async replaceWatcher(state: RootWatchState): Promise<void> {
|
||||
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) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup
|
||||
if (!this.closing) {
|
||||
state.unhealthy = true
|
||||
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis
|
||||
// service; keep skill filtering and invalidation here.
|
||||
private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
|
||||
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<RootWatchMode, { kind: 'ancestor' }>): WatchHandle {
|
||||
const listener = (_current: Stats, _previous: Stats): void => {
|
||||
void this.handleAncestorWatchEvent(state, mode)
|
||||
}
|
||||
watchFile(mode.nextPath, {
|
||||
persistent: false,
|
||||
interval: this.config.pollIntervalMs,
|
||||
}, listener)
|
||||
return {
|
||||
mode,
|
||||
close() {
|
||||
unwatchFile(mode.nextPath, listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAncestorWatchEvent(
|
||||
state: RootWatchState,
|
||||
mode: Extract<RootWatchMode, { kind: 'ancestor' }>,
|
||||
): Promise<void> {
|
||||
let current: RootWatchMode
|
||||
try {
|
||||
current = await resolveRootWatchMode(state.root.path)
|
||||
} catch (error) {
|
||||
/* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
|
||||
if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error)
|
||||
return
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return
|
||||
this.queueInvalidation()
|
||||
state.unhealthy = true
|
||||
this.scheduleRewatch(state)
|
||||
}
|
||||
|
||||
private async openRootWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
|
||||
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,
|
||||
})
|
||||
const handle: WatchHandle = {
|
||||
mode,
|
||||
close: () => watcher.close(),
|
||||
}
|
||||
let ready = false
|
||||
const readiness = Promise.withResolvers<undefined>()
|
||||
const signal = this.lifecycle.signal
|
||||
if (signal.aborted) {
|
||||
await this.closeWatcher(handle)
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
const onAbort = (): void => { readiness.reject(signal.reason) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
const onError = (error: unknown): void => {
|
||||
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, event, path) })
|
||||
}
|
||||
try {
|
||||
await readiness.promise
|
||||
} catch (error) {
|
||||
await this.closeWatcher(handle)
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
private handleWatchEvent(
|
||||
state: RootWatchState,
|
||||
event: SkillWatchEvent,
|
||||
path: string,
|
||||
): void {
|
||||
if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return
|
||||
this.queueInvalidation()
|
||||
if (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
|
||||
/* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */
|
||||
if (this.closing) return
|
||||
this.invalidate()
|
||||
})
|
||||
}
|
||||
|
||||
private async closeWatcher(watcher: WatchHandle): Promise<void> {
|
||||
try {
|
||||
await watcher.close()
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settleWatcherOpening(opening: Promise<void> | undefined): Promise<void> {
|
||||
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<RootWatchMode> {
|
||||
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,
|
||||
event: SkillWatchEvent,
|
||||
path: string,
|
||||
): boolean {
|
||||
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<SkillCandidate[]> {
|
||||
const skills: SkillCandidate[] = []
|
||||
const entries = await listSkillRootEntries(root, ctx)
|
||||
@@ -205,9 +730,12 @@ async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<Skil
|
||||
}
|
||||
|
||||
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
|
||||
// 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<FsDirEntry[]> {
|
||||
@@ -223,9 +751,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[] = []
|
||||
@@ -293,31 +823,39 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, t
|
||||
}
|
||||
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<string | undefined> {
|
||||
// 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
|
||||
}
|
||||
|
||||
370
packages/skill/skill-local/tests/skill-local-watcher.spec.ts
Normal file
370
packages/skill/skill-local/tests/skill-local-watcher.spec.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
|
||||
interface FakeWatcherControl {
|
||||
emitter: EventEmitter
|
||||
closeCalls: number
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface FakeWatchFileControl {
|
||||
path: string
|
||||
listener(current: Stats, previous: Stats): void
|
||||
}
|
||||
|
||||
interface FakeStatGate {
|
||||
started: PromiseWithResolvers<undefined>
|
||||
release: PromiseWithResolvers<undefined>
|
||||
}
|
||||
|
||||
const watcherHarness = vi.hoisted(() => ({
|
||||
watchers: [] as FakeWatcherControl[],
|
||||
startupErrors: [] as Error[],
|
||||
closeErrors: 0,
|
||||
deferredReady: 0,
|
||||
watchFiles: [] as FakeWatchFileControl[],
|
||||
statGates: [] as FakeStatGate[],
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) {
|
||||
watcherHarness.watchFiles.push({ path, listener })
|
||||
},
|
||||
unwatchFile(path: string, listener: FakeWatchFileControl['listener']) {
|
||||
const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener)
|
||||
if (index !== -1) watcherHarness.watchFiles.splice(index, 1)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async stat(...args: Parameters<typeof actual.stat>) {
|
||||
const gate = watcherHarness.statGates.shift()
|
||||
if (gate !== undefined) {
|
||||
gate.started.resolve(undefined)
|
||||
await gate.release.promise
|
||||
}
|
||||
return await actual.stat(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('chokidar', () => ({
|
||||
default: {
|
||||
watch(_path: unknown, options: Record<string, unknown>) {
|
||||
const emitter = new EventEmitter() as EventEmitter & { close(): Promise<void> }
|
||||
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<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
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<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
watcherHarness.watchers.length = 0
|
||||
watcherHarness.startupErrors.length = 0
|
||||
watcherHarness.closeErrors = 0
|
||||
watcherHarness.deferredReady = 0
|
||||
watcherHarness.watchFiles.length = 0
|
||||
watcherHarness.statGates.length = 0
|
||||
})
|
||||
|
||||
describe('skill-local watcher failures', () => {
|
||||
it('ignores missing-path probes until the observed path actually changes', async () => {
|
||||
const home = await tempDir('skill-watch-missing-stable')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const fiber = await ctx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
})
|
||||
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
|
||||
expect(watcherHarness.watchFiles).toHaveLength(2)
|
||||
let invalidations = 0
|
||||
ctx.on('skills/change', () => { invalidations += 1 })
|
||||
|
||||
for (const control of watcherHarness.watchFiles) {
|
||||
control.listener({} as Stats, {} as Stats)
|
||||
}
|
||||
await settle()
|
||||
|
||||
expect(invalidations).toBe(0)
|
||||
expect(watcherHarness.watchFiles).toHaveLength(2)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps skills loadable across persistent watcher startup failures without caching them', async () => {
|
||||
const home = await tempDir('skill-watch-start-error')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'retry-skill')
|
||||
watcherHarness.startupErrors.push(
|
||||
new Error('watch failed once'),
|
||||
new Error('watch failed twice'),
|
||||
new Error('watch failed three times'),
|
||||
)
|
||||
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()).toMatchObject({
|
||||
skills: [{ name: 'retry-skill' }],
|
||||
complete: false,
|
||||
})
|
||||
expect((await ctx.skills.get('retry-skill'))?.content).toBe('Body.')
|
||||
expect(await ctx.skills.snapshot()).toMatchObject({
|
||||
skills: [{ name: 'retry-skill' }],
|
||||
complete: false,
|
||||
})
|
||||
expect(watcherHarness.watchers).toHaveLength(3)
|
||||
expect(watcherHarness.watchers[0]?.options).toMatchObject({
|
||||
atomic: true,
|
||||
depth: 1,
|
||||
followSymlinks: false,
|
||||
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'])
|
||||
let invalidations = 0
|
||||
ctx.on('skills/change', () => { invalidations += 1 })
|
||||
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('re-probes a retained root after child unlink and observes immediate recreation', async () => {
|
||||
const home = await tempDir('skill-watch-root-reprobe')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'old-skill')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const fiber = await ctx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['old-skill'])
|
||||
const original = watcherHarness.watchers[0]
|
||||
if (original === undefined) throw new Error('expected a root watcher')
|
||||
|
||||
await rm(root, { recursive: true })
|
||||
original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md'))
|
||||
await settle()
|
||||
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
|
||||
|
||||
const missingRoot = watcherHarness.watchFiles.find(control => control.path === root)
|
||||
expect(missingRoot).toBeDefined()
|
||||
await writeSkill(root, 'recreated-skill')
|
||||
missingRoot!.listener({} as Stats, {} as Stats)
|
||||
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) })
|
||||
await settle()
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill'])
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('settles an opening watcher when plugin disposal races its ready event', async () => {
|
||||
const home = await tempDir('skill-watch-opening-dispose')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'racing-skill')
|
||||
watcherHarness.deferredReady = 1
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
|
||||
const discovery = provider.list({})
|
||||
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
|
||||
const first = watcherHarness.watchers[0]
|
||||
if (first === undefined) throw new Error('expected an opening root watcher')
|
||||
const disposal = provider.dispose()
|
||||
|
||||
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
|
||||
await disposal
|
||||
disposeProvider()
|
||||
await settle()
|
||||
expect(first.closeCalls).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('closes an opening watcher when disposal wins the mode probe', async () => {
|
||||
const home = await tempDir('skill-watch-probe-dispose')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'racing-skill')
|
||||
watcherHarness.deferredReady = 1
|
||||
const statGate: FakeStatGate = {
|
||||
started: Promise.withResolvers<undefined>(),
|
||||
release: Promise.withResolvers<undefined>(),
|
||||
}
|
||||
watcherHarness.statGates.push(statGate)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
|
||||
const discovery = provider.list({})
|
||||
await statGate.started.promise
|
||||
const disposal = provider.dispose()
|
||||
statGate.release.resolve(undefined)
|
||||
|
||||
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
|
||||
await disposal
|
||||
expect(watcherHarness.watchers).toHaveLength(1)
|
||||
expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0)
|
||||
disposeProvider()
|
||||
})
|
||||
|
||||
it('contains an opening watcher rejection during provider teardown', async () => {
|
||||
const home = await tempDir('skill-watch-opening-reject')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'rejected-skill')
|
||||
watcherHarness.deferredReady = 1
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
|
||||
const discovery = provider.list({})
|
||||
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
|
||||
const first = watcherHarness.watchers[0]
|
||||
if (first === undefined) throw new Error('expected an opening root watcher')
|
||||
first.emitter.emit('error', new Error('opening failed during disposal'))
|
||||
const disposal = provider.dispose()
|
||||
|
||||
await expect(discovery).rejects.toThrow('opening failed during disposal')
|
||||
await disposal
|
||||
disposeProvider()
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
@@ -26,19 +26,26 @@ class TestFileSystem extends FileSystem {
|
||||
listDirCalls = 0
|
||||
failResolvePaths = new Set<string>()
|
||||
failStatPaths = new Set<string>()
|
||||
failListDirPaths = new Set<string>()
|
||||
errorResolvePaths = new Set<string>()
|
||||
errorStatPaths = new Set<string>()
|
||||
errorReadPaths = new Set<string>()
|
||||
missingReadPaths = new Set<string>()
|
||||
statOverrides = new Map<string, FsInfo | undefined>()
|
||||
statSignals: Array<AbortSignal | undefined> = []
|
||||
readTextSignals: Array<AbortSignal | undefined> = []
|
||||
readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
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<FsInfo | undefined> {
|
||||
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<string> {
|
||||
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<FsDirEntry[]> {
|
||||
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<SkillLocal.Config> = {})
|
||||
await ctx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: false,
|
||||
...config,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function waitFor<T>(read: () => Promise<T>, accept: (value: T) => boolean): Promise<T> {
|
||||
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')
|
||||
@@ -235,7 +256,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',
|
||||
@@ -420,7 +441,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'],
|
||||
@@ -444,6 +465,92 @@ describe('LocalSkillProvider', () => {
|
||||
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -452,7 +559,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 = []
|
||||
@@ -479,6 +586,223 @@ 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([])
|
||||
let invalidations = 0
|
||||
ctx.on('skills/change', () => { invalidations += 1 })
|
||||
|
||||
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)
|
||||
let provider!: SkillLocal.LocalSkillProvider
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
customSkillDirs: [nonDirectoryRoot],
|
||||
watch: true,
|
||||
watchStabilityThresholdMs: 20,
|
||||
watchPollIntervalMs: 10,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
const beforeDisposal = await provider.list({})
|
||||
expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name))
|
||||
.toEqual(['disposed-skill'])
|
||||
|
||||
await provider.dispose()
|
||||
await provider.dispose()
|
||||
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
|
||||
|
||||
const afterDisposal = await provider.list({})
|
||||
expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name))
|
||||
.toEqual(['disposed-skill'])
|
||||
disposeProvider()
|
||||
})
|
||||
|
||||
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
|
||||
@@ -493,7 +817,7 @@ describe('LocalSkillProvider', () => {
|
||||
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled 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-bundled-skill', 'env-skill'])
|
||||
|
||||
process.env.DSH_HOME = join(envHome, 'empty-dsh')
|
||||
@@ -501,11 +825,14 @@ describe('LocalSkillProvider', () => {
|
||||
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
|
||||
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
|
||||
expect(new SkillLocal.LocalSkillProvider(empty, {
|
||||
signal: new AbortController().signal,
|
||||
invalidate() {},
|
||||
}, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
|
||||
} finally {
|
||||
if (previousDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
|
||||
README.md: d2b75ff73e97089ed3d8da4192e71898e6b7eac6
|
||||
README.zh.md: 1b559e7584fc1646e868d933d13469dfb5d3cef4
|
||||
README.md: f538ae668ccff291be86348627d5547150f460df
|
||||
README.zh.md: 8a44f684ea4d9519a0af7866d272a8e7834aeda6
|
||||
|
||||
@@ -10,11 +10,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.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. The result is invocation-neutral; consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
|
||||
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
|
||||
- `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 regardless of invocation policy.
|
||||
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `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 an active provider's registration control invalidates. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
@@ -36,11 +41,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
|
||||
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
|
||||
|
||||
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
|
||||
|
||||
Contract violations fail fast. A rejected `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 and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
|
||||
|
||||
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
@@ -48,19 +55,19 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou
|
||||
|
||||
## Consumer boundary
|
||||
|
||||
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface.
|
||||
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide durable session catalogs and the `skill` tool, so providers remain independent of the model surface.
|
||||
|
||||
## 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 durable initial or 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 the durable initial catalog and append-only 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 retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
|
||||
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
|
||||
- **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.
|
||||
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
|
||||
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
|
||||
### 公开 API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR(热模块替换);Cordis 返回的原始 disposer 支持有序组合拆卸。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。结果与调用策略无关;消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。
|
||||
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
|
||||
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
|
||||
|
||||
### 事件
|
||||
|
||||
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
|
||||
|
||||
### 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
@@ -36,11 +41,13 @@
|
||||
|
||||
## 提供方契约
|
||||
|
||||
提供方同步注册,并在可等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现由提供方可等待的 `list(options)` 调用执行。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
|
||||
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
|
||||
|
||||
违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败:系统记录并跳过该失败,且不缓存结果。只缓存已完成的目录;提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
|
||||
违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
|
||||
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
|
||||
|
||||
## 运行时 skill
|
||||
|
||||
@@ -48,19 +55,19 @@
|
||||
|
||||
## 消费方边界
|
||||
|
||||
注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供会话前缀目录和 `skill` 工具,因此提供方仍与模型接口独立。
|
||||
注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供持久会话目录和 `skill` 工具,因此提供方仍与模型接口独立。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。
|
||||
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到持久的初始目录或替换目录消息中,并将已加载指令渲染到已保留工具结果中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
|
||||
不直接影响提示词。指定的消费方负责持久初始目录,以及失效后的仅追加式目录替换。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **已完成的目录没有 TTL 或监听失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存的 cwd 会保持陈旧,直到缓存条目被淘汰或提供方/运行时重新加载。
|
||||
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
|
||||
- **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。
|
||||
- **提供方列表失败会使该请求无法使用整个来源**:注册表会记录并跳过该来源,不提供模型可见诊断或部分目录恢复契约。
|
||||
- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。
|
||||
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
|
||||
|
||||
@@ -15,6 +15,7 @@ import type Schema from 'schemastery'
|
||||
|
||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
|
||||
const MAX_COLLECT_ATTEMPTS = 2
|
||||
const RUNTIME_PROVIDER = 'runtime'
|
||||
const RUNTIME_RANK = 250
|
||||
|
||||
@@ -118,6 +119,22 @@ export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolea
|
||||
return skill.invocation.userInvocable
|
||||
}
|
||||
|
||||
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
|
||||
export interface SkillCatalogSnapshot {
|
||||
/** Sorted invocation-neutral summaries collected in this observation. */
|
||||
readonly skills: SkillSummary[]
|
||||
/** Whether every registered provider completed without a concurrent catalog revision. */
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
/** Provider candidates plus whether the current discovery is authoritative. */
|
||||
export interface SkillProviderObservation {
|
||||
/** Candidates available from the current provider discovery. */
|
||||
readonly candidates: readonly SkillCandidate[]
|
||||
/** Whether discovery completed and these candidates may be cached. */
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
/** Provider interface for one source of skills, such as local directories or a remote registry. */
|
||||
export interface SkillProvider {
|
||||
/** Unique provider name in the `ctx.skills` registry. */
|
||||
@@ -128,9 +145,10 @@ export interface SkillProvider {
|
||||
* authentication, and discovery are awaited inside this method. Implementations
|
||||
* should settle promptly when `options.signal` aborts.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns provider candidates with precedence ranks and opaque locators.
|
||||
* @returns provider candidates as a complete-array shorthand, or an explicit
|
||||
* observation when usable candidates came from incomplete discovery.
|
||||
*/
|
||||
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
|
||||
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
|
||||
/**
|
||||
* Load a complete skill body for a previously listed candidate.
|
||||
* @param candidate - the winning candidate originally returned by this provider.
|
||||
@@ -140,6 +158,14 @@ export interface SkillProvider {
|
||||
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
|
||||
}
|
||||
|
||||
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
|
||||
export interface SkillProviderControl {
|
||||
/** Aborts if registration fails or when the exact provider registration is disposed. */
|
||||
readonly signal: AbortSignal
|
||||
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
|
||||
readonly invalidate: () => void
|
||||
}
|
||||
|
||||
/** Skill registry configuration. */
|
||||
export interface Config {
|
||||
/** Maximum number of completed cwd/provider catalogs kept in memory. */
|
||||
@@ -150,6 +176,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 {
|
||||
@@ -166,7 +203,7 @@ interface CollectResult {
|
||||
|
||||
/**
|
||||
* Registry of skill providers. It merges provider catalogs with stable
|
||||
* first-wins duplicate handling, exposes sorted model-visible summaries, and
|
||||
* first-wins duplicate handling, exposes sorted invocation-neutral summaries, and
|
||||
* loads full skill bodies on demand.
|
||||
*/
|
||||
export class SkillService extends Service {
|
||||
@@ -192,32 +229,50 @@ export class SkillService extends Service {
|
||||
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
|
||||
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
|
||||
* the provider and invalidates catalog caches.
|
||||
* @param provider - the provider to register by `provider.name`.
|
||||
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
|
||||
* @returns the exact Cordis effect disposer that unregisters this provider;
|
||||
* composite effects may yield it directly to preserve teardown ordering.
|
||||
*/
|
||||
registerProvider(provider: SkillProvider): () => void {
|
||||
const name = provider.name
|
||||
if (name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
|
||||
const lifecycle = new AbortController()
|
||||
let active = false
|
||||
let provider: SkillProvider
|
||||
const control: SkillProviderControl = {
|
||||
signal: lifecycle.signal,
|
||||
invalidate: () => {
|
||||
if (active) this.invalidateProvider(provider)
|
||||
},
|
||||
}
|
||||
if (this.providers.has(name)) {
|
||||
throw new Error(`a skill provider named "${name}" is already registered`)
|
||||
}
|
||||
const providers = this.providers
|
||||
const order = this.nextProviderOrder
|
||||
const invalidateCache = (): void => { this.invalidateCache() }
|
||||
this.nextProviderOrder += 1
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
providers.set(name, { provider, order })
|
||||
invalidateCache()
|
||||
yield () => {
|
||||
providers.delete(name)
|
||||
invalidateCache()
|
||||
try {
|
||||
provider = create(control)
|
||||
const name = provider.name
|
||||
if (name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
}
|
||||
}, 'skills.registerProvider()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
if (this.providers.has(name)) {
|
||||
throw new Error(`a skill provider named "${name}" is already registered`)
|
||||
}
|
||||
const providers = this.providers
|
||||
const order = this.nextProviderOrder
|
||||
const invalidateCache = (): void => { this.invalidateCache() }
|
||||
this.nextProviderOrder += 1
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
active = true
|
||||
providers.set(name, { provider, order })
|
||||
invalidateCache()
|
||||
yield () => {
|
||||
active = false
|
||||
providers.delete(name)
|
||||
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
|
||||
invalidateCache()
|
||||
}
|
||||
}, 'skills.registerProvider()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
|
||||
return dispose
|
||||
} catch (error) {
|
||||
lifecycle.abort(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,10 +320,25 @@ export class SkillService extends Service {
|
||||
* @returns all sorted winning summaries.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
.map(entry => entry.candidate)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary)
|
||||
return (await this.snapshot(options)).skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
|
||||
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
||||
* retry on their next request boundary.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries plus discovery-completeness state.
|
||||
*/
|
||||
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
|
||||
const collected = await this.collect(options)
|
||||
return {
|
||||
skills: collected.entries
|
||||
.map(entry => entry.candidate)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary),
|
||||
complete: collected.cacheable,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,7 +353,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),
|
||||
@@ -291,21 +361,32 @@ 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<IndexedCandidate[]> {
|
||||
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
throwIfAborted(options.signal)
|
||||
let attempt = 1
|
||||
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)
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
|
||||
if (attempt < MAX_COLLECT_ATTEMPTS) {
|
||||
attempt += 1
|
||||
continue
|
||||
}
|
||||
return { entries: result.entries, cacheable: false }
|
||||
}
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
||||
@@ -313,7 +394,7 @@ export class SkillService extends Service {
|
||||
this.collectCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
return result.entries
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,11 +440,9 @@ export class SkillService extends Service {
|
||||
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
|
||||
}
|
||||
if (output === undefined) continue
|
||||
if (!Array.isArray(output)) {
|
||||
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
|
||||
}
|
||||
const listed = output as readonly SkillCandidate[]
|
||||
for (const candidate of listed) {
|
||||
const observation = normalizeProviderObservation(output, provider.name)
|
||||
if (!observation.complete) cacheable = false
|
||||
for (const candidate of observation.candidates) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
localOrder += 1
|
||||
@@ -375,7 +454,45 @@ export class SkillService extends Service {
|
||||
private invalidateCache(): void {
|
||||
this.providerRevision += 1
|
||||
this.collectCache.clear()
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private invalidateProvider(provider: SkillProvider): void {
|
||||
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
|
||||
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
|
||||
}
|
||||
|
||||
/** Notify catalog observers without making their refresh work load-bearing. */
|
||||
private notifyChange(): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {
|
||||
if (Array.isArray(output)) {
|
||||
return { candidates: output as readonly SkillCandidate[], complete: true }
|
||||
}
|
||||
if (output === null || typeof output !== 'object') {
|
||||
throw invalidProviderObservation(providerName)
|
||||
}
|
||||
const observation = output as Partial<SkillProviderObservation>
|
||||
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {
|
||||
throw invalidProviderObservation(providerName)
|
||||
}
|
||||
return observation as SkillProviderObservation
|
||||
}
|
||||
|
||||
function invalidProviderObservation(providerName: string): TypeError {
|
||||
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
|
||||
}
|
||||
|
||||
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import SkillService, {
|
||||
type SkillInvocationPolicy,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillProviderObservation,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
@@ -43,6 +44,10 @@ class MemoryProvider implements SkillProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function registerProvider(ctx: Context, provider: SkillProvider): () => void {
|
||||
return ctx.skills.registerProvider(() => provider)
|
||||
}
|
||||
|
||||
describe('SkillService registry', () => {
|
||||
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -69,8 +74,8 @@ describe('SkillService registry', () => {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
const disposeMemory = ctx.skills.registerProvider(provider)
|
||||
ctx.skills.registerProvider(overrideProvider)
|
||||
const disposeMemory = registerProvider(ctx, provider)
|
||||
registerProvider(ctx, overrideProvider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
|
||||
['a-skill', 'A skill', 'memory'],
|
||||
@@ -95,24 +100,52 @@ describe('SkillService registry', () => {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(sameRankProvider)
|
||||
registerProvider(ctx, sameRankProvider)
|
||||
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
|
||||
await expect(ctx.plugin({
|
||||
name: 'duplicate-memory',
|
||||
inject: ['skills'],
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.skills.registerProvider(new MemoryProvider([]))
|
||||
registerProvider(pluginCtx, new MemoryProvider([]))
|
||||
},
|
||||
})).rejects.toThrow('already registered')
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: 'runtime',
|
||||
async list() {
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
let rejectedSignal: AbortSignal | undefined
|
||||
expect(() => ctx.skills.registerProvider((control) => {
|
||||
rejectedSignal = control.signal
|
||||
return {
|
||||
name: 'runtime',
|
||||
async list() {
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
})).toThrow('reserved')
|
||||
expect(rejectedSignal?.aborted).toBe(true)
|
||||
|
||||
const factoryFailure = new Error('factory failed')
|
||||
let failedSignal: AbortSignal | undefined
|
||||
expect(() => ctx.skills.registerProvider((control) => {
|
||||
failedSignal = control.signal
|
||||
throw factoryFailure
|
||||
})).toThrow(factoryFailure)
|
||||
expect(failedSignal?.reason).toBe(factoryFailure)
|
||||
|
||||
const effectContext = new Context()
|
||||
const effectService = new SkillService(effectContext)
|
||||
const effectFailure = new Error('effect registration failed')
|
||||
vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure })
|
||||
let effectSignal: AbortSignal | undefined
|
||||
expect(() => effectService.registerProvider((control) => {
|
||||
effectSignal = control.signal
|
||||
return {
|
||||
name: 'effect-provider',
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
}
|
||||
})).toThrow(effectFailure)
|
||||
expect(effectSignal?.reason).toBe(effectFailure)
|
||||
|
||||
disposeMemory()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
@@ -150,7 +183,7 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const badDescription = { value: 'object-description' }
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'bad-candidate',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-candidate', 'placeholder', 1),
|
||||
@@ -164,7 +197,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
const badBoolean = new Context()
|
||||
await badBoolean.plugin(SkillService)
|
||||
badBoolean.skills.registerProvider({
|
||||
registerProvider(badBoolean, {
|
||||
name: 'bad-boolean',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-boolean', 'Bad boolean', 1),
|
||||
@@ -176,15 +209,18 @@ describe('SkillService registry', () => {
|
||||
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.modelInvocable')
|
||||
})
|
||||
|
||||
it('rejects non-array provider results and every malformed candidate scalar', async () => {
|
||||
const badList = new Context()
|
||||
await badList.plugin(SkillService)
|
||||
badList.skills.registerProvider({
|
||||
name: 'non-array-list',
|
||||
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
|
||||
it('rejects malformed provider results and every malformed candidate scalar', async () => {
|
||||
const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }]
|
||||
for (const [index, output] of malformedOutputs.entries()) {
|
||||
const badList = new Context()
|
||||
await badList.plugin(SkillService)
|
||||
registerProvider(badList, {
|
||||
name: `malformed-list-${index}`,
|
||||
list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation')
|
||||
}
|
||||
|
||||
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
|
||||
@@ -210,7 +246,7 @@ describe('SkillService registry', () => {
|
||||
path: '/skills/candidate/SKILL.md',
|
||||
...patch,
|
||||
} as SkillCandidate
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([candidate]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -235,7 +271,7 @@ describe('SkillService registry', () => {
|
||||
rank: 1,
|
||||
locator: 'skill-a',
|
||||
}
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'contextual',
|
||||
async list(received) {
|
||||
listedWith = received
|
||||
@@ -258,7 +294,7 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let getCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'cached',
|
||||
async list() {
|
||||
return [{
|
||||
@@ -309,7 +345,7 @@ describe('SkillService registry', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'held',
|
||||
async list() {
|
||||
return [{
|
||||
@@ -391,7 +427,7 @@ describe('SkillService registry', () => {
|
||||
}
|
||||
let listCalls = 0
|
||||
let received: SkillCandidate | undefined
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'detached',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
@@ -485,7 +521,7 @@ describe('SkillService registry', () => {
|
||||
await ctx.plugin(SkillService)
|
||||
const providerName = `definition-provider-${index}`
|
||||
const skillName = `definition-${index}`
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([{
|
||||
name: skillName,
|
||||
@@ -519,7 +555,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'bad',
|
||||
async list() {
|
||||
return [memorySkill('Bad_Name', 'bad', 1)]
|
||||
@@ -538,7 +574,7 @@ describe('SkillService registry', () => {
|
||||
for (const candidate of invalidCandidates) {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SkillService)
|
||||
invalid.skills.registerProvider({
|
||||
registerProvider(invalid, {
|
||||
name: candidate.name,
|
||||
async list() {
|
||||
return [candidate]
|
||||
@@ -556,7 +592,7 @@ describe('SkillService registry', () => {
|
||||
it('sorts model-visible summaries without locale-sensitive collation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
registerProvider(ctx, new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 10),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
]))
|
||||
@@ -581,7 +617,7 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
ctx.skills.registerProvider(provider)
|
||||
registerProvider(ctx, provider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
provider.replace([memorySkill('second-skill', 'Second', 10)])
|
||||
@@ -608,7 +644,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
let fail = true
|
||||
let flakyCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'flaky',
|
||||
async list() {
|
||||
flakyCalls += 1
|
||||
@@ -619,7 +655,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)
|
||||
@@ -630,6 +668,231 @@ describe('SkillService registry', () => {
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps candidates from incomplete provider observations loadable without caching them', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let listCalls = 0
|
||||
registerProvider(ctx, {
|
||||
name: 'incomplete-candidates',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
return {
|
||||
candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }],
|
||||
complete: false,
|
||||
}
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
})
|
||||
|
||||
expect(await ctx.skills.snapshot()).toMatchObject({
|
||||
skills: [{ name: 'available-skill' }],
|
||||
complete: false,
|
||||
})
|
||||
expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.')
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill'])
|
||||
expect(listCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('invalidates only the exact registered provider and ignores its late callbacks', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
let invalidate = (): void => {}
|
||||
let signal: AbortSignal | undefined
|
||||
const dispose = ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
signal = control.signal
|
||||
return provider
|
||||
})
|
||||
|
||||
expect((await ctx.skills.snapshot()).complete).toBe(true)
|
||||
provider.replace([memorySkill('second-skill', 'Second', 10)])
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
|
||||
invalidate()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
dispose()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
|
||||
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
|
||||
registerProvider(ctx, replacement)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
|
||||
invalidate()
|
||||
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 })
|
||||
|
||||
let invalidate = (): void => {}
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
expect(changes).toBe(1)
|
||||
invalidate()
|
||||
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)
|
||||
invalidate()
|
||||
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(() => registerProvider(ctx, 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<undefined>()
|
||||
const gate = new Promise<void>((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)
|
||||
}
|
||||
let invalidate = (): void => {}
|
||||
ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
|
||||
const pending = ctx.skills.list()
|
||||
await started.promise
|
||||
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
|
||||
invalidate()
|
||||
release?.()
|
||||
|
||||
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
|
||||
expect(provider.listCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('bounds repeated in-flight invalidation and leaves the result uncached', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let listCalls = 0
|
||||
ctx.skills.registerProvider(control => ({
|
||||
name: 'self-invalidating',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
control.invalidate()
|
||||
return [{
|
||||
...memorySkill('bounded-skill', `Attempt ${listCalls}`, 10),
|
||||
provider: 'self-invalidating',
|
||||
}]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}))
|
||||
|
||||
expect(await ctx.skills.snapshot()).toEqual({
|
||||
skills: [{
|
||||
name: 'bounded-skill',
|
||||
description: 'Attempt 2',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'self-invalidating',
|
||||
source: 'memory',
|
||||
}],
|
||||
complete: false,
|
||||
})
|
||||
expect(listCalls).toBe(2)
|
||||
|
||||
expect((await ctx.skills.snapshot()).skills[0]?.description).toBe('Attempt 4')
|
||||
expect(listCalls).toBe(4)
|
||||
})
|
||||
|
||||
it('invalidates a provider whose loaded definition changed identity', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let listCalls = 0
|
||||
const provider: SkillProvider = {
|
||||
name: 'renamed',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
return [{
|
||||
name: 'old-name',
|
||||
description: 'Old name',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'renamed',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'old-name',
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
|
||||
},
|
||||
}
|
||||
registerProvider(ctx, 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)
|
||||
registerProvider(ctx, {
|
||||
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)
|
||||
@@ -640,7 +903,7 @@ describe('SkillService registry', () => {
|
||||
throw new Error('provider failure coercion failed')
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'hostile-failure',
|
||||
list() {
|
||||
// Deliberately violate the provider contract to prove containment is total.
|
||||
@@ -665,7 +928,7 @@ describe('SkillService registry', () => {
|
||||
let release: (() => void) | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const dispose = ctx.skills.registerProvider({
|
||||
const dispose = registerProvider(ctx, {
|
||||
name: 'delayed',
|
||||
async list() {
|
||||
markStarted?.()
|
||||
@@ -695,7 +958,7 @@ describe('SkillService registry', () => {
|
||||
const held = new Promise<SkillCandidate[]>((resolve) => {
|
||||
release = () => { resolve([]) }
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'uncooperative',
|
||||
list(options) {
|
||||
seenSignal = options.signal
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
|
||||
README.md: ddf2b7503e8dad47677aa5260b0aa3349f4c4dd1
|
||||
README.zh.md: 0d06210efae4060aa591aff39d9bd27c8c59d341
|
||||
README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce
|
||||
README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3
|
||||
|
||||
@@ -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 catalog
|
||||
## Catalog lifecycle
|
||||
|
||||
The plugin injects one durable user-role `<system-reminder>` catalog at the first `agent/step` of a live session. It resolves skills for the calling session's cwd, forwards the step 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.
|
||||
At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `<system-reminder>` before the request. Catalog messages contain 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 catalog is a sourced `user/message` injected before the first request and retained in ordinary session history.
|
||||
The digest covers the exact rendered text between the `<available_skills>` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. 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 [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.
|
||||
|
||||
## 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 skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface.
|
||||
|
||||
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 one durable user-role message.
|
||||
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `<available_skills>` 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
|
||||
|
||||
Append-only after the existing reusable prefix. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may affect cache reuse from the newly appended catalog position.
|
||||
The initial durable catalog is appended after the existing reusable prefix. Dynamic changes are append-only history after that catalog, so earlier reusable tokens stay intact while each newly appended catalog and later turns form a new suffix. A new or resumed instance with a changed digest may affect cache reuse from the newly appended catalog position.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
|
||||
面向模型的 skill(技能)目录和 `skill` 工具。
|
||||
|
||||
需要 `ctx.tools` 和 `ctx.skills`(`inject: ['tools', 'skills']`)。
|
||||
需要 `ctx.agents`、`ctx.tools` 和 `ctx.skills`(`inject: ['agents', 'tools', 'skills']`)。
|
||||
|
||||
## 会话目录
|
||||
## 目录生命周期
|
||||
|
||||
该插件在活动会话的第一个 `agent/step` 注入一条持久的用户角色 `<system-reminder>` 目录。它为调用会话的 cwd 解析 skill,将步骤中止信号转发到发现流程,并只列出已排序的 `name` 和 `description` 条目;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。这项对工具定义的精确匹配检查使提示词指引、模型可见 schema 和可执行分派保持对齐。
|
||||
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
|
||||
|
||||
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。目录是一条带来源的 `user/message`,在第一个请求前注入,并保留在普通会话历史中。
|
||||
该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
|
||||
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
|
||||
|
||||
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
|
||||
|
||||
## 工具:`skill`
|
||||
|
||||
@@ -24,7 +28,7 @@
|
||||
|
||||
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
|
||||
|
||||
该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。
|
||||
工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -32,7 +36,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。
|
||||
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 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 Cache 影响
|
||||
|
||||
仅追加,位于现有可重用前缀之后。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从新追加的目录位置起影响缓存重用。
|
||||
初始持久目录追加在现有可重用前缀之后。动态变更作为该目录之后的仅追加历史,因此较早的可重用 token 保持不变,每条新追加的目录和后续轮次都会形成新的后缀。新建或恢复的实例如果 digest 发生变化,可能会从新追加的目录位置起影响缓存重用。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
@@ -146,3 +150,5 @@ Load referenced resources only as needed.
|
||||
- **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill;只有目录描述会被截断。
|
||||
- **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。
|
||||
- **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分内容、流式输出或缓存内容句柄。
|
||||
- **目录替换采用全量列表**:一个名称或描述发生变化,就会追加当前所有可见摘要;这样能显式停用陈旧名称,但 token 成本与目录大小成正比。
|
||||
- **正文不做版本化**:仅修改正文不会改变目录 digest,也不会通知模型;后续工具调用会读取提供方的当前内容,而先前工具结果仍是历史事实。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-tool-skill
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -18,9 +19,12 @@ import {
|
||||
} 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_ENTRIES_START = '<available_skills>\n'
|
||||
const CATALOG_ENTRIES_END = '</available_skills>'
|
||||
const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
|
||||
|
||||
/** Model-facing skill catalog configuration. */
|
||||
export interface Config {
|
||||
@@ -130,20 +134,22 @@ 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.
|
||||
const catalogLoaded = new WeakSet<object>()
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
if (catalogLoaded.has(agent.session)) return
|
||||
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) {
|
||||
catalogLoaded.add(agent.session)
|
||||
return
|
||||
}
|
||||
const skills = (await ctx.skills.list({ cwd: agent.session.header.cwd, signal }))
|
||||
.filter(isModelInvocable)
|
||||
if (skills.length > 0) {
|
||||
const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
|
||||
agent.inject(catalog)
|
||||
}
|
||||
catalogLoaded.add(agent.session)
|
||||
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 skills = snapshot.skills.filter(isModelInvocable)
|
||||
const digest = catalogDigest(skills, catalogDescriptionMaxLength)
|
||||
const history = catalogHistory(agent)
|
||||
if (history.visibleDigest === digest) return
|
||||
if (!history.published && skills.length === 0) return
|
||||
const catalog = history.published
|
||||
? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
|
||||
: renderCatalogMessage(skills, catalogDescriptionMaxLength)
|
||||
agent.inject(catalog)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -194,7 +200,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB
|
||||
}
|
||||
|
||||
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
|
||||
const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
const entries = renderCatalogEntries(skills, descriptionMaxLength)
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -210,10 +216,84 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
source: PLUGIN_SOURCE,
|
||||
})
|
||||
}
|
||||
|
||||
function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
|
||||
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 createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
'</available_skills>',
|
||||
'',
|
||||
...availability,
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: PLUGIN_SOURCE,
|
||||
})
|
||||
}
|
||||
|
||||
function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
|
||||
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
}
|
||||
|
||||
function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
|
||||
return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
|
||||
}
|
||||
|
||||
function digestCatalogEntries(entries: string): string {
|
||||
return createHash('sha256')
|
||||
.update(entries)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
|
||||
const visible = new Set(agent.session.surface.nodes)
|
||||
const events = agent.session.events
|
||||
let published = false
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
// The loop bounds prove the read-only event view contains this index.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue
|
||||
const digest = catalogContentDigest(event.data.content)
|
||||
if (digest === undefined) continue
|
||||
published = true
|
||||
if (visible.has(event.seq)) return { visibleDigest: digest, published }
|
||||
}
|
||||
return { published }
|
||||
}
|
||||
|
||||
function catalogContentDigest(content: UserMessage['content']): string | undefined {
|
||||
if (content.length !== 1 || content[0]?.type !== 'text') return undefined
|
||||
const text = content[0].text
|
||||
const start = text.indexOf(CATALOG_ENTRIES_START)
|
||||
if (start === -1) return undefined
|
||||
const entriesStart = start + CATALOG_ENTRIES_START.length
|
||||
const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
|
||||
if (end === -1) return undefined
|
||||
const renderedEntries = text.slice(entriesStart, end)
|
||||
const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
|
||||
return digestCatalogEntries(entries)
|
||||
}
|
||||
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
|
||||
@@ -4,12 +4,11 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import {} from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
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, 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'
|
||||
@@ -30,8 +29,9 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
|
||||
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 ctx.plugin(toolSkill, config)
|
||||
return ctx
|
||||
}
|
||||
@@ -57,6 +57,50 @@ function agentForCwd(cwd: string): Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
|
||||
return {
|
||||
id: SessionId(id),
|
||||
options: {},
|
||||
session,
|
||||
status: 'running',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
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', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
|
||||
await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal)
|
||||
}
|
||||
|
||||
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
|
||||
return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'dsh-tool-skill')
|
||||
}
|
||||
|
||||
function catalogContent(entries: string[]): Message['content'] {
|
||||
return [{
|
||||
type: 'text',
|
||||
text: ['<system-reminder>', '<available_skills>', ...entries, '</available_skills>', '</system-reminder>'].join('\n'),
|
||||
}]
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
||||
return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
|
||||
}
|
||||
@@ -66,8 +110,8 @@ async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new Ab
|
||||
return agent.session.deriveMessages()
|
||||
}
|
||||
|
||||
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'],
|
||||
@@ -80,9 +124,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)
|
||||
@@ -106,7 +151,7 @@ describe('dsh-tool-skill', () => {
|
||||
const home = await tempDir('tool-prefix-signal')
|
||||
const ctx = await setup(home)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'signal-probe',
|
||||
async list(options) {
|
||||
seenSignal = options.signal
|
||||
@@ -115,7 +160,7 @@ describe('dsh-tool-skill', () => {
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
}))
|
||||
const controller = new AbortController()
|
||||
|
||||
await composePrefix(ctx, '/workspace', controller.signal)
|
||||
@@ -216,15 +261,233 @@ describe('dsh-tool-skill', () => {
|
||||
expect(await composePrefixForAgent(ctx, agent)).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
|
||||
},
|
||||
}
|
||||
let invalidate = (): void => {}
|
||||
ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
const session = new Session(SessionId('incomplete-prefix'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
|
||||
await composePrefixForAgent(ctx, agent)
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
failing = false
|
||||
invalidate()
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
})
|
||||
|
||||
it('records an empty baseline across repeated step observations', async () => {
|
||||
const home = await tempDir('tool-empty-step')
|
||||
const ctx = await setup(home)
|
||||
const session = new Session(SessionId('empty-step'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
await fireStep(ctx, agent, 1, 2)
|
||||
|
||||
expect(catalogMessages(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 fireStep(ctx, agent, 1, 1)
|
||||
expect(catalogMessages(session)).toHaveLength(1)
|
||||
|
||||
const disposeSecond = ctx.skills.register({
|
||||
name: 'second-skill',
|
||||
description: 'Second skill',
|
||||
source: 'runtime',
|
||||
content: 'Second body.',
|
||||
})
|
||||
await fireStep(ctx, agent, 1, 2)
|
||||
|
||||
const addition = catalogMessages(session)[1]
|
||||
if (addition?.type !== 'user/message') throw new Error('expected catalog addition')
|
||||
expect(JSON.stringify(addition.data.content)).toContain('first-skill')
|
||||
expect(JSON.stringify(addition.data.content)).toContain('second-skill')
|
||||
|
||||
disposeSecond()
|
||||
disposeFirst()
|
||||
await fireStep(ctx, agent, 1, 3)
|
||||
|
||||
const removal = catalogMessages(session)[2]
|
||||
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')
|
||||
|
||||
await fireStep(ctx, agent, 1, 4)
|
||||
expect(catalogMessages(session)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('resumes from the latest valid visible catalog content', 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', createUserMessage({
|
||||
content: catalogContent(['- `old-skill`: Old skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing catalog markers' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toHaveLength(6)
|
||||
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
|
||||
})
|
||||
|
||||
it('re-establishes the current catalog after compaction hides its durable message', 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')
|
||||
const initial = catalogMessages(session)[0]
|
||||
if (initial === undefined) throw new Error('expected initial catalog')
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted history' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: initial.seq, end: initial.seq },
|
||||
sourceEventSeqs: [initial.seq],
|
||||
})
|
||||
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toHaveLength(2)
|
||||
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('first-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 fireStep(ctx, agent, 1, 1)
|
||||
expect(catalogMessages(session)).toHaveLength(1)
|
||||
|
||||
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 fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toHaveLength(1)
|
||||
})
|
||||
|
||||
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 composePrefixForAgent(ctx, agent)
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
|
||||
await scope.dispose()
|
||||
})
|
||||
@@ -254,8 +517,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')
|
||||
})
|
||||
@@ -392,7 +656,7 @@ describe('dsh-tool-skill', () => {
|
||||
const home = await tempDir('tool-policy-before-load')
|
||||
const ctx = await setup(home)
|
||||
const getCalls: string[] = []
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'policy-probe',
|
||||
async list() {
|
||||
return [
|
||||
@@ -434,7 +698,7 @@ describe('dsh-tool-skill', () => {
|
||||
content: 'Instructions must not be disclosed.',
|
||||
}
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
const denied = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } })
|
||||
const raced = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } })
|
||||
|
||||
Reference in New Issue
Block a user