Merge latest master into skill catalog hot refresh

This commit is contained in:
Tianyi Cui
2026-07-28 01:10:33 +08:00
1109 changed files with 33736 additions and 20360 deletions

View File

@@ -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: 0e25e5a964902d84edbd2ca53f4e63b5f9c21065
README.zh.md: 19b1091ec08eec9f844f339cdd0226309735ca09
README.md: 1fe545f15c12a6b540b1feaa7b612ae27c1f4f56
README.zh.md: 15911b45c229f41ed2ebca4db22a2050b2d71431

View File

@@ -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
@@ -60,7 +60,7 @@ Indirectly, through `dsh-tool-skill`, which renders this provider's invocable na
#### KV Cache effect
Watcher invalidation can cause the named consumer to append a replacement catalog after the reusable session prefix. Body-only edits leave the catalog digest unchanged.
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

View File

@@ -4,7 +4,7 @@
`ctx.skills` 注册表的本地文件系统提供方。
该包实现一个 skill 来源。它扫描本地项目、自定义和用户 skill 根,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;会话前缀目录和面向模型的加载器工具仍位于 `@deepseek-ai/dsh-tool-skill`。
该包实现一个 skill 来源。它扫描本地项目、自定义和用户 skill 根,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久会话目录和面向模型的加载器工具仍位于 `@deepseek-ai/dsh-tool-skill`。
## 插件
@@ -60,7 +60,7 @@ Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 M
#### KV 缓存影响
watcher 触发的失效可促使指定的消费方在可复用会话前缀之后追加替换目录。仅涉及正文的编辑不会改变目录 digest。
watcher 触发的失效可促使指定的消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。
## 已知限制与待完成工作

View File

