structured.spec.ts 35 KB

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