Merge remote-tracking branch 'github/master' into feat/agent-event-payload
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 {
|
||||
@@ -148,28 +176,29 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
signal.throwIfAborted()
|
||||
if (!snapshot.complete) return decision
|
||||
const skills = snapshot.skills.filter(isModelInvocable)
|
||||
const digest = catalogDigest(skills, catalogDescriptionMaxLength)
|
||||
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
|
||||
const digest = digestCatalogEntries(entries)
|
||||
const history = catalogHistory(agent)
|
||||
const existing = catalogMessage(decision.messages)
|
||||
if (history.visibleDigest === digest) {
|
||||
return existing === undefined
|
||||
? decision
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) }
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
|
||||
}
|
||||
if (existing !== undefined && catalogContentDigest(existing.content) === digest) return decision
|
||||
if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision
|
||||
if (!history.published && skills.length === 0) {
|
||||
return existing === undefined
|
||||
? decision
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) }
|
||||
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
|
||||
}
|
||||
const catalog = history.published
|
||||
? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
|
||||
: renderCatalogMessage(skills, catalogDescriptionMaxLength)
|
||||
? renderCatalogUpdate(entries)
|
||||
: renderCatalogMessage(entries)
|
||||
return {
|
||||
kind: 'enter',
|
||||
messages: existing === undefined
|
||||
? [...decision.messages, catalog]
|
||||
: decision.messages.map(message => message.id === existing.id ? catalog : message),
|
||||
: decision.messages.map(message => message.id === existing.message.id ? catalog : message),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -220,8 +249,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',
|
||||
@@ -230,20 +258,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.',
|
||||
]
|
||||
@@ -258,31 +289,70 @@ 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.
|
||||
* The pseudo-XML escaping belongs to this frame, not to the published fact, so it
|
||||
* is applied here and never stored. Names are `isSkillName`-validated and carry
|
||||
* no escapable character.
|
||||
*/
|
||||
function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
|
||||
return entries.map(entry => `- \`${entry.name}\`: ${escapeText(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 {
|
||||
// JSON per entry rather than a separator character: every separator is itself
|
||||
// a legal description character, so only quoting makes the boundary exact.
|
||||
const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
|
||||
return createHash('sha256')
|
||||
.update(entries)
|
||||
.update(canonical)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries of one durable catalog message, or undefined when the record is not a
|
||||
* usable catalog.
|
||||
*
|
||||
* `agent.session.events` may be a resumed, forked, or externally written seed,
|
||||
* and seed validation only guarantees a source object with a non-empty `kind`;
|
||||
* no per-kind field is checked there. An unreadable record is therefore treated
|
||||
* as "not this plugin's catalog" — the posture the replaced content digest had —
|
||||
* rather than throwing inside the step listener, which would fail every
|
||||
* subsequent turn of that session.
|
||||
*/
|
||||
function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
|
||||
const entries = (source as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries)) return undefined
|
||||
const readable: { name: string; description: string }[] = []
|
||||
for (const entry of entries as readonly unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) return undefined
|
||||
const { name, description } = entry as { name?: unknown; description?: unknown }
|
||||
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
|
||||
readable.push({ name, description })
|
||||
}
|
||||
return readable
|
||||
}
|
||||
|
||||
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
|
||||
const visible = new Set(agent.session.surface.nodes)
|
||||
const events = agent.session.events
|
||||
@@ -291,43 +361,31 @@ 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 entries = readCatalogEntries(event.data.source)
|
||||
if (entries === undefined) continue
|
||||
const digest = digestCatalogEntries(entries)
|
||||
published = true
|
||||
if (visible.has(event.seq)) return { visibleDigest: digest, published }
|
||||
}
|
||||
return { published }
|
||||
}
|
||||
|
||||
function catalogMessage(messages: readonly UserMessage[]): UserMessage | undefined {
|
||||
return messages.find(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === PLUGIN_SOURCE.plugin
|
||||
&& catalogContentDigest(message.content) !== undefined)
|
||||
}
|
||||
|
||||
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 catalogMessage(
|
||||
messages: readonly UserMessage[],
|
||||
): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.source.kind !== 'skill-catalog') continue
|
||||
const entries = readCatalogEntries(message.source)
|
||||
if (entries !== undefined) return { message, entries }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(truncated)
|
||||
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
|
||||
Reference in New Issue
Block a user