Merge remote-tracking branch 'github/master' into feat/agent-event-payload

This commit is contained in:
_Kerman
2026-08-06 13:26:04 +08:00
354 changed files with 7317 additions and 1710 deletions

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 {
@@ -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 {