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:
creatixchu
2026-08-05 12:15:03 +08:00
parent e5d4ae72fc
commit 340c9ba576
51 changed files with 779 additions and 174 deletions
+64 -41
View File
@@ -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