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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user