tool-skill.spec.ts 37 KB

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