tool-skill.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  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 { createScope, type Scope } from '@deepseek-ai/dsh-scope'
  8. import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  11. import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
  12. import SkillService from '@deepseek-ai/dsh-skill'
  13. import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
  14. import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
  15. const testToolSignal = new AbortController().signal
  16. async function tempDir(name: string): Promise<string> {
  17. return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
  18. }
  19. async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
  20. const dir = join(root, name)
  21. await mkdir(dir, { recursive: true })
  22. await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
  23. }
  24. async function setup(home: string, config: toolSkill.Config = {}): Promise<Context> {
  25. const ctx = new Context()
  26. await ctx.plugin(SystemPrompt)
  27. await ctx.plugin(ToolRegistry)
  28. await ctx.plugin(AgentRegistry)
  29. await ctx.plugin(SkillService)
  30. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  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 = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
  37. return {
  38. ctx: new Context(),
  39. id,
  40. options: {},
  41. session,
  42. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  43. status: 'idle',
  44. send: () => {},
  45. followup: () => {},
  46. steer: () => {},
  47. inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
  48. cancel() {},
  49. runMaintenance: task => task(new AbortController().signal),
  50. whenIdle: () => Promise.resolve(),
  51. }
  52. }
  53. function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
  54. return {
  55. id: SessionId(id),
  56. options: {},
  57. session,
  58. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  59. status: 'running',
  60. ctx: new Context(),
  61. send: () => {},
  62. followup: () => {},
  63. steer: () => {},
  64. inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
  65. cancel() {},
  66. runMaintenance: task => task(new AbortController().signal),
  67. whenIdle: () => Promise.resolve(),
  68. }
  69. }
  70. function openMessageTurn(session: Session, turn = 1): void {
  71. session.append('turn/start', { turn })
  72. session.append('user/message', createUserMessage({
  73. content: [{ type: 'text', text: `turn ${turn}` }],
  74. source: { kind: 'user' },
  75. }), { surfaceOp: 'append' })
  76. }
  77. async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
  78. const signal = new AbortController().signal
  79. const decision = await agentEvents(ctx, agent).waterfall(
  80. 'agent/pre-step',
  81. [],
  82. { turn, step, signal },
  83. () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
  84. )
  85. if (decision.kind === 'enter') {
  86. for (const message of decision.messages) {
  87. agent.session.append('user/message', message, { surfaceOp: 'append' })
  88. }
  89. }
  90. }
  91. async function proposeStep(
  92. ctx: Context,
  93. agent: Agent,
  94. messages: UserMessage[],
  95. ): Promise<PreStepDecision> {
  96. const signal = new AbortController().signal
  97. return await agentEvents(ctx, agent).waterfall(
  98. 'agent/pre-step',
  99. messages,
  100. { turn: 1, step: 1, signal },
  101. () => Promise.resolve({ kind: 'enter' as const, messages }),
  102. )
  103. }
  104. function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
  105. return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
  106. && event.data.source.kind === 'plugin'
  107. && event.data.source.plugin === 'dsh-tool-skill')
  108. }
  109. function catalogContent(entries: string[]): Message['content'] {
  110. return [{
  111. type: 'text',
  112. text: ['<system-reminder>', '<available_skills>', ...entries, '</available_skills>', '</system-reminder>'].join('\n'),
  113. }]
  114. }
  115. async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
  116. return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
  117. }
  118. async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
  119. const decision = await agentEvents(ctx, agent).waterfall(
  120. 'agent/pre-step',
  121. [],
  122. { turn: 1, step: 1, signal },
  123. () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
  124. )
  125. if (decision.kind === 'enter') {
  126. for (const message of decision.messages) {
  127. agent.session.append('user/message', message, { surfaceOp: 'append' })
  128. }
  129. }
  130. return agent.session.deriveMessages()
  131. }
  132. async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> {
  133. const agent = typeof subject === 'string' ? agentForCwd(subject) : subject
  134. let scope!: Scope
  135. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, {
  136. inject: ['tools'],
  137. }))
  138. return { agent, scope }
  139. }
  140. describe('dsh-tool-skill', () => {
  141. it('registers the skill tool schema and removes it on dispose', async () => {
  142. const ctx = new Context()
  143. await ctx.plugin(SystemPrompt)
  144. await ctx.plugin(ToolRegistry)
  145. await ctx.plugin(AgentRegistry)
  146. const home = await tempDir('tool-schema')
  147. await ctx.plugin(SkillService)
  148. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  149. ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
  150. const fiber = await ctx.plugin(toolSkill)
  151. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
  152. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  153. expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
  154. card: 'generic',
  155. title: 'Load skill project-skill',
  156. kind: 'read',
  157. rawInput: 'project-skill',
  158. })
  159. await fiber.dispose()
  160. expect(ctx.tools.schemas()).toEqual([])
  161. expect(await composePrefix(ctx, '/workspace')).toEqual([])
  162. toolSkill.apply(ctx)
  163. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
  164. })
  165. it('forwards the step abort signal to skill discovery', async () => {
  166. const home = await tempDir('tool-prefix-signal')
  167. const ctx = await setup(home)
  168. let seenSignal: AbortSignal | undefined
  169. ctx.skills.registerProvider(() => ({
  170. name: 'signal-probe',
  171. async list(options) {
  172. seenSignal = options.signal
  173. return []
  174. },
  175. async get() {
  176. return undefined
  177. },
  178. }))
  179. const controller = new AbortController()
  180. await composePrefix(ctx, '/workspace', controller.signal)
  181. expect(seenSignal).toBe(controller.signal)
  182. })
  183. it('injects a stable durable name-and-description catalog at the first step', async () => {
  184. const home = await tempDir('tool-catalog')
  185. const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
  186. ctx.skills.register({
  187. name: 'z-skill',
  188. description: 'Long description '.repeat(5),
  189. whenToUse: 'Never render this routing hint.',
  190. source: 'secret-source',
  191. provider: 'runtime',
  192. resourceBase: { kind: 'directory', path: '/secret/path' },
  193. content: 'Secret body.',
  194. })
  195. ctx.skills.register({
  196. name: 'a-skill',
  197. description: 'Use {{placeholder}} <safely> & carefully.',
  198. source: 'runtime',
  199. provider: 'runtime',
  200. content: 'A body.',
  201. })
  202. ctx.skills.register({
  203. name: 'model-only-skill',
  204. description: 'Model-only skill.',
  205. invocation: { modelInvocable: true, userInvocable: false },
  206. source: 'runtime',
  207. content: 'Model-only body.',
  208. })
  209. ctx.skills.register({
  210. name: 'user-only-skill',
  211. description: 'User-only skill.',
  212. invocation: { modelInvocable: false, userInvocable: true },
  213. source: 'runtime',
  214. content: 'User-only body.',
  215. })
  216. ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
  217. const decision = await next()
  218. if (decision.kind === 'reject') return decision
  219. return {
  220. ...decision,
  221. messages: [
  222. ...decision.messages,
  223. createUserMessage({
  224. content: [{ type: 'text', text: 'later contribution' }],
  225. source: { kind: 'plugin', plugin: 'later-contribution' },
  226. }),
  227. ],
  228. }
  229. })
  230. const prefix = await composePrefix(ctx, '/workspace')
  231. expect(prefix).toEqual([
  232. {
  233. id: expect.any(String) as unknown,
  234. role: 'user',
  235. content: [{ type: 'text', text: 'later contribution' }],
  236. source: { kind: 'plugin', plugin: 'later-contribution' },
  237. },
  238. {
  239. id: expect.any(String) as unknown,
  240. role: 'user',
  241. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  242. content: [{
  243. type: 'text',
  244. text: [
  245. '<system-reminder>',
  246. 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
  247. '',
  248. '<available_skills>',
  249. '- `a-skill`: Use {{placeholder}} &lt;safely&gt; &amp; carefully.',
  250. '- `model-only-skill`: Model-only skill.',
  251. '- `z-skill`: Long description Long description Long descript...',
  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. '</system-reminder>',
  256. ].join('\n'),
  257. }],
  258. },
  259. ])
  260. const rendered = JSON.stringify(prefix[1])
  261. expect(rendered).not.toContain('whenToUse')
  262. expect(rendered).not.toContain('secret-source')
  263. expect(rendered).not.toContain('/secret/path')
  264. expect(rendered).not.toContain('Secret body')
  265. expect(rendered).not.toContain('user-only-skill')
  266. expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
  267. })
  268. it('does not inject a catalog when no model-invocable skills are available', async () => {
  269. const home = await tempDir('tool-empty-catalog')
  270. const ctx = await setup(home)
  271. ctx.skills.register({
  272. name: 'user-only-skill',
  273. description: 'User-only skill',
  274. invocation: { modelInvocable: false, userInvocable: true },
  275. source: 'runtime',
  276. content: 'User-only body.',
  277. })
  278. const agent = agentForCwd('/workspace')
  279. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  280. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  281. })
  282. it('omits an incomplete initial catalog and retries on a later request boundary', async () => {
  283. const home = await tempDir('tool-incomplete-prefix')
  284. const ctx = await setup(home)
  285. let failing = true
  286. const provider = {
  287. name: 'recovering',
  288. async list() {
  289. if (failing) throw new Error('temporarily unavailable')
  290. return []
  291. },
  292. async get() {
  293. return undefined
  294. },
  295. }
  296. let invalidate = (): void => {}
  297. ctx.skills.registerProvider((control) => {
  298. invalidate = control.invalidate
  299. return provider
  300. })
  301. const session = Session.create(SessionId('incomplete-prefix'))
  302. const agent = sessionAgent(session)
  303. openMessageTurn(session)
  304. await composePrefixForAgent(ctx, agent)
  305. expect(catalogMessages(session)).toEqual([])
  306. failing = false
  307. invalidate()
  308. await fireStep(ctx, agent, 1, 1)
  309. expect(catalogMessages(session)).toEqual([])
  310. })
  311. it('records an empty baseline across repeated step observations', async () => {
  312. const home = await tempDir('tool-empty-step')
  313. const ctx = await setup(home)
  314. const session = Session.create(SessionId('empty-step'))
  315. const agent = sessionAgent(session)
  316. openMessageTurn(session)
  317. await fireStep(ctx, agent, 1, 1)
  318. await fireStep(ctx, agent, 1, 2)
  319. expect(catalogMessages(session)).toEqual([])
  320. })
  321. it('deduplicates or replaces a catalog already proposed for the same step', async () => {
  322. const home = await tempDir('tool-proposed-catalog')
  323. const ctx = await setup(home)
  324. const disposeFirst = ctx.skills.register({
  325. name: 'first-skill',
  326. description: 'First skill',
  327. source: 'runtime',
  328. content: 'First body.',
  329. })
  330. const session = Session.create(SessionId('proposed-catalog'))
  331. const agent = sessionAgent(session)
  332. openMessageTurn(session)
  333. await fireStep(ctx, agent, 1, 1)
  334. const initial = catalogMessages(session)[0]?.data
  335. if (initial === undefined) throw new Error('expected initial catalog')
  336. const duplicate = await proposeStep(ctx, agent, [initial])
  337. expect(duplicate).toEqual({ kind: 'enter', messages: [] })
  338. ctx.skills.register({
  339. name: 'second-skill',
  340. description: 'Second skill',
  341. source: 'runtime',
  342. content: 'Second body.',
  343. })
  344. const companion = createUserMessage({
  345. content: [{ type: 'text', text: 'keep this message' }],
  346. source: { kind: 'user' },
  347. })
  348. const replaced = await proposeStep(ctx, agent, [companion, initial])
  349. expect(replaced.kind).toBe('enter')
  350. if (replaced.kind === 'reject') throw new Error('expected catalog replacement')
  351. expect(replaced.messages).toHaveLength(2)
  352. expect(replaced.messages[0]).toBe(companion)
  353. expect(replaced.messages[1]?.id).not.toBe(initial.id)
  354. expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill')
  355. disposeFirst()
  356. })
  357. it('removes a stale proposed catalog before the first empty baseline', async () => {
  358. const home = await tempDir('tool-proposed-empty-catalog')
  359. const ctx = await setup(home)
  360. const session = Session.create(SessionId('proposed-empty-catalog'))
  361. const stale = createUserMessage({
  362. content: catalogContent(['- `stale-skill`: Stale skill']),
  363. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  364. })
  365. const decision = await proposeStep(ctx, sessionAgent(session), [stale])
  366. expect(decision).toEqual({ kind: 'enter', messages: [] })
  367. })
  368. it('keeps a proposed catalog that already matches the current snapshot', async () => {
  369. const home = await tempDir('tool-matching-proposal')
  370. const ctx = await setup(home)
  371. ctx.skills.register({
  372. name: 'first-skill',
  373. description: 'First skill',
  374. source: 'runtime',
  375. content: 'First body.',
  376. })
  377. const session = Session.create(SessionId('matching-proposal'))
  378. const proposed = createUserMessage({
  379. content: catalogContent(['- `first-skill`: First skill']),
  380. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  381. })
  382. const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
  383. expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
  384. })
  385. it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
  386. const home = await tempDir('tool-dynamic-catalog')
  387. const ctx = await setup(home)
  388. const disposeFirst = ctx.skills.register({
  389. name: 'first-skill',
  390. description: 'First skill',
  391. source: 'runtime',
  392. content: 'First body.',
  393. })
  394. const session = Session.create(SessionId('dynamic-catalog'))
  395. const agent = sessionAgent(session)
  396. openMessageTurn(session)
  397. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
  398. await fireStep(ctx, agent, 1, 1)
  399. expect(catalogMessages(session)).toHaveLength(1)
  400. const disposeSecond = ctx.skills.register({
  401. name: 'second-skill',
  402. description: 'Second skill',
  403. source: 'runtime',
  404. content: 'Second body.',
  405. })
  406. await fireStep(ctx, agent, 1, 2)
  407. const addition = catalogMessages(session)[1]
  408. if (addition?.type !== 'user/message') throw new Error('expected catalog addition')
  409. expect(JSON.stringify(addition.data.content)).toContain('first-skill')
  410. expect(JSON.stringify(addition.data.content)).toContain('second-skill')
  411. disposeSecond()
  412. disposeFirst()
  413. await fireStep(ctx, agent, 1, 3)
  414. const removal = catalogMessages(session)[2]
  415. if (removal?.type !== 'user/message') throw new Error('expected catalog removal')
  416. expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available')
  417. expect(JSON.stringify(removal.data.content)).not.toContain('first-skill')
  418. expect(JSON.stringify(removal.data.content)).not.toContain('second-skill')
  419. await fireStep(ctx, agent, 1, 4)
  420. expect(catalogMessages(session)).toHaveLength(3)
  421. })
  422. it('resumes from the latest valid visible catalog content', async () => {
  423. const home = await tempDir('tool-catalog-resume')
  424. const ctx = await setup(home)
  425. ctx.skills.register({
  426. name: 'resumed-skill',
  427. description: 'Resumed skill',
  428. source: 'runtime',
  429. content: 'Resumed body.',
  430. })
  431. const session = Session.create(SessionId('catalog-resume'))
  432. const agent = sessionAgent(session)
  433. openMessageTurn(session)
  434. session.append('user/message', createUserMessage({
  435. content: catalogContent(['- `old-skill`: Old skill']),
  436. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  437. }), { surfaceOp: 'append' })
  438. session.append('user/message', createUserMessage({
  439. content: [{ type: 'text', text: 'missing catalog markers' }],
  440. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  441. }), { surfaceOp: 'append' })
  442. session.append('user/message', createUserMessage({
  443. content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
  444. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  445. }), { surfaceOp: 'append' })
  446. session.append('user/message', createUserMessage({
  447. content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
  448. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  449. }), { surfaceOp: 'append' })
  450. session.append('user/message', createUserMessage({
  451. content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
  452. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  453. }), { surfaceOp: 'append' })
  454. await fireStep(ctx, agent, 1, 1)
  455. expect(catalogMessages(session)).toHaveLength(6)
  456. expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
  457. })
  458. it('re-establishes the current catalog after compaction hides its durable message', async () => {
  459. const home = await tempDir('tool-catalog-compaction')
  460. const ctx = await setup(home)
  461. ctx.skills.register({
  462. name: 'first-skill',
  463. description: 'First skill',
  464. source: 'runtime',
  465. content: 'First body.',
  466. })
  467. const session = Session.create(SessionId('catalog-compaction'))
  468. const agent = sessionAgent(session)
  469. openMessageTurn(session)
  470. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
  471. const initial = catalogMessages(session)[0]
  472. if (initial === undefined) throw new Error('expected initial catalog')
  473. session.append('user/message', createUserMessage({
  474. content: [{ type: 'text', text: 'compacted history' }],
  475. source: { kind: 'plugin', plugin: 'compact' },
  476. }), {
  477. surfaceOp: { op: 'replace', start: initial.seq, end: initial.seq },
  478. sourceEventSeqs: [initial.seq],
  479. })
  480. await fireStep(ctx, agent, 1, 1)
  481. expect(catalogMessages(session)).toHaveLength(2)
  482. expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('first-skill')
  483. })
  484. it('keeps body-only edits out of the catalog and loads the latest body on demand', async () => {
  485. const home = await tempDir('tool-body-refresh')
  486. const root = join(home, '.dsh/skills')
  487. await writeSkill(root, 'body-skill', 'Stable description', 'First body.')
  488. const ctx = await setup(home)
  489. const session = Session.create(SessionId('body-refresh'))
  490. const agent = sessionAgent(session)
  491. openMessageTurn(session)
  492. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description')
  493. await writeSkill(root, 'body-skill', 'Stable description', 'Second body.')
  494. await fireStep(ctx, agent, 1, 1)
  495. expect(catalogMessages(session)).toHaveLength(1)
  496. const result = await ctx.tools.execute({
  497. signal: testToolSignal,
  498. callId: CallId('body-refresh'),
  499. name: 'skill',
  500. arguments: { name: 'body-skill' },
  501. agent,
  502. })
  503. expect(result.isError).toBe(false)
  504. expect(JSON.stringify(result.content)).toContain('Second body.')
  505. expect(JSON.stringify(result.content)).not.toContain('First body.')
  506. })
  507. it('retains the last-good catalog while any provider discovery is incomplete', async () => {
  508. const home = await tempDir('tool-incomplete-catalog')
  509. const ctx = await setup(home)
  510. const disposeStable = ctx.skills.register({
  511. name: 'stable-skill',
  512. description: 'Stable skill',
  513. source: 'runtime',
  514. content: 'Stable body.',
  515. })
  516. const session = Session.create(SessionId('incomplete-catalog'))
  517. const agent = sessionAgent(session)
  518. openMessageTurn(session)
  519. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
  520. ctx.skills.registerProvider(() => ({
  521. name: 'failing',
  522. async list() {
  523. throw new Error('temporarily unavailable')
  524. },
  525. async get() {
  526. return undefined
  527. },
  528. }))
  529. disposeStable()
  530. await fireStep(ctx, agent, 1, 1)
  531. expect(catalogMessages(session)).toHaveLength(1)
  532. })
  533. it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
  534. const home = await tempDir('tool-restricted-catalog')
  535. const ctx = await setup(home)
  536. ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
  537. const session = Session.create(SessionId('restricted-catalog'))
  538. const agent = sessionAgent(session)
  539. openMessageTurn(session)
  540. const { scope } = await mintAgentScope(ctx, agent)
  541. scope.ctx.tools.restrict({ deny: ['skill'] })
  542. expect(ctx.tools.get('skill', agent)).toBeUndefined()
  543. await composePrefixForAgent(ctx, agent)
  544. expect(catalogMessages(session)).toEqual([])
  545. await fireStep(ctx, agent, 1, 1)
  546. expect(catalogMessages(session)).toEqual([])
  547. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  548. await scope.dispose()
  549. })
  550. it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => {
  551. const home = await tempDir('tool-shadowed-catalog')
  552. const ctx = await setup(home)
  553. ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
  554. const { agent, scope } = await mintAgentScope(ctx, '/workspace')
  555. scope.ctx.tools.register(defineContentToolFixture({
  556. name: 'skill',
  557. description: 'A scoped tool with unrelated semantics.',
  558. parameters: {},
  559. execute() {
  560. return Promise.resolve([{ type: 'text', text: 'shadow' }])
  561. },
  562. }))
  563. expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill'))
  564. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  565. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  566. await scope.dispose()
  567. })
  568. it('validates the catalog description cap', async () => {
  569. const home = await tempDir('tool-invalid-catalog-cap')
  570. const ctx = new Context()
  571. await ctx.plugin(SystemPrompt)
  572. await ctx.plugin(ToolRegistry)
  573. await ctx.plugin(AgentRegistry)
  574. await ctx.plugin(SkillService)
  575. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  576. await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
  577. })
  578. it('loads a skill for the calling agent cwd', async () => {
  579. const home = await tempDir('tool-load')
  580. const project = await tempDir('tool-project')
  581. await mkdir(join(project, '.git'), { recursive: true })
  582. await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
  583. const ctx = await setup(home)
  584. const result = await ctx.tools.execute({
  585. signal: testToolSignal,
  586. callId: CallId('c1'),
  587. name: 'skill',
  588. arguments: { name: 'project-skill' },
  589. agent: { session: { header: { cwd: project } } } as never,
  590. })
  591. expect(result.isError).toBe(false)
  592. if (result.isError) throw new Error('expected skill success')
  593. expect(result.value).toEqual({
  594. name: 'project-skill',
  595. provider: 'local',
  596. resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') },
  597. content: 'Project instructions.',
  598. })
  599. const block = result.content[0]
  600. expect(block?.type).toBe('text')
  601. if (block?.type !== 'text') throw new Error('expected text skill result')
  602. expect(block.text).toBe([
  603. '<skill_content name="project-skill">',
  604. '<skill_resources>',
  605. `Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`,
  606. 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
  607. '</skill_resources>',
  608. '',
  609. '<skill_instructions>',
  610. 'Project instructions.',
  611. '</skill_instructions>',
  612. '</skill_content>',
  613. ].join('\n'))
  614. expect(block.text).not.toContain('# Skill:')
  615. })
  616. it('renders provider-managed resource hints for non-local skills', async () => {
  617. const home = await tempDir('tool-resource-hints')
  618. const ctx = await setup(home)
  619. ctx.skills.register({
  620. name: 'opaque-skill',
  621. description: 'Opaque skill',
  622. source: 'runtime',
  623. provider: 'runtime',
  624. resourceBase: { kind: 'opaque', description: 'runtime memory' },
  625. content: 'Opaque instructions.',
  626. })
  627. ctx.skills.register({
  628. name: 'url-skill',
  629. description: 'URL skill',
  630. source: 'runtime',
  631. provider: 'runtime',
  632. resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
  633. content: 'URL instructions.',
  634. })
  635. ctx.skills.register({
  636. name: 'provider-skill',
  637. description: 'Provider skill',
  638. source: 'runtime',
  639. provider: 'runtime',
  640. content: 'Provider instructions.',
  641. })
  642. const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
  643. const url = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
  644. const provider = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
  645. if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
  646. throw new Error('expected text tool results')
  647. }
  648. expect(opaque.content[0].text).toContain('<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>')
  649. 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>')
  650. 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>')
  651. })
  652. it('rejects an unknown resource-base kind at the canonical output boundary', async () => {
  653. const home = await tempDir('tool-resource-assert-never')
  654. const ctx = await setup(home)
  655. ctx.skills.register({
  656. name: 'rogue-resource-skill',
  657. description: 'Rogue resource skill',
  658. source: 'runtime',
  659. provider: 'runtime',
  660. resourceBase: { kind: 'future' } as never,
  661. content: 'Rogue instructions.',
  662. })
  663. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
  664. expect(result.isError).toBe(true)
  665. expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT')
  666. const block = result.content[0]
  667. if (block?.type !== 'text') throw new Error('expected text tool result')
  668. expect(block.text).toContain('value.resourceBase')
  669. })
  670. it('returns isError for unknown, invalid, and model-disabled skills', async () => {
  671. const home = await tempDir('tool-errors')
  672. await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
  673. await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n')
  674. const ctx = await setup(home)
  675. ctx.skills.register({
  676. name: 'model-only-skill',
  677. description: 'Model-only skill',
  678. invocation: { modelInvocable: true, userInvocable: false },
  679. source: 'runtime',
  680. content: 'Model-only instructions.',
  681. })
  682. const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
  683. const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
  684. const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
  685. const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } })
  686. expect(unknown.isError).toBe(true)
  687. expect(invalid.isError).toBe(true)
  688. expect(disabled.isError).toBe(true)
  689. expect(modelOnly.isError).toBe(false)
  690. const unknownBlock = unknown.content[0]
  691. if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
  692. expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
  693. })
  694. it('checks model policy before provider loading and rechecks the loaded definition', async () => {
  695. const home = await tempDir('tool-policy-before-load')
  696. const ctx = await setup(home)
  697. const getCalls: string[] = []
  698. ctx.skills.registerProvider(() => ({
  699. name: 'policy-probe',
  700. async list() {
  701. return [
  702. {
  703. name: 'denied-skill',
  704. description: 'Denied skill',
  705. invocation: { modelInvocable: false, userInvocable: true },
  706. provider: 'policy-probe',
  707. source: 'test',
  708. rank: 1,
  709. locator: 'denied-skill',
  710. },
  711. {
  712. name: 'policy-race-skill',
  713. description: 'Policy race skill',
  714. invocation: { modelInvocable: true, userInvocable: true },
  715. provider: 'policy-probe',
  716. source: 'test',
  717. rank: 1,
  718. locator: 'policy-race-skill',
  719. },
  720. {
  721. name: 'vanishing-skill',
  722. description: 'Vanishing skill',
  723. invocation: { modelInvocable: true, userInvocable: true },
  724. provider: 'policy-probe',
  725. source: 'test',
  726. rank: 1,
  727. locator: 'vanishing-skill',
  728. },
  729. ]
  730. },
  731. async get(candidate) {
  732. getCalls.push(candidate.name)
  733. if (candidate.name === 'vanishing-skill') return undefined
  734. return {
  735. ...candidate,
  736. invocation: { modelInvocable: false, userInvocable: true },
  737. content: 'Instructions must not be disclosed.',
  738. }
  739. },
  740. }))
  741. const denied = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } })
  742. const raced = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } })
  743. const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } })
  744. expect(denied.isError).toBe(true)
  745. expect(raced.isError).toBe(true)
  746. expect(vanished.isError).toBe(true)
  747. expect(getCalls).toEqual(['policy-race-skill', 'vanishing-skill'])
  748. for (const result of [denied, raced]) {
  749. const block = result.content[0]
  750. if (block?.type !== 'text') throw new Error('expected text tool result')
  751. expect(block.text).toContain('is not available for model invocation')
  752. expect(block.text).not.toContain('Instructions must not be disclosed.')
  753. }
  754. const vanishedBlock = vanished.content[0]
  755. if (vanishedBlock?.type !== 'text') throw new Error('expected text tool result')
  756. expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
  757. })
  758. })