scoped.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. import { describe, expect, expectTypeOf, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import type { Events } from 'cordis'
  4. import { createScope } from '@deepseek-ai/dsh-scope'
  5. import type { Scope } from '@deepseek-ai/dsh-scope'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRegistry from '@deepseek-ai/dsh-tools'
  8. import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
  9. import type { Agent } from '@deepseek-ai/dsh-agent'
  10. import { CallId } from '@deepseek-ai/dsh-llm'
  11. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  12. import type { SessionId } from '@deepseek-ai/dsh-session'
  13. /** Mount the registry (with its systemPrompt dependency) on a fresh context. */
  14. async function mount(): Promise<Context> {
  15. const ctx = new Context()
  16. await ctx.plugin(SystemPrompt, {})
  17. await ctx.plugin(ToolRegistry)
  18. return ctx
  19. }
  20. /** Mint a scope whose key doubles as a minimal Agent-like object. */
  21. async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
  22. const key = { id: name as SessionId } as Agent
  23. let scope!: Scope
  24. // The scoped context resolves services through the MINTING plugin's
  25. // dependency chain — the minter must inject what scope holders will reach
  26. // (in production the agent loop's inject list plays this role).
  27. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
  28. { inject: ['tools', 'systemPrompt'] }))
  29. return { scope, key }
  30. }
  31. function tool(name: string, reply = `ran:${name}`): ToolDefinition {
  32. return {
  33. name,
  34. description: `tool ${name}`,
  35. parameters: { type: 'object', properties: {} },
  36. execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
  37. }
  38. }
  39. async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
  40. const result = await ctx.tools.execute({
  41. callId: CallId('c1'),
  42. name,
  43. arguments: {},
  44. ...agent ? { agent } : {},
  45. })
  46. const first = result.content[0]
  47. return first?.type === 'text' ? first.text : JSON.stringify(result.content)
  48. }
  49. describe('scoped tool registration', () => {
  50. it('keeps final-result observers synchronous', () => {
  51. type ToolResultListener = Events['tools/result']
  52. type AsyncToolResultListener = () => Promise<void>
  53. expectTypeOf<AsyncToolResultListener>().not.toExtend<ToolResultListener>()
  54. expectTypeOf<ReturnType<ToolResultListener>>().toEqualTypeOf<undefined>()
  55. })
  56. it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
  57. const ctx = await mount()
  58. const { scope, key } = await mintAgentScope(ctx, 'a')
  59. const other = { id: 'other' as SessionId } as Agent
  60. ctx.tools.register(tool('shared'))
  61. scope.ctx.tools.register(tool('mine'))
  62. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['mine', 'shared'])
  63. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['shared'])
  64. expect(ctx.tools.schemas(other).map(t => t.name)).toEqual(['shared'])
  65. expect(await run(ctx, 'mine', key)).toBe('ran:mine')
  66. // Out-of-view execution is indistinguishable from a nonexistent tool.
  67. expect(await run(ctx, 'mine', other)).toBe('Error: unknown tool "mine"')
  68. expect(await run(ctx, 'mine')).toBe('Error: unknown tool "mine"')
  69. })
  70. it('scoped shadows global on a name conflict, in either registration order', async () => {
  71. const ctx = await mount()
  72. const { scope, key } = await mintAgentScope(ctx, 'a')
  73. // scoped-then-global
  74. scope.ctx.tools.register(tool('bash', 'restricted-bash'))
  75. ctx.tools.register(tool('bash', 'global-bash'))
  76. expect(await run(ctx, 'bash', key)).toBe('restricted-bash')
  77. expect(await run(ctx, 'bash')).toBe('global-bash')
  78. expect(ctx.tools.get('bash', key)?.description).toBe(ctx.tools.get('bash', key)?.description)
  79. // Exactly one 'bash' in the scope's schema view (the shadow, not a double).
  80. expect(ctx.tools.schemas(key).filter(t => t.name === 'bash')).toHaveLength(1)
  81. })
  82. it('rejects a duplicate name within one layer, naming agent.ctx for the global case', async () => {
  83. const ctx = await mount()
  84. const { scope } = await mintAgentScope(ctx, 'a')
  85. ctx.tools.register(tool('x'))
  86. expect(() => ctx.tools.register(tool('x'))).toThrow(/agent\.ctx/)
  87. scope.ctx.tools.register(tool('y'))
  88. expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
  89. })
  90. it('disposing the scope unwinds its registrations and leaves no residue', async () => {
  91. const ctx = await mount()
  92. const { scope, key } = await mintAgentScope(ctx, 'a')
  93. scope.ctx.tools.register(tool('mine'))
  94. expect(ctx.tools.get('mine', key)).toBeDefined()
  95. await scope.dispose()
  96. expect(ctx.tools.get('mine', key)).toBeUndefined()
  97. expect(ctx.tools.schemas(key)).toEqual([])
  98. })
  99. })
  100. describe('restrict()', () => {
  101. it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', async () => {
  102. const ctx = await mount()
  103. const { scope, key } = await mintAgentScope(ctx, 'a')
  104. ctx.tools.register(tool('read'))
  105. ctx.tools.register(tool('bash'))
  106. scope.ctx.tools.register(tool('capture'))
  107. scope.ctx.tools.restrict({ allow: ['read'] })
  108. // The scope-local registration survives the allow-list; the unlisted global is gone.
  109. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
  110. expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
  111. expect(await run(ctx, 'read', key)).toBe('ran:read')
  112. expect(await run(ctx, 'capture', key)).toBe('ran:capture')
  113. // Other scopes and the global view are untouched.
  114. expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
  115. })
  116. it('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => {
  117. const ctx = await mount()
  118. const denied = await mintAgentScope(ctx, 'denied')
  119. const allowed = await mintAgentScope(ctx, 'allowed')
  120. ctx.tools.register(tool('read'))
  121. ctx.tools.register(tool('bash'))
  122. denied.scope.ctx.tools.restrict({ deny: ['bash'] })
  123. allowed.scope.ctx.tools.restrict({ allow: ['read'] })
  124. ctx.tools.register(tool('web'))
  125. denied.scope.ctx.tools.register(tool('denied-local'))
  126. allowed.scope.ctx.tools.register(tool('allowed-local'))
  127. expect(ctx.tools.schemas(denied.key).map(t => t.name).sort())
  128. .toEqual(['denied-local', 'read', 'web'])
  129. expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort())
  130. .toEqual(['allowed-local', 'read'])
  131. expect(await run(ctx, 'web', denied.key)).toBe('ran:web')
  132. expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"')
  133. expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local')
  134. expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local')
  135. })
  136. it('composes multiple restrictions by intersection and lifts each independently', async () => {
  137. const ctx = await mount()
  138. const { scope, key } = await mintAgentScope(ctx, 'a')
  139. for (const name of ['a', 'b', 'c']) ctx.tools.register(tool(name))
  140. const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
  141. scope.ctx.tools.restrict({ deny: ['b'] })
  142. expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
  143. liftAllow()
  144. // The deny remains after the allow-list is lifted.
  145. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
  146. })
  147. it('compiles the readonly filter values at registration', async () => {
  148. const ctx = await mount()
  149. const { scope, key } = await mintAgentScope(ctx, 'a')
  150. ctx.tools.register(tool('a'))
  151. ctx.tools.register(tool('b'))
  152. const filter = { deny: ['a'] }
  153. scope.ctx.tools.restrict(filter)
  154. filter.deny.push('b')
  155. expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
  156. })
  157. it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
  158. const ctx = await mount()
  159. const { scope } = await mintAgentScope(ctx, 'a')
  160. ctx.tools.register(tool('real'))
  161. scope.ctx.tools.register(tool('local'))
  162. expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
  163. expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
  164. expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
  165. expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
  166. expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
  167. const emptyCtx = await mount()
  168. const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
  169. expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
  170. .toThrow(/known global tools: \(none\)/)
  171. })
  172. })
  173. describe('scoped execution dispatch', () => {
  174. it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
  175. const ctx = await mount()
  176. const { scope, key } = await mintAgentScope(ctx, 'a')
  177. const other = { id: 'other' as SessionId } as Agent
  178. ctx.tools.register(tool('t'))
  179. const seen: (string | undefined)[] = []
  180. scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise<PreToolDecision>) => {
  181. seen.push(exec.agent?.id)
  182. return Promise.resolve<PreToolDecision>({ kind: 'deny', reason: 'scoped veto' })
  183. })
  184. expect(await run(ctx, 't', key)).toBe('Error: scoped veto')
  185. expect(await run(ctx, 't', other)).toBe('ran:t')
  186. expect(await run(ctx, 't')).toBe('ran:t')
  187. expect(seen).toEqual(['a'])
  188. })
  189. it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
  190. const ctx = await mount()
  191. const { scope, key } = await mintAgentScope(ctx, 'a')
  192. const other = { id: 'other' as SessionId } as Agent
  193. let bodyCalls = 0
  194. ctx.tools.register({
  195. ...tool('t'),
  196. execute: () => {
  197. bodyCalls += 1
  198. return Promise.resolve([{ type: 'text', text: 'ran:t' }])
  199. },
  200. })
  201. const guard = (execution: Readonly<ToolExecution>): string => {
  202. expect(Object.isFrozen(execution.arguments)).toBe(true)
  203. return 'terminal policy'
  204. }
  205. const liftFirst = scope.ctx.tools.guard(guard)
  206. scope.ctx.tools.guard(guard)
  207. // Registered later and prepended outside every existing waterfall listener:
  208. // it can force the extensible pre decision to allow, but cannot bypass the
  209. // owner-level monotonic guard that runs after the waterfall.
  210. scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
  211. expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
  212. expect(await run(ctx, 't', other)).toBe('ran:t')
  213. expect(bodyCalls).toBe(1)
  214. liftFirst()
  215. expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
  216. await scope.dispose()
  217. expect(await run(ctx, 't', key)).toBe('ran:t')
  218. expect(bodyCalls).toBe(2)
  219. })
  220. it('composes global guards monotonically when one abstains and a later one denies', async () => {
  221. const ctx = await mount()
  222. let bodyCalls = 0
  223. ctx.tools.register({
  224. ...tool('t'),
  225. execute: () => {
  226. bodyCalls += 1
  227. return Promise.resolve([])
  228. },
  229. })
  230. ctx.tools.guard(() => undefined)
  231. ctx.tools.guard(() => 'global denial')
  232. expect(await run(ctx, 't')).toBe('Error: global denial')
  233. expect(bodyCalls).toBe(0)
  234. })
  235. it('shares one token and materialized argument value across the pipeline', async () => {
  236. const ctx = await mount()
  237. const { scope, key } = await mintAgentScope(ctx, 'a')
  238. let safeCalls = 0
  239. let dangerCalls = 0
  240. let scopedResults = 0
  241. let safeArguments: unknown
  242. const tokens = new Set<ToolExecutionToken>()
  243. ctx.tools.register({
  244. ...tool('safe'),
  245. execute: (args) => {
  246. safeCalls += 1
  247. safeArguments = args
  248. return Promise.resolve([{ type: 'text', text: 'safe' }])
  249. },
  250. })
  251. ctx.tools.register({
  252. ...tool('danger'),
  253. execute: () => {
  254. dangerCalls += 1
  255. return Promise.resolve([{ type: 'text', text: 'danger' }])
  256. },
  257. })
  258. scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
  259. ctx.on('tools/pre-execute', (exec, next) => {
  260. tokens.add(exec.token)
  261. expect(Object.isFrozen(exec.arguments)).toBe(true)
  262. return next()
  263. })
  264. ctx.on('tools/execute', (exec, next) => {
  265. tokens.add(exec.token)
  266. return next()
  267. })
  268. ctx.on('tools/post-execute', (exec, _result, next) => {
  269. tokens.add(exec.token)
  270. return next()
  271. })
  272. scope.ctx.on('tools/result', () => { scopedResults += 1 })
  273. expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
  274. const callerArguments = { source: true }
  275. const safeResult = await ctx.tools.execute({
  276. callId: CallId('safe-call'),
  277. name: 'safe',
  278. arguments: callerArguments,
  279. agent: key,
  280. })
  281. expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
  282. expect(Object.isFrozen(callerArguments)).toBe(false)
  283. expect(safeArguments).not.toBe(callerArguments)
  284. expect(Object.isFrozen(safeArguments)).toBe(true)
  285. expect(callerArguments).toEqual({ source: true })
  286. // One token for danger and one shared by every phase of safe.
  287. expect(tokens.size).toBe(2)
  288. expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
  289. safeCalls: 1,
  290. dangerCalls: 0,
  291. scopedResults: 2,
  292. })
  293. })
  294. it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
  295. const ctx = await mount()
  296. const { scope, key } = await mintAgentScope(ctx, 'a')
  297. let policyCalls = 0
  298. let bodyCalls = 0
  299. let scopedObserved = 0
  300. let globalObserved = 0
  301. ctx.tools.register({
  302. ...tool('t'),
  303. execute: () => {
  304. bodyCalls += 1
  305. return Promise.resolve([])
  306. },
  307. })
  308. ctx.on('tools/pre-execute', (_exec, next) => {
  309. policyCalls += 1
  310. return next()
  311. })
  312. let parent!: ToolExecutionToken
  313. ctx.tools.register(tool('parent'))
  314. const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
  315. if (exec.name === 'parent') parent = exec.token
  316. return next()
  317. })
  318. await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
  319. stopCapture()
  320. policyCalls = 0
  321. const signal = new AbortController().signal
  322. scope.ctx.on('tools/result', (exec, result) => {
  323. scopedObserved += 1
  324. expect(exec.arguments).toBeUndefined()
  325. expect(exec.parent).toBe(parent)
  326. expect(exec.signal).toBe(signal)
  327. expect(Object.isFrozen(exec)).toBe(true)
  328. expect(result.isError).toBe(true)
  329. })
  330. ctx.on('tools/result', () => { globalObserved += 1 })
  331. const callerArguments = { invalid: () => undefined }
  332. const scopedResult = await ctx.tools.execute({
  333. callId: CallId('non-cloneable'),
  334. name: 't',
  335. arguments: callerArguments,
  336. agent: key,
  337. parent,
  338. signal,
  339. })
  340. const subjectlessResult = await ctx.tools.execute({
  341. callId: CallId('non-cloneable-subjectless'),
  342. name: 't',
  343. arguments: { invalid: () => undefined },
  344. })
  345. expect(scopedResult.isError).toBe(true)
  346. expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
  347. expect(subjectlessResult.isError).toBe(true)
  348. expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
  349. policyCalls: 0,
  350. bodyCalls: 0,
  351. scopedObserved: 1,
  352. globalObserved: 2,
  353. })
  354. expect(Object.isFrozen(callerArguments)).toBe(false)
  355. expect(callerArguments.invalid).toBeTypeOf('function')
  356. })
  357. it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
  358. const ctx = await mount()
  359. const observed: (ToolExecutionToken | undefined)[] = []
  360. ctx.tools.register({
  361. ...tool('t'),
  362. execute: (_args, exec) => {
  363. observed.push(exec.parent)
  364. return Promise.resolve([{ type: 'text', text: 'ran:t' }])
  365. },
  366. })
  367. ctx.on('tools/pre-execute', (exec, next) => {
  368. observed.push(exec.parent)
  369. return next()
  370. })
  371. ctx.on('tools/execute', (exec, next) => {
  372. observed.push(exec.parent)
  373. return next()
  374. })
  375. ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
  376. const forged = { fake: true } as unknown as ToolExecutionToken
  377. let parentReads = 0
  378. const input = {
  379. callId: CallId('stateful-parent'),
  380. name: 't',
  381. arguments: {},
  382. get parent(): ToolExecutionToken | undefined {
  383. parentReads += 1
  384. return parentReads === 1 ? undefined : forged
  385. },
  386. } as ToolExecutionInput
  387. const result = await ctx.tools.execute(input)
  388. expect(result.isError).toBe(false)
  389. expect(parentReads).toBe(1)
  390. expect(observed).toEqual([undefined, undefined, undefined, undefined])
  391. })
  392. it('uses one input snapshot for the normalized error shell', async () => {
  393. const ctx = await mount()
  394. const { scope, key } = await mintAgentScope(ctx, 'accepted')
  395. const driftAgent = { id: 'drift' as SessionId } as Agent
  396. ctx.tools.register(tool('parent'))
  397. ctx.tools.register(tool('t'))
  398. let parent!: ToolExecutionToken
  399. const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
  400. if (exec.name === 'parent') parent = exec.token
  401. return next()
  402. })
  403. await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
  404. stopCapture()
  405. const acceptedSignal = new AbortController().signal
  406. const driftSignal = new AbortController().signal
  407. const forged = { fake: true } as unknown as ToolExecutionToken
  408. const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
  409. const input = {
  410. get callId() { reads.callId += 1; return CallId('unstable-error') },
  411. get name() { reads.name += 1; return 't' },
  412. get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
  413. get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
  414. get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
  415. get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
  416. } as ToolExecutionInput
  417. let observed: Readonly<ToolExecution> | undefined
  418. let scopedObserved = 0
  419. ctx.on('tools/result', (exec) => { observed = exec })
  420. scope.ctx.on('tools/result', () => { scopedObserved += 1 })
  421. const result = await ctx.tools.execute(input)
  422. expect(result.isError).toBe(true)
  423. expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
  424. expect(scopedObserved).toBe(1)
  425. expect(observed).toMatchObject({
  426. callId: CallId('unstable-error'),
  427. name: 't',
  428. agent: key,
  429. parent,
  430. signal: acceptedSignal,
  431. })
  432. expect(Object.isFrozen(observed)).toBe(true)
  433. })
  434. it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
  435. const ctx = await mount()
  436. ctx.tools.register(tool('t'))
  437. let argumentReads = 0
  438. let observed = 0
  439. ctx.on('tools/result', (exec, result) => {
  440. observed += 1
  441. expect(exec.arguments).toBeUndefined()
  442. expect(result.isError).toBe(true)
  443. })
  444. const input = {
  445. callId: CallId('throwing-arguments'),
  446. name: 't',
  447. get arguments(): unknown {
  448. argumentReads += 1
  449. throw new Error('getter exploded')
  450. },
  451. } as ToolExecutionInput
  452. const result = await ctx.tools.execute(input)
  453. expect(result.isError).toBe(true)
  454. expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
  455. expect(argumentReads).toBe(1)
  456. expect(observed).toBe(1)
  457. })
  458. it.each([
  459. ['Map', new Map([['mutable', true]])],
  460. ['class instance', new (class Arguments { value = 1 })()],
  461. ])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
  462. const ctx = await mount()
  463. let policyCalls = 0
  464. let bodyCalls = 0
  465. let observed = 0
  466. ctx.tools.register({
  467. ...tool('t'),
  468. execute: () => {
  469. bodyCalls += 1
  470. return Promise.resolve([])
  471. },
  472. })
  473. ctx.on('tools/pre-execute', (_exec, next) => {
  474. policyCalls += 1
  475. return next()
  476. })
  477. ctx.on('tools/result', (exec, result) => {
  478. observed += 1
  479. expect(exec.arguments).toBeUndefined()
  480. expect(result.isError).toBe(true)
  481. })
  482. const result = await ctx.tools.execute({
  483. callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
  484. })
  485. expect(result.isError).toBe(true)
  486. expect(result.content).toEqual([{
  487. type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
  488. }])
  489. expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
  490. })
  491. it('reads nested arguments once into the executed snapshot', async () => {
  492. const ctx = await mount()
  493. ctx.tools.register(tool('t'))
  494. let reads = 0
  495. const argumentsValue = Object.defineProperty({}, 'value', {
  496. enumerable: true,
  497. get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
  498. })
  499. const result = await ctx.tools.execute({
  500. callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
  501. })
  502. expect(reads).toBe(1)
  503. expect(result).toEqual({
  504. content: [{ type: 'text', text: 'ran:t' }],
  505. isError: false,
  506. })
  507. })
  508. it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
  509. const ctx = await mount()
  510. const { scope, key } = await mintAgentScope(ctx, 'a')
  511. ctx.tools.register(tool('t'))
  512. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
  513. const seen: boolean[] = []
  514. const dispatchModes: string[] = []
  515. ctx.on('internal/dispatch', (mode, name) => {
  516. if (name === 'tools/result') dispatchModes.push(mode)
  517. })
  518. ctx.on('tools/execute', async (_exec, next) => {
  519. await next()
  520. return {
  521. content: [{ type: 'text', text: 'outer failure' }],
  522. isError: true,
  523. }
  524. }, { prepend: true })
  525. scope.ctx.on('tools/result', (_exec, result) => {
  526. expect(Object.isFrozen(_exec)).toBe(true)
  527. expect(Object.isFrozen(_exec.arguments)).toBe(true)
  528. expect(Object.isFrozen(result)).toBe(true)
  529. expect(Object.isFrozen(result.content)).toBe(true)
  530. seen.push(result.isError)
  531. })
  532. ctx.on('tools/result', () => {
  533. throw { toString: () => { throw new Error('coercion trap') } }
  534. })
  535. ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
  536. const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
  537. expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
  538. expect(seen).toEqual([true, true])
  539. expect(dispatchModes).toEqual(['emit'])
  540. expect(warn).toHaveBeenCalledOnce()
  541. expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
  542. })
  543. })