From e133e4bddb4b539b0c3618e410c151bb40c6767d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 17:22:41 +0800 Subject: [PATCH] feat(skill): add invocation controls --- .../feature/2026-07-05-skill-system.i18n.yaml | 4 +- .../feature/2026-07-05-skill-system.md | 6 +- .../feature/2026-07-05-skill-system.zh.md | 6 +- ...26-07-21-tui-skill-slash-command.i18n.yaml | 6 +- .../2026-07-21-tui-skill-slash-command.md | 6 +- .../2026-07-21-tui-skill-slash-command.zh.md | 6 +- ...26-07-28-skill-invocation-policy.i18n.yaml | 6 ++ .../2026-07-28-skill-invocation-policy.md | 50 +++++++++++ .../2026-07-28-skill-invocation-policy.zh.md | 50 +++++++++++ docs/config-catalog.md | 6 +- docs/cordis-catalog/services.md | 13 +-- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 34 ++++--- docs/core-data-structures/skills.zh.md | 34 ++++--- .../tests/snapshots/skill-load/session.jsonl | 2 +- .../.dsh/skills/model-only-skill/SKILL.md | 7 ++ .../.dsh/skills/user-only-skill/SKILL.md | 7 ++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 + packages/client/ui-skill/README.i18n.yaml | 6 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 7 +- packages/host/apiproxy/src/api/skills.ts | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 25 +++++- packages/skill/skill-local/README.i18n.yaml | 6 +- packages/skill/skill-local/README.md | 4 +- packages/skill/skill-local/README.zh.md | 4 +- packages/skill/skill-local/src/index.ts | 54 ++++++++++-- .../skill-local/tests/skill-local.spec.ts | 88 +++++++++++++++++-- packages/skill/skill/README.i18n.yaml | 6 +- packages/skill/skill/README.md | 17 +++- packages/skill/skill/README.zh.md | 17 +++- packages/skill/skill/src/index.ts | 85 +++++++++++++----- packages/skill/skill/tests/skill.spec.ts | 72 ++++++++++++--- packages/skill/tool-skill/README.i18n.yaml | 6 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 12 ++- .../skill/tool-skill/tests/tool-skill.spec.ts | 36 +++++++- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/README.zh.md | 4 +- packages/ui/tui/src/index.ts | 18 ++-- packages/ui/tui/tests/tui.spec.ts | 48 ++++++++-- scripts/type-equiv.manifest.json | 5 ++ 49 files changed, 646 insertions(+), 157 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md create mode 100644 examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md create mode 100644 examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index cb3d84e8a9..364c552df9 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: 4fc621a9fdfa8042ebf3eb1975f0930cb1bf116c -2026-07-05-skill-system.zh.md: 0dbebd211a1fa9e434d3f0a189c936f2b1c76574 +2026-07-05-skill-system.md: e90af985fc66dec93b3d71ce45d9aa0683cd5371 +2026-07-05-skill-system.zh.md: 4187f9bbb7f190a94b1fa1bc0dd331a040a114d5 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 4fc621a9fd..e90af985fc 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -18,13 +18,13 @@ Provider plugins register synchronously during `apply()`. Provider membership is The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. -Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names are kebab-case. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. +Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. Local skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: project-root lookup probes `.git` with `resolve` and `stat`, root discovery uses `listDir`, and skill reads use `readText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill-local` without the fs seam. Missing roots, unreadable or malformed skill files, and transient provider `list()` failures degrade to warn-and-skip so one bad source does not make every agent request fail; malformed candidates still fail fast because they are provider contract violations. `dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) -The `skill({ name })` tool loads one full skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation` retain distinct tool errors. The tool result is the model-visible disclosure path. +The registry's `list()` returns every winning summary, while model and user consumers apply the invocation predicates owned by the [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md). The `skill({ name })` tool loads one model-invocable skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills marked `invocation.disableModelInvocation` retain distinct tool errors. The tool result is the model-visible disclosure path. The data structures and catalog/tool contract are documented in [skills.md](../../../../docs/core-data-structures/skills.md), with service signatures in the generated [services catalog](../../../../docs/cordis-catalog/services.md). @@ -52,4 +52,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis ## Deferred -Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields, and the `user-invocable` frontmatter field is likewise unparsed. Direct user invocation itself ships as a consumer-side affordance instead: the TUI front door offers a manual `/skill:` command over the registry's existing `list()` and `get()` methods, without a registry, provider, or tool contract change — see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). +Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. Direct user invocation ships as a TUI affordance over the shared invocation policy and trusted `get()` primitive; see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 0dbebd211a..4187f9bbb7 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -18,13 +18,13 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 -每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`disableModelInvocation` 和 `metadata` 为可选。名称采用 kebab-case。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 +每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 `dsh-tool-skill` 在会话的第一个 `agent/step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) -`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 +注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `invocation.disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 数据结构与目录/工具契约记录在 [skills.md](../../../../docs/core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/cordis-catalog/services.md)。 @@ -52,4 +52,4 @@ agent-core 主干包含一个目录贡献者、一个本地提供方和一个面 ## 延后 -Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段,`user-invocable` frontmatter 字段同样不会被解析。直接用户调用本身则作为消费方层面的能力交付:TUI 前门基于注册表现有的 `list()` 与 `get()` 方法提供手动 `/skill:` 命令,无需变更注册表、提供方或工具契约——见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 +Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 6d9112d663..4574ce7c75 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 -2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +2026-07-21-tui-skill-slash-command.md: 872e1f109728731e0d55e81a538c81e794724856 +2026-07-21-tui-skill-slash-command.zh.md: 772e25745ea7ab25f715208a9c6b1d10cf0c6e65 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index 8370ab61f5..872e1f1097 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -10,17 +10,17 @@ The [skill system](2026-07-05-skill-system.md) shipped with model-initiated load ## Decision -The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract. +The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool. Its visibility and loading policy comes from the shared [independent model and user skill invocation policy](2026-07-28-skill-invocation-policy.md). The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. -Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. +Autocomplete filters the invocation-neutral `list()` result with `isUserInvocable`, and manual submission applies the same predicate after trusted `get()` resolves the definition. A user-only skill can therefore appear and load even when model invocation is disabled, while a user-disabled skill is neither advertised nor loadable by exact name. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, a user-disabled name, and a lookup failure each surface as a transcript notice without sending anything. `renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. ## Alternatives considered -**Add a `user-invocable` frontmatter field and enforce it in the registry.** Rejected for this change. The skill-system note defers that field, and manual invocation does not need it: the TUI is a trusted local caller, so `get()` already authorizes loading any skill, and autocomplete visibility keys off the existing `disableModelInvocation`. A new per-skill field would add a contract to the registry, local provider, and tool with no current consumer beyond visibility, which `disableModelInvocation` already covers. +**Add a `user-invocable` frontmatter field only inside the original TUI change.** Rejected there because a TUI-only field would have changed the registry, provider, and tool contract without a shared invocation model. The later [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md) adds it across every relevant consumer and preserves `get()` as a trusted primitive. **Declare `skills` as a TUI injection.** Rejected because skills mount conditionally; a declared injection would make the front door require the registry and refuse to mount without it, contradicting the package's optional-service stance. `ctx.get('skills')` reads the global store and tolerates absence. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 66edec6ecd..772e25745e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。 +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的功能;它不新增任何面向模型的工具。其可见性和加载策略来自共享的[模型与用户独立 skill 调用策略](2026-07-28-skill-invocation-policy.md)。 TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 -自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 +自动补全使用 `isUserInvocable` 过滤与调用策略无关的 `list()` 结果;手动提交则在受信的 `get()` 解析定义后应用相同判定。因此,即使模型调用已禁用,仅供用户调用的 skill 仍会显示并可加载;用户禁用的 skill 既不会展示,也无法按精确名称加载。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、用户禁用的名称以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 `renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 ## Alternatives considered -**新增 `user-invocable` frontmatter 字段并在注册表中强制执行。** 本次改动否决。skill 系统 note 把该字段列为待办,而手动调用并不需要它:TUI 是可信的本地调用方,`get()` 已经授权加载任意 skill,自动补全的可见性以既有的 `disableModelInvocation` 为准。新增一个逐 skill 字段会给注册表、本地提供方和工具都加上一条契约,而除了可见性之外没有任何现有消费方,可见性又已由 `disableModelInvocation` 覆盖。 +**仅在最初的 TUI 变更内新增 `user-invocable` frontmatter 字段。** 当时未采纳,因为 TUI 独有的字段会在没有共享调用模型的情况下改变注册表、提供方和工具契约。后续的[独立调用策略决策](2026-07-28-skill-invocation-policy.md)将其扩展到每个相关消费方,并保留 `get()` 作为受信原语。 **把 `skills` 声明为 TUI 注入。** 否决,因为 skill 是条件挂载的;声明式注入会使前门必须依赖注册表,缺少它就拒绝挂载,与本包可选服务的立场相悖。`ctx.get('skills')` 读取全局存储并容忍其缺失。 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml new file mode 100644 index 0000000000..ce5b1d462c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +2026-07-28-skill-invocation-policy.md: e2c01c4343fec72ba285b13f78c1995b05dae928 +2026-07-28-skill-invocation-policy.zh.md: d5f9bac59fd3d21107ed3ffca52fa16038a63832 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md new file mode 100644 index 0000000000..e2c01c4343 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -0,0 +1,50 @@ +# Agent Note: Independent model and user skill invocation policy + +Status: implemented + +English | [中文](2026-07-28-skill-invocation-policy.zh.md) + +## Problem + +The skill registry originally treated discovery as a model catalog: `ctx.skills.list()` removed model-disabled skills, while `ctx.skills.get()` remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete from the model-filtered list and allowing every exact name through `get()`. + +The local parser also exposed the internal camel-case `disableModelInvocation` spelling as frontmatter. Supporting the established `disable-model-invocation` and `user-invocable` fields requires a durable domain representation without turning every possible YAML key into an untyped cross-package contract. + +## Decision + +`SkillSummary` carries an optional typed `invocation: SkillInvocationPolicy` object. Its current fields are `disableModelInvocation?: boolean` and `userInvocable?: boolean`; future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist. The local provider still parses frontmatter as an open `Record`, then projects only recognized fields into the typed policy. + +`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` excludes only `disableModelInvocation: true`; `isUserInvocable(skill)` excludes only `userInvocable: false`. Missing fields preserve model and user invocation. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. + +The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. The old camel-case spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. + +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce `isUserInvocable`, so a user-only skill is visible and loadable there even when it is absent from model discovery. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. + +These rules permit all four combinations: + +| Policy | Model surface | User surface | +|---|---|---| +| defaults | included | included | +| `userInvocable: false` | included | excluded | +| `disableModelInvocation: true` | excluded | included | +| both restrictive values | excluded | excluded | + +This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [TUI skill slash command](2026-07-21-tui-skill-slash-command.md). + +## Alternatives considered + +**Store all frontmatter in a generic `Map` and read string keys in `isModelInvocable` / `isUserInvocable`.** Rejected because misspelled keys, non-boolean values, and consumer-specific coercion would cross package seams without type checking. The parser boundary remains open; the domain model is deliberately typed and narrow. + +**Keep `ctx.skills.list()` model-filtered and add a second user list.** Rejected because discovery, duplicate resolution, caching, and ordering are surface-neutral work. One complete catalog plus explicit predicates prevents those mechanisms from drifting while making each consumer's policy visible at its boundary. + +**Enforce invocation policy inside `ctx.skills.get()`.** Rejected because `get()` cannot know whether its caller is a model tool, a human command, or trusted orchestration. Filtering there would also make the both-disabled quadrant impossible to inspect or administer. + +**Treat camel-case frontmatter as an alias.** Rejected because the external format is the kebab-case Claude skills contract and the repository has no released compatibility obligation. Failing loud avoids silently preserving a nonstandard spelling. + +**Add a browser-side direct skill invocation RPC.** Rejected for this change because the existing browser flow inserts a model reference rather than a loaded instruction body. Its correct policy is therefore the intersection; a direct user-loading surface needs its own wire and logging design. + +## Consequences + +Providers and runtime registrations expose a small typed invocation contract, while local YAML remains extensible. Every new discovery consumer must consciously choose the model predicate, the user predicate, their intersection, or trusted unfiltered access; forgetting that choice is now review-visible rather than hidden in registry behavior. + +The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. TUI unit coverage exercises all four policy quadrants, and the real Loader/PTY smoke invokes a user-only local skill through `/skill:`. Registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection. diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md new file mode 100644 index 0000000000..d5f9bac59f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 模型与用户彼此独立的 skill(技能)调用策略 + +Status: implemented + +[English](2026-07-28-skill-invocation-policy.md) | 中文 + +## 问题 + +skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会移除禁止模型调用的 skill,而 `ctx.skills.get()` 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载,却无法表示与 Claude 兼容的四类 skill:仅向用户公开、仅向模型公开、同时向两者公开,或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 `get()` 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。 + +本地解析器还将内部使用的驼峰式 `disableModelInvocation` 拼写暴露为 frontmatter。若要支持既有的 `disable-model-invocation` 和 `user-invocable` 字段,需要建立持久的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。 + +## 决策 + +`SkillSummary` 包含一个可选且类型明确的 `invocation: SkillInvocationPolicy` 对象。该对象当前提供 `disableModelInvocation?: boolean` 和 `userInvocable?: boolean` 两个字段;未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型。本地提供方仍将 frontmatter 解析为开放的 `Record`,然后只把已识别字段投影到类型化策略中。 + +`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 只排除 `disableModelInvocation: true` 的 skill;`isUserInvocable(skill)` 只排除 `userInvocable: false` 的 skill。字段缺失时,模型和用户均可调用。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。 + +本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。旧的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。 + +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 执行 `isUserInvocable`,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 + +这些规则允许以下四种组合: + +| 策略 | 模型侧接口 | 用户侧接口 | +|---|---|---| +| 默认值 | 包含 | 包含 | +| `userInvocable: false` | 包含 | 排除 | +| `disableModelInvocation: true` | 排除 | 包含 | +| 两个限制值同时存在 | 排除 | 排除 | + +该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。 + +## 曾考虑的替代方案 + +**将所有 frontmatter 存入通用 `Map`,并在 `isModelInvocable` / `isUserInvocable` 中读取字符串键。** 不予采纳,因为拼写错误的键、非布尔值以及各消费方自行采用的类型转换都会越过包边界,且无法获得类型检查。解析器边界仍保持开放;领域模型则有意采用类型明确的窄接口。 + +**保持 `ctx.skills.list()` 仅返回允许模型调用的 skill,并另增一份用户列表。** 不予采纳,因为发现、重复项解析、缓存和排序都是与调用接口无关的工作。采用一份完整目录和显式判定函数,可以避免这些机制逐渐分化,并在各消费方边界清楚呈现其策略。 + +**在 `ctx.skills.get()` 内执行调用策略。** 不予采纳,因为 `get()` 无法判断调用方是模型工具、人类命令还是可信编排逻辑。在此处过滤还会使两个接口均禁止调用的组合无法被检查或管理。 + +**将驼峰式 frontmatter 作为别名处理。** 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 契约,而本仓库尚未发布,无需承担兼容义务。快速失败可以避免暗中保留不符合标准的拼写。 + +**增加由浏览器端直接调用 skill 的 RPC。** 本次改动不予采纳,因为现有浏览器流程插入的是模型引用,而非已经加载的指令正文。因此,该流程应当取模型与用户调用策略的交集;直接由用户加载的接口需要单独设计协议与日志记录方式。 + +## 后果 + +提供方与运行时注册对外提供小而类型明确的调用契约,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。 + +无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。TUI 单元测试覆盖全部四种策略组合,真实 Loader/PTY 冒烟测试则通过 `/skill:` 调用仅允许用户调用的本地 skill。注册表、本地解析器、模型工具和 API 代理测试覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b89a49529c..b6989b0ffc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1187,7 +1187,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:113`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1207,7 +1207,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:42`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1666,7 +1666,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:20`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:25`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 75464938b0..cbaa6af43a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1434,11 +1434,12 @@ registerProvider(provider: SkillProvider): () => void register(skill: SkillRegistration): () => void /** - * List model-invocable skill summaries for a workspace. Lookup options and - * provider candidates are readonly same-process values borrowed throughout - * discovery. + * List invocation-neutral skill summaries for a workspace. Consumers apply + * model or user invocation policy at their operational boundary. Lookup + * options and provider candidates are readonly same-process values borrowed + * throughout discovery. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries, excluding skills disabled for model invocation. + * @returns all sorted winning summaries. */ async list(options: SkillLookupOptions = {}): Promise @@ -1455,7 +1456,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise