tool-skill.spec.ts 15 KB

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