| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770 |
- import { describe, expect, it } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import { createUserMessage, ToolCallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
- import { SessionId } from '@deepseek-ai/dsh-session'
- import AgentLoop from '@deepseek-ai/dsh-agent-loop'
- import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
- import InvariantRegistry from '@deepseek-ai/dsh-invariants'
- import type {} from '@deepseek-ai/dsh-system-prompt'
- import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
- import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
- import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
- import SubagentRuntime, {
- type ResolvedSubagentStartRequest,
- type SubagentStartRequest,
- } from '@deepseek-ai/dsh-subagent'
- import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
- import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
- import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
- import { startInProcessRun } from '../src/index.ts'
- import {
- STRUCTURED_OUTPUT_INSTRUCTION,
- STRUCTURED_OUTPUT_TOOL,
- } from '../src/structured.ts'
- const testToolSignal = new AbortController().signal
- type Script = ConstructorParameters<typeof MockAdapter>[0]
- async function mountInvariants(ctx: Context): Promise<void> {
- await ctx.plugin(InvariantRegistry)
- await ctx.plugin(SessionInvariant)
- await ctx.plugin(AgentInvariant)
- await ctx.plugin(AgentLoopInvariant)
- }
- interface CodeRunRequestLike {
- bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
- }
- interface SetupOptions {
- toolMode?: ToolConfig['mode']
- codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
- }
- const SCHEMA: ObjectJsonSchema = {
- type: 'object',
- properties: { answer: { type: 'number' }, note: { type: 'string' } },
- required: ['answer'],
- }
- /**
- * Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading
- * spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while
- * this fixture isolates driver behavior and scripts the child's `structured_output` calls.
- */
- async function setup(script: Script, options: SetupOptions = {}) {
- const ctx = new Context()
- const adapter = new MockAdapter(script)
- await mountAgentLoopTestDependencies(ctx, {
- tools: { mode: options.toolMode ?? 'native' },
- })
- if (options.toolMode === 'ptc' || options.toolMode === 'both') {
- ctx.provide('codeRuntime', {
- language: 'typescript',
- isolation: 'test',
- run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
- } as never)
- }
- await mountInvariants(ctx)
- await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(SubagentRuntime)
- const disposeProvider = ctx.subagents.registerProvider({
- name: 'spawn',
- capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
- inheritsParentContext: false,
- start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}),
- })
- ctx.llm.registerAdapter(['mock'], adapter)
- const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
- return { ctx, parent, adapter, disposeProvider }
- }
- function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
- return {
- label: 'produce the answer',
- prompt: [{ type: 'text', text: 'produce the answer' }],
- parent,
- signal: new AbortController().signal,
- outputSchema: SCHEMA,
- ...extra,
- }
- }
- /** The tool names of one recorded model request. */
- function toolNames(request: GenerateOptions): string[] {
- return (request.tools ?? []).map(tool => tool.name)
- }
- /** Text of a loop-built request's leading system message; `''` when the request has none. */
- function requestSystem(request: GenerateOptions): string {
- const head = request.messages[0]
- if (head?.role !== 'system') return ''
- return head.content.filter(block => block.type === 'text').map(block => block.text).join('')
- }
- describe('in-process structured output', () => {
- it('captures a valid structured_output call and surfaces result.structured', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
- ])
- let acknowledgement: unknown
- ctx.on('tools/result', (exec, toolResult) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
- })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.stopReason).toBe('completed')
- expect(result.structured).toEqual({ answer: 42, note: 'done' })
- expect(acknowledgement).toEqual({ recorded: true })
- await run.dispose()
- })
- it('stops the turn after a successful capture — no extra model step is spent', async () => {
- const { ctx, parent, adapter } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
- textResponse('MUST NOT BE CONSUMED'),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- await run.result
- // The structured tool marks its successful result as turn-concluding.
- expect(adapter.requests.length).toBe(1)
- await run.dispose()
- })
- it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => {
- // One model response carrying structured_output FIRST and a side-effecting
- // call after it: the continuation veto only fires at step end, so without
- // the pre-execute deny the trailing call would still run after the final
- // answer was accepted.
- const response = [
- ...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
- { type: 'block-start', index: 1, blockType: 'tool-call' },
- { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('c2'), name: 'side_effect', arguments: '{}' } },
- { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
- { type: 'finish', reason: { kind: 'tool-calls' } },
- ] as Script[number]
- const { ctx, parent } = await setup([response])
- let sideEffectRan = false
- ctx.tools.register(defineContentToolFixture({
- name: 'side_effect',
- description: 'probe',
- parameters: {},
- execute(): Promise<ContentBlock[]> {
- sideEffectRan = true
- return Promise.resolve([{ type: 'text', text: 'ran' }])
- },
- }))
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.stopReason).toBe('completed')
- expect(result.structured).toEqual({ answer: 5 })
- // The deny skipped dispatch entirely: the probe body never ran.
- expect(sideEffectRan).toBe(false)
- await run.dispose()
- })
- it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
- const response = [
- ...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
- { type: 'block-start', index: 1, blockType: 'tool-call' },
- { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('c2'), name: 'side_effect', arguments: '{}' } },
- { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
- { type: 'finish', reason: { kind: 'tool-calls' } },
- ] as Script[number]
- const { ctx, parent } = await setup([response])
- let sideEffectRan = false
- ctx.tools.register(defineContentToolFixture({
- name: 'side_effect',
- description: 'probe',
- parameters: {},
- execute(): Promise<ContentBlock[]> {
- sideEffectRan = true
- return Promise.resolve([{ type: 'text', text: 'ran' }])
- },
- }))
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- // Registered after the child and prepended: this listener returns allow
- // after every downstream pre-execute decision. The service-owned guard
- // runs after the waterfall and can only deny, so the body still cannot run.
- ctx.on('tools/pre-execute', async (_exec, next) => {
- await next()
- return { kind: 'allow' as const }
- }, { prepend: true })
- const result = await run.result
- expect(result.structured).toEqual({ answer: 5 })
- expect(sideEffectRan).toBe(false)
- const child = ctx.agents.get(run.id)
- const sideEffectResult = child?.session.snapshotEvents().find(event =>
- event.type === 'tool/result' && event.data.message.source.callId === 'c2')
- expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.content[0].isError).toBe(true)
- await run.dispose()
- })
- it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
- const response = [
- { type: 'block-start', index: 0, blockType: 'tool-call' },
- { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('c1'), name: 'side_effect', arguments: '{}' } },
- ...toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 6 }).map(chunk =>
- 'index' in chunk ? { ...chunk, index: 1 } : chunk),
- ] as Script[number]
- const { ctx, parent } = await setup([response])
- let sideEffectRan = false
- ctx.tools.register(defineContentToolFixture({
- name: 'side_effect',
- description: 'probe',
- parameters: {},
- execute(): Promise<ContentBlock[]> {
- sideEffectRan = true
- return Promise.resolve([{ type: 'text', text: 'ran' }])
- },
- }))
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- // The call ran BEFORE captured was set: the deny gate only guards the
- // window after the terminal answer landed.
- expect(sideEffectRan).toBe(true)
- expect(result.structured).toEqual({ answer: 6 })
- await run.dispose()
- })
- it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
- toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.structured).toEqual({ answer: 7 })
- expect(result.stopReason).toBe('completed')
- // The child's log carries the isError tool/result for the invalid call.
- const child = ctx.agents.get(run.id)!
- const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result')
- expect(results.length).toBe(2)
- expect(results[0]!.data.message.content[0].isError).toBe(true)
- await run.dispose()
- })
- it('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
- const { ctx, parent, adapter } = await setup([
- textResponse('here is my answer in prose'),
- textResponse('MUST NOT BE CONSUMED'),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.stopReason).toBe('error')
- expect(result.structured).toBeUndefined()
- // Exactly one model request and one caller-supplied user message: no nudge turn exists.
- expect(adapter.requests.length).toBe(1)
- const child = ctx.agents.get(run.id)!
- expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1)
- await run.dispose()
- })
- it('an errored child keeps its honest error result (no capture expected)', async () => {
- // Script exhaustion on the first call → the child turn errors.
- const { ctx, parent, adapter } = await setup([])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.stopReason).toBe('error')
- expect(adapter.requests.length).toBe(1)
- await run.dispose()
- })
- it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
- const { ctx, parent } = await setup([textResponse('prose, no capture')])
- const controller = new AbortController()
- const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
- // Cancel synchronously inside the turn's end recording: the cancel
- // contract outranks the schema shortfall, so the result maps to aborted.
- ctx.on('session/event', (session, event) => {
- const child = ctx.agents.get(run.id)
- if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
- })
- const result = await run.result
- expect(result.stopReason).toBe('aborted')
- await run.dispose()
- })
- it('rejects a schema outside the subset loud, before any child exists', async () => {
- const { ctx, parent } = await setup([])
- await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
- outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
- }))).rejects.toThrow(/unsupported JSON schema/)
- expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
- })
- it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
- const { ctx, parent } = await setup([])
- // Semantic assertion runs before provider startup.
- await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
- outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
- }))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
- })
- it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
- const { ctx, parent, adapter } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
- textResponse('continues after the blocked capture'),
- ])
- // A PostToolUse-style hook turns the tool body's provisional success into
- // the authoritative final error observed by the commit notification.
- ctx.on('tools/post-execute', (exec, _result, next) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL) {
- return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
- }
- return next()
- })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- // No capture was committed: the run reports the schema shortfall...
- expect(result.structured).toBeUndefined()
- expect(result.stopReason).toBe('error')
- // ...the logged tool result is the blocked isError with the feedback...
- const child = ctx.agents.get(run.id)!
- const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result')
- expect(results[0]!.data.message.content[0].isError).toBe(true)
- expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook')
- // ...and the turn CONTINUED past the blocked call (no captured veto):
- // the model got to react to the failure with a second step.
- expect(adapter.requests.length).toBe(2)
- await run.dispose()
- })
- it('a post-execute accept-with-replacement still commits the capture', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
- ])
- ctx.on('tools/post-execute', (exec, _result, next) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL) {
- return Promise.resolve({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'recorded (rewritten)' }] })
- }
- return next()
- })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.stopReason).toBe('completed')
- expect(result.structured).toEqual({ answer: 8 })
- await run.dispose()
- })
- it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
- textResponse('capture was rejected'),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- // Registered after attachment and prepended, so it wraps every listener
- // the child installed. It delegates first, then converts the apparent
- // capture success into the pipeline's authoritative failure.
- ctx.on('tools/post-execute', async (exec, _result, next) => {
- const downstream = await next()
- if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
- return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
- }, { prepend: true })
- const result = await run.result
- expect(result.structured).toBeUndefined()
- expect(result.stopReason).toBe('error')
- const child = ctx.agents.get(run.id)
- const captureResult = child?.session.snapshotEvents().find(event =>
- event.type === 'tool/result' && event.data.message.source.callId === 'c1')
- expect(captureResult?.type === 'tool/result' && captureResult.data.message.content[0].isError).toBe(true)
- await run.dispose()
- })
- it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
- const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
- // A context-wide section stands in for the deployment persona: the
- // instruction must APPEND to the other scoped and global sections, not
- // replace them (AgentOptions has no prompt field — the instruction is an
- // ordinary child-scoped prompt registration).
- ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- await run.result
- const childSystem = requestSystem(adapter.requests.at(-1)!)
- expect(childSystem).toContain('You are a counter.')
- expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
- expect(childSystem.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
- await run.dispose()
- })
- it('keeps pure PTC mode at one wire tool and exposes structured capture through the SDK only', async () => {
- const { ctx, parent, adapter } = await setup([
- toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
- ], {
- toolMode: 'ptc',
- codeRun: async (request) => {
- const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
- if (!capture) throw new Error('structured_output binding missing')
- await capture({ answer: 12 })
- return { logs: [], value: 'captured' }
- },
- })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.structured).toEqual({ answer: 12 })
- const request = adapter.requests[0]!
- expect(toolNames(request)).toEqual([RUN_CODE_NAME])
- const system = requestSystem(request)
- expect(system).toContain('interface ToolArgsMap')
- expect(system).toContain('interface ToolOutputMap')
- expect(system).toContain('recorded: true;')
- expect(system).toContain('Promise<ToolOutputMap[K]>')
- expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
- await run.dispose()
- })
- it('discards a nested capture when the enclosing run_code execution fails', async () => {
- const { ctx, parent, adapter } = await setup([
- toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")', description: 'Capture then fail the program' }),
- textResponse('outer code failed'),
- ], {
- toolMode: 'ptc',
- codeRun: async (request) => {
- const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
- if (!capture) throw new Error('structured_output binding missing')
- await capture({ answer: 12 })
- return {
- logs: [],
- error: { kind: 'runtime', message: 'boom after capture' },
- } as never
- },
- })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.structured).toBeUndefined()
- expect(result.stopReason).toBe('error')
- expect(adapter.requests).toHaveLength(2)
- const child = ctx.agents.get(run.id)!
- const outer = child.session.snapshotEvents().find(event =>
- event.type === 'tool/result' && event.data.message.source.callId === ToolCallId('c1'))
- expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true)
- await run.dispose()
- })
- it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
- const { ctx, parent, adapter } = await setup([
- toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
- textResponse('outer code was blocked'),
- ], {
- toolMode: 'ptc',
- codeRun: async (request) => {
- const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
- if (!capture) throw new Error('structured_output binding missing')
- await capture({ answer: 12 })
- return { logs: [], value: 'captured' }
- },
- })
- ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
- ? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
- : next())
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- const result = await run.result
- expect(result.structured).toBeUndefined()
- expect(result.stopReason).toBe('error')
- expect(adapter.requests).toHaveLength(2)
- await run.dispose()
- })
- it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
- const { ctx, parent, adapter } = await setup([
- textResponse('parent answer'),
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
- ])
- parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
- await parent.whenIdle()
- expect(requestSystem(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- await run.result
- // The loop always assembles a base prompt (the harness identity section),
- // so the instruction APPENDS — never replaces.
- const childSystem = requestSystem(adapter.requests.at(-1)!)
- expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
- expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
- await run.dispose()
- })
- describe('scoped registration (each child owns its capture tool)', () => {
- it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
- const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
- parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
- await parent.whenIdle()
- // Scoped registration: the global view has no capture tool, ever.
- expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
- expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
- })
- it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
- const { ctx, parent, adapter } = await setup([
- // Parent turn (a plain agent): must NOT see the tool.
- textResponse('parent answer'),
- // Child turn: must see it, with the run's schema.
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
- ])
- parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
- await parent.whenIdle()
- expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- await run.result
- const childRequest = adapter.requests[1]!
- expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
- const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
- expect(entry.parameters).toEqual(SCHEMA)
- await run.dispose()
- })
- it('two concurrent structured children each see their OWN schema', async () => {
- const otherSchema: ObjectJsonSchema = {
- type: 'object',
- properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
- required: ['verdict'],
- }
- const { ctx, parent, adapter } = await setup([
- (options: GenerateOptions) => {
- // Answer with whatever schema this child was given — proves each
- // request carried the right one regardless of scheduling order.
- const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
- const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
- ? { verdict: 'real' }
- : { answer: 1 }
- return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
- },
- (options: GenerateOptions) => {
- const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
- const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
- ? { verdict: 'real' }
- : { answer: 1 }
- return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
- },
- ])
- const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
- const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
- const [a, b] = await Promise.all([runA.result, runB.result])
- expect(a.structured).toEqual({ answer: 1 })
- expect(b.structured).toEqual({ verdict: 'real' })
- const schemas = adapter.requests.map(request =>
- request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
- expect(schemas).toContainEqual(SCHEMA)
- expect(schemas).toContainEqual(otherSchema)
- await runA.dispose()
- await runB.dispose()
- })
- it('places the capture tool and instruction in their canonical orders', async () => {
- const { ctx, parent, adapter } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
- ])
- // A global tool sorts lexicographically after structured_output, while a
- // global section after the final-output slot follows the capture instruction.
- ctx.tools.register(defineContentToolFixture({
- name: 'zz_probe',
- description: 'probe',
- parameters: {},
- execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
- }))
- ctx.systemPrompt.section({
- name: 'after-band',
- order: ctx.systemPrompt.getSectionOrder('STRUCTURED_OUTPUT') + 10,
- text: 'AFTER-BAND',
- })
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- await run.result
- const request = adapter.requests[0]!
- const names = toolNames(request)
- expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0)
- expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe'))
- const system = requestSystem(request)
- const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)
- expect(instructionAt).toBeGreaterThanOrEqual(0)
- expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt)
- await run.dispose()
- })
- it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
- const { parent, adapter } = await setup([textResponse('plain')])
- parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
- await parent.whenIdle()
- const request = adapter.requests[0]!
- expect(request.tools).toBeUndefined()
- await new Promise(resolve => setTimeout(resolve, 0))
- })
- it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
- const { ctx, parent, disposeProvider } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
- ])
- expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- // A backend hot-reload mid-run must not unregister the capture tool out
- // from under the live child: the registration rides the CHILD's fiber.
- disposeProvider()
- const result = await run.result
- expect(result.structured).toEqual({ answer: 4 })
- const child = ctx.agents.get(run.id)!
- expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
- await run.dispose()
- // Child disposed ⇒ its scoped registrations are gone.
- expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
- })
- })
- it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
- const { ctx, parent } = await setup([])
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'x' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 1 },
- agent: parent,
- })
- expect(result.isError).toBe(true)
- expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
- })
- it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
- const { ctx } = await setup([])
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'x' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 1 },
- })
- expect(result.isError).toBe(true)
- expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
- })
- it('a failed execution stage is discarded and never promoted by a later call', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- // A prepended post-execute listener blocks the first capture without
- // delegating. The final-result notification discards that execution's
- // stage when it observes the error.
- let blocks = 1
- ctx.on('tools/post-execute', (exec, _result, next) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
- blocks -= 1
- return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
- }
- return next()
- }, { prepend: true })
- const result = await run.result
- const child = ctx.agents.get(run.id)!
- // The blocked capture must NOT surface as structured success…
- expect(result.stopReason).toBe('error')
- expect(result.structured).toBeUndefined()
- // …and a LATER invalid call (its own body staged nothing) must not
- // resurrect c1's discarded value: drive the pipeline directly.
- const invalid = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'c2' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 'not-a-number' },
- agent: child,
- })
- expect(invalid.isError).toBe(true)
- // A fresh valid call still captures ITS OWN value.
- const valid = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'c3' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 9 },
- agent: child,
- })
- expect(valid.isError).toBeFalsy()
- await run.dispose()
- })
- it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- // Block the first capture after its body stages a value. Its final error
- // discards that execution's stage.
- let blocks = 1
- ctx.on('tools/post-execute', (exec, _result, next) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
- blocks -= 1
- return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
- }
- return next()
- }, { prepend: true })
- await run.result
- const child = ctx.agents.get(run.id)!
- // A SECOND capture call with the SAME call id whose body never stages
- // (invalid args throw before the stage): the discarded value must not ride
- // its acceptance.
- const reused = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'c1' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 'not-a-number' },
- agent: child,
- })
- expect(reused.isError).toBe(true)
- // Nothing was ever committed: a fresh valid call is still required.
- const valid = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'c1' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 5 },
- agent: child,
- })
- expect(valid.isError).toBeFalsy()
- await run.dispose()
- })
- it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
- const { ctx, parent } = await setup([
- toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
- ])
- const run = await ctx.subagents.start('spawn', structuredRequest(parent))
- // Discard the first capture's stage via a final post-execute block.
- let blocks = 1
- ctx.on('tools/post-execute', (exec, _result, next) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
- blocks -= 1
- return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
- }
- return next()
- }, { prepend: true })
- await run.result
- const child = ctx.agents.get(run.id)!
- // A prepended pre-execute deny skips the body, while the denied call still
- // reaches the final notification with the same adapter-minted call id.
- const offDeny = ctx.on('tools/pre-execute', (exec) => {
- if (exec.name === STRUCTURED_OUTPUT_TOOL) {
- return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
- }
- return undefined as never
- }, { prepend: true })
- const denied = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'c1' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 2 },
- agent: child,
- })
- expect(denied.isError).toBe(true)
- offDeny()
- // The discarded value was never promoted: a fresh valid call is required
- // (and succeeds, proving the runtime is not wedged).
- const valid = await ctx.tools.execute({
- signal: testToolSignal,
- callId: 'c1' as never,
- name: STRUCTURED_OUTPUT_TOOL,
- arguments: { answer: 5 },
- agent: child,
- })
- expect(valid.isError).toBeFalsy()
- await run.dispose()
- })
- })
|