Merge latest master into skill catalog hot refresh
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
|
||||
README.md: 0e25e5a964902d84edbd2ca53f4e63b5f9c21065
|
||||
README.zh.md: 19b1091ec08eec9f844f339cdd0226309735ca09
|
||||
README.md: 1fe545f15c12a6b540b1feaa7b612ae27c1f4f56
|
||||
README.zh.md: 15911b45c229f41ed2ebca4db22a2050b2d71431
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 缓存影响
|
||||
|
||||
不直接影响提示词。指定的消费方负责初始前缀组装,以及失效后的仅追加式目录替换。
|
||||
不直接影响提示词。指定的消费方负责持久初始目录,以及失效后的仅追加式目录替换。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user