Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md # docs/architecture.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/defensive-patterns.i18n.yaml # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/agent/tests/agent.spec.ts # packages/core/scope/src/scoped-events.generated.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal-session/src/index.ts # packages/goal/goal-session/tests/goal-session.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/goal/tests/projection.spec.ts # packages/goal/tool-goal/tests/tool-goal.spec.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts # scripts/gen-cordis-catalog.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 6b0af04a15bfda985be3e04868f18b9af702eae7
|
||||
README.zh.md: 4ca0ccd7734848d5774e92910e916bdf71c88c13
|
||||
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
|
||||
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`
|
||||
|
||||
@@ -22,9 +26,9 @@ Execution uses the calling agent's `session.header.cwd` so workspace-sensitive p
|
||||
|
||||
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance.
|
||||
|
||||
An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results.
|
||||
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.
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型的 skill 目录和 `skill` 工具。
|
||||
面向模型的 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`
|
||||
|
||||
@@ -18,21 +22,21 @@
|
||||
|---|---|---|
|
||||
| `name` | string(必填) | 可用 skill 列表中精确的 kebab-case skill 名称。 |
|
||||
|
||||
执行使用调用 agent 的 `session.header.cwd`,使工作区敏感提供方解析胜出 skill。成功调用返回规范 `{ name, provider, resourceBase?, content }`,排除目录 rank 和提供方内部机制;其 Native 渲染器产生一个文本结果,其中包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>`。
|
||||
执行使用调用 agent 的 `session.header.cwd`,使结果随工作区变化的提供方能够解析出胜出的 skill。成功调用返回规范形式的 `{ name, provider, resourceBase?, content }`,其中不包含目录排名和提供方内部机制;其 Native 渲染器会生成一个文本结果,其中包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>`。
|
||||
|
||||
资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL;脚本、参考资料和产物按需加载,结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。
|
||||
资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL;脚本、参考资料和资源文件按需加载,结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。
|
||||
|
||||
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。
|
||||
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
|
||||
|
||||
该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。
|
||||
工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 会话目录
|
||||
|
||||
#### 模型所见
|
||||
#### 模型看到的内容
|
||||
|
||||
如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。该目录是一条持久的用户角色消息。
|
||||
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
|
||||
|
||||
##### Skill 目录模板
|
||||
|
||||
@@ -50,31 +54,31 @@ If the user names a skill, or the task clearly matches a skill's description, ca
|
||||
|
||||
#### Token 影响
|
||||
|
||||
重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送目录 token。
|
||||
重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送初始目录 token。每次实际目录变更都会添加一条保留的完整替换消息。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加,位于现有可重用前缀之后。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从新追加的目录位置起影响缓存重用。
|
||||
初始持久目录追加在现有可重用前缀之后。动态变更作为该目录之后的仅追加历史,因此较早的可重用 token 保持不变,每条新追加的目录和后续轮次都会形成新的后缀。新建或恢复的实例如果 digest 发生变化,可能会从新追加的目录位置起影响缓存重用。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型所见
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到生成的 [`skill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见时,每次请求都有固定 schema 成本。
|
||||
工具可见时,每次请求都有固定的 schema token 开销。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
工具定义和可见性不变时,前缀稳定。遮蔽、限制或插件生命周期变更可能从该 schema 起使重用失效。
|
||||
|
||||
### 工具结果
|
||||
|
||||
#### 模型所见
|
||||
#### 模型看到的内容
|
||||
|
||||
成功调用使用下方结果模板,以及由提供方管理、目录、URL 或不透明的资源指引。
|
||||
成功调用使用下方结果模板,以及提供方管理的资源指引、目录资源指引、URL 资源指引或不透明资源指引。
|
||||
|
||||
##### Skill 结果模板
|
||||
|
||||
@@ -120,15 +124,15 @@ Load referenced resources only as needed.
|
||||
|
||||
#### Token 影响
|
||||
|
||||
已加载指令是取决于数据的工具结果 token,并在后续步骤中重新发送,直到压缩;不会制作重复的 `agent.inject()` 副本。
|
||||
已加载指令是取决于数据的工具结果 token,并在后续步骤中重新发送,直到压缩(compaction);不会制作重复的 `agent.inject()` 副本。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV 缓存条目失效。
|
||||
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
|
||||
#### 模型所见
|
||||
#### 模型看到的内容
|
||||
|
||||
无效或陈旧选择会精确返回 `Error: invalid skill name "<name>"`、`Error: skill "<name>" is unknown or no longer available` 或 `Error: skill "<name>" is not available for model invocation`。提供方抛出的查找文本取决于数据,并接收同一个 `Error: <message>` 包装层。
|
||||
|
||||
@@ -136,13 +140,15 @@ Load referenced resources only as needed.
|
||||
|
||||
只有失败调用会添加这些已保留 token。
|
||||
|
||||
#### KV 缓存影响
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV 缓存条目失效。
|
||||
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有上限描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。
|
||||
- **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。
|
||||
- **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill;只有目录描述会被截断。
|
||||
- **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。
|
||||
- **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分、流式或缓存内容句柄。
|
||||
- **加载是一次性文本**:远程提供方缓慢或 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,18 +4,27 @@
|
||||
* @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'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import {
|
||||
isModelInvocable,
|
||||
isSkillName,
|
||||
type SkillDefinition,
|
||||
type SkillSummary,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
export const name = 'tool-skill'
|
||||
export const inject = ['tools', 'skills']
|
||||
export const inject = ['agents', 'tools', 'skills']
|
||||
|
||||
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
|
||||
const CATALOG_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 {
|
||||
@@ -88,11 +97,19 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
}
|
||||
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal })
|
||||
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal }
|
||||
const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
|
||||
if (!summary) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
}
|
||||
if (!isModelInvocable(summary)) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
const skill = await ctx.skills.get(args.name, lookup)
|
||||
if (!skill) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
}
|
||||
if (skill.disableModelInvocation === true) {
|
||||
if (!isModelInvocable(skill)) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return {
|
||||
@@ -117,19 +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 })
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,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',
|
||||
@@ -196,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.
|
||||
// oxlint-disable-next-line typescript/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,11 +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 { 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'
|
||||
@@ -29,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
|
||||
}
|
||||
@@ -45,6 +46,8 @@ function agentForCwd(cwd: string): Agent {
|
||||
session,
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
@@ -55,6 +58,51 @@ 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: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
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 })
|
||||
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)
|
||||
}
|
||||
@@ -64,8 +112,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'],
|
||||
@@ -78,9 +126,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)
|
||||
@@ -104,7 +153,7 @@ describe('dsh-tool-skill', () => {
|
||||
const home = await tempDir('tool-prefix-signal')
|
||||
const ctx = await setup(home)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'signal-probe',
|
||||
async list(options) {
|
||||
seenSignal = options.signal
|
||||
@@ -113,7 +162,7 @@ describe('dsh-tool-skill', () => {
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
}))
|
||||
const controller = new AbortController()
|
||||
|
||||
await composePrefix(ctx, '/workspace', controller.signal)
|
||||
@@ -140,6 +189,20 @@ describe('dsh-tool-skill', () => {
|
||||
provider: 'runtime',
|
||||
content: 'A body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill.',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill.',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only body.',
|
||||
})
|
||||
ctx.on('agent/step', (agent) => {
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } }))
|
||||
})
|
||||
@@ -159,6 +222,7 @@ describe('dsh-tool-skill', () => {
|
||||
'',
|
||||
'<available_skills>',
|
||||
'- `a-skill`: Use {{placeholder}} <safely> & carefully.',
|
||||
'- `model-only-skill`: Model-only skill.',
|
||||
'- `z-skill`: Long description Long description Long descript...',
|
||||
'</available_skills>',
|
||||
'',
|
||||
@@ -179,25 +243,253 @@ describe('dsh-tool-skill', () => {
|
||||
expect(rendered).not.toContain('secret-source')
|
||||
expect(rendered).not.toContain('/secret/path')
|
||||
expect(rendered).not.toContain('Secret body')
|
||||
expect(rendered).not.toContain('user-only-skill')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
|
||||
})
|
||||
|
||||
it('does not inject a catalog when no skills are available', async () => {
|
||||
it('does not inject a catalog when no model-invocable skills are available', async () => {
|
||||
const home = await tempDir('tool-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only body.',
|
||||
})
|
||||
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
const agent = agentForCwd('/workspace')
|
||||
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
|
||||
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()
|
||||
})
|
||||
@@ -227,8 +519,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')
|
||||
})
|
||||
@@ -337,18 +630,94 @@ describe('dsh-tool-skill', () => {
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
const home = await tempDir('tool-errors')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only instructions.',
|
||||
})
|
||||
|
||||
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
|
||||
const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
|
||||
const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
|
||||
const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } })
|
||||
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(invalid.isError).toBe(true)
|
||||
expect(disabled.isError).toBe(true)
|
||||
expect(modelOnly.isError).toBe(false)
|
||||
const unknownBlock = unknown.content[0]
|
||||
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
|
||||
})
|
||||
|
||||
it('checks model policy before provider loading and rechecks the loaded definition', async () => {
|
||||
const home = await tempDir('tool-policy-before-load')
|
||||
const ctx = await setup(home)
|
||||
const getCalls: string[] = []
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'policy-probe',
|
||||
async list() {
|
||||
return [
|
||||
{
|
||||
name: 'denied-skill',
|
||||
description: 'Denied skill',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
provider: 'policy-probe',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'denied-skill',
|
||||
},
|
||||
{
|
||||
name: 'policy-race-skill',
|
||||
description: 'Policy race skill',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'policy-probe',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'policy-race-skill',
|
||||
},
|
||||
{
|
||||
name: 'vanishing-skill',
|
||||
description: 'Vanishing skill',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'policy-probe',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'vanishing-skill',
|
||||
},
|
||||
]
|
||||
},
|
||||
async get(candidate) {
|
||||
getCalls.push(candidate.name)
|
||||
if (candidate.name === 'vanishing-skill') return undefined
|
||||
return {
|
||||
...candidate,
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
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' } })
|
||||
const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } })
|
||||
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(raced.isError).toBe(true)
|
||||
expect(vanished.isError).toBe(true)
|
||||
expect(getCalls).toEqual(['policy-race-skill', 'vanishing-skill'])
|
||||
for (const result of [denied, raced]) {
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(block.text).toContain('is not available for model invocation')
|
||||
expect(block.text).not.toContain('Instructions must not be disclosed.')
|
||||
}
|
||||
const vanishedBlock = vanished.content[0]
|
||||
if (vanishedBlock?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user