2026-07-10 14:19:06 +08:00
/**
2026-07-24 13:18:57 +08:00
* Durable session skill catalog and model-facing `skill` loader tool.
2026-07-10 14:19:06 +08:00
*
* @module @deepseek-ai/dsh-tool-skill
*/
2026-07-27 16:50:56 +08:00
import { createHash } from 'node:crypto'
2026-07-10 14:19:06 +08:00
import type { Context } from 'cordis'
import z from 'schemastery'
2026-07-31 19:21:16 +08:00
import type { Agent , PreStepDecision } from '@deepseek-ai/dsh-agent'
2026-07-10 14:19:06 +08:00
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-29 01:29:39 +08:00
import { assertNever , createUserMessage } from '@deepseek-ai/dsh-llm'
2026-07-28 13:55:59 +08:00
import type { UserMessage } from '@deepseek-ai/dsh-session'
2026-07-28 17:22:41 +08:00
import {
isModelInvocable ,
isSkillName ,
type SkillDefinition ,
type SkillSummary ,
} from '@deepseek-ai/dsh-skill'
2026-07-10 14:19:06 +08:00
export const name = 'tool-skill'
2026-07-27 16:50:56 +08:00
export const inject = [ 'agents' , 'tools' , 'skills' ]
2026-07-10 14:19:06 +08:00
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
2026-08-05 12:15:03 +08:00
/**
* 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 ) ,
} ) )
}
2026-07-10 14:19:06 +08:00
/** Model-facing skill catalog configuration. */
export interface Config {
/** Maximum normalized description length rendered in the session catalog; minimum 3. */
catalogDescriptionMaxLength? : number
}
/** Validate and default the model-facing skill catalog configuration. */
export const Config : z < Config > = z . object ( {
catalogDescriptionMaxLength : z.number ( ) . default ( DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH ) ,
} )
2026-07-11 23:41:37 +08:00
/**
* Register the model-facing skill loader and its visibility-matched
2026-07-24 22:38:50 +08:00
* durable session catalog. The catalog is emitted only when the calling agent
2026-07-11 23:41:37 +08:00
* resolves this plugin's exact tool registration; a restriction or scoped
* same-name shadow therefore removes both the schema and its call guidance.
*/
2026-07-10 14:19:06 +08:00
export function apply ( ctx : Context , config : Config = { } ) : void {
const catalogDescriptionMaxLength = config . catalogDescriptionMaxLength ? ? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
assertPositiveInteger ( 'catalogDescriptionMaxLength' , catalogDescriptionMaxLength , 3 )
const skillTool = defineTool ( {
name : 'skill' ,
description : 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.' ,
parameters : {
name : { type : 'string' , required : true , description : 'The exact skill name from the available skills list.' } ,
} ,
2026-07-21 03:08:35 +08:00
output : {
schema : {
type : 'object' ,
additionalProperties : false ,
properties : {
name : { type : 'string' , required : true } ,
provider : { type : 'string' , required : true } ,
resourceBase : {
oneOf : [
{
type : 'object' ,
additionalProperties : false ,
properties : {
kind : { type : 'string' , required : true , const : 'directory' } ,
path : { type : 'string' , required : true } ,
} ,
} ,
{
type : 'object' ,
additionalProperties : false ,
properties : {
kind : { type : 'string' , required : true , const : 'url' } ,
url : { type : 'string' , required : true } ,
} ,
} ,
{
type : 'object' ,
additionalProperties : false ,
properties : {
kind : { type : 'string' , required : true , const : 'opaque' } ,
description : { type : 'string' , required : true } ,
} ,
} ,
] ,
} ,
content : { type : 'string' , required : true } ,
} ,
} ,
render : ( _args , value ) = > [ { type : 'text' , text : renderSkillContent ( value ) } ] ,
} ,
2026-07-10 14:19:06 +08:00
async execute ( args , exec ) {
if ( ! isSkillName ( args . name ) ) {
throw new Error ( ` invalid skill name " ${ args . name } " ` )
}
2026-07-29 21:48:15 +08:00
const lookup = { cwd : exec.agent?.session.header.cwd , signal : exec.signal }
const summary = ( await ctx . skills . list ( lookup ) ) . find ( skill = > skill . name === args . name )
if ( ! summary ) {
throw new Error ( ` skill " ${ args . name } " is unknown or no longer available ` )
}
if ( ! isModelInvocable ( summary ) ) {
throw new Error ( ` skill " ${ args . name } " is not available for model invocation ` )
}
const skill = await ctx . skills . get ( args . name , lookup )
2026-07-10 14:19:06 +08:00
if ( ! skill ) {
throw new Error ( ` skill " ${ args . name } " is unknown or no longer available ` )
}
2026-07-28 17:22:41 +08:00
if ( ! isModelInvocable ( skill ) ) {
2026-07-10 14:19:06 +08:00
throw new Error ( ` skill " ${ args . name } " is not available for model invocation ` )
}
2026-07-21 03:08:35 +08:00
return {
name : skill.name ,
provider : skill.provider ,
. . . skill . resourceBase !== undefined ? {
resourceBase : { . . . skill . resourceBase } ,
} : { } ,
content : skill.content ,
}
2026-07-10 14:19:06 +08:00
} ,
presentCall ( args ) {
return { card : 'generic' , title : ` Load skill ${ args . name } ` , kind : 'read' , rawInput : args.name }
} ,
} )
ctx . tools . register ( skillTool )
2026-07-11 23:41:37 +08:00
2026-07-13 23:27:00 +08:00
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
2026-08-03 23:44:09 +08:00
//
// The comparison is against the definition this plugin registered, not against
// a lookup of its own name: `register()` files into the CALLING context's
// scope, so a plugin mounted inside an agent preset registers for that agent
// alone and an unscoped lookup correctly finds nothing.
2026-07-31 19:21:16 +08:00
ctx . on ( 'agent/pre-step' , async (
2026-08-06 12:13:14 +08:00
{ agent , signal } ,
2026-07-31 19:21:16 +08:00
next ,
) : Promise < PreStepDecision > = > {
const decision = await next ( )
if ( decision . kind === 'reject' ) return decision
signal . throwIfAborted ( )
2026-08-03 23:44:09 +08:00
const toolVisible = ctx . tools . get ( skillTool . name , agent ) === skillTool
2026-07-27 16:50:56 +08:00
const snapshot = toolVisible
? await ctx . skills . snapshot ( { cwd : agent.session.header.cwd , signal } )
: { skills : [ ] , complete : true }
signal . throwIfAborted ( )
2026-07-31 19:21:16 +08:00
if ( ! snapshot . complete ) return decision
2026-07-29 23:36:49 +08:00
const skills = snapshot . skills . filter ( isModelInvocable )
2026-08-05 12:15:03 +08:00
const entries = catalogSourceEntries ( skills , catalogDescriptionMaxLength )
const digest = digestCatalogEntries ( entries )
2026-07-28 01:10:33 +08:00
const history = catalogHistory ( agent )
2026-07-31 19:21:16 +08:00
const existing = catalogMessage ( decision . messages )
if ( history . visibleDigest === digest ) {
return existing === undefined
? decision
2026-08-06 11:28:48 +08:00
: { kind : 'enter' , messages : decision.messages.filter ( message = > message . id !== existing . message . id ) }
2026-07-31 19:21:16 +08:00
}
2026-08-06 11:28:48 +08:00
if ( existing !== undefined && digestCatalogEntries ( existing . entries ) === digest ) return decision
2026-07-31 19:21:16 +08:00
if ( ! history . published && skills . length === 0 ) {
return existing === undefined
? decision
2026-08-06 11:28:48 +08:00
: { kind : 'enter' , messages : decision.messages.filter ( message = > message . id !== existing . message . id ) }
2026-07-31 19:21:16 +08:00
}
2026-07-28 01:10:33 +08:00
const catalog = history . published
2026-08-05 12:15:03 +08:00
? renderCatalogUpdate ( entries )
: renderCatalogMessage ( entries )
2026-07-31 19:21:16 +08:00
return {
kind : 'enter' ,
messages : existing === undefined
? [ . . . decision . messages , catalog ]
2026-08-06 11:28:48 +08:00
: decision . messages . map ( message = > message . id === existing . message . id ? catalog : message ) ,
2026-07-31 19:21:16 +08:00
}
2026-07-11 23:41:37 +08:00
} )
2026-07-10 14:19:06 +08:00
}
2026-07-21 03:08:35 +08:00
function renderSkillContent ( skill : Pick < SkillDefinition , 'name' | 'provider' | 'resourceBase' | 'content' > ) : string {
2026-07-10 14:19:06 +08:00
const resourceHint = renderResourceHint ( skill )
return [
` <skill_content name=" ${ escapeAttr ( skill . name ) } "> ` ,
'<skill_resources>' ,
. . . resourceHint ,
'</skill_resources>' ,
'' ,
'<skill_instructions>' ,
skill . content ,
'</skill_instructions>' ,
'</skill_content>' ,
] . join ( '\n' )
}
2026-07-21 03:08:35 +08:00
function renderResourceHint ( skill : Pick < SkillDefinition , 'provider' | 'resourceBase' > ) : string [ ] {
2026-07-10 14:19:06 +08:00
const base = skill . resourceBase
if ( base === undefined ) {
return [
` Resources for this skill are managed by provider " ${ escapeText ( skill . provider ) } ". ` ,
'Load referenced resources only as needed.' ,
]
}
switch ( base . kind ) {
case 'directory' :
return [
` Base directory for this skill: ${ escapeText ( base . path ) } ` ,
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.' ,
]
case 'url' :
return [
` Base URL for this skill: ${ escapeText ( base . url ) } ` ,
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.' ,
]
case 'opaque' :
return [
` Resources for this skill: ${ escapeText ( base . description ) } ` ,
'Load referenced resources only as needed.' ,
]
2026-07-21 03:08:35 +08:00
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
2026-07-10 14:19:06 +08:00
default :
return assertNever ( base , 'SkillResourceBase.kind' )
2026-07-21 03:08:35 +08:00
/* v8 ignore stop */
2026-07-10 14:19:06 +08:00
}
}
2026-08-05 12:15:03 +08:00
function renderCatalogMessage ( entries : SkillCatalogSource [ 'entries' ] ) : UserMessage {
2026-07-28 13:55:59 +08:00
return createUserMessage ( {
2026-07-10 14:19:06 +08:00
content : [ {
type : 'text' ,
text : [
'<system-reminder>' ,
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:' ,
'' ,
'<available_skills>' ,
2026-08-05 12:15:03 +08:00
. . . renderCatalogEntries ( entries ) ,
2026-07-10 14:19:06 +08:00
'</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' ) ,
} ] ,
2026-08-05 12:15:03 +08:00
source : {
kind : 'skill-catalog' ,
form : 'catalog' ,
entries ,
} ,
2026-07-28 13:55:59 +08:00
} )
2026-07-10 14:19:06 +08:00
}
2026-08-05 12:15:03 +08:00
function renderCatalogUpdate ( entries : SkillCatalogSource [ 'entries' ] ) : UserMessage {
const availability = entries . length === 0
2026-07-27 16:50:56 +08:00
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.' ,
]
: [
'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.' ,
]
2026-07-29 16:40:40 +08:00
return createUserMessage ( {
2026-07-27 16:50:56 +08:00
content : [ {
type : 'text' ,
text : [
'<system-reminder>' ,
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:' ,
'' ,
'<available_skills>' ,
2026-08-05 12:15:03 +08:00
. . . renderCatalogEntries ( entries ) ,
2026-07-27 16:50:56 +08:00
'</available_skills>' ,
'' ,
. . . availability ,
'</system-reminder>' ,
] . join ( '\n' ) ,
} ] ,
2026-08-05 12:15:03 +08:00
source : {
kind : 'skill-catalog' ,
form : 'catalog' ,
update : true ,
entries ,
} ,
2026-07-29 16:40:40 +08:00
} )
2026-07-27 16:50:56 +08:00
}
2026-08-05 14:55:21 +08:00
/**
* 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.
*/
2026-08-05 12:15:03 +08:00
function renderCatalogEntries ( entries : SkillCatalogSource [ 'entries' ] ) : string [ ] {
2026-08-05 14:55:21 +08:00
return entries . map ( entry = > ` - \` ${ entry . name } \` : ${ escapeText ( entry . description ) } ` )
2026-07-28 01:10:33 +08:00
}
2026-08-05 12:15:03 +08:00
/**
* 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 {
2026-08-05 14:55:21 +08:00
// 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' )
2026-07-27 16:50:56 +08:00
return createHash ( 'sha256' )
2026-08-05 12:15:03 +08:00
. update ( canonical )
2026-07-27 16:50:56 +08:00
. digest ( 'hex' )
}
2026-08-05 14:55:21 +08:00
/**
* 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
}
2026-07-28 01:10:33 +08:00
function catalogHistory ( agent : Agent ) : { visibleDigest? : string ; published : boolean } {
2026-07-27 16:50:56 +08:00
const visible = new Set ( agent . session . surface . nodes )
2026-07-27 17:12:08 +08:00
const events = agent . session . events
2026-07-28 01:10:33 +08:00
let published = false
2026-07-27 17:12:08 +08:00
for ( let index = events . length - 1 ; index >= 0 ; index -= 1 ) {
// The loop bounds prove the read-only event view contains this index.
2026-07-30 00:13:30 +08:00
// oxlint-disable-next-line typescript/no-non-null-assertion
2026-07-27 17:12:08 +08:00
const event = events [ index ] !
2026-08-05 12:15:03 +08:00
if ( event . type !== 'user/message' || event . data . source . kind !== 'skill-catalog' ) continue
2026-08-05 14:55:21 +08:00
const entries = readCatalogEntries ( event . data . source )
if ( entries === undefined ) continue
const digest = digestCatalogEntries ( entries )
2026-07-28 01:10:33 +08:00
published = true
if ( visible . has ( event . seq ) ) return { visibleDigest : digest , published }
2026-07-27 16:50:56 +08:00
}
2026-07-28 01:10:33 +08:00
return { published }
2026-07-27 16:50:56 +08:00
}
2026-08-06 11:28:48 +08:00
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
2026-07-27 16:50:56 +08:00
}
2026-08-05 14:55:21 +08:00
/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
2026-07-10 14:19:06 +08:00
function catalogDescription ( value : string , maxLength : number ) : string {
const normalized = value . replaceAll ( /\s+/g , ' ' ) . trim ( )
2026-08-05 14:55:21 +08:00
return normalized . length <= maxLength ? normalized : ` ${ normalized . slice ( 0 , maxLength - 3 ) } ... `
2026-07-10 14:19:06 +08:00
}
function assertPositiveInteger ( name : string , value : number , minimum = 1 ) : void {
if ( ! Number . isInteger ( value ) || value < minimum ) {
throw new Error ( ` tool-skill: ${ name } must be an integer greater than or equal to ${ minimum } ` )
}
}
function escapeAttr ( value : string ) : string {
return value . replaceAll ( '&' , '&' ) . replaceAll ( '"' , '"' ) . replaceAll ( '<' , '<' )
}
function escapeText ( value : string ) : string {
return value . replaceAll ( '&' , '&' ) . replaceAll ( '<' , '<' ) . replaceAll ( '>' , '>' )
}