tool-skill.spec.ts 14 KB

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