tool-skill.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { describe, expect, it } from 'vitest'
  2. import { mkdir, writeFile } from 'node:fs/promises'
  3. import { join } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { Context } from 'cordis'
  6. import { CallId, type Message } from '@deepseek-ai/dsh-llm'
  7. import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
  8. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  10. import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  11. import SkillService from '@deepseek-ai/dsh-skill'
  12. import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
  13. import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
  14. async function tempDir(name: string): Promise<string> {
  15. return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
  16. }
  17. async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
  18. const dir = join(root, name)
  19. await mkdir(dir, { recursive: true })
  20. await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
  21. }
  22. async function setup(home: string, config: toolSkill.Config = {}): Promise<Context> {
  23. const ctx = new Context()
  24. await ctx.plugin(SystemPrompt)
  25. await ctx.plugin(ToolRegistry)
  26. await ctx.plugin(SkillService)
  27. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
  28. await ctx.plugin(toolSkill, config)
  29. return ctx
  30. }
  31. function agentForCwd(cwd: string): Agent {
  32. return { session: { header: { cwd } } } as unknown as Agent
  33. }
  34. async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
  35. return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
  36. }
  37. async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
  38. const empty: Message[] = []
  39. return await agentEvents(ctx, agent).waterfall(
  40. 'agent/session-prefix', empty, signal,
  41. () => Promise.resolve(empty),
  42. )
  43. }
  44. async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> {
  45. const agent = agentForCwd(cwd)
  46. let scope!: Scope
  47. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, {
  48. inject: ['tools'],
  49. }))
  50. return { agent, scope }
  51. }
  52. describe('dsh-tool-skill', () => {
  53. it('registers the skill tool schema and removes it on dispose', async () => {
  54. const ctx = new Context()
  55. await ctx.plugin(SystemPrompt)
  56. await ctx.plugin(ToolRegistry)
  57. const home = await tempDir('tool-schema')
  58. await ctx.plugin(SkillService)
  59. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
  60. ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
  61. const fiber = await ctx.plugin(toolSkill)
  62. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
  63. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  64. expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
  65. card: 'generic',
  66. title: 'Load skill project-skill',
  67. kind: 'read',
  68. rawInput: 'project-skill',
  69. })
  70. await fiber.dispose()
  71. expect(ctx.tools.schemas()).toEqual([])
  72. expect(await composePrefix(ctx, '/workspace')).toEqual([])
  73. toolSkill.apply(ctx)
  74. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
  75. })
  76. it('forwards the session-prefix abort signal to skill discovery', async () => {
  77. const home = await tempDir('tool-prefix-signal')
  78. const ctx = await setup(home)
  79. let seenSignal: AbortSignal | undefined
  80. ctx.skills.registerProvider({
  81. name: 'signal-probe',
  82. async list(options) {
  83. seenSignal = options.signal
  84. return []
  85. },
  86. async get() {
  87. return undefined
  88. },
  89. })
  90. const controller = new AbortController()
  91. await composePrefix(ctx, '/workspace', controller.signal)
  92. expect(seenSignal).toBe(controller.signal)
  93. })
  94. it('contributes a stable name-and-description catalog through the session prefix', async () => {
  95. const home = await tempDir('tool-catalog')
  96. const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
  97. ctx.skills.register({
  98. name: 'z-skill',
  99. description: 'Long description '.repeat(5),
  100. whenToUse: 'Never render this routing hint.',
  101. source: 'secret-source',
  102. provider: 'runtime',
  103. resourceBase: { kind: 'directory', path: '/secret/path' },
  104. content: 'Secret body.',
  105. })
  106. ctx.skills.register({
  107. name: 'a-skill',
  108. description: 'Use {{placeholder}} <safely> & carefully.',
  109. source: 'runtime',
  110. provider: 'runtime',
  111. content: 'A body.',
  112. })
  113. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [
  114. { role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
  115. ...await next(),
  116. ])
  117. const prefix = await composePrefix(ctx, '/workspace')
  118. expect(prefix).toEqual([
  119. {
  120. role: 'user',
  121. content: [{
  122. type: 'text',
  123. text: [
  124. '<system-reminder>',
  125. 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
  126. '',
  127. '<available_skills>',
  128. '- `a-skill`: Use {{placeholder}} &lt;safely&gt; &amp; carefully.',
  129. '- `z-skill`: Long description Long description Long descript...',
  130. '</available_skills>',
  131. '',
  132. "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.",
  133. '</system-reminder>',
  134. ].join('\n'),
  135. }],
  136. },
  137. { role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
  138. ])
  139. const rendered = JSON.stringify(prefix[0])
  140. expect(rendered).not.toContain('whenToUse')
  141. expect(rendered).not.toContain('secret-source')
  142. expect(rendered).not.toContain('/secret/path')
  143. expect(rendered).not.toContain('Secret body')
  144. expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
  145. })
  146. it('does not contribute a session-prefix message when no skills are available', async () => {
  147. const home = await tempDir('tool-empty-catalog')
  148. const ctx = await setup(home)
  149. expect(await composePrefix(ctx, '/workspace')).toEqual([])
  150. })
  151. it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
  152. const home = await tempDir('tool-restricted-catalog')
  153. const ctx = await setup(home)
  154. ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
  155. const { agent, scope } = await mintAgentScope(ctx, '/workspace')
  156. scope.ctx.tools.restrict({ deny: ['skill'] })
  157. expect(ctx.tools.get('skill', agent)).toBeUndefined()
  158. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  159. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  160. await scope.dispose()
  161. })
  162. it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => {
  163. const home = await tempDir('tool-shadowed-catalog')
  164. const ctx = await setup(home)
  165. ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
  166. const { agent, scope } = await mintAgentScope(ctx, '/workspace')
  167. scope.ctx.tools.register(defineTool({
  168. name: 'skill',
  169. description: 'A scoped tool with unrelated semantics.',
  170. parameters: {},
  171. execute() {
  172. return Promise.resolve([{ type: 'text', text: 'shadow' }])
  173. },
  174. }))
  175. expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill'))
  176. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  177. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  178. await scope.dispose()
  179. })
  180. it('validates the catalog description cap', async () => {
  181. const home = await tempDir('tool-invalid-catalog-cap')
  182. const ctx = new Context()
  183. await ctx.plugin(SystemPrompt)
  184. await ctx.plugin(ToolRegistry)
  185. await ctx.plugin(SkillService)
  186. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
  187. await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
  188. })
  189. it('loads a skill for the calling agent cwd', async () => {
  190. const home = await tempDir('tool-load')
  191. const project = await tempDir('tool-project')
  192. await mkdir(join(project, '.git'), { recursive: true })
  193. await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
  194. const ctx = await setup(home)
  195. const result = await ctx.tools.execute({
  196. callId: CallId('c1'),
  197. name: 'skill',
  198. arguments: { name: 'project-skill' },
  199. agent: { session: { header: { cwd: project } } } as never,
  200. })
  201. expect(result.isError).toBe(false)
  202. const block = result.content[0]
  203. expect(block?.type).toBe('text')
  204. if (block?.type !== 'text') throw new Error('expected text skill result')
  205. expect(block.text).toBe([
  206. '<skill_content name="project-skill">',
  207. '<skill_resources>',
  208. `Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`,
  209. 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
  210. '</skill_resources>',
  211. '',
  212. '<skill_instructions>',
  213. 'Project instructions.',
  214. '</skill_instructions>',
  215. '</skill_content>',
  216. ].join('\n'))
  217. expect(block.text).not.toContain('# Skill:')
  218. })
  219. it('renders provider-managed resource hints for non-local skills', async () => {
  220. const home = await tempDir('tool-resource-hints')
  221. const ctx = await setup(home)
  222. ctx.skills.register({
  223. name: 'opaque-skill',
  224. description: 'Opaque skill',
  225. source: 'runtime',
  226. provider: 'runtime',
  227. resourceBase: { kind: 'opaque', description: 'runtime memory' },
  228. content: 'Opaque instructions.',
  229. })
  230. ctx.skills.register({
  231. name: 'url-skill',
  232. description: 'URL skill',
  233. source: 'runtime',
  234. provider: 'runtime',
  235. resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
  236. content: 'URL instructions.',
  237. })
  238. ctx.skills.register({
  239. name: 'provider-skill',
  240. description: 'Provider skill',
  241. source: 'runtime',
  242. provider: 'runtime',
  243. content: 'Provider instructions.',
  244. })
  245. const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
  246. const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
  247. const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
  248. if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
  249. throw new Error('expected text tool results')
  250. }
  251. expect(opaque.content[0].text).toContain('<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>')
  252. expect(url.content[0].text).toContain('<skill_resources>\nBase URL for this skill: https://skills.example.test/url-skill\nResolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.\n</skill_resources>')
  253. expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
  254. })
  255. it('fails loud on an unknown resource base kind', async () => {
  256. const home = await tempDir('tool-resource-assert-never')
  257. const ctx = await setup(home)
  258. ctx.skills.register({
  259. name: 'rogue-resource-skill',
  260. description: 'Rogue resource skill',
  261. source: 'runtime',
  262. provider: 'runtime',
  263. resourceBase: { kind: 'future' } as never,
  264. content: 'Rogue instructions.',
  265. })
  266. const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
  267. expect(result.isError).toBe(true)
  268. const block = result.content[0]
  269. if (block?.type !== 'text') throw new Error('expected text tool result')
  270. expect(block.text).toContain('unreachable variant')
  271. })
  272. it('returns isError for unknown, invalid, and model-disabled skills', async () => {
  273. const home = await tempDir('tool-errors')
  274. await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
  275. await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
  276. const ctx = await setup(home)
  277. const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
  278. const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
  279. const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
  280. expect(unknown.isError).toBe(true)
  281. expect(invalid.isError).toBe(true)
  282. expect(disabled.isError).toBe(true)
  283. const unknownBlock = unknown.content[0]
  284. if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
  285. expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
  286. })
  287. })