index.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. /**
  2. * Durable session skill catalog and model-facing `skill` loader tool.
  3. *
  4. * @module @deepseek-ai/dsh-tool-skill
  5. */
  6. import { createHash } from 'node:crypto'
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import z from '@deepseek-ai/schemastery'
  9. import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
  10. import { defineTool } from '@deepseek-ai/dsh-tools'
  11. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  12. import type { UserMessage } from '@deepseek-ai/dsh-session'
  13. import {
  14. escapeText,
  15. isModelInvocable,
  16. isSkillName,
  17. isUserInvocable,
  18. renderSkillContent,
  19. type SkillInvocationSource,
  20. type SkillSummary,
  21. } from '@deepseek-ai/dsh-skill'
  22. export const name = 'tool-skill'
  23. export const inject = ['agents', 'tools', 'skills']
  24. const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
  25. /**
  26. * Durable provider and item records for one published session skill catalog. The catalog is a
  27. * `catalog`-form context, so it records the entries it published beside the
  28. * model-facing prose: a consumer presenting the list must not re-parse the
  29. * `<available_skills>` block, whose framing exists for the model.
  30. */
  31. export interface SkillCatalogSource {
  32. readonly kind: 'skill-catalog'
  33. readonly form: 'catalog'
  34. /** Marks a replacement catalog rather than this session's first publication. */
  35. readonly update?: true
  36. /** Exactly the entries this message published, in catalog order. */
  37. readonly entries: readonly { readonly name: string; readonly description: string }[]
  38. }
  39. declare module '@deepseek-ai/dsh-llm' {
  40. interface MessageSourceMap {
  41. 'skill-catalog': SkillCatalogSource
  42. }
  43. }
  44. /** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
  45. function catalogSourceEntries(
  46. skills: SkillSummary[],
  47. descriptionMaxLength: number,
  48. ): SkillCatalogSource['entries'] {
  49. return skills.map(skill => ({
  50. name: skill.name,
  51. description: catalogDescription(skill.description, descriptionMaxLength),
  52. }))
  53. }
  54. /** Model-facing skill catalog configuration. */
  55. export interface Config {
  56. /** Maximum normalized description length rendered in the session catalog; minimum 3. */
  57. catalogDescriptionMaxLength?: number
  58. }
  59. /** Validate and default the model-facing skill catalog configuration. */
  60. export const Config: z<Config> = z.object({
  61. catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
  62. })
  63. /**
  64. * Register the model-facing skill loader and its visibility-matched
  65. * durable session catalog. The catalog is emitted only when the calling agent
  66. * resolves this plugin's exact tool registration; a restriction or scoped
  67. * same-name shadow therefore removes both the schema and its call guidance.
  68. */
  69. export function apply(ctx: Context, config: Config = {}): void {
  70. const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
  71. assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
  72. const skillTool = defineTool({
  73. name: 'skill',
  74. 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.',
  75. parameters: {
  76. name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
  77. },
  78. output: {
  79. schema: {
  80. type: 'object',
  81. additionalProperties: false,
  82. properties: {
  83. name: { type: 'string', required: true },
  84. provider: { type: 'string', required: true },
  85. resourceBase: {
  86. oneOf: [
  87. {
  88. type: 'object',
  89. additionalProperties: false,
  90. properties: {
  91. kind: { type: 'string', required: true, const: 'directory' },
  92. path: { type: 'string', required: true },
  93. },
  94. },
  95. {
  96. type: 'object',
  97. additionalProperties: false,
  98. properties: {
  99. kind: { type: 'string', required: true, const: 'url' },
  100. url: { type: 'string', required: true },
  101. },
  102. },
  103. {
  104. type: 'object',
  105. additionalProperties: false,
  106. properties: {
  107. kind: { type: 'string', required: true, const: 'opaque' },
  108. description: { type: 'string', required: true },
  109. },
  110. },
  111. ],
  112. },
  113. content: { type: 'string', required: true },
  114. },
  115. },
  116. render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }],
  117. },
  118. async execute(args, exec) {
  119. if (!isSkillName(args.name)) {
  120. throw new Error(`invalid skill name "${args.name}"`)
  121. }
  122. // The agent is its own scope key, so the lookup resolves the layered
  123. // registry exactly as this agent's composition sees it.
  124. const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent }
  125. const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
  126. if (!summary) {
  127. throw new Error(`skill "${args.name}" is unknown or no longer available`)
  128. }
  129. if (!isModelInvocable(summary)) {
  130. throw new Error(`skill "${args.name}" is not available for model invocation`)
  131. }
  132. const skill = await ctx.skills.get(args.name, lookup)
  133. if (!skill) {
  134. throw new Error(`skill "${args.name}" is unknown or no longer available`)
  135. }
  136. if (!isModelInvocable(skill)) {
  137. throw new Error(`skill "${args.name}" is not available for model invocation`)
  138. }
  139. return {
  140. name: skill.name,
  141. provider: skill.provider,
  142. ...skill.resourceBase !== undefined ? {
  143. resourceBase: { ...skill.resourceBase },
  144. } : {},
  145. content: skill.content,
  146. }
  147. },
  148. presentCall(args) {
  149. return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
  150. },
  151. })
  152. ctx.tools.register(skillTool)
  153. // User-explicit skill invocation: a claimed user message whose first line
  154. // starts with `/<name>` naming a user-invocable skill is a deterministic
  155. // load gesture. The rendered body enters this step as injected
  156. // instructions context appended after every other injection — background
  157. // first (workspace rules, runtime policy, the catalog), the material the
  158. // model must act on last, closest to its answer. Registration order makes
  159. // that placement deterministic: this listener registers before the catalog
  160. // listener, so the waterfall hands it the catalog-bearing list to extend.
  161. // Only `source.kind === 'user'` messages are scanned — external text
  162. // cannot forge the gesture — and a token naming no user-invocable skill
  163. // stays ordinary prose (the command registry is a different closed
  164. // namespace, resolved client-side before a line ever becomes a prompt).
  165. // This is the only entry point for `disable-model-invocation` skills; the
  166. // catalog and the `skill` tool below never see them.
  167. ctx.on('agent/pre-step', async (
  168. { agent, messages, signal },
  169. next,
  170. ): Promise<PreStepDecision> => {
  171. const decision = await next()
  172. if (decision.kind === 'reject') return decision
  173. const names = invokedSkillNames(messages)
  174. if (names.length === 0) return decision
  175. signal.throwIfAborted()
  176. const lookup = { cwd: agent.session.header.cwd, signal, scope: agent }
  177. const injections: UserMessage[] = []
  178. for (const name of names) {
  179. const skill = await ctx.skills.get(name, lookup)
  180. signal.throwIfAborted()
  181. // Unknown names and user-disabled skills stay plain prose: the
  182. // gesture was never a claim this boundary recognizes. The check sits
  183. // on the loaded definition — the single lookup that produces what is
  184. // actually injected.
  185. if (skill === undefined || !isUserInvocable(skill)) continue
  186. const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' }
  187. injections.push(createUserMessage({
  188. content: [{ type: 'text', text: renderSkillContent(skill) }],
  189. source,
  190. }))
  191. }
  192. if (injections.length === 0) return decision
  193. return { ...decision, messages: [...decision.messages, ...injections] }
  194. })
  195. // Register after the tool so reverse teardown removes guidance first. Exact definition
  196. // identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
  197. //
  198. // The comparison is against the definition this plugin registered, not against
  199. // a lookup of its own name: `register()` files into the CALLING context's
  200. // scope, so a plugin mounted inside an agent preset registers for that agent
  201. // alone and an unscoped lookup correctly finds nothing.
  202. ctx.on('agent/pre-step', async (
  203. { agent, signal },
  204. next,
  205. ): Promise<PreStepDecision> => {
  206. const decision = await next()
  207. if (decision.kind === 'reject') return decision
  208. signal.throwIfAborted()
  209. const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool
  210. const snapshot = toolVisible
  211. ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal, scope: agent })
  212. : { skills: [], complete: true }
  213. signal.throwIfAborted()
  214. if (!snapshot.complete) return decision
  215. const skills = snapshot.skills.filter(isModelInvocable)
  216. const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
  217. const digest = digestCatalogEntries(entries)
  218. const history = catalogHistory(agent)
  219. const existing = catalogMessage(decision.messages)
  220. if (history.visibleDigest === digest) {
  221. return existing === undefined
  222. ? decision
  223. : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) }
  224. }
  225. if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision
  226. if (!history.published && skills.length === 0) {
  227. return existing === undefined
  228. ? decision
  229. : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) }
  230. }
  231. const catalog = history.published
  232. ? renderCatalogUpdate(entries)
  233. : renderCatalogMessage(entries)
  234. return {
  235. ...decision,
  236. messages: existing === undefined
  237. ? [...decision.messages, catalog]
  238. : decision.messages.map(message => message.id === existing.message.id ? catalog : message),
  239. }
  240. })
  241. }
  242. function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage {
  243. return createUserMessage({
  244. content: [{
  245. type: 'text',
  246. text: [
  247. '<system-reminder>',
  248. 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
  249. '',
  250. '<available_skills>',
  251. ...renderCatalogEntries(entries),
  252. '</available_skills>',
  253. '',
  254. "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.",
  255. 'A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
  256. '</system-reminder>',
  257. ].join('\n'),
  258. }],
  259. source: {
  260. kind: 'skill-catalog',
  261. form: 'catalog',
  262. entries,
  263. },
  264. })
  265. }
  266. function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage {
  267. const availability = entries.length === 0
  268. ? [
  269. 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
  270. 'A user may still invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool for it.',
  271. ]
  272. : [
  273. '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.',
  274. 'A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
  275. ]
  276. return createUserMessage({
  277. content: [{
  278. type: 'text',
  279. text: [
  280. '<system-reminder>',
  281. 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
  282. '',
  283. '<available_skills>',
  284. ...renderCatalogEntries(entries),
  285. '</available_skills>',
  286. '',
  287. ...availability,
  288. '</system-reminder>',
  289. ].join('\n'),
  290. }],
  291. source: {
  292. kind: 'skill-catalog',
  293. form: 'catalog',
  294. update: true,
  295. entries,
  296. },
  297. })
  298. }
  299. /**
  300. * Model-facing catalog lines, projected from the same entries the source records.
  301. * The pseudo-XML escaping belongs to this frame, not to the published fact, so it
  302. * is applied here and never stored. Names are `isSkillName`-validated and carry
  303. * no escapable character.
  304. */
  305. function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
  306. return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`)
  307. }
  308. /**
  309. * Catalog identity over the durable entry list rather than the rendered prose.
  310. * The entries are what changes; the surrounding `<system-reminder>` framing is
  311. * written for the model and must not decide whether a republish is needed.
  312. */
  313. function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
  314. // JSON per entry rather than a separator character: every separator is itself
  315. // a legal description character, so only quoting makes the boundary exact.
  316. const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
  317. return createHash('sha256')
  318. .update(canonical)
  319. .digest('hex')
  320. }
  321. /**
  322. * Entries of one durable catalog message, or undefined when the record is not a
  323. * usable catalog.
  324. *
  325. * `agent.session.snapshotEvents()` may contain a resumed, forked, or externally written seed,
  326. * and seed validation only guarantees a source object with a non-empty `kind`;
  327. * no per-kind field is checked there. An unreadable record is therefore treated
  328. * as "not this plugin's catalog" — the posture the replaced content digest had —
  329. * rather than throwing inside the step listener, which would fail every
  330. * subsequent turn of that session.
  331. */
  332. function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
  333. const entries = (source as { entries?: unknown }).entries
  334. if (!Array.isArray(entries)) return undefined
  335. const readable: { name: string; description: string }[] = []
  336. for (const entry of entries as readonly unknown[]) {
  337. if (typeof entry !== 'object' || entry === null) return undefined
  338. const { name, description } = entry as { name?: unknown; description?: unknown }
  339. if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
  340. readable.push({ name, description })
  341. }
  342. return readable
  343. }
  344. function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
  345. const visible = new Set(agent.session.surface.nodes)
  346. let published = false
  347. for (let index = agent.session.seq - 1; index >= 0; index -= 1) {
  348. const event = agent.session.eventAt(index)
  349. if (event === undefined) {
  350. throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`)
  351. }
  352. if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
  353. const entries = readCatalogEntries(event.data.source)
  354. if (entries === undefined) continue
  355. const digest = digestCatalogEntries(entries)
  356. published = true
  357. if (visible.has(event.seq)) return { visibleDigest: digest, published }
  358. }
  359. return { published }
  360. }
  361. function catalogMessage(
  362. messages: readonly UserMessage[],
  363. ): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined {
  364. for (const message of messages) {
  365. if (message.source.kind !== 'skill-catalog') continue
  366. const entries = readCatalogEntries(message.source)
  367. if (entries !== undefined) return { message, entries }
  368. }
  369. return undefined
  370. }
  371. function catalogDescription(value: string, maxLength: number): string {
  372. const normalized = value.replaceAll(/\s+/g, ' ').trim()
  373. return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
  374. }
  375. function assertPositiveInteger(name: string, value: number, minimum = 1): void {
  376. if (!Number.isInteger(value) || value < minimum) {
  377. throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
  378. }
  379. }
  380. /**
  381. * A whitespace-bounded `/name` token (the public skill-name grammar) anywhere
  382. * in the text — the same word-boundary shape the transcript chip decoration
  383. * uses, so a gesture reads as one wherever it sits in the sentence. A second
  384. * `/` or any non-boundary character breaks the match, which keeps file paths
  385. * (`/usr/bin`) and fractions (`5/8`) out.
  386. */
  387. const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
  388. /**
  389. * `/name` gesture tokens from the claimed user messages, deduplicated in
  390. * first-seen order. Every text block of direct user input is scanned; no
  391. * other source can forge a gesture.
  392. * @param messages - the step's claimed batch.
  393. * @returns candidate skill names, unvalidated against the registry.
  394. */
  395. function invokedSkillNames(messages: readonly UserMessage[]): string[] {
  396. const names: string[] = []
  397. for (const message of messages) {
  398. if ((message.source as { kind?: unknown }).kind !== 'user') continue
  399. for (const block of message.content) {
  400. if (block.type !== 'text') continue
  401. for (const match of block.text.matchAll(SKILL_GESTURE)) {
  402. const name = match[2]
  403. if (name !== undefined && !names.includes(name)) names.push(name)
  404. }
  405. }
  406. }
  407. return names
  408. }