scoped.spec.ts 25 KB

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