structured.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
  4. import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
  5. import { SessionId } from '@deepseek-ai/dsh-session'
  6. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  7. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  8. import InvariantService from '@deepseek-ai/dsh-invariants'
  9. import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
  10. import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
  11. import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
  12. import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
  13. import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
  14. import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
  15. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  16. import { startInProcessRun } from '../src/index.ts'
  17. import {
  18. STRUCTURED_OUTPUT_INSTRUCTION,
  19. STRUCTURED_OUTPUT_TOOL,
  20. } from '../src/structured.ts'
  21. const testToolSignal = new AbortController().signal
  22. type Script = ConstructorParameters<typeof MockAdapter>[0]
  23. async function mountInvariants(ctx: Context): Promise<void> {
  24. await ctx.plugin(InvariantService)
  25. await ctx.plugin(SessionInvariant)
  26. await ctx.plugin(AgentInvariant)
  27. await ctx.plugin(AgentLoopInvariant)
  28. }
  29. interface CodeRunRequestLike {
  30. bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
  31. }
  32. interface SetupOptions {
  33. toolMode?: ToolConfig['mode']
  34. codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
  35. }
  36. const SCHEMA: ObjectJsonSchema = {
  37. type: 'object',
  38. properties: { answer: { type: 'number' }, note: { type: 'string' } },
  39. required: ['answer'],
  40. }
  41. /**
  42. * Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading
  43. * spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while
  44. * this fixture isolates driver behavior and scripts the child's `structured_output` calls.
  45. */
  46. async function setup(script: Script, options: SetupOptions = {}) {
  47. const ctx = new Context()
  48. const adapter = new MockAdapter(script)
  49. await mountAgentLoopTestDependencies(ctx, {
  50. tools: { mode: options.toolMode ?? 'native' },
  51. })
  52. if (options.toolMode === 'code' || options.toolMode === 'both') {
  53. ctx.provide('codeRuntime', {
  54. language: 'typescript',
  55. isolation: 'test',
  56. run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
  57. } as never)
  58. }
  59. await mountInvariants(ctx)
  60. await ctx.plugin(AgentLoop, { agents: [] })
  61. await ctx.plugin(SubagentService)
  62. const disposeProvider = ctx.subagents.registerProvider({
  63. name: 'spawn',
  64. capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
  65. inheritsParentContext: false,
  66. start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
  67. })
  68. ctx.llm.registerAdapter(['mock'], adapter)
  69. const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  70. return { ctx, parent, adapter, disposeProvider }
  71. }
  72. function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
  73. return {
  74. prompt: [{ type: 'text', text: 'produce the answer' }],
  75. parent,
  76. signal: new AbortController().signal,
  77. outputSchema: SCHEMA,
  78. ...extra,
  79. }
  80. }
  81. /** The tool names of one recorded model request. */
  82. function toolNames(request: GenerateOptions): string[] {
  83. return (request.tools ?? []).map(tool => tool.name)
  84. }
  85. describe('in-process structured output', () => {
  86. it('captures a valid structured_output call and surfaces result.structured', async () => {
  87. const { ctx, parent } = await setup([
  88. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
  89. ])
  90. let acknowledgement: unknown
  91. ctx.on('tools/result', (exec, toolResult) => {
  92. if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
  93. })
  94. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  95. const result = await run.result
  96. expect(result.stopReason).toBe('completed')
  97. expect(result.structured).toEqual({ answer: 42, note: 'done' })
  98. expect(acknowledgement).toEqual({ recorded: true })
  99. await run.dispose()
  100. })
  101. it('stops the turn after a successful capture — no extra model step is spent', async () => {
  102. const { ctx, parent, adapter } = await setup([
  103. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
  104. textResponse('MUST NOT BE CONSUMED'),
  105. ])
  106. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  107. await run.result
  108. // Default continuation would run a second step after the tool call; the
  109. // structured runtime's turn-continuation veto stops the turn instead.
  110. expect(adapter.requests.length).toBe(1)
  111. await run.dispose()
  112. })
  113. it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => {
  114. // One model response carrying structured_output FIRST and a side-effecting
  115. // call after it: the continuation veto only fires at step end, so without
  116. // the pre-execute deny the trailing call would still run after the final
  117. // answer was accepted.
  118. const response = [
  119. ...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
  120. { type: 'block-start', index: 1, blockType: 'tool-call' },
  121. { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
  122. { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
  123. { type: 'finish', reason: { kind: 'tool-calls' } },
  124. ] as Script[number]
  125. const { ctx, parent } = await setup([response])
  126. let sideEffectRan = false
  127. ctx.tools.register(defineContentToolFixture({
  128. name: 'side_effect',
  129. description: 'probe',
  130. parameters: {},
  131. execute(): Promise<ContentBlock[]> {
  132. sideEffectRan = true
  133. return Promise.resolve([{ type: 'text', text: 'ran' }])
  134. },
  135. }))
  136. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  137. const result = await run.result
  138. expect(result.stopReason).toBe('completed')
  139. expect(result.structured).toEqual({ answer: 5 })
  140. // The deny skipped dispatch entirely: the probe body never ran.
  141. expect(sideEffectRan).toBe(false)
  142. await run.dispose()
  143. })
  144. it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
  145. const response = [
  146. ...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
  147. { type: 'block-start', index: 1, blockType: 'tool-call' },
  148. { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
  149. { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
  150. { type: 'finish', reason: { kind: 'tool-calls' } },
  151. ] as Script[number]
  152. const { ctx, parent } = await setup([response])
  153. let sideEffectRan = false
  154. ctx.tools.register(defineContentToolFixture({
  155. name: 'side_effect',
  156. description: 'probe',
  157. parameters: {},
  158. execute(): Promise<ContentBlock[]> {
  159. sideEffectRan = true
  160. return Promise.resolve([{ type: 'text', text: 'ran' }])
  161. },
  162. }))
  163. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  164. // Registered after the child and prepended: this listener returns allow
  165. // after every downstream pre-execute decision. The service-owned guard
  166. // runs after the waterfall and can only deny, so the body still cannot run.
  167. ctx.on('tools/pre-execute', async (_exec, next) => {
  168. await next()
  169. return { kind: 'allow' as const }
  170. }, { prepend: true })
  171. const result = await run.result
  172. expect(result.structured).toEqual({ answer: 5 })
  173. expect(sideEffectRan).toBe(false)
  174. const child = ctx.agents.get(run.id)
  175. const sideEffectResult = child?.session.events.find(event =>
  176. event.type === 'tool/result' && event.data.callId === 'c2')
  177. expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.isError).toBe(true)
  178. await run.dispose()
  179. })
  180. it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
  181. const response = [
  182. { type: 'block-start', index: 0, blockType: 'tool-call' },
  183. { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'side_effect', arguments: '{}' } },
  184. ...toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 6 }).map(chunk =>
  185. 'index' in chunk ? { ...chunk, index: 1 } : chunk),
  186. ] as Script[number]
  187. const { ctx, parent } = await setup([response])
  188. let sideEffectRan = false
  189. ctx.tools.register(defineContentToolFixture({
  190. name: 'side_effect',
  191. description: 'probe',
  192. parameters: {},
  193. execute(): Promise<ContentBlock[]> {
  194. sideEffectRan = true
  195. return Promise.resolve([{ type: 'text', text: 'ran' }])
  196. },
  197. }))
  198. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  199. const result = await run.result
  200. // The call ran BEFORE captured was set: the deny gate only guards the
  201. // window after the terminal answer landed.
  202. expect(sideEffectRan).toBe(true)
  203. expect(result.structured).toEqual({ answer: 6 })
  204. await run.dispose()
  205. })
  206. it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
  207. const { ctx, parent, adapter } = await setup([
  208. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
  209. textResponse('MUST NOT BE CONSUMED'),
  210. ])
  211. ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
  212. let wrapperInstalled = false
  213. // Register before ready-only start: structured output is attached before session-start and the
  214. // loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose
  215. // to the later terminal checkpoint.
  216. ctx.on('agent/session-start', (child) => {
  217. if (child === parent) return
  218. wrapperInstalled = true
  219. child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
  220. const downstream = await next()
  221. expect(downstream).toEqual({ action: 'stop' })
  222. return { action: 'continue' }
  223. }, { prepend: true })
  224. })
  225. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  226. const result = await run.result
  227. expect(wrapperInstalled).toBe(true)
  228. expect(result.structured).toEqual({ answer: 7 })
  229. expect(result.stopReason).toBe('completed')
  230. expect(adapter.requests).toHaveLength(1)
  231. await run.dispose()
  232. })
  233. it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
  234. const { ctx, parent, adapter } = await setup([
  235. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
  236. textResponse('MUST NOT BE CONSUMED'),
  237. ])
  238. // A downstream policy stops, then a later wrapper delegates and queues steering that ordinary
  239. // folding would turn into continue. The terminal checkpoint must discard that steering.
  240. ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
  241. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  242. ctx.on('agent/session-start', (child) => {
  243. if (child.id !== run.id) return
  244. child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
  245. const downstream = await next()
  246. expect(downstream).toEqual({ action: 'stop' })
  247. subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
  248. return downstream
  249. }, { prepend: true })
  250. })
  251. const result = await run.result
  252. const child = ctx.agents.get(run.id)
  253. expect(result.structured).toEqual({ answer: 9 })
  254. expect(adapter.requests).toHaveLength(1)
  255. expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  256. expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
  257. await run.dispose()
  258. })
  259. it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
  260. const { ctx, parent } = await setup([
  261. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
  262. toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
  263. ])
  264. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  265. const result = await run.result
  266. expect(result.structured).toEqual({ answer: 7 })
  267. expect(result.stopReason).toBe('completed')
  268. // The child's log carries the isError tool/result for the invalid call.
  269. const child = ctx.agents.get(run.id)!
  270. const results = child.session.events.filter(e => e.type === 'tool/result')
  271. expect(results.length).toBe(2)
  272. expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
  273. await run.dispose()
  274. })
  275. it('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
  276. const { ctx, parent, adapter } = await setup([
  277. textResponse('here is my answer in prose'),
  278. textResponse('MUST NOT BE CONSUMED'),
  279. ])
  280. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  281. const result = await run.result
  282. expect(result.stopReason).toBe('error')
  283. expect(result.structured).toBeUndefined()
  284. // Exactly one model request and one user message: no nudge turn exists.
  285. expect(adapter.requests.length).toBe(1)
  286. const child = ctx.agents.get(run.id)!
  287. expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1)
  288. await run.dispose()
  289. })
  290. it('an errored child keeps its honest error result (no capture expected)', async () => {
  291. // Script exhaustion on the first call → the child turn errors.
  292. const { ctx, parent, adapter } = await setup([])
  293. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  294. const result = await run.result
  295. expect(result.stopReason).toBe('error')
  296. expect(adapter.requests.length).toBe(1)
  297. await run.dispose()
  298. })
  299. it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
  300. const { ctx, parent } = await setup([textResponse('prose, no capture')])
  301. const controller = new AbortController()
  302. const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
  303. // Cancel synchronously inside the turn's end recording: the cancel
  304. // contract outranks the schema shortfall, so the result maps to aborted.
  305. ctx.on('session/event', (session, event) => {
  306. const child = ctx.agents.get(run.id)
  307. if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
  308. })
  309. const result = await run.result
  310. expect(result.stopReason).toBe('aborted')
  311. await run.dispose()
  312. })
  313. it('rejects a schema outside the subset loud, before any child exists', async () => {
  314. const { ctx, parent } = await setup([])
  315. await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
  316. outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
  317. }))).rejects.toThrow(/unsupported JSON schema/)
  318. expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
  319. })
  320. it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
  321. const { ctx, parent } = await setup([])
  322. // Semantic assertion runs before provider startup.
  323. await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
  324. outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
  325. }))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
  326. })
  327. it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
  328. const { ctx, parent, adapter } = await setup([
  329. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
  330. textResponse('continues after the blocked capture'),
  331. ])
  332. // A PostToolUse-style hook turns the tool body's provisional success into
  333. // the authoritative final error observed by the commit notification.
  334. ctx.on('tools/post-execute', (exec, _result, next) => {
  335. if (exec.name === STRUCTURED_OUTPUT_TOOL) {
  336. return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
  337. }
  338. return next()
  339. })
  340. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  341. const result = await run.result
  342. // No capture was committed: the run reports the schema shortfall...
  343. expect(result.structured).toBeUndefined()
  344. expect(result.stopReason).toBe('error')
  345. // ...the logged tool result is the blocked isError with the feedback...
  346. const child = ctx.agents.get(run.id)!
  347. const results = child.session.events.filter(e => e.type === 'tool/result')
  348. expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
  349. expect(JSON.stringify((results[0]!.data as { content: unknown }).content)).toContain('capture rejected by hook')
  350. // ...and the turn CONTINUED past the blocked call (no captured veto):
  351. // the model got to react to the failure with a second step.
  352. expect(adapter.requests.length).toBe(2)
  353. await run.dispose()
  354. })
  355. it('a post-execute accept-with-replacement still commits the capture', async () => {
  356. const { ctx, parent } = await setup([
  357. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
  358. ])
  359. ctx.on('tools/post-execute', (exec, _result, next) => {
  360. if (exec.name === STRUCTURED_OUTPUT_TOOL) {
  361. return Promise.resolve({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'recorded (rewritten)' }] })
  362. }
  363. return next()
  364. })
  365. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  366. const result = await run.result
  367. expect(result.stopReason).toBe('completed')
  368. expect(result.structured).toEqual({ answer: 8 })
  369. await run.dispose()
  370. })
  371. it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
  372. const { ctx, parent } = await setup([
  373. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
  374. textResponse('capture was rejected'),
  375. ])
  376. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  377. // Registered after attachment and prepended, so it wraps every listener
  378. // the child installed. It delegates first, then converts the apparent
  379. // capture success into the pipeline's authoritative failure.
  380. ctx.on('tools/post-execute', async (exec, _result, next) => {
  381. const downstream = await next()
  382. if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
  383. return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
  384. }, { prepend: true })
  385. const result = await run.result
  386. expect(result.structured).toBeUndefined()
  387. expect(result.stopReason).toBe('error')
  388. const child = ctx.agents.get(run.id)
  389. const captureResult = child?.session.events.find(event =>
  390. event.type === 'tool/result' && event.data.callId === 'c1')
  391. expect(captureResult?.type === 'tool/result' && captureResult.data.isError).toBe(true)
  392. await run.dispose()
  393. })
  394. it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
  395. const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
  396. // A context-wide section stands in for the deployment persona: the
  397. // instruction must APPEND to the other scoped and global sections, not
  398. // replace them (AgentOptions has no prompt field — the instruction is an
  399. // ordinary child-scoped prompt registration).
  400. ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
  401. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  402. await run.result
  403. const childRequest = adapter.requests.at(-1)!
  404. expect(childRequest.system).toContain('You are a counter.')
  405. expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
  406. expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
  407. await run.dispose()
  408. })
  409. it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
  410. const { ctx, parent, adapter } = await setup([
  411. toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
  412. ], {
  413. toolMode: 'code',
  414. codeRun: async (request) => {
  415. const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
  416. if (!capture) throw new Error('structured_output binding missing')
  417. await capture({ answer: 12 })
  418. return { logs: [], value: 'captured' }
  419. },
  420. })
  421. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  422. const result = await run.result
  423. expect(result.structured).toEqual({ answer: 12 })
  424. const request = adapter.requests[0]!
  425. expect(toolNames(request)).toEqual([RUN_CODE_NAME])
  426. expect(request.system).toContain('interface ToolArgsMap')
  427. expect(request.system).toContain('interface ToolOutputMap')
  428. expect(request.system).toContain('recorded: true;')
  429. expect(request.system).toContain('Promise<ToolOutputMap[K]>')
  430. expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
  431. await run.dispose()
  432. })
  433. it('discards a nested capture when the enclosing run_code execution fails', async () => {
  434. const { ctx, parent, adapter } = await setup([
  435. toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }),
  436. textResponse('outer code failed'),
  437. ], {
  438. toolMode: 'code',
  439. codeRun: async (request) => {
  440. const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
  441. if (!capture) throw new Error('structured_output binding missing')
  442. await capture({ answer: 12 })
  443. return {
  444. logs: [],
  445. error: { kind: 'runtime', message: 'boom after capture' },
  446. } as never
  447. },
  448. })
  449. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  450. const result = await run.result
  451. expect(result.structured).toBeUndefined()
  452. expect(result.stopReason).toBe('error')
  453. expect(adapter.requests).toHaveLength(2)
  454. const child = ctx.agents.get(run.id)!
  455. const outer = child.session.events.find(event =>
  456. event.type === 'tool/result' && event.data.callId === CallId('c1'))
  457. expect(outer?.type === 'tool/result' && outer.data.isError).toBe(true)
  458. await run.dispose()
  459. })
  460. it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
  461. const { ctx, parent, adapter } = await setup([
  462. toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
  463. textResponse('outer code was blocked'),
  464. ], {
  465. toolMode: 'code',
  466. codeRun: async (request) => {
  467. const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
  468. if (!capture) throw new Error('structured_output binding missing')
  469. await capture({ answer: 12 })
  470. return { logs: [], value: 'captured' }
  471. },
  472. })
  473. ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
  474. ? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
  475. : next())
  476. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  477. const result = await run.result
  478. expect(result.structured).toBeUndefined()
  479. expect(result.stopReason).toBe('error')
  480. expect(adapter.requests).toHaveLength(2)
  481. await run.dispose()
  482. })
  483. it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
  484. const { ctx, parent, adapter } = await setup([
  485. textResponse('parent answer'),
  486. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
  487. ])
  488. parent.followup([{ type: 'text', text: 'hello' }])
  489. await parent.whenIdle()
  490. expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
  491. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  492. await run.result
  493. // The loop always assembles a base prompt (the harness identity section),
  494. // so the instruction APPENDS — never replaces.
  495. const childSystem = adapter.requests.at(-1)!.system!
  496. expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
  497. expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
  498. await run.dispose()
  499. })
  500. describe('scoped registration (each child owns its capture tool)', () => {
  501. it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
  502. const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
  503. parent.followup([{ type: 'text', text: 'hello' }])
  504. await parent.whenIdle()
  505. // Scoped registration: the global view has no capture tool, ever.
  506. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
  507. expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
  508. })
  509. it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
  510. const { ctx, parent, adapter } = await setup([
  511. // Parent turn (a plain agent): must NOT see the tool.
  512. textResponse('parent answer'),
  513. // Child turn: must see it, with the run's schema.
  514. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
  515. ])
  516. parent.followup([{ type: 'text', text: 'hello' }])
  517. await parent.whenIdle()
  518. expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
  519. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  520. await run.result
  521. const childRequest = adapter.requests[1]!
  522. expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
  523. const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
  524. expect(entry.parameters).toEqual(SCHEMA)
  525. await run.dispose()
  526. })
  527. it('two concurrent structured children each see their OWN schema', async () => {
  528. const otherSchema: ObjectJsonSchema = {
  529. type: 'object',
  530. properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
  531. required: ['verdict'],
  532. }
  533. const { ctx, parent, adapter } = await setup([
  534. (options: GenerateOptions) => {
  535. // Answer with whatever schema this child was given — proves each
  536. // request carried the right one regardless of scheduling order.
  537. const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
  538. const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
  539. ? { verdict: 'real' }
  540. : { answer: 1 }
  541. return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
  542. },
  543. (options: GenerateOptions) => {
  544. const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
  545. const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
  546. ? { verdict: 'real' }
  547. : { answer: 1 }
  548. return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
  549. },
  550. ])
  551. const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
  552. const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
  553. const [a, b] = await Promise.all([runA.result, runB.result])
  554. expect(a.structured).toEqual({ answer: 1 })
  555. expect(b.structured).toEqual({ verdict: 'real' })
  556. const schemas = adapter.requests.map(request =>
  557. request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
  558. expect(schemas).toContainEqual(SCHEMA)
  559. expect(schemas).toContainEqual(otherSchema)
  560. await runA.dispose()
  561. await runB.dispose()
  562. })
  563. it('places the capture tool and instruction in their canonical orders', async () => {
  564. const { ctx, parent, adapter } = await setup([
  565. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
  566. ])
  567. // A global tool sorts lexicographically after structured_output, while a
  568. // global section above the 190 band follows the capture instruction.
  569. ctx.tools.register(defineContentToolFixture({
  570. name: 'zz_probe',
  571. description: 'probe',
  572. parameters: {},
  573. execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
  574. }))
  575. ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
  576. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  577. await run.result
  578. const request = adapter.requests[0]!
  579. const names = toolNames(request)
  580. expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0)
  581. expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe'))
  582. const system = request.system ?? ''
  583. const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)
  584. expect(instructionAt).toBeGreaterThanOrEqual(0)
  585. expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt)
  586. await run.dispose()
  587. })
  588. it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
  589. const { parent, adapter } = await setup([textResponse('plain')])
  590. parent.followup([{ type: 'text', text: 'q' }])
  591. await parent.whenIdle()
  592. const request = adapter.requests[0]!
  593. expect(request.tools).toBeUndefined()
  594. await new Promise(resolve => setTimeout(resolve, 0))
  595. })
  596. it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
  597. const { ctx, parent, disposeProvider } = await setup([
  598. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
  599. ])
  600. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
  601. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  602. // A backend hot-reload mid-run must not unregister the capture tool out
  603. // from under the live child: the registration rides the CHILD's fiber.
  604. disposeProvider()
  605. const result = await run.result
  606. expect(result.structured).toEqual({ answer: 4 })
  607. const child = ctx.agents.get(run.id)!
  608. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
  609. await run.dispose()
  610. // Child disposed ⇒ its scoped registrations are gone.
  611. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
  612. })
  613. })
  614. it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
  615. const { ctx, parent } = await setup([])
  616. const result = await ctx.tools.execute({
  617. signal: testToolSignal,
  618. callId: 'x' as never,
  619. name: STRUCTURED_OUTPUT_TOOL,
  620. arguments: { answer: 1 },
  621. agent: parent,
  622. })
  623. expect(result.isError).toBe(true)
  624. expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
  625. })
  626. it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
  627. const { ctx } = await setup([])
  628. const result = await ctx.tools.execute({
  629. signal: testToolSignal,
  630. callId: 'x' as never,
  631. name: STRUCTURED_OUTPUT_TOOL,
  632. arguments: { answer: 1 },
  633. })
  634. expect(result.isError).toBe(true)
  635. expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
  636. })
  637. it('a failed execution stage is discarded and never promoted by a later call', async () => {
  638. const { ctx, parent } = await setup([
  639. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
  640. ])
  641. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  642. // A prepended post-execute listener blocks the first capture without
  643. // delegating. The final-result notification discards that execution's
  644. // stage when it observes the error.
  645. let blocks = 1
  646. ctx.on('tools/post-execute', (exec, _result, next) => {
  647. if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
  648. blocks -= 1
  649. return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
  650. }
  651. return next()
  652. }, { prepend: true })
  653. const result = await run.result
  654. const child = ctx.agents.get(run.id)!
  655. // The blocked capture must NOT surface as structured success…
  656. expect(result.stopReason).toBe('error')
  657. expect(result.structured).toBeUndefined()
  658. // …and a LATER invalid call (its own body staged nothing) must not
  659. // resurrect c1's discarded value: drive the pipeline directly.
  660. const invalid = await ctx.tools.execute({
  661. signal: testToolSignal,
  662. callId: 'c2' as never,
  663. name: STRUCTURED_OUTPUT_TOOL,
  664. arguments: { answer: 'not-a-number' },
  665. agent: child,
  666. })
  667. expect(invalid.isError).toBe(true)
  668. // A fresh valid call still captures ITS OWN value.
  669. const valid = await ctx.tools.execute({
  670. signal: testToolSignal,
  671. callId: 'c3' as never,
  672. name: STRUCTURED_OUTPUT_TOOL,
  673. arguments: { answer: 9 },
  674. agent: child,
  675. })
  676. expect(valid.isError).toBeFalsy()
  677. await run.dispose()
  678. })
  679. it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
  680. const { ctx, parent } = await setup([
  681. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
  682. ])
  683. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  684. // Block the first capture after its body stages a value. Its final error
  685. // discards that execution's stage.
  686. let blocks = 1
  687. ctx.on('tools/post-execute', (exec, _result, next) => {
  688. if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
  689. blocks -= 1
  690. return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
  691. }
  692. return next()
  693. }, { prepend: true })
  694. await run.result
  695. const child = ctx.agents.get(run.id)!
  696. // A SECOND capture call with the SAME call id whose body never stages
  697. // (invalid args throw before the stage): the discarded value must not ride
  698. // its acceptance.
  699. const reused = await ctx.tools.execute({
  700. signal: testToolSignal,
  701. callId: 'c1' as never,
  702. name: STRUCTURED_OUTPUT_TOOL,
  703. arguments: { answer: 'not-a-number' },
  704. agent: child,
  705. })
  706. expect(reused.isError).toBe(true)
  707. // Nothing was ever committed: a fresh valid call is still required.
  708. const valid = await ctx.tools.execute({
  709. signal: testToolSignal,
  710. callId: 'c1' as never,
  711. name: STRUCTURED_OUTPUT_TOOL,
  712. arguments: { answer: 5 },
  713. agent: child,
  714. })
  715. expect(valid.isError).toBeFalsy()
  716. await run.dispose()
  717. })
  718. it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
  719. const { ctx, parent } = await setup([
  720. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
  721. ])
  722. const run = await ctx.subagents.start('spawn', structuredRequest(parent))
  723. // Discard the first capture's stage via a final post-execute block.
  724. let blocks = 1
  725. ctx.on('tools/post-execute', (exec, _result, next) => {
  726. if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
  727. blocks -= 1
  728. return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
  729. }
  730. return next()
  731. }, { prepend: true })
  732. await run.result
  733. const child = ctx.agents.get(run.id)!
  734. // A prepended pre-execute deny skips the body, while the denied call still
  735. // reaches the final notification with the same adapter-minted call id.
  736. const offDeny = ctx.on('tools/pre-execute', (exec) => {
  737. if (exec.name === STRUCTURED_OUTPUT_TOOL) {
  738. return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
  739. }
  740. return undefined as never
  741. }, { prepend: true })
  742. const denied = await ctx.tools.execute({
  743. signal: testToolSignal,
  744. callId: 'c1' as never,
  745. name: STRUCTURED_OUTPUT_TOOL,
  746. arguments: { answer: 2 },
  747. agent: child,
  748. })
  749. expect(denied.isError).toBe(true)
  750. offDeny()
  751. // The discarded value was never promoted: a fresh valid call is required
  752. // (and succeeds, proving the runtime is not wedged).
  753. const valid = await ctx.tools.execute({
  754. signal: testToolSignal,
  755. callId: 'c1' as never,
  756. name: STRUCTURED_OUTPUT_TOOL,
  757. arguments: { answer: 5 },
  758. agent: child,
  759. })
  760. expect(valid.isError).toBeFalsy()
  761. await run.dispose()
  762. })
  763. })