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/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()
})