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-24 13:18:57 +08:00
import type { Agent } 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-07-28 01:10:33 +08:00
const CATALOG_ENTRIES_START = '<available_skills>\n'
2026-07-29 20:44:06 +08:00
const CATALOG_ENTRIES_END = '</available_skills>'
2026-07-29 16:40:40 +08:00
const PLUGIN_SOURCE = { kind : 'plugin' , plugin : 'dsh-tool-skill' } as const
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
const registeredSkillTool = ctx . tools . get ( skillTool . name )
/* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */
if ( registeredSkillTool === undefined ) {
throw new Error ( 'dsh-tool-skill: registered skill tool is not visible in the global registry' )
}
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-07-24 13:18:57 +08:00
ctx . on ( 'agent/step' , async ( agent : Agent , _turn , _step , signal ) : Promise < void > = > {
2026-07-27 16:50:56 +08:00
const toolVisible = ctx . tools . get ( skillTool . name , agent ) === registeredSkillTool
const snapshot = toolVisible
? await ctx . skills . snapshot ( { cwd : agent.session.header.cwd , signal } )
: { skills : [ ] , complete : true }
signal . throwIfAborted ( )
if ( ! snapshot . complete ) return
2026-07-29 23:36:49 +08:00
const skills = snapshot . skills . filter ( isModelInvocable )
const digest = catalogDigest ( skills , catalogDescriptionMaxLength )
2026-07-28 01:10:33 +08:00
const history = catalogHistory ( agent )
if ( history . visibleDigest === digest ) return
2026-07-29 23:36:49 +08:00
if ( ! history . published && skills . length === 0 ) return
2026-07-28 01:10:33 +08:00
const catalog = history . published
2026-07-29 23:36:49 +08:00
? renderCatalogUpdate ( skills , catalogDescriptionMaxLength )
: renderCatalogMessage ( skills , catalogDescriptionMaxLength )
2026-07-29 16:40:40 +08:00
agent . inject ( catalog )
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-07-28 13:55:59 +08:00
function renderCatalogMessage ( skills : SkillSummary [ ] , descriptionMaxLength : number ) : UserMessage {
2026-07-27 16:50:56 +08:00
const entries = renderCatalogEntries ( skills , descriptionMaxLength )
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>' ,
. . . 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' ) ,
} ] ,
2026-07-29 16:40:40 +08:00
source : PLUGIN_SOURCE ,
2026-07-28 13:55:59 +08:00
} )
2026-07-10 14:19:06 +08:00
}
2026-07-29 16:40:40 +08:00
function renderCatalogUpdate ( skills : SkillSummary [ ] , descriptionMaxLength : number ) : UserMessage {
2026-07-27 16:50:56 +08:00
const entries = renderCatalogEntries ( skills , descriptionMaxLength )
const availability = skills . length === 0
? [
'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>' ,
. . . entries ,
'</available_skills>' ,
'' ,
. . . availability ,
'</system-reminder>' ,
] . join ( '\n' ) ,
} ] ,
2026-07-29 16:40:40 +08:00
source : PLUGIN_SOURCE ,
} )
2026-07-27 16:50:56 +08:00
}
function renderCatalogEntries ( skills : SkillSummary [ ] , descriptionMaxLength : number ) : string [ ] {
return skills . map ( skill = > ` - \` ${ skill . name } \` : ${ catalogDescription ( skill . description , descriptionMaxLength ) } ` )
}
2026-07-28 01:10:33 +08:00
function catalogDigest ( skills : SkillSummary [ ] , descriptionMaxLength : number ) : string {
return digestCatalogEntries ( renderCatalogEntries ( skills , descriptionMaxLength ) . join ( '\n' ) )
}
function digestCatalogEntries ( entries : string ) : string {
2026-07-27 16:50:56 +08:00
return createHash ( 'sha256' )
2026-07-28 01:10:33 +08:00
. update ( entries )
2026-07-27 16:50:56 +08:00
. digest ( 'hex' )
}
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-07-28 01:10:33 +08:00
if ( event . type !== 'user/message'
2026-07-27 16:50:56 +08:00
|| event . data . source . kind !== 'plugin'
2026-07-29 16:40:40 +08:00
|| event . data . source . plugin !== PLUGIN_SOURCE . plugin ) continue
2026-07-28 01:10:33 +08:00
const digest = catalogContentDigest ( event . data . content )
if ( digest === undefined ) continue
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-07-29 16:40:40 +08:00
function catalogContentDigest ( content : UserMessage [ 'content' ] ) : string | undefined {
2026-07-28 01:10:33 +08:00
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
2026-07-29 20:44:06 +08:00
const renderedEntries = text . slice ( entriesStart , end )
const entries = renderedEntries . endsWith ( '\n' ) ? renderedEntries . slice ( 0 , - 1 ) : renderedEntries
return digestCatalogEntries ( entries )
2026-07-27 16:50:56 +08:00
}
2026-07-10 14:19:06 +08:00
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 )
}
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 ( '>' , '>' )
}