@@ -37,6 +37,7 @@ 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'
export const inject = ['skills']
@@ -61,6 +62,8 @@ export interface Config {
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
}
export const Config: Schema<Config> = z.object({
@@ -73,6 +76,7 @@ export const Config: Schema<Config> = z.object({
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(),
})
interface SkillRoot {
@@ -81,6 +85,7 @@ interface SkillRoot {
rank: number
skipSystem?: boolean
projectRoot?: string
trustedHost?: boolean
}
interface SkillRootEntry {
@@ -132,12 +137,15 @@ export class LocalSkillProvider implements SkillProvider {
private readonly agentsHome: string
private readonly customSkillDirs: string[]
private readonly watchManager: SkillWatchManager
private readonly bundledSkillDir: string | undefined
constructor(private readonly ctx: Context, config: Config = {}) {
this.dshHome = resolveDshHome(config.dshHome)
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config))
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
}
/**
@@ -165,7 +173,7 @@ export class LocalSkillProvider implements SkillProvider {
*/
async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
const locator = candidate.locator as LocalLocator
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal)
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')
if (parsed === undefined) return undefined
return {
name: parsed.name,
@@ -207,6 +215,9 @@ export class LocalSkillProvider implements SkillProvider {
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
...this.bundledSkillDir === undefined
? []
: [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }],
)
return roots
}
@@ -622,7 +633,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
? { path: entry.path, directory: root.path }
: undefined
if (locator === undefined) continue
const parsed = await parseSkillFile(locator.path, ctx)
const parsed = await parseSkillFile(locator.path, ctx, undefined, root.trustedHost === true)
if (parsed === undefined) continue
skills.push({
name: parsed.name,
@@ -643,7 +654,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs)
return await listSkillRootEntriesFromNode(root, ctx)
}
@@ -685,8 +696,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom
return result
}
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
const raw = await readSkillText(ctx, path, signal)
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise<ParsedSkill | undefined> {
const raw = await readSkillText(ctx, path, signal, trustedHost)
signal?.throwIfAborted()
if (raw === undefined) {
return undefined
@@ -726,10 +737,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined {
return ctx.get('fs')
}
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise<string | undefined> {
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<string | undefined> {
signal?.throwIfAborted()
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
if (fs !== undefined && !trustedHost) {
return await readSkillTextFromFileSystem(ctx, fs, path, signal)
}
try {

View File

@@ -170,15 +170,23 @@ describe('LocalSkillProvider', () => {
await writeSkill(custom, 'custom-only', 'custom only')
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
const bundled = await tempDir('skill-bundled')
await writeSkill(bundled, 'bundled-only', 'bundled skill')
await writeSkill(bundled, 'same', 'bundled skill')
const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled })
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
['custom-only', 'custom only'],
['same', 'project dsh skill'],
expect(skills.map(skill => skill.name)).toEqual([
'bundled-only',
'custom-only',
'same',
])
expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only')
expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill')
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' })
expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.')
const noGit = await tempDir('skill-no-git')
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
@@ -356,6 +364,20 @@ describe('LocalSkillProvider', () => {
])
expect(fs.listDirCalls).toBeGreaterThan(0)
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
const bundled = await tempDir('skill-backend-bundled')
await writeSkill(bundled, 'bundled-host', 'Bundled host skill')
const bundledCtx = new Context()
await bundledCtx.plugin(TestFileSystem)
const bundledFs = bundledCtx.fs as TestFileSystem
bundledFs.failResolvePaths.add(bundled)
await bundledCtx.plugin(SkillService)
await bundledCtx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
bundledSkillDir: bundled,
})
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
})
it('reports transient root reads as incomplete without caching an empty catalog', async () => {
@@ -695,17 +717,22 @@ describe('LocalSkillProvider', () => {
it('uses default home root resolution without exposing builtin skills', async () => {
const previousDshHome = process.env.DSH_HOME
const previousAgentsHome = process.env.DSH_AGENTS_HOME
const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR
const envHome = await tempDir('skill-env-home')
try {
process.env.DSH_HOME = join(envHome, '.dsh')
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
const bundled = join(envHome, 'bundled-skills')
process.env.DSH_BUNDLED_SKILL_DIR = bundled
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill')
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
process.env.DSH_HOME = join(envHome, 'empty-dsh')
delete process.env.DSH_BUNDLED_SKILL_DIR
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
const empty = new Context()
await empty.plugin(SkillService)
@@ -725,6 +752,11 @@ describe('LocalSkillProvider', () => {
} else {
process.env.DSH_AGENTS_HOME = previousAgentsHome
}
if (previousBundledSkillDir === undefined) {
delete process.env.DSH_BUNDLED_SKILL_DIR
} else {
process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir
}
}
})
})

View File

@@ -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: f4f933576c60c8d750e3bd8eb0183ccb4b37e2da
README.zh.md: baa0d0ca43a8da3e3177d5c05437501a1fbe1735
README.md: 54362bd3a0b8bcbf8161ce45f13535b49eab18a1
README.zh.md: 8f15a44c815ffa687d01f8fc8f6070a8f1d28195

View File

@@ -43,15 +43,15 @@ Definitions remain progressively loaded. `get()` asks the winning provider for t
## 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 initial session prefix or durable replacement catalog messages 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 prompt effect. The named consumer owns initial prefix composition and append-only catalog replacements after invalidation.
No direct prompt effect. The named consumer owns the durable initial catalog and append-only replacements after invalidation.
## Known Limitations and Deferred Work

View File

@@ -43,15 +43,15 @@
## 消费方边界
注册表不渲染模型指引,也不注册面向模型的工具。[`@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 缓存影响
不直接影响提示词。指定的消费方负责初始前缀组装,以及失效后的仅追加式目录替换。
不直接影响提示词。指定的消费方负责持久初始目录,以及失效后的仅追加式目录替换。
## 已知限制与待完成工作

View File

@@ -28,7 +28,7 @@ export function isSkillName(name: string): boolean {
}
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =

View File

@@ -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: 7cbb2bef77bd32f188a4d3068a287fbee31b9371
README.zh.md: fe17e2e7080151e9bd41d4e0b96764719b27baa5
README.md: 53f1494b8d0a348910ff7fb3965ae1798fc3b3b8
README.zh.md: 1993c274beed72da5741a3c35ae2c10ed963d9aa

View File

@@ -8,13 +8,13 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
## Catalog lifecycle
The plugin contributes the initial user-role `<system-reminder>` catalog through `agent/session-prefix`. Before every later model step it observes `ctx.skills.snapshot()` and computes a digest over exact `skill` tool visibility plus the ordered rendered `name` and `description` entries. It resolves skills for the calling session's cwd and lists only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog.
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.
When that digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog and metadata `{ kind: 'skill-catalog', version: 1, digest }`. An empty replacement explicitly retires names from earlier catalogs. The latest still-visible metadata supplies the comparison baseline across replay or plugin reload. If compaction shadows that replacement, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
The 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 [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of the initial message; the [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns durable replacements.
`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`
@@ -32,11 +32,11 @@ Tool execution does not call `agent.inject()`. Its freshly loaded result is alre
## Model Experience
### Session prefix
### Session catalog
#### 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 initial catalog is a user-role session prefix. 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.
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
@@ -58,7 +58,7 @@ Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; n
#### KV Cache effect
The initial catalog remains prefix-stable. Dynamic changes are append-only history after that prefix, so existing reusable tokens stay intact while the replacement and later turns form a new suffix.
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

View File

@@ -8,13 +8,13 @@
## 目录生命周期
该插件通过 `agent/session-prefix` 提供初始的用户角色 `<system-reminder>` 目录。之后每个模型步骤开始前,它都会观察 `ctx.skills.snapshot()`,并针对 `skill` 工具的精确可见性,以及按顺序渲染的 `name` 和 `description` 条目计算 digest。它根据调用会话的 cwd 解析 skill,且只列出这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将该步骤的 abort signal 转发给发现,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
该 digest 变化时,`agent.inject()` 会记录一条持久的用户角色消息,其中包含完整替换目录和元数据 `{ kind: 'skill-catalog', version: 1, digest }`。空替换会显式停用较早目录中的名称。恢复后,最新且仍可见的元数据充当比较基线;若压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以会话前缀为基线,并在必要时重新发布当前完整目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
如果最初没有模型可调用 skill,则省略目录;如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了初始消息仅存在于请求中、记录于 header 的生命周期;[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久替换。
`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
## 工具:`skill`
@@ -32,11 +32,11 @@
## 模型体验
### 会话前缀
### 会话目录
#### 模型所见
如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。初始目录是用户角色会话前缀。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会在第一个请求之前收到下方目录模板,它是一条持久的用户角色消息,其中包含每个已排序 skill 的一条数据依赖条目。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
##### Skill 目录模板
@@ -58,7 +58,7 @@ If the user names a skill, or the task clearly matches a skill's description, ca
#### KV 缓存影响
初始目录保持前缀稳定。动态变更作为该前缀之后的仅追加历史,因此现有可重用 token 保持不变,替换消息和后续轮次则形成新的后缀。
初始持久目录追加在现有可重用前缀之后。动态变更作为该目录之后的仅追加历史,因此较早的可重用 token 保持不变,每条新追加的目录和后续轮次都会形成新的后缀。新建或恢复的实例如果 digest 发生变化,可能会从新追加的目录位置起影响缓存重用。
### 工具 schema

View File

@@ -1,5 +1,5 @@
/**
* Session-prefix skill catalog and model-facing `skill` loader tool.
* Durable session skill catalog and model-facing `skill` loader tool.
*
* @module @deepseek-ai/dsh-tool-skill
*/
@@ -7,17 +7,17 @@
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 { assertNever, type Message } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = ['agents', 'tools', 'skills']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
const CATALOG_META_KIND = 'skill-catalog'
const CATALOG_META_VERSION = 1
const CATALOG_ENTRIES_START = '<available_skills>\n'
const CATALOG_ENTRIES_END = '\n</available_skills>'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
/** Model-facing skill catalog configuration. */
@@ -33,14 +33,13 @@ export const Config: z<Config> = z.object({
/**
* Register the model-facing skill loader and its visibility-matched
* session-prefix catalog. The catalog is emitted only when the calling agent
* durable session catalog. The catalog is emitted only when the calling agent
* resolves this plugin's exact tool registration; a restriction or scoped
* same-name shadow therefore removes both the schema and its call guidance.
*/
export function apply(ctx: Context, config: Config = {}): void {
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
const baselineBySession = new WeakMap<object, string>()
const skillTool = defineTool({
name: 'skill',
@@ -121,41 +120,24 @@ export function apply(ctx: Context, config: Config = {}): void {
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
: { skills: [], complete: true }
const rest = await next()
signal.throwIfAborted()
if (!snapshot.complete) return rest
const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength)
baselineBySession.set(agent.session, digest)
if (!toolVisible || snapshot.skills.length === 0) return rest
return [renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength), ...rest]
})
ctx.on('agent/pre-step', async (agent, _turn, _step, signal) => {
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
: { skills: [], complete: true }
signal.throwIfAborted()
if (!snapshot.complete) return
const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength)
const effective = latestVisibleCatalogDigest(agent) ?? baselineBySession.get(agent.session)
if (effective === digest) return
if (effective === undefined && snapshot.skills.length === 0) {
baselineBySession.set(agent.session, digest)
return
}
agent.inject(
renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength).content,
{
source: PLUGIN_SOURCE,
meta: { kind: CATALOG_META_KIND, version: CATALOG_META_VERSION, digest },
},
)
const digest = catalogDigest(snapshot.skills, catalogDescriptionMaxLength)
const history = catalogHistory(agent)
if (history.visibleDigest === digest) return
if (!history.published && snapshot.skills.length === 0) return
const catalog = history.published
? renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength)
: renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength)
agent.inject({
content: catalog.content,
source: PLUGIN_SOURCE,
})
})
}
@@ -258,38 +240,44 @@ function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: numb
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
}
function catalogDigest(toolVisible: boolean, skills: SkillSummary[], descriptionMaxLength: number): string {
function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
}
function digestCatalogEntries(entries: string): string {
return createHash('sha256')
.update(JSON.stringify({
toolVisible,
entries: renderCatalogEntries(skills, descriptionMaxLength),
}))
.update(entries)
.digest('hex')
}
function latestVisibleCatalogDigest(agent: Agent): string | undefined {
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 (!visible.has(event.seq)
|| event.type !== 'user/message'
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== name) continue
const meta = event.data.meta
if (!isRecord(meta)
|| meta.kind !== CATALOG_META_KIND
|| meta.version !== CATALOG_META_VERSION
|| typeof meta.digest !== 'string') continue
return meta.digest
const digest = catalogContentDigest(event.data.content)
if (digest === undefined) continue
published = true
if (visible.has(event.seq)) return { visibleDigest: digest, published }
}
return undefined
return { published }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
function catalogContentDigest(content: Message['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
return digestCatalogEntries(text.slice(entriesStart, end))
}
function catalogDescription(value: string, maxLength: number): string {

View File

@@ -37,7 +37,25 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
}
function agentForCwd(cwd: string): Agent {
return { session: { header: { cwd } } } as unknown as Agent
const id = SessionId(`tool-skill-${cwd}`)
const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd })
return {
ctx: new Context(),
id,
options: {},
session,
status: 'idle',
acceptsNextStep: false,
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
@@ -46,19 +64,15 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
options: {},
session,
status: 'running',
acceptsNextStep: false,
ctx: new Context(),
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...(options?.meta === undefined ? {} : { meta: options.meta }),
}, { surfaceOp: 'append' })
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -72,26 +86,30 @@ function openMessageTurn(session: Session, turn = 1): void {
}, { surfaceOp: 'append' })
}
async function firePreStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, new AbortController().signal)
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 catalogUpdates(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
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 === '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)
}
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, signal,
() => Promise.resolve(empty),
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, signal)
return agent.session.deriveMessages()
}
async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> {
@@ -131,7 +149,7 @@ describe('dsh-tool-skill', () => {
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
})
it('forwards the session-prefix abort signal to skill discovery', async () => {
it('forwards the step abort signal to skill discovery', async () => {
const home = await tempDir('tool-prefix-signal')
const ctx = await setup(home)
let seenSignal: AbortSignal | undefined
@@ -152,7 +170,7 @@ describe('dsh-tool-skill', () => {
expect(seenSignal).toBe(controller.signal)
})
it('contributes a stable name-and-description catalog through the session prefix', async () => {
it('injects a stable durable name-and-description catalog at the first step', async () => {
const home = await tempDir('tool-catalog')
const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
ctx.skills.register({
@@ -171,10 +189,9 @@ describe('dsh-tool-skill', () => {
provider: 'runtime',
content: 'A body.',
})
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
...await next(),
])
ctx.on('agent/step', (agent) => {
agent.inject({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } })
})
const prefix = await composePrefix(ctx, '/workspace')
@@ -207,7 +224,7 @@ describe('dsh-tool-skill', () => {
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
})
it('does not contribute a session-prefix message when no skills are available', async () => {
it('does not inject a catalog when no skills are available', async () => {
const home = await tempDir('tool-empty-catalog')
const ctx = await setup(home)
@@ -233,25 +250,26 @@ describe('dsh-tool-skill', () => {
const agent = sessionAgent(session)
openMessageTurn(session)
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
await composePrefixForAgent(ctx, agent)
expect(catalogMessages(session)).toEqual([])
failing = false
ctx.skills.invalidateProvider(provider)
await firePreStep(ctx, agent, 1, 1)
await fireStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
expect(catalogMessages(session)).toEqual([])
})
it('records an empty baseline when pre-step runs before prefix composition', async () => {
const home = await tempDir('tool-empty-pre-step')
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-pre-step'))
const session = new Session(SessionId('empty-step'))
const agent = sessionAgent(session)
openMessageTurn(session)
await firePreStep(ctx, agent, 1, 1)
await firePreStep(ctx, agent, 1, 2)
await fireStep(ctx, agent, 1, 1)
await fireStep(ctx, agent, 1, 2)
expect(catalogUpdates(session)).toEqual([])
expect(catalogMessages(session)).toEqual([])
})
it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
@@ -268,8 +286,8 @@ describe('dsh-tool-skill', () => {
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(1)
const disposeSecond = ctx.skills.register({
name: 'second-skill',
@@ -277,26 +295,25 @@ describe('dsh-tool-skill', () => {
source: 'runtime',
content: 'Second body.',
})
await firePreStep(ctx, agent, 1, 2)
await fireStep(ctx, agent, 1, 2)
const addition = catalogUpdates(session)[0]
const addition = catalogMessages(session)[1]
if (addition?.type !== 'user/message') throw new Error('expected catalog addition')
expect(addition.data.meta).toMatchObject({ kind: 'skill-catalog', version: 1 })
expect(JSON.stringify(addition.data.content)).toContain('first-skill')
expect(JSON.stringify(addition.data.content)).toContain('second-skill')
disposeSecond()
disposeFirst()
await firePreStep(ctx, agent, 1, 3)
await fireStep(ctx, agent, 1, 3)
const removal = catalogUpdates(session)[1]
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')
})
it('resumes from the latest valid visible catalog metadata', async () => {
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({
@@ -309,28 +326,33 @@ describe('dsh-tool-skill', () => {
const agent = sessionAgent(session)
openMessageTurn(session)
session.append('user/message', {
content: [{ type: 'text', text: 'old catalog' }],
content: catalogContent(['- `old-skill`: Old skill']),
source: { kind: 'plugin', plugin: 'tool-skill' },
meta: { kind: 'skill-catalog', version: 1, digest: 'old-digest' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: 'malformed metadata' }],
content: [{ type: 'text', text: 'missing catalog markers' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
meta: { kind: 'skill-catalog', version: 1, digest: 42 },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: 'non-record metadata' }],
content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
source: { kind: 'plugin', plugin: 'tool-skill' },
meta: [],
}, { surfaceOp: 'append' })
await firePreStep(ctx, agent, 1, 1)
await fireStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toHaveLength(4)
expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('resumed-skill')
expect(catalogMessages(session)).toHaveLength(6)
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
})
it('re-establishes a replacement catalog after compaction shadows its metadata', async () => {
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({
@@ -343,27 +365,20 @@ describe('dsh-tool-skill', () => {
const agent = sessionAgent(session)
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
ctx.skills.register({
name: 'second-skill',
description: 'Second skill',
source: 'runtime',
content: 'Second body.',
})
await firePreStep(ctx, agent, 1, 1)
const replacement = catalogUpdates(session)[0]
if (replacement === undefined) throw new Error('expected replacement catalog')
const initial = catalogMessages(session)[0]
if (initial === undefined) throw new Error('expected initial catalog')
session.append('user/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start: replacement.seq, end: replacement.seq },
sourceEventSeqs: [replacement.seq],
surfaceOp: { op: 'replace', start: initial.seq, end: initial.seq },
sourceEventSeqs: [initial.seq],
})
await firePreStep(ctx, agent, 1, 2)
await fireStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toHaveLength(2)
expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('second-skill')
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 () => {
@@ -377,8 +392,8 @@ describe('dsh-tool-skill', () => {
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description')
await writeSkill(root, 'body-skill', 'Stable description', 'Second body.')
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(1)
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -416,9 +431,9 @@ describe('dsh-tool-skill', () => {
},
})
disposeStable()
await firePreStep(ctx, agent, 1, 1)
await fireStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
expect(catalogMessages(session)).toHaveLength(1)
})
it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
@@ -432,9 +447,10 @@ describe('dsh-tool-skill', () => {
scope.ctx.tools.restrict({ deny: ['skill'] })
expect(ctx.tools.get('skill', agent)).toBeUndefined()
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
await firePreStep(ctx, agent, 1, 1)
expect(catalogUpdates(session)).toEqual([])
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()
})