index.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * Durable session skill catalog and model-facing `skill` loader tool.
  3. *
  4. * @module @deepseek-ai/dsh-tool-skill
  5. */
  6. import type { Context } from 'cordis'
  7. import z from 'schemastery'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import { defineTool } from '@deepseek-ai/dsh-tools'
  10. import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
  11. import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
  12. export const name = 'tool-skill'
  13. export const inject = ['tools', 'skills']
  14. const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
  15. /** Model-facing skill catalog configuration. */
  16. export interface Config {
  17. /** Maximum normalized description length rendered in the session catalog; minimum 3. */
  18. catalogDescriptionMaxLength?: number
  19. }
  20. /** Validate and default the model-facing skill catalog configuration. */
  21. export const Config: z<Config> = z.object({
  22. catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
  23. })
  24. /**
  25. * Register the model-facing skill loader and its visibility-matched
  26. * durable session catalog. The catalog is emitted only when the calling agent
  27. * resolves this plugin's exact tool registration; a restriction or scoped
  28. * same-name shadow therefore removes both the schema and its call guidance.
  29. */
  30. export function apply(ctx: Context, config: Config = {}): void {
  31. const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
  32. assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
  33. const skillTool = defineTool({
  34. name: 'skill',
  35. 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.',
  36. parameters: {
  37. name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
  38. },
  39. output: {
  40. schema: {
  41. type: 'object',
  42. additionalProperties: false,
  43. properties: {
  44. name: { type: 'string', required: true },
  45. provider: { type: 'string', required: true },
  46. resourceBase: {
  47. oneOf: [
  48. {
  49. type: 'object',
  50. additionalProperties: false,
  51. properties: {
  52. kind: { type: 'string', required: true, const: 'directory' },
  53. path: { type: 'string', required: true },
  54. },
  55. },
  56. {
  57. type: 'object',
  58. additionalProperties: false,
  59. properties: {
  60. kind: { type: 'string', required: true, const: 'url' },
  61. url: { type: 'string', required: true },
  62. },
  63. },
  64. {
  65. type: 'object',
  66. additionalProperties: false,
  67. properties: {
  68. kind: { type: 'string', required: true, const: 'opaque' },
  69. description: { type: 'string', required: true },
  70. },
  71. },
  72. ],
  73. },
  74. content: { type: 'string', required: true },
  75. },
  76. },
  77. render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }],
  78. },
  79. async execute(args, exec) {
  80. if (!isSkillName(args.name)) {
  81. throw new Error(`invalid skill name "${args.name}"`)
  82. }
  83. const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal })
  84. if (!skill) {
  85. throw new Error(`skill "${args.name}" is unknown or no longer available`)
  86. }
  87. if (skill.disableModelInvocation === true) {
  88. throw new Error(`skill "${args.name}" is not available for model invocation`)
  89. }
  90. return {
  91. name: skill.name,
  92. provider: skill.provider,
  93. ...skill.resourceBase !== undefined ? {
  94. resourceBase: { ...skill.resourceBase },
  95. } : {},
  96. content: skill.content,
  97. }
  98. },
  99. presentCall(args) {
  100. return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
  101. },
  102. })
  103. ctx.tools.register(skillTool)
  104. const registeredSkillTool = ctx.tools.get(skillTool.name)
  105. /* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */
  106. if (registeredSkillTool === undefined) {
  107. throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry')
  108. }
  109. // Register after the tool so reverse teardown removes guidance first. Exact definition
  110. // identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
  111. const catalogLoaded = new WeakSet<object>()
  112. ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
  113. if (catalogLoaded.has(agent.session)) return
  114. if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) {
  115. catalogLoaded.add(agent.session)
  116. return
  117. }
  118. const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
  119. if (skills.length > 0) {
  120. const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
  121. agent.inject({ content: catalog.content, source: { kind: 'plugin', plugin: 'dsh-tool-skill' } })
  122. }
  123. catalogLoaded.add(agent.session)
  124. })
  125. }
  126. function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
  127. const resourceHint = renderResourceHint(skill)
  128. return [
  129. `<skill_content name="${escapeAttr(skill.name)}">`,
  130. '<skill_resources>',
  131. ...resourceHint,
  132. '</skill_resources>',
  133. '',
  134. '<skill_instructions>',
  135. skill.content,
  136. '</skill_instructions>',
  137. '</skill_content>',
  138. ].join('\n')
  139. }
  140. function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
  141. const base = skill.resourceBase
  142. if (base === undefined) {
  143. return [
  144. `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,
  145. 'Load referenced resources only as needed.',
  146. ]
  147. }
  148. switch (base.kind) {
  149. case 'directory':
  150. return [
  151. `Base directory for this skill: ${escapeText(base.path)}`,
  152. 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
  153. ]
  154. case 'url':
  155. return [
  156. `Base URL for this skill: ${escapeText(base.url)}`,
  157. 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',
  158. ]
  159. case 'opaque':
  160. return [
  161. `Resources for this skill: ${escapeText(base.description)}`,
  162. 'Load referenced resources only as needed.',
  163. ]
  164. /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
  165. default:
  166. return assertNever(base, 'SkillResourceBase.kind')
  167. /* v8 ignore stop */
  168. }
  169. }
  170. function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): Message {
  171. const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
  172. return {
  173. role: 'user',
  174. content: [{
  175. type: 'text',
  176. text: [
  177. '<system-reminder>',
  178. 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
  179. '',
  180. '<available_skills>',
  181. ...entries,
  182. '</available_skills>',
  183. '',
  184. "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.",
  185. '</system-reminder>',
  186. ].join('\n'),
  187. }],
  188. }
  189. }
  190. function catalogDescription(value: string, maxLength: number): string {
  191. const normalized = value.replaceAll(/\s+/g, ' ').trim()
  192. const truncated = normalized.length <= maxLength
  193. ? normalized
  194. : `${normalized.slice(0, maxLength - 3)}...`
  195. return escapeText(truncated)
  196. }
  197. function assertPositiveInteger(name: string, value: number, minimum = 1): void {
  198. if (!Number.isInteger(value) || value < minimum) {
  199. throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
  200. }
  201. }
  202. function escapeAttr(value: string): string {
  203. return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
  204. }
  205. function escapeText(value: string): string {
  206. return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
  207. }