tool-skill.spec.ts 15 KB

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