2026-07-10 14:19:06 +08:00
/**
* Session-prefix skill catalog and model-facing `skill` loader tool.
*
* @module @deepseek-ai/dsh-tool-skill
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever , type Message } from '@deepseek-ai/dsh-llm'
import { isSkillName , type SkillDefinition , type SkillSummary } from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = [ 'tools' , 'skills' ]
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
/** 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
* session-prefix catalog. The catalog is emitted only when the calling agent
* 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 } " ` )
}
const skill = await ctx . skills . get ( args . name , { cwd : exec.agent?.session.header.cwd , signal : exec.signal } )
if ( ! skill ) {
throw new Error ( ` skill " ${ args . name } " is unknown or no longer available ` )
}
if ( skill . disableModelInvocation === true ) {
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-11 23:41:37 +08:00
ctx . on ( 'agent/session-prefix' , async ( agent , _prefix , signal , next ) : Promise < Message [ ] > = > {
if ( ctx . tools . get ( skillTool . name , agent ) !== registeredSkillTool ) return await next ( )
const skills = await ctx . skills . list ( { cwd : agent.session.header.cwd , signal } )
const rest = await next ( )
if ( skills . length === 0 ) return rest
return [ renderCatalogMessage ( skills , catalogDescriptionMaxLength ) , . . . rest ]
} )
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
}
}
function renderCatalogMessage ( skills : SkillSummary [ ] , descriptionMaxLength : number ) : Message {
const entries = skills . map ( skill = > ` - \` ${ skill . name } \` : ${ catalogDescription ( skill . description , descriptionMaxLength ) } ` )
return {
role : 'user' ,
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' ) ,
} ] ,
}
}
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 ( '>' , '>' )
}