| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- /**
- * Durable session skill catalog and model-facing `skill` loader tool.
- *
- * @module @deepseek-ai/dsh-tool-skill
- */
- import type { Context } from 'cordis'
- import z from 'schemastery'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- 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),
- })
- /**
- * Register the model-facing skill loader and its visibility-matched
- * durable session 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.
- */
- 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.' },
- },
- 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) }],
- },
- 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`)
- }
- return {
- name: skill.name,
- provider: skill.provider,
- ...skill.resourceBase !== undefined ? {
- resourceBase: { ...skill.resourceBase },
- } : {},
- content: skill.content,
- }
- },
- presentCall(args) {
- return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
- },
- })
- ctx.tools.register(skillTool)
- 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')
- }
- // Register after the tool so reverse teardown removes guidance first. Exact definition
- // identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
- const catalogLoaded = new WeakSet<object>()
- ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
- if (catalogLoaded.has(agent.session)) return
- if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) {
- catalogLoaded.add(agent.session)
- return
- }
- const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
- if (skills.length > 0) {
- const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
- agent.inject({ content: catalog.content, source: { kind: 'plugin', plugin: 'dsh-tool-skill' } })
- }
- catalogLoaded.add(agent.session)
- })
- }
- function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
- 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')
- }
- function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
- 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.',
- ]
- /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
- default:
- return assertNever(base, 'SkillResourceBase.kind')
- /* v8 ignore stop */
- }
- }
- 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('>', '>')
- }
|