scoped.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. import { describe, expect, expectTypeOf, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import type { Events } from '@deepseek-ai/cordis'
  4. import { bindScopeParent, 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 ToolRuntime 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(ToolRuntime)
  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 names it does not inherit', 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. // A scope's own registration is exempt from its own filter, so naming it
  170. // is a caller error rather than a silent no-op.
  171. expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
  172. expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall".*known global tools: real/s)
  173. expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
  174. const emptyCtx = await mount()
  175. const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
  176. expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
  177. .toThrow(/known global tools: \(none\)/)
  178. })
  179. })
  180. describe('restrict() over an inherited scope layer', () => {
  181. /** Mint a child scope parented to `parent`, as a subagent's creation window does. */
  182. async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> {
  183. const key = { id: name as SessionId } as Agent
  184. bindScopeParent(key, parentKey)
  185. let scope!: Scope
  186. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
  187. { inject: ['tools', 'systemPrompt'] }))
  188. return { scope, key }
  189. }
  190. it('filters tools the child inherits from an ancestor scope, not only global ones', async () => {
  191. // The shape every preset deployment has: no model-facing row in the global
  192. // layer, all of them contributed by an ancestor scope the child joined.
  193. const ctx = await mount()
  194. const parent = await mintAgentScope(ctx, 'parent')
  195. parent.scope.ctx.tools.register(tool('bash'))
  196. parent.scope.ctx.tools.register(tool('read'))
  197. const child = await mintChild(ctx, parent.key, 'child')
  198. expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
  199. child.scope.ctx.tools.restrict({ deny: ['bash'] })
  200. // Reading the exempt set as "the global layer" left this unfiltered, and
  201. // the name unrestrictable in the first place.
  202. expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read'])
  203. expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"')
  204. // The ancestor keeps its whole surface: a child's filter is its own.
  205. expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
  206. })
  207. it('keeps the child\'s own registrations outside its own filter', async () => {
  208. // The delegation runtime registers a child's reporting and structured
  209. // output tools into the child's own layer; an `allow` naming only the
  210. // capabilities the child may use must not strip them.
  211. const ctx = await mount()
  212. const parent = await mintAgentScope(ctx, 'parent')
  213. parent.scope.ctx.tools.register(tool('bash'))
  214. parent.scope.ctx.tools.register(tool('read'))
  215. const child = await mintChild(ctx, parent.key, 'child')
  216. child.scope.ctx.tools.register(tool('report'))
  217. child.scope.ctx.tools.restrict({ allow: ['read'] })
  218. expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report'])
  219. expect(await run(ctx, 'report', child.key)).toBe('ran:report')
  220. })
  221. it('lets an ancestor\'s restriction reach every scope nested inside it', async () => {
  222. const ctx = await mount()
  223. ctx.tools.register(tool('web'))
  224. const parent = await mintAgentScope(ctx, 'parent')
  225. parent.scope.ctx.tools.register(tool('bash'))
  226. const child = await mintChild(ctx, parent.key, 'child')
  227. parent.scope.ctx.tools.restrict({ deny: ['web'] })
  228. expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash'])
  229. expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash'])
  230. })
  231. })
  232. describe('scoped execution dispatch', () => {
  233. it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
  234. const ctx = await mount()
  235. const { scope, key } = await mintAgentScope(ctx, 'a')
  236. const other = { id: 'other' as SessionId } as Agent
  237. ctx.tools.register(tool('t'))
  238. const seen: (string | undefined)[] = []
  239. scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise<PreToolDecision>) => {
  240. seen.push(exec.agent?.id)
  241. return Promise.resolve<PreToolDecision>({ kind: 'deny', reason: 'scoped veto' })
  242. })
  243. expect(await run(ctx, 't', key)).toBe('Error: scoped veto')
  244. expect(await run(ctx, 't', other)).toBe('ran:t')
  245. expect(await run(ctx, 't')).toBe('ran:t')
  246. expect(seen).toEqual(['a'])
  247. })
  248. it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
  249. const ctx = await mount()
  250. const { scope, key } = await mintAgentScope(ctx, 'a')
  251. const other = { id: 'other' as SessionId } as Agent
  252. let bodyCalls = 0
  253. ctx.tools.register({
  254. ...tool('t'),
  255. execute: () => {
  256. bodyCalls += 1
  257. return Promise.resolve('ran:t')
  258. },
  259. })
  260. const guard = (execution: Readonly<ToolExecution>): string => {
  261. expect(Object.isFrozen(execution.arguments)).toBe(true)
  262. return 'terminal policy'
  263. }
  264. const liftFirst = scope.ctx.tools.guard(guard)
  265. scope.ctx.tools.guard(guard)
  266. // Registered later and prepended outside every existing waterfall listener:
  267. // it can force the extensible pre decision to allow, but cannot bypass the
  268. // owner-level monotonic guard that runs after the waterfall.
  269. scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
  270. expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
  271. expect(await run(ctx, 't', other)).toBe('ran:t')
  272. expect(bodyCalls).toBe(1)
  273. liftFirst()
  274. expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
  275. await scope.dispose()
  276. expect(await run(ctx, 't', key)).toBe('ran:t')
  277. expect(bodyCalls).toBe(2)
  278. })
  279. it('composes global guards monotonically when one abstains and a later one denies', async () => {
  280. const ctx = await mount()
  281. let bodyCalls = 0
  282. ctx.tools.register({
  283. ...tool('t'),
  284. execute: () => {
  285. bodyCalls += 1
  286. return Promise.resolve('ran:t')
  287. },
  288. })
  289. ctx.tools.guard(() => undefined)
  290. ctx.tools.guard(() => 'global denial')
  291. expect(await run(ctx, 't')).toBe('Error: global denial')
  292. expect(bodyCalls).toBe(0)
  293. })
  294. it('live-iterates a guard registered by an earlier guard', async () => {
  295. const ctx = await mount()
  296. const calls: string[] = []
  297. let added = false
  298. ctx.tools.register(tool('t'))
  299. ctx.tools.guard(() => {
  300. calls.push('first')
  301. if (!added) {
  302. added = true
  303. ctx.tools.guard(() => {
  304. calls.push('late')
  305. return 'late denial'
  306. })
  307. }
  308. return undefined
  309. })
  310. expect(await run(ctx, 't')).toBe('Error: late denial')
  311. expect(calls).toEqual(['first', 'late'])
  312. })
  313. it('defers a scoped guard that replaces the last guard in its generation', async () => {
  314. const ctx = await mount()
  315. const { scope, key } = await mintAgentScope(ctx, 'a')
  316. const calls: string[] = []
  317. ctx.tools.register(tool('t'))
  318. scope.ctx.tools.register(tool('scope_sibling'))
  319. const lift = scope.ctx.tools.guard(() => {
  320. calls.push('first')
  321. lift()
  322. scope.ctx.tools.guard(() => {
  323. calls.push('replacement')
  324. return 'replacement denial'
  325. })
  326. return undefined
  327. })
  328. expect(await run(ctx, 't', key)).toBe('ran:t')
  329. expect(calls).toEqual(['first'])
  330. expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
  331. expect(calls).toEqual(['first', 'replacement'])
  332. })
  333. it('shares one token and materialized argument value across the pipeline', async () => {
  334. const ctx = await mount()
  335. const { scope, key } = await mintAgentScope(ctx, 'a')
  336. let safeCalls = 0
  337. let dangerCalls = 0
  338. let scopedResults = 0
  339. let safeArguments: unknown
  340. const tokens = new Set<ToolExecutionToken>()
  341. ctx.tools.register({
  342. ...tool('safe'),
  343. execute: (args) => {
  344. safeCalls += 1
  345. safeArguments = args
  346. return Promise.resolve('safe')
  347. },
  348. })
  349. ctx.tools.register({
  350. ...tool('danger'),
  351. execute: () => {
  352. dangerCalls += 1
  353. return Promise.resolve('danger')
  354. },
  355. })
  356. scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
  357. ctx.on('tools/pre-execute', (exec, next) => {
  358. tokens.add(exec.token)
  359. expect(Object.isFrozen(exec.arguments)).toBe(true)
  360. return next()
  361. })
  362. ctx.on('tools/execute', (exec, next) => {
  363. tokens.add(exec.token)
  364. return next()
  365. })
  366. ctx.on('tools/post-execute', (exec, _result, next) => {
  367. tokens.add(exec.token)
  368. return next()
  369. })
  370. scope.ctx.on('tools/result', () => { scopedResults += 1 })
  371. expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
  372. const callerArguments = { source: true }
  373. const safeResult = await ctx.tools.execute({
  374. signal: testToolSignal,
  375. callId: CallId('safe-call'),
  376. name: 'safe',
  377. arguments: callerArguments,
  378. agent: key,
  379. })
  380. expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
  381. expect(Object.isFrozen(callerArguments)).toBe(false)
  382. expect(safeArguments).not.toBe(callerArguments)
  383. expect(Object.isFrozen(safeArguments)).toBe(true)
  384. expect(callerArguments).toEqual({ source: true })
  385. // One token for danger and one shared by every phase of safe.
  386. expect(tokens.size).toBe(2)
  387. expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
  388. safeCalls: 1,
  389. dangerCalls: 0,
  390. scopedResults: 2,
  391. })
  392. })
  393. it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
  394. const ctx = await mount()
  395. const { scope, key } = await mintAgentScope(ctx, 'a')
  396. let policyCalls = 0
  397. let bodyCalls = 0
  398. let scopedObserved = 0
  399. let globalObserved = 0
  400. ctx.tools.register({
  401. ...tool('t'),
  402. execute: () => {
  403. bodyCalls += 1
  404. return Promise.resolve('ran:t')
  405. },
  406. })
  407. ctx.on('tools/pre-execute', (_exec, next) => {
  408. policyCalls += 1
  409. return next()
  410. })
  411. let parent!: ToolExecutionToken
  412. ctx.tools.register(tool('parent'))
  413. const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
  414. if (exec.name === 'parent') parent = exec.token
  415. return next()
  416. })
  417. await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
  418. stopCapture()
  419. policyCalls = 0
  420. const signal = new AbortController().signal
  421. scope.ctx.on('tools/result', (exec, result) => {
  422. scopedObserved += 1
  423. expect(exec.arguments).toBeUndefined()
  424. expect(exec.parent).toBe(parent)
  425. expect(exec.signal).toBe(signal)
  426. expect(Object.isFrozen(exec)).toBe(true)
  427. expect(result.isError).toBe(true)
  428. })
  429. ctx.on('tools/result', () => { globalObserved += 1 })
  430. const callerArguments = { invalid: () => undefined }
  431. const scopedResult = await ctx.tools.execute({
  432. callId: CallId('non-cloneable'),
  433. name: 't',
  434. arguments: callerArguments,
  435. agent: key,
  436. parent,
  437. signal,
  438. })
  439. const subjectlessResult = await ctx.tools.execute({
  440. signal: testToolSignal,
  441. callId: CallId('non-cloneable-subjectless'),
  442. name: 't',
  443. arguments: { invalid: () => undefined },
  444. })
  445. expect(scopedResult.isError).toBe(true)
  446. expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
  447. expect(subjectlessResult.isError).toBe(true)
  448. expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
  449. policyCalls: 0,
  450. bodyCalls: 0,
  451. scopedObserved: 1,
  452. globalObserved: 2,
  453. })
  454. expect(Object.isFrozen(callerArguments)).toBe(false)
  455. expect(callerArguments.invalid).toBeTypeOf('function')
  456. })
  457. it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
  458. const ctx = await mount()
  459. const observed: (ToolExecutionToken | undefined)[] = []
  460. ctx.tools.register({
  461. ...tool('t'),
  462. execute: (_args, exec) => {
  463. observed.push(exec.parent)
  464. return Promise.resolve('ran:t')
  465. },
  466. })
  467. ctx.on('tools/pre-execute', (exec, next) => {
  468. observed.push(exec.parent)
  469. return next()
  470. })
  471. ctx.on('tools/execute', (exec, next) => {
  472. observed.push(exec.parent)
  473. return next()
  474. })
  475. ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
  476. const forged = { fake: true } as unknown as ToolExecutionToken
  477. let parentReads = 0
  478. const input = {
  479. callId: CallId('stateful-parent'),
  480. name: 't',
  481. arguments: {},
  482. signal: testToolSignal,
  483. get parent(): ToolExecutionToken | undefined {
  484. parentReads += 1
  485. return parentReads === 1 ? undefined : forged
  486. },
  487. } as ToolExecutionInput
  488. const result = await ctx.tools.execute(input)
  489. expect(result.isError).toBe(false)
  490. expect(parentReads).toBe(1)
  491. expect(observed).toEqual([undefined, undefined, undefined, undefined])
  492. })
  493. it('uses one input snapshot for the normalized error shell', async () => {
  494. const ctx = await mount()
  495. const { scope, key } = await mintAgentScope(ctx, 'accepted')
  496. const driftAgent = { id: 'drift' as SessionId } as Agent
  497. ctx.tools.register(tool('parent'))
  498. ctx.tools.register(tool('t'))
  499. let parent!: ToolExecutionToken
  500. const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
  501. if (exec.name === 'parent') parent = exec.token
  502. return next()
  503. })
  504. await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
  505. stopCapture()
  506. const acceptedSignal = new AbortController().signal
  507. const driftSignal = new AbortController().signal
  508. const forged = { fake: true } as unknown as ToolExecutionToken
  509. const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
  510. const input = {
  511. get callId() { reads.callId += 1; return CallId('unstable-error') },
  512. get name() { reads.name += 1; return 't' },
  513. get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
  514. get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
  515. get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
  516. get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
  517. } as ToolExecutionInput
  518. let observed: Readonly<ToolExecution> | undefined
  519. let scopedObserved = 0
  520. ctx.on('tools/result', (exec) => { observed = exec })
  521. scope.ctx.on('tools/result', () => { scopedObserved += 1 })
  522. const result = await ctx.tools.execute(input)
  523. expect(result.isError).toBe(true)
  524. expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
  525. expect(scopedObserved).toBe(1)
  526. expect(observed).toMatchObject({
  527. callId: CallId('unstable-error'),
  528. name: 't',
  529. agent: key,
  530. parent,
  531. signal: acceptedSignal,
  532. })
  533. expect(Object.isFrozen(observed)).toBe(true)
  534. })
  535. it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
  536. const ctx = await mount()
  537. ctx.tools.register(tool('t'))
  538. let argumentReads = 0
  539. let observed = 0
  540. ctx.on('tools/result', (exec, result) => {
  541. observed += 1
  542. expect(exec.arguments).toBeUndefined()
  543. expect(result.isError).toBe(true)
  544. })
  545. const input = {
  546. callId: CallId('throwing-arguments'),
  547. name: 't',
  548. signal: testToolSignal,
  549. get arguments(): unknown {
  550. argumentReads += 1
  551. throw new Error('getter exploded')
  552. },
  553. } as ToolExecutionInput
  554. const result = await ctx.tools.execute(input)
  555. expect(result.isError).toBe(true)
  556. expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
  557. expect(argumentReads).toBe(1)
  558. expect(observed).toBe(1)
  559. })
  560. it.each([
  561. ['Map', new Map([['mutable', true]])],
  562. ['class instance', new (class Arguments { value = 1 })()],
  563. ])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
  564. const ctx = await mount()
  565. let policyCalls = 0
  566. let bodyCalls = 0
  567. let observed = 0
  568. ctx.tools.register({
  569. ...tool('t'),
  570. execute: () => {
  571. bodyCalls += 1
  572. return Promise.resolve('ran:t')
  573. },
  574. })
  575. ctx.on('tools/pre-execute', (_exec, next) => {
  576. policyCalls += 1
  577. return next()
  578. })
  579. ctx.on('tools/result', (exec, result) => {
  580. observed += 1
  581. expect(exec.arguments).toBeUndefined()
  582. expect(result.isError).toBe(true)
  583. })
  584. const result = await ctx.tools.execute({
  585. signal: testToolSignal,
  586. callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
  587. })
  588. expect(result.isError).toBe(true)
  589. expect(result.content).toEqual([{
  590. type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
  591. }])
  592. expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
  593. })
  594. it('reads nested arguments once into the executed snapshot', async () => {
  595. const ctx = await mount()
  596. ctx.tools.register(tool('t'))
  597. let reads = 0
  598. const argumentsValue = Object.defineProperty({}, 'value', {
  599. enumerable: true,
  600. get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
  601. })
  602. const result = await ctx.tools.execute({
  603. signal: testToolSignal,
  604. callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
  605. })
  606. expect(reads).toBe(1)
  607. expect(result).toEqual({
  608. content: [{ type: 'text', text: 'ran:t' }],
  609. isError: false,
  610. value: 'ran:t',
  611. })
  612. })
  613. it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
  614. const ctx = await mount()
  615. const { scope, key } = await mintAgentScope(ctx, 'a')
  616. ctx.tools.register(tool('t'))
  617. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
  618. const seen: boolean[] = []
  619. const dispatchModes: string[] = []
  620. ctx.on('internal/dispatch', (mode, name) => {
  621. if (name === 'tools/result') dispatchModes.push(mode)
  622. })
  623. ctx.on('tools/execute', async (_exec, next) => {
  624. await next()
  625. return {
  626. content: [{ type: 'text', text: 'outer failure' }],
  627. isError: true,
  628. error: { message: 'outer failure' },
  629. }
  630. }, { prepend: true })
  631. scope.ctx.on('tools/result', (_exec, result) => {
  632. expect(Object.isFrozen(_exec)).toBe(true)
  633. expect(Object.isFrozen(_exec.arguments)).toBe(true)
  634. expect(Object.isFrozen(result)).toBe(true)
  635. expect(Object.isFrozen(result.content)).toBe(true)
  636. seen.push(result.isError)
  637. })
  638. ctx.on('tools/result', () => {
  639. throw { toString: () => { throw new Error('coercion trap') } }
  640. })
  641. ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never)
  642. ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
  643. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key })
  644. await Promise.resolve()
  645. expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
  646. expect(seen).toEqual([true, true])
  647. expect(dispatchModes).toEqual(['emit'])
  648. expect(warn).toHaveBeenCalledTimes(2)
  649. expect(warn.mock.calls.map(call => String(call[0]))).toEqual(expect.arrayContaining([
  650. expect.stringContaining('<unprintable thrown value>'),
  651. expect.stringContaining('async observer failure'),
  652. ]))
  653. })
  654. })