feat(web): split context injection into producer-declared forms
Injected context reached the transcript as one anonymous shape whose expanded body was the whole message serialized as inline JSON, so the escaping collapsed the only readable part — the model-facing prose — into a single line. `MessageSource` gains an optional producer-declared `form`, a small semantic vocabulary of information shapes independent of `kind`: kind says who produced the context, form says what shape it is, so several producers may share one presentation. Two values ship. `instructions` (workspace-context) lists the reconciled files above their text and keeps the `<system-reminder>` framing verbatim, because the framing is part of what the model read. `catalog` moves dsh-tool-skill off the shared plugin kind onto a `skill-catalog` source carrying the published name/description entries, and the body lists those instead of re-parsing `<available_skills>` out of the prose. Catalog identity moves with it: the republish digest now covers the durable entries, deleting the text-slicing that recovered them from a logged message. Everything else renders the opaque body — the model-facing text with its real line breaks, then the remaining provenance as fields. That is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here, which is why the classification lives in the durable source rather than a client-side table keyed by producer.
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/tool-skill/README.md
|
||||
README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce
|
||||
README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3
|
||||
README.md: deccda0ece1ffe2cbdb576a8af3801f28695d8d5
|
||||
README.zh.md: 67ec0d32759c5768af32b5a505e7a080e19eb396
|
||||
|
||||
@@ -10,7 +10,7 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message. 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.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
|
||||
|
||||
该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `<system-reminder>` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见的 `skill-catalog` 消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
|
||||
|
||||
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
|
||||
|
||||
|
||||
@@ -22,9 +22,37 @@ export const name = 'tool-skill'
|
||||
export const inject = ['agents', 'tools', 'skills']
|
||||
|
||||
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
|
||||
const CATALOG_ENTRIES_START = '<available_skills>\n'
|
||||
const CATALOG_ENTRIES_END = '</available_skills>'
|
||||
const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
|
||||
/**
|
||||
* Durable provenance for one published session skill catalog. The catalog is a
|
||||
* `catalog`-form context, so it records the entries it published beside the
|
||||
* model-facing prose: a consumer presenting the list must not re-parse the
|
||||
* `<available_skills>` block, whose framing exists for the model.
|
||||
*/
|
||||
export interface SkillCatalogSource {
|
||||
readonly kind: 'skill-catalog'
|
||||
readonly form: 'catalog'
|
||||
/** Marks a replacement catalog rather than this session's first publication. */
|
||||
readonly update?: true
|
||||
/** Exactly the entries this message published, in catalog order. */
|
||||
readonly entries: readonly { readonly name: string; readonly description: string }[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
'skill-catalog': SkillCatalogSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
|
||||
function catalogSourceEntries(
|
||||
skills: SkillSummary[],
|
||||
descriptionMaxLength: number,
|
||||
): SkillCatalogSource['entries'] {
|
||||
return skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: catalogDescription(skill.description, descriptionMaxLength),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Model-facing skill catalog configuration. */
|
||||
export interface Config {
|
||||
@@ -142,13 +170,14 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
signal.throwIfAborted()
|
||||
if (!snapshot.complete) return
|
||||
const skills = snapshot.skills.filter(isModelInvocable)
|
||||
const digest = catalogDigest(skills, catalogDescriptionMaxLength)
|
||||
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
|
||||
const digest = digestCatalogEntries(entries)
|
||||
const history = catalogHistory(agent)
|
||||
if (history.visibleDigest === digest) return
|
||||
if (!history.published && skills.length === 0) return
|
||||
const catalog = history.published
|
||||
? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
|
||||
: renderCatalogMessage(skills, catalogDescriptionMaxLength)
|
||||
? renderCatalogUpdate(entries)
|
||||
: renderCatalogMessage(entries)
|
||||
agent.inject(catalog)
|
||||
})
|
||||
}
|
||||
@@ -199,8 +228,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB
|
||||
}
|
||||
}
|
||||
|
||||
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
|
||||
const entries = renderCatalogEntries(skills, descriptionMaxLength)
|
||||
function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage {
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -209,20 +237,23 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
...renderCatalogEntries(entries),
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: PLUGIN_SOURCE,
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
|
||||
const entries = renderCatalogEntries(skills, descriptionMaxLength)
|
||||
const availability = skills.length === 0
|
||||
function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage {
|
||||
const availability = entries.length === 0
|
||||
? [
|
||||
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
|
||||
]
|
||||
@@ -237,28 +268,36 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe
|
||||
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
...renderCatalogEntries(entries),
|
||||
'</available_skills>',
|
||||
'',
|
||||
...availability,
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: PLUGIN_SOURCE,
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
|
||||
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
/** Model-facing catalog lines, projected from the same entries the source records. */
|
||||
function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
|
||||
return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`)
|
||||
}
|
||||
|
||||
function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
|
||||
return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
|
||||
}
|
||||
|
||||
function digestCatalogEntries(entries: string): string {
|
||||
/**
|
||||
* Catalog identity over the durable entry list rather than the rendered prose.
|
||||
* The entries are what changes; the surrounding `<system-reminder>` framing is
|
||||
* written for the model and must not decide whether a republish is needed.
|
||||
*/
|
||||
function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
|
||||
const canonical = entries.map(entry => `${entry.name}\u0000${entry.description}`).join('\n')
|
||||
return createHash('sha256')
|
||||
.update(entries)
|
||||
.update(canonical)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
@@ -270,30 +309,14 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
|
||||
// The loop bounds prove the read-only event view contains this index.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue
|
||||
const digest = catalogContentDigest(event.data.content)
|
||||
if (digest === undefined) continue
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
|
||||
const digest = digestCatalogEntries(event.data.source.entries)
|
||||
published = true
|
||||
if (visible.has(event.seq)) return { visibleDigest: digest, published }
|
||||
}
|
||||
return { published }
|
||||
}
|
||||
|
||||
function catalogContentDigest(content: UserMessage['content']): string | undefined {
|
||||
if (content.length !== 1 || content[0]?.type !== 'text') return undefined
|
||||
const text = content[0].text
|
||||
const start = text.indexOf(CATALOG_ENTRIES_START)
|
||||
if (start === -1) return undefined
|
||||
const entriesStart = start + CATALOG_ENTRIES_START.length
|
||||
const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
|
||||
if (end === -1) return undefined
|
||||
const renderedEntries = text.slice(entriesStart, end)
|
||||
const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
|
||||
return digestCatalogEntries(entries)
|
||||
}
|
||||
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
|
||||
@@ -94,8 +94,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number):
|
||||
|
||||
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
|
||||
return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'dsh-tool-skill')
|
||||
&& event.data.source.kind === 'skill-catalog')
|
||||
}
|
||||
|
||||
function catalogContent(entries: string[]): Message['content'] {
|
||||
@@ -215,7 +214,15 @@ describe('dsh-tool-skill', () => {
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [
|
||||
{ name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' },
|
||||
{ name: 'model-only-skill', description: 'Model-only skill.' },
|
||||
{ name: 'z-skill', description: 'Long description Long description Long descript...' },
|
||||
],
|
||||
},
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
@@ -354,7 +361,12 @@ describe('dsh-tool-skill', () => {
|
||||
expect(catalogMessages(session)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('resumes from the latest valid visible catalog content', async () => {
|
||||
it('resumes from the durable entries of the latest visible catalog', async () => {
|
||||
// Catalog identity moved onto `source.entries` when the catalog became a
|
||||
// `catalog`-form context: the model-facing prose no longer decides whether
|
||||
// a republish is needed, so a seeded message is recognized by its source
|
||||
// alone and malformed prose can no longer hide (or fake) a published
|
||||
// catalog. A foreign-sourced message is not this plugin's catalog at all.
|
||||
const home = await tempDir('tool-catalog-resume')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
@@ -367,30 +379,36 @@ describe('dsh-tool-skill', () => {
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: catalogContent(['- `old-skill`: Old skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
content: [{ type: 'text', text: 'prose a reader cannot rely on' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'old-skill', description: 'Old skill' }],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing catalog markers' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
|
||||
content: catalogContent(['- `resumed-skill`: Resumed skill']),
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toHaveLength(6)
|
||||
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
|
||||
// The seeded entries differ from the live snapshot, so one replacement
|
||||
// lands; the foreign-sourced lookalike neither counts as published nor
|
||||
// suppresses it.
|
||||
expect(catalogMessages(session)).toHaveLength(2)
|
||||
const latest = catalogMessages(session).at(-1)
|
||||
expect(latest?.data.source).toMatchObject({
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries: [{ name: 'resumed-skill', description: 'Resumed skill' }],
|
||||
})
|
||||
expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill')
|
||||
|
||||
// A second step over unchanged entries republishes nothing.
|
||||
await fireStep(ctx, agent, 1, 2)
|
||||
expect(catalogMessages(session)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('re-establishes the current catalog after compaction hides its durable message', async () => {
|
||||
|
||||
Reference in New Issue
Block a user