structured.spec.ts 43 KB

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