diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 8463a622aa4..e17b324c462 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -521,6 +521,42 @@ describe('parseArguments', () => { expect(argv.includePartialMessages).toBe(true); }); + it('should parse --json-schema for non-interactive prompt mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"type":"object","properties":{"summary":{"type":"string"}}}', + 'Summarize', + ]; + + const argv = await parseArguments(); + + expect(argv.jsonSchema).toBe( + '{"type":"object","properties":{"summary":{"type":"string"}}}', + ); + expect(argv.structuredOutputMaxRetries).toBe(5); + }); + + it('should reject --json-schema without a prompt', async () => { + process.argv = ['node', 'script.js', '--json-schema', '{"type":"object"}']; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + '--json-schema requires a prompt or positional query', + ), + ); + + mockExit.mockRestore(); + }); + it('should allow --approval-mode without --yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit']; const argv = await parseArguments(); @@ -662,6 +698,140 @@ describe('loadCliConfig', () => { expect(config.getIncludePartialMessages()).toBe(true); }); + it('should propagate structured output schema to config', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"type":"object","properties":{"ok":{"type":"boolean"}}}', + 'Return status', + ]; + const argv = await parseArguments(); + const settings: Settings = {}; + const config = await loadCliConfig(settings, argv); + + expect(config.getStructuredOutput()).toEqual({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + maxRetries: 5, + }); + expect(config.getAppendSystemPrompt()).toContain('structured_output'); + }); + + it('should accept a root $ref when it resolves to an object schema', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"definitions":{"payload":{"type":"object","properties":{"ok":{"type":"boolean"}}}},"$ref":"#/definitions/payload"}', + 'Return status', + ]; + const argv = await parseArguments(); + const config = await loadCliConfig({}, argv); + + expect(config.getStructuredOutput()?.schema).toEqual({ + definitions: { + payload: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + }, + $ref: '#/definitions/payload', + }); + }); + + it('should reject a root $ref that resolves to a scalar schema', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"definitions":{"payload":{"type":"string"}},"$ref":"#/definitions/payload"}', + 'Return status', + ]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'Root schema must describe an object', + ); + }); + + it('should reject allOf branches that resolve to scalar schemas', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"definitions":{"scalar":{"type":"string"}},"allOf":[{"type":"object","properties":{"ok":{"type":"boolean"}}},{"$ref":"#/definitions/scalar"}]}', + 'Return status', + ]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'Root schema must describe an object', + ); + }); + + it('should reject type object schemas with scalar allOf siblings', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"type":"object","definitions":{"scalar":{"type":"string"}},"allOf":[{"$ref":"#/definitions/scalar"}]}', + 'Return status', + ]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'Root schema must describe an object', + ); + }); + + it('should reject object schemas whose combinator siblings exclude objects', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"type":"object","definitions":{"scalar":{"type":"string"}},"allOf":[{"anyOf":[{"type":"object","properties":{"ok":{"type":"boolean"}}}],"allOf":[{"$ref":"#/definitions/scalar"}]}]}', + 'Return status', + ]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'Root schema must describe an object', + ); + }); + + it('should reject root type unions that allow non-objects', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"type":["object","null"],"properties":{"ok":{"type":"boolean"}}}', + 'Return status', + ]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'Root schema must describe an object', + ); + }); + + it('should reject root anyOf schemas with non-object branches', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"anyOf":[{"type":"object","properties":{"ok":{"type":"boolean"}}},{"type":"string"}]}', + 'Return status', + ]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'Root schema must describe an object', + ); + }); + it('should reset context filenames to defaults when context.fileName is not configured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 69c6cdae9bc..fdc11736f10 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -32,6 +32,8 @@ import { isToolEnabled, type ConfigParameters, type MCPServerConfig, + SchemaValidator, + hasCycleInSchema, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; @@ -158,6 +160,8 @@ export interface CliArgs { /** Specify a session ID without session resumption */ sessionId: string | undefined; maxSessionTurns: number | undefined; + jsonSchema?: string | undefined; + structuredOutputMaxRetries?: number | undefined; coreTools: string[] | undefined; excludeTools: string[] | undefined; disabledSlashCommands: string[] | undefined; @@ -498,6 +502,17 @@ export async function parseArguments(): Promise { type: 'number', description: 'Maximum number of session turns', }) + .option('json-schema', { + type: 'string', + description: + 'Require the final non-interactive response to match a JSON Schema. Accepts a file path or inline JSON.', + }) + .option('structured-output-max-retries', { + type: 'number', + description: + 'Maximum structured-output repair retries before failing.', + default: DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, + }) .option('core-tools', { type: 'array', string: true, @@ -594,6 +609,17 @@ export async function parseArguments(): Promise { if (argv['sessionId'] && (argv['continue'] || argv['resume'])) { return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.'; } + if (argv['jsonSchema']) { + if (argv['promptInteractive']) { + return '--json-schema is only supported in non-interactive prompt mode'; + } + if (argv['inputFormat'] === 'stream-json') { + return '--json-schema is not supported with --input-format stream-json'; + } + if (!argv['prompt'] && !hasPositionalQuery) { + return '--json-schema requires a prompt or positional query'; + } + } if ( argv['sessionId'] && !isValidSessionId(argv['sessionId'] as string) @@ -812,6 +838,299 @@ function parseMcpConfig( } } +const DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES = 5; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasRemoteRef(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasRemoteRef); + } + if (!isRecord(value)) { + return false; + } + const ref = value['$ref']; + if (typeof ref === 'string' && ref !== '#' && !ref.startsWith('#/')) { + return true; + } + return Object.values(value).some(hasRemoteRef); +} + +function decodeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function resolveLocalRef(root: Record, ref: string): unknown { + if (ref === '#') { + return root; + } + if (!ref.startsWith('#/')) { + return undefined; + } + + let current: unknown = root; + for (const rawSegment of ref.slice(2).split('/')) { + const segment = decodeJsonPointerSegment(rawSegment); + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= current.length) { + return undefined; + } + current = current[index]; + continue; + } + if (!isRecord(current)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function isObjectOnlyType(type: unknown): boolean { + if (type === 'object') { + return true; + } + return Array.isArray(type) && type.length > 0 + ? type.every((entry) => entry === 'object') + : false; +} + +function typeAllowsObject(type: unknown): boolean { + if (type === 'object') { + return true; + } + return Array.isArray(type) && type.some((entry) => entry === 'object'); +} + +function hasObjectShapeKeywords(schema: Record): boolean { + return ( + isRecord(schema['properties']) || + Array.isArray(schema['required']) || + isRecord(schema['additionalProperties']) || + typeof schema['additionalProperties'] === 'boolean' || + isRecord(schema['patternProperties']) || + isRecord(schema['propertyNames']) || + typeof schema['minProperties'] === 'number' || + typeof schema['maxProperties'] === 'number' || + isRecord(schema['dependentRequired']) || + isRecord(schema['dependentSchemas']) || + isRecord(schema['unevaluatedProperties']) || + typeof schema['unevaluatedProperties'] === 'boolean' + ); +} + +function allSubschemasDescribeObjects( + schemas: unknown, + root: Record, + seenRefs: Set, +): boolean { + return ( + Array.isArray(schemas) && + schemas.length > 0 && + schemas.every( + (subschema) => + isRecord(subschema) && isObjectRootSchema(subschema, root, seenRefs), + ) + ); +} + +function schemaCanAcceptObject( + schema: Record, + root: Record, + seenRefs: Set, +): boolean { + const ref = schema['$ref']; + if (typeof ref === 'string') { + if (seenRefs.has(ref)) { + return false; + } + const resolved = resolveLocalRef(root, ref); + if (!isRecord(resolved)) { + return false; + } + seenRefs.add(ref); + const result = schemaCanAcceptObject(resolved, root, seenRefs); + seenRefs.delete(ref); + return result; + } + + const type = schema['type']; + if (type !== undefined && !typeAllowsObject(type)) { + return false; + } + + const anyOf = schema['anyOf']; + if ( + Array.isArray(anyOf) && + !anyOf.some( + (subschema) => + isRecord(subschema) && schemaCanAcceptObject(subschema, root, seenRefs), + ) + ) { + return false; + } + + const oneOf = schema['oneOf']; + if ( + Array.isArray(oneOf) && + !oneOf.some( + (subschema) => + isRecord(subschema) && schemaCanAcceptObject(subschema, root, seenRefs), + ) + ) { + return false; + } + + const allOf = schema['allOf']; + if ( + Array.isArray(allOf) && + !allOf.every( + (subschema) => + isRecord(subschema) && schemaCanAcceptObject(subschema, root, seenRefs), + ) + ) { + return false; + } + + return true; +} + +function isObjectRootSchema( + schema: Record, + root: Record = schema, + seenRefs: Set = new Set(), +): boolean { + const ref = schema['$ref']; + if (typeof ref === 'string') { + if (seenRefs.has(ref)) { + return false; + } + const resolved = resolveLocalRef(root, ref); + if (!isRecord(resolved)) { + return false; + } + seenRefs.add(ref); + const result = isObjectRootSchema(resolved, root, seenRefs); + seenRefs.delete(ref); + return result; + } + + const type = schema['type']; + const hasExplicitType = type !== undefined; + if (hasExplicitType && !isObjectOnlyType(type)) { + return false; + } + + const anyOf = schema['anyOf']; + const oneOf = schema['oneOf']; + if (Array.isArray(anyOf)) { + const anyOfCanProduceObject = hasExplicitType + ? anyOf.some( + (subschema) => + isRecord(subschema) && + schemaCanAcceptObject(subschema, root, seenRefs), + ) + : allSubschemasDescribeObjects(anyOf, root, seenRefs); + if (!anyOfCanProduceObject) { + return false; + } + } + if (Array.isArray(oneOf)) { + const oneOfCanProduceObject = hasExplicitType + ? oneOf.some( + (subschema) => + isRecord(subschema) && + schemaCanAcceptObject(subschema, root, seenRefs), + ) + : allSubschemasDescribeObjects(oneOf, root, seenRefs); + if (!oneOfCanProduceObject) { + return false; + } + } + + const allOf = schema['allOf']; + if (Array.isArray(allOf)) { + const allOfCanProduceObject = allOf.every( + (subschema) => + isRecord(subschema) && schemaCanAcceptObject(subschema, root, seenRefs), + ); + if (!allOfCanProduceObject) { + return false; + } + } + + if (hasExplicitType) { + return true; + } + + return ( + hasObjectShapeKeywords(schema) || + Array.isArray(anyOf) || + Array.isArray(oneOf) || + (Array.isArray(allOf) && + allOf.length > 0 && + allOf.some( + (subschema) => + isRecord(subschema) && isObjectRootSchema(subschema, root, seenRefs), + )) + ); +} + +function parseStructuredOutputConfig( + jsonSchemaArg: string | undefined, + maxRetriesArg: number | undefined, +): ConfigParameters['structuredOutput'] | undefined { + if (!jsonSchemaArg) { + return undefined; + } + + const maxRetries = maxRetriesArg ?? DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES; + if (!Number.isInteger(maxRetries) || maxRetries < 0) { + throw new FatalConfigError( + '--structured-output-max-retries must be a non-negative integer.', + ); + } + + try { + let parsed: unknown; + const schemaPath = resolvePath(jsonSchemaArg); + if (fs.existsSync(schemaPath)) { + debugLogger.debug(`Reading JSON schema from file: ${schemaPath}`); + const content = fs.readFileSync(schemaPath, 'utf-8'); + parsed = JSON.parse(stripJsonComments(content)); + } else { + debugLogger.debug('Parsing JSON schema as inline JSON'); + parsed = JSON.parse(jsonSchemaArg); + } + + if (!isRecord(parsed)) { + throw new Error('Schema must be a JSON object.'); + } + if (hasRemoteRef(parsed)) { + throw new Error('Remote $ref values are not supported.'); + } + if (hasCycleInSchema(parsed)) { + throw new Error('Cyclic $ref values are not supported.'); + } + SchemaValidator.compileStrict(parsed); + if (!isObjectRootSchema(parsed)) { + throw new Error( + 'Root schema must describe an object. Root arrays and scalars are not supported in --json-schema MVP.', + ); + } + + return { schema: parsed, maxRetries }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new FatalConfigError( + `Invalid JSON Schema provided via --json-schema: ${errorMessage}`, + ); + } +} + export async function loadCliConfig( settings: Settings, argv: CliArgs, @@ -964,6 +1283,17 @@ export async function loadCliConfig( // (fallback for edge cases where query/prompt is provided with TEXT output) interactive = false; } + + const structuredOutput = parseStructuredOutputConfig( + argv.jsonSchema, + argv.structuredOutputMaxRetries, + ); + if (structuredOutput && interactive) { + throw new FatalConfigError( + '--json-schema is only supported in non-interactive prompt mode.', + ); + } + // ── Unified permissions construction ───────────────────────────────────── // All permission sources are merged here, before constructing Config. // The resulting three arrays are the single source of truth that Config / @@ -1261,6 +1591,7 @@ export async function loadCliConfig( sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, maxSessionTurns: argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, + structuredOutput, experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, cronEnabled: settings.experimental?.cron ?? false, emitToolUseSummaries: settings.experimental?.emitToolUseSummaries ?? true, diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 08a6fb76d13..b52cd14b745 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -68,6 +68,10 @@ export interface ResultOptions { readonly subtype?: string; } +export interface StructuredResultOptions extends ResultOptions { + readonly structuredOutput: unknown; +} + /** * Interface for message emission strategies. * Implementations decide whether to emit messages immediately (streaming) @@ -108,6 +112,7 @@ export interface JsonOutputAdapterInterface extends MessageEmitter { processEvent(event: ServerGeminiStreamEvent): void; finalizeAssistantMessage(): CLIAssistantMessage; emitResult(options: ResultOptions): void; + emitStructuredResult(options: StructuredResultOptions): void; startSubagentAssistantMessage?(parentToolUseId: string): void; processSubagentToolCall?( @@ -1207,6 +1212,12 @@ export abstract class BaseJsonOutputAdapter { success.stats = options.stats; } + if ('structuredOutput' in options) { + success.structured_output = ( + options as StructuredResultOptions + ).structuredOutput; + } + return success; } } diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts index 53d9fda5732..c7477727094 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts @@ -468,6 +468,48 @@ describe('JsonOutputAdapter', () => { expect(output).toBe('Custom summary text\n'); }); + it('should emit structured output as raw JSON in text mode', () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT); + + adapter.emitStructuredResult({ + isError: false, + structuredOutput: { summary: 'done', count: 2 }, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + expect(output).toBe('{"summary":"done","count":2}\n'); + }); + + it('should include structured_output in JSON result', () => { + adapter.emitStructuredResult({ + isError: false, + structuredOutput: { summary: 'done', count: 2 }, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage.result).toBe('{"summary":"done","count":2}'); + expect(resultMessage.structured_output).toEqual({ + summary: 'done', + count: 2, + }); + }); + it('should handle empty error message in text mode', () => { const stderrWriteSpy = vi .spyOn(process.stderr, 'write') diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts index 5d36ac7f0fe..a722345d072 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -10,6 +10,7 @@ import { BaseJsonOutputAdapter, type JsonOutputAdapterInterface, type ResultOptions, + type StructuredResultOptions, } from './BaseJsonOutputAdapter.js'; /** @@ -78,6 +79,21 @@ export class JsonOutputAdapter } } + emitStructuredResult(options: StructuredResultOptions): void { + const resultText = JSON.stringify(options.structuredOutput); + const resultMessage = this.buildResultMessage( + { ...options, summary: resultText }, + this.lastAssistantMessage, + ); + this.messages.push(resultMessage); + + if (this.config.getOutputFormat() === 'text') { + process.stdout.write(`${resultText}\n`); + } else { + process.stdout.write(`${JSON.stringify(this.messages)}\n`); + } + } + emitMessage(message: CLIMessage): void { // In JSON mode, messages are collected in the messages array // This is called by the base class's finalizeAssistantMessageInternal diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index 58095221ac1..88cd6971659 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -25,6 +25,7 @@ import { type MessageState, type ResultOptions, type JsonOutputAdapterInterface, + type StructuredResultOptions, } from './BaseJsonOutputAdapter.js'; /** @@ -113,6 +114,14 @@ export class StreamJsonOutputAdapter this.emitMessageImpl(resultMessage); } + emitStructuredResult(options: StructuredResultOptions): void { + const resultMessage = this.buildResultMessage( + { ...options, summary: JSON.stringify(options.structuredOutput) }, + this.lastAssistantMessage, + ); + this.emitMessageImpl(resultMessage); + } + emitMessage(message: CLIMessage | ControlMessage): void { // In stream mode, emit immediately this.emitMessageImpl(message); diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 84efda11ea8..ae6a11611a9 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -164,6 +164,7 @@ export interface CLIResultMessageSuccess { duration_api_ms: number; num_turns: number; result: string; + structured_output?: unknown; usage: ExtendedUsage; modelUsage?: Record; permission_denials: CLIPermissionDenial[]; @@ -172,7 +173,10 @@ export interface CLIResultMessageSuccess { export interface CLIResultMessageError { type: 'result'; - subtype: 'error_max_turns' | 'error_during_execution'; + subtype: + | 'error_max_turns' + | 'error_during_execution' + | 'error_max_structured_output_retries'; uuid: string; session_id: string; is_error: true; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index df030142f35..c78c36a38d9 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -21,6 +21,7 @@ import { FatalInputError, ApprovalMode, SendMessageType, + ToolNames, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; @@ -171,6 +172,7 @@ describe('runNonInteractive', () => { getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMaxSessionTurns: vi.fn().mockReturnValue(10), + getStructuredOutput: vi.fn().mockReturnValue(undefined), getProjectRoot: vi.fn().mockReturnValue('/test/project'), getTargetDir: vi.fn().mockReturnValue('/test/project'), getMcpServers: vi.fn().mockReturnValue(undefined), @@ -1135,6 +1137,25 @@ describe('runNonInteractive', () => { ); }); + it('should reject slash commands when structured output is required', async () => { + (mockConfig.getStructuredOutput as Mock).mockReturnValue({ + schema: { type: 'object', properties: { ok: { type: 'boolean' } } }, + maxRetries: 5, + }); + setupMetricsMock(); + + await expect( + runNonInteractive(mockConfig, mockSettings, '/help', 'prompt-id-help'), + ).rejects.toThrow('--json-schema is not supported with slash commands.'); + + expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(processStderrSpy).toHaveBeenCalledWith( + expect.stringContaining( + '--json-schema is not supported with slash commands.', + ), + ); + }); + it('should handle unhandled command result types by returning early with error', async () => { setupMetricsMock(); const mockCommand = { @@ -2344,6 +2365,180 @@ describe('runNonInteractive', () => { expect(toolResultMessages.length).toBe(2); }); + it('should terminate with raw JSON when structured output succeeds', async () => { + (mockConfig.getStructuredOutput as Mock).mockReturnValue({ + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + count: { type: 'integer' }, + }, + required: ['summary', 'count'], + }, + maxRetries: 5, + }); + setupMetricsMock(); + + const toolCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'structured-1', + name: ToolNames.STRUCTURED_OUTPUT, + args: { summary: 'done', count: 2 }, + isClientInitiated: false, + prompt_id: 'prompt-structured', + }, + }; + + mockCoreExecuteToolCall.mockResolvedValueOnce({ + callId: 'structured-1', + responseParts: [ + { + functionResponse: { + id: 'structured-1', + name: ToolNames.STRUCTURED_OUTPUT, + response: { output: 'Structured output provided successfully.' }, + }, + }, + ], + resultDisplay: '', + error: undefined, + errorType: undefined, + terminalResult: { + kind: 'structured_output', + data: { summary: 'done', count: 2 }, + }, + }); + + mockGeminiClient.sendMessageStream.mockReturnValueOnce( + createStreamFromEvents([ + toolCall, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 8 } }, + }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Structured output', + 'prompt-structured', + ); + + expect(processStdoutSpy).toHaveBeenCalledWith( + '{"summary":"done","count":2}\n', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should not record rejected mixed structured output calls as completed', async () => { + (mockConfig.getStructuredOutput as Mock).mockReturnValue({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + maxRetries: 5, + }); + setupMetricsMock(); + + const mixedStructuredCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'structured-1', + name: ToolNames.STRUCTURED_OUTPUT, + args: { ok: true }, + isClientInitiated: false, + prompt_id: 'prompt-structured-mixed', + }, + }; + const mixedWriteCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'write-1', + name: 'write_file', + args: { file_path: '/test/project/.qwen/skills/example.md' }, + isClientInitiated: false, + prompt_id: 'prompt-structured-mixed', + }, + }; + const finalStructuredCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'structured-2', + name: ToolNames.STRUCTURED_OUTPUT, + args: { ok: true }, + isClientInitiated: false, + prompt_id: 'prompt-structured-mixed', + }, + }; + + mockCoreExecuteToolCall.mockResolvedValueOnce({ + callId: 'structured-2', + responseParts: [ + { + functionResponse: { + id: 'structured-2', + name: ToolNames.STRUCTURED_OUTPUT, + response: { output: 'Structured output provided successfully.' }, + }, + }, + ], + resultDisplay: '', + terminalResult: { + kind: 'structured_output', + data: { ok: true }, + }, + }); + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce( + createStreamFromEvents([ + mixedStructuredCall, + mixedWriteCall, + { + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { totalTokenCount: 8 }, + }, + }, + ]), + ) + .mockReturnValueOnce( + createStreamFromEvents([ + finalStructuredCall, + { + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { totalTokenCount: 8 }, + }, + }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Structured output', + 'prompt-structured-mixed', + ); + + expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); + expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); + expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledWith( + ToolNames.STRUCTURED_OUTPUT, + { ok: true }, + ); + expect(mockGeminiClient.recordCompletedToolCall).not.toHaveBeenCalledWith( + 'write_file', + { file_path: '/test/project/.qwen/skills/example.md' }, + ); + }); + it('should handle userMessage with text content blocks in stream-json input mode', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 3b9a6f8d4ac..b1e6c7726ee 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -8,6 +8,7 @@ import type { BackgroundTaskStatus, Config, ToolCallRequestInfo, + ToolCallResponseInfo, } from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; @@ -25,6 +26,8 @@ import { parseAndFormatApiError, createDebugLogger, SendMessageType, + ToolErrorType, + ToolNames, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -42,8 +45,6 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; - -const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); import { normalizePartList, extractPartsFromUserMessage, @@ -53,6 +54,8 @@ import { computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; +const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); + // Human-readable labels for the detectors that can fire mid-stream. // Surfaced to stderr in TEXT mode so a headless run that halts on a loop // doesn't exit with empty stdout and no explanation — see PR #3236 review. @@ -69,6 +72,39 @@ const LOOP_TYPE_LABELS: Record = { 'the model kept calling the same tool without making progress', }; +const STRUCTURED_OUTPUT_REQUIRED_REMINDER = + `You must call the ${ToolNames.STRUCTURED_OUTPUT} tool to complete this request. ` + + 'Do not respond with natural language.'; + +class StructuredOutputMaxRetriesError extends Error { + constructor(message: string) { + super(message); + this.name = 'StructuredOutputMaxRetriesError'; + } +} + +function buildStructuredOutputToolError( + request: ToolCallRequestInfo, + message: string, +): ToolCallResponseInfo { + return { + callId: request.callId, + error: new Error(message), + errorType: ToolErrorType.INVALID_TOOL_PARAMS, + resultDisplay: message, + responseParts: [ + { + functionResponse: { + id: request.callId, + name: request.name, + response: { error: message }, + }, + }, + ], + contentLength: message.length, + }; +} + function emitLoopDetectedMessage( config: Config, loopType: LoopType | undefined, @@ -266,6 +302,11 @@ export async function runNonInteractive( if (!initialPartList) { let slashHandled = false; if (isSlashCommand(input)) { + if (config.getStructuredOutput()) { + throw new FatalInputError( + '--json-schema is not supported with slash commands.', + ); + } const slashCommandResult = await handleSlashCommand( input, abortController, @@ -413,8 +454,308 @@ export async function runNonInteractive( }); } + const emitFinalResult = async (structuredOutput?: unknown) => { + // Drain-turns count toward getMaxSessionTurns() for symmetry with the main + // loop — otherwise a looping cron or a model that keeps replying to + // notifications could exceed the cap silently in headless runs. + const drainOneItem = async () => { + if (localQueue.length === 0) return; + const item = localQueue.shift()!; + + emitNotificationToSdk(item); + + turnCount++; + if ( + config.getMaxSessionTurns() >= 0 && + turnCount > config.getMaxSessionTurns() + ) { + await handleMaxTurnsExceededError(config); + } + + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; + + let itemMessages: Content[] = [ + { role: 'user', parts: [{ text: item.modelText }] }, + ]; + let itemIsFirstTurn = true; + let itemModelOverride: string | undefined; + + while (true) { + const itemToolCallRequests: ToolCallRequestInfo[] = []; + const itemApiStartTime = Date.now(); + const itemStream = geminiClient.sendMessageStream( + itemMessages[0]?.parts || [], + abortController.signal, + prompt_id, + { + type: itemIsFirstTurn + ? item.sendMessageType + : SendMessageType.ToolResult, + modelOverride: itemModelOverride, + ...(itemIsFirstTurn && { + notificationDisplayText: item.displayText, + }), + }, + ); + itemIsFirstTurn = false; + + adapter.startAssistantMessage(); + + for await (const event of itemStream) { + if (abortController.signal.aborted) { + // Pair the startAssistantMessage() above so stream-json mode doesn't + // leave an unterminated message_start. + adapter.finalizeAssistantMessage(); + return; + } + adapter.processEvent(event); + if (event.type === GeminiEventType.ToolCallRequest) { + itemToolCallRequests.push(event.value); + } + if (event.type === GeminiEventType.LoopDetected) { + emitLoopDetectedMessage(config, event.value?.loopType); + } + if ( + outputFormat === OutputFormat.TEXT && + event.type === GeminiEventType.Error + ) { + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + ); + process.stderr.write(`${errorText}\n`); + // See the matching note in the first stream loop above — + // we mark the throw so handleError doesn't reformat or + // reprint downstream. + throw new AlreadyReportedError(errorText); + } + } + + adapter.finalizeAssistantMessage(); + totalApiDurationMs += Date.now() - itemApiStartTime; + + if (itemToolCallRequests.length > 0) { + const itemToolResponseParts: Part[] = []; + + for (const requestInfo of itemToolCallRequests) { + const isAgentTool = requestInfo.name === 'agent'; + const { handler: outputUpdateHandler } = isAgentTool + ? createAgentToolProgressHandler( + config, + requestInfo.callId, + adapter, + ) + : createToolProgressHandler(requestInfo, adapter); + + const toolResponse = await executeToolCall( + config, + requestInfo, + abortController.signal, + { + outputUpdateHandler, + ...(toolCallUpdateCallback && { + onToolCallsUpdate: toolCallUpdateCallback, + }), + }, + ); + + if (toolResponse.error) { + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + } + + adapter.emitToolResult(requestInfo, toolResponse); + config + .getGeminiClient() + .recordCompletedToolCall( + requestInfo.name, + requestInfo.args as Record, + ); + + if (toolResponse.responseParts) { + itemToolResponseParts.push(...toolResponse.responseParts); + } + + if ('modelOverride' in toolResponse) { + itemModelOverride = toolResponse.modelOverride; + } + } + itemMessages = [{ role: 'user', parts: itemToolResponseParts }]; + } else { + break; + } + } + }; + + // Single-flight drain: concurrent callers wait for the running drain so + // cron jobs firing mid-stream don't produce overlapping turns. + // + // Clear via outer `.finally()` rather than inside the async body: when the + // queue is empty the body runs synchronously, so an inner finally would + // null the slot BEFORE the outer `drainPromise = p` assignment and leave + // it stuck forever. + let drainPromise: Promise | null = null; + const drainLocalQueue = (): Promise => { + if (drainPromise) return drainPromise; + const p = (async () => { + while (localQueue.length > 0) { + await drainOneItem(); + } + })(); + drainPromise = p; + void p.finally(() => { + if (drainPromise === p) drainPromise = null; + }); + return p; + }; + + // Start cron scheduler — fires enqueue onto the shared queue. + const scheduler = !config.isCronEnabled() + ? null + : config.getCronScheduler(); + + if (scheduler && scheduler.size > 0) { + await new Promise((resolve, reject) => { + // Resolve on SIGINT/SIGTERM too — recurring cron jobs never + // drop scheduler.size to 0 on their own, so without this the + // hold-back loop below is unreachable after an abort. + const onAbort = () => { + scheduler.stop(); + resolve(); + }; + if (abortController.signal.aborted) { + onAbort(); + return; + } + abortController.signal.addEventListener('abort', onAbort, { + once: true, + }); + + const checkCronDone = () => { + if (scheduler.size === 0 && !drainPromise) { + abortController.signal.removeEventListener('abort', onAbort); + scheduler.stop(); + resolve(); + } + }; + + // Propagate drain failures. Without this, a rejected + // drainLocalQueue() (e.g. a text-mode API error surfacing + // out of drainOneItem) would be swallowed by `void` and + // checkCronDone would never fire — hanging the run. + const onDrainError = (err: unknown) => { + abortController.signal.removeEventListener('abort', onAbort); + scheduler.stop(); + reject(err); + }; + + scheduler.start((job: { prompt: string }) => { + const label = job.prompt.slice(0, 40); + localQueue.push({ + displayText: `Cron: ${label}`, + modelText: job.prompt, + sendMessageType: SendMessageType.Cron, + }); + drainLocalQueue().then(checkCronDone, onDrainError); + }); + + // Check immediately in case jobs were already deleted + checkCronDone(); + }); + } + + // Wait for running background agents to complete before emitting the final + // result. On SIGINT/SIGTERM, abort them and route through + // handleCancellationError — otherwise the success emitResult below would + // silently convert a cancellation into a completion. + while (true) { + if (abortController.signal.aborted) { + registry.abortAll(); + // Flush queued terminal notifications before handleCancellationError + // exits so stream-json consumers always see a task_notification paired + // with every task_started. + flushQueuedNotificationsToSdk(localQueue); + finalizeOneShotMonitors(); + await handleCancellationError(config); + } + // Once we enter the final holdback loop, monitor events should no + // longer extend one-shot runtime. Already-queued events still drain + // through the model, but later monitor output is SDK-only. + captureMonitorTurnsInLocalQueue = false; + await drainLocalQueue(); + // Wait for every background task's terminal notification, not + // just the running ones: cancel() marks status 'cancelled' + // synchronously but the notification is emitted later by the + // natural handler, and SDK consumers need every task_started + // paired with one. Monitors are different: they intentionally + // continue in the background, so final result emission is not + // gated on monitor lifetime. + if (!registry.hasUnfinalizedTasks() && localQueue.length === 0) break; + await new Promise((r) => setTimeout(r, 100)); + } + + const memoryTaskPromises = config + .getGeminiClient() + .consumePendingMemoryTaskPromises(); + if (memoryTaskPromises.length > 0) { + await Promise.allSettled(memoryTaskPromises); + } + finalizeOneShotMonitors(); + + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + // Get stats for JSON format output + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + const resultOptions = { + isError: false, + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + usage, + stats, + }; + if (structuredOutput !== undefined) { + adapter.emitStructuredResult({ + ...resultOptions, + structuredOutput, + }); + } else { + adapter.emitResult(resultOptions); + } + }; + let isFirstTurn = true; let modelOverride: string | undefined; + const structuredOutputConfig = config.getStructuredOutput(); + let structuredOutputRetryCount = 0; + const noteStructuredOutputRetry = () => { + if (!structuredOutputConfig) { + return; + } + structuredOutputRetryCount++; + if (structuredOutputRetryCount > structuredOutputConfig.maxRetries) { + throw new StructuredOutputMaxRetriesError( + `Structured output was not produced after ${structuredOutputConfig.maxRetries} retries.`, + ); + } + }; while (true) { turnCount++; if ( @@ -481,6 +822,42 @@ export async function runNonInteractive( if (toolCallRequests.length > 0) { const toolResponseParts: Part[] = []; + const structuredOutputRequests = structuredOutputConfig + ? toolCallRequests.filter( + (request) => request.name === ToolNames.STRUCTURED_OUTPUT, + ) + : []; + + if ( + structuredOutputRequests.length > 0 && + (structuredOutputRequests.length !== 1 || + toolCallRequests.length !== 1) + ) { + noteStructuredOutputRetry(); + const message = + `${ToolNames.STRUCTURED_OUTPUT} must be the only tool call in the final turn. ` + + `Call ${ToolNames.STRUCTURED_OUTPUT} exactly once after all other work is complete.`; + + for (const requestInfo of toolCallRequests) { + const toolResponse = buildStructuredOutputToolError( + requestInfo, + message, + ); + handleToolError( + requestInfo.name, + toolResponse.error!, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + adapter.emitToolResult(requestInfo, toolResponse); + toolResponseParts.push(...toolResponse.responseParts); + } + currentMessages = [{ role: 'user', parts: toolResponseParts }]; + continue; + } for (const requestInfo of toolCallRequests) { const finalRequestInfo = requestInfo; @@ -553,286 +930,34 @@ export async function runNonInteractive( if ('modelOverride' in toolResponse) { modelOverride = toolResponse.modelOverride; } - } - currentMessages = [{ role: 'user', parts: toolResponseParts }]; - } else { - // Drain-turns count toward getMaxSessionTurns() for symmetry with the main - // loop — otherwise a looping cron or a model that keeps replying to - // notifications could exceed the cap silently in headless runs. - const drainOneItem = async () => { - if (localQueue.length === 0) return; - const item = localQueue.shift()!; - - emitNotificationToSdk(item); - turnCount++; if ( - config.getMaxSessionTurns() >= 0 && - turnCount > config.getMaxSessionTurns() + structuredOutputConfig && + finalRequestInfo.name === ToolNames.STRUCTURED_OUTPUT ) { - await handleMaxTurnsExceededError(config); - } - - const inputFormat = - typeof config.getInputFormat === 'function' - ? config.getInputFormat() - : InputFormat.TEXT; - const toolCallUpdateCallback = - inputFormat === InputFormat.STREAM_JSON && options.controlService - ? options.controlService.permission.getToolCallUpdateCallback() - : undefined; - - let itemMessages: Content[] = [ - { role: 'user', parts: [{ text: item.modelText }] }, - ]; - let itemIsFirstTurn = true; - let itemModelOverride: string | undefined; - - while (true) { - const itemToolCallRequests: ToolCallRequestInfo[] = []; - const itemApiStartTime = Date.now(); - const itemStream = geminiClient.sendMessageStream( - itemMessages[0]?.parts || [], - abortController.signal, - prompt_id, - { - type: itemIsFirstTurn - ? item.sendMessageType - : SendMessageType.ToolResult, - modelOverride: itemModelOverride, - ...(itemIsFirstTurn && { - notificationDisplayText: item.displayText, - }), - }, - ); - itemIsFirstTurn = false; - - adapter.startAssistantMessage(); - - for await (const event of itemStream) { - if (abortController.signal.aborted) { - // Pair the startAssistantMessage() above so stream-json mode doesn't - // leave an unterminated message_start. - adapter.finalizeAssistantMessage(); - return; - } - adapter.processEvent(event); - if (event.type === GeminiEventType.ToolCallRequest) { - itemToolCallRequests.push(event.value); - } - if (event.type === GeminiEventType.LoopDetected) { - emitLoopDetectedMessage(config, event.value?.loopType); - } - if ( - outputFormat === OutputFormat.TEXT && - event.type === GeminiEventType.Error - ) { - const errorText = parseAndFormatApiError( - event.value.error, - config.getContentGeneratorConfig()?.authType, - ); - process.stderr.write(`${errorText}\n`); - // See the matching note in the first stream loop above — - // we mark the throw so handleError doesn't reformat or - // reprint downstream. - throw new AlreadyReportedError(errorText); - } - } - - adapter.finalizeAssistantMessage(); - totalApiDurationMs += Date.now() - itemApiStartTime; - - if (itemToolCallRequests.length > 0) { - const itemToolResponseParts: Part[] = []; - - for (const requestInfo of itemToolCallRequests) { - const isAgentTool = requestInfo.name === 'agent'; - const { handler: outputUpdateHandler } = isAgentTool - ? createAgentToolProgressHandler( - config, - requestInfo.callId, - adapter, - ) - : createToolProgressHandler(requestInfo, adapter); - - const toolResponse = await executeToolCall( - config, - requestInfo, - abortController.signal, - { - outputUpdateHandler, - ...(toolCallUpdateCallback && { - onToolCallsUpdate: toolCallUpdateCallback, - }), - }, - ); - - if (toolResponse.error) { - handleToolError( - requestInfo.name, - toolResponse.error, - config, - toolResponse.errorType || 'TOOL_EXECUTION_ERROR', - typeof toolResponse.resultDisplay === 'string' - ? toolResponse.resultDisplay - : undefined, - ); - } - - adapter.emitToolResult(requestInfo, toolResponse); - config - .getGeminiClient() - .recordCompletedToolCall( - requestInfo.name, - requestInfo.args as Record, - ); - - if (toolResponse.responseParts) { - itemToolResponseParts.push(...toolResponse.responseParts); - } - - if ('modelOverride' in toolResponse) { - itemModelOverride = toolResponse.modelOverride; - } - } - itemMessages = [{ role: 'user', parts: itemToolResponseParts }]; - } else { - break; - } - } - }; - - // Single-flight drain: concurrent callers wait for the running drain so - // cron jobs firing mid-stream don't produce overlapping turns. - // - // Clear via outer `.finally()` rather than inside the async body: when the - // queue is empty the body runs synchronously, so an inner finally would - // null the slot BEFORE the outer `drainPromise = p` assignment and leave - // it stuck forever. - let drainPromise: Promise | null = null; - const drainLocalQueue = (): Promise => { - if (drainPromise) return drainPromise; - const p = (async () => { - while (localQueue.length > 0) { - await drainOneItem(); - } - })(); - drainPromise = p; - void p.finally(() => { - if (drainPromise === p) drainPromise = null; - }); - return p; - }; - - // Start cron scheduler — fires enqueue onto the shared queue. - const scheduler = !config.isCronEnabled() - ? null - : config.getCronScheduler(); - - if (scheduler && scheduler.size > 0) { - await new Promise((resolve, reject) => { - // Resolve on SIGINT/SIGTERM too — recurring cron jobs never - // drop scheduler.size to 0 on their own, so without this the - // hold-back loop below is unreachable after an abort. - const onAbort = () => { - scheduler.stop(); - resolve(); - }; - if (abortController.signal.aborted) { - onAbort(); + if (toolResponse.error) { + noteStructuredOutputRetry(); + } else if ( + toolResponse.terminalResult?.kind === 'structured_output' + ) { + await emitFinalResult(toolResponse.terminalResult.data); return; } - abortController.signal.addEventListener('abort', onAbort, { - once: true, - }); - - const checkCronDone = () => { - if (scheduler.size === 0 && !drainPromise) { - abortController.signal.removeEventListener('abort', onAbort); - scheduler.stop(); - resolve(); - } - }; - - // Propagate drain failures. Without this, a rejected - // drainLocalQueue() (e.g. a text-mode API error surfacing - // out of drainOneItem) would be swallowed by `void` and - // checkCronDone would never fire — hanging the run. - const onDrainError = (err: unknown) => { - abortController.signal.removeEventListener('abort', onAbort); - scheduler.stop(); - reject(err); - }; - - scheduler.start((job: { prompt: string }) => { - const label = job.prompt.slice(0, 40); - localQueue.push({ - displayText: `Cron: ${label}`, - modelText: job.prompt, - sendMessageType: SendMessageType.Cron, - }); - drainLocalQueue().then(checkCronDone, onDrainError); - }); - - // Check immediately in case jobs were already deleted - checkCronDone(); - }); - } - - // Wait for running background agents to complete before emitting the final - // result. On SIGINT/SIGTERM, abort them and route through - // handleCancellationError — otherwise the success emitResult below would - // silently convert a cancellation into a completion. - while (true) { - if (abortController.signal.aborted) { - registry.abortAll(); - // Flush queued terminal notifications before handleCancellationError - // exits so stream-json consumers always see a task_notification paired - // with every task_started. - flushQueuedNotificationsToSdk(localQueue); - finalizeOneShotMonitors(); - await handleCancellationError(config); } - // Once we enter the final holdback loop, monitor events should no - // longer extend one-shot runtime. Already-queued events still drain - // through the model, but later monitor output is SDK-only. - captureMonitorTurnsInLocalQueue = false; - await drainLocalQueue(); - // Wait for every background task's terminal notification, not - // just the running ones: cancel() marks status 'cancelled' - // synchronously but the notification is emitted later by the - // natural handler, and SDK consumers need every task_started - // paired with one. Monitors are different: they intentionally - // continue in the background, so final result emission is not - // gated on monitor lifetime. - if (!registry.hasUnfinalizedTasks() && localQueue.length === 0) - break; - await new Promise((r) => setTimeout(r, 100)); } - - const memoryTaskPromises = config - .getGeminiClient() - .consumePendingMemoryTaskPromises(); - if (memoryTaskPromises.length > 0) { - await Promise.allSettled(memoryTaskPromises); + currentMessages = [{ role: 'user', parts: toolResponseParts }]; + } else { + if (structuredOutputConfig) { + noteStructuredOutputRetry(); + currentMessages = [ + { + role: 'user', + parts: [{ text: STRUCTURED_OUTPUT_REQUIRED_REMINDER }], + }, + ]; + continue; } - finalizeOneShotMonitors(); - - const metrics = uiTelemetryService.getMetrics(); - const usage = computeUsageFromMetrics(metrics); - // Get stats for JSON format output - const stats = - outputFormat === OutputFormat.JSON - ? uiTelemetryService.getMetrics() - : undefined; - adapter.emitResult({ - isError: false, - durationMs: Date.now() - startTime, - apiDurationMs: totalApiDurationMs, - numTurns: turnCount, - usage, - stats, - }); + await emitFinalResult(); return; } } @@ -880,6 +1005,10 @@ export async function runNonInteractive( errorMessage: message, usage, stats, + subtype: + error instanceof StructuredOutputMaxRetriesError + ? 'error_max_structured_output_retries' + : undefined, }); } await handleError(error, config); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ae5d25c00fa..5a25b674809 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -465,6 +465,7 @@ export interface ConfigParameters { model?: string; outputLanguageFilePath?: string; maxSessionTurns?: number; + structuredOutput?: StructuredOutputConfig; clearContextOnIdle?: ClearContextOnIdleSettings; sessionTokenLimit?: number; experimentalZedIntegration?: boolean; @@ -589,6 +590,11 @@ export interface ConfigParameters { ) => Promise; } +export interface StructuredOutputConfig { + schema: Record; + maxRetries: number; +} + function normalizeConfigOutputFormat( format: OutputFormat | undefined, ): OutputFormat | undefined { @@ -721,6 +727,7 @@ export class Config { private ideMode: boolean; private readonly maxSessionTurns: number; + private readonly structuredOutput: StructuredOutputConfig | undefined; private readonly clearContextOnIdle: ClearContextOnIdleSettings; private readonly sessionTokenLimit: number; private readonly listExtensions: boolean; @@ -870,6 +877,7 @@ export class Config { this.fileDiscoveryService = params.fileDiscoveryService ?? null; this.bugCommand = params.bugCommand; this.maxSessionTurns = params.maxSessionTurns ?? -1; + this.structuredOutput = params.structuredOutput; this.clearContextOnIdle = { toolResultsThresholdMinutes: params.clearContextOnIdle?.toolResultsThresholdMinutes ?? 60, @@ -1681,6 +1689,10 @@ export class Config { return this.maxSessionTurns; } + getStructuredOutput(): StructuredOutputConfig | undefined { + return this.structuredOutput; + } + getClearContextOnIdle(): ClearContextOnIdleSettings { return this.clearContextOnIdle; } @@ -1787,7 +1799,19 @@ export class Config { } getAppendSystemPrompt(): string | undefined { - return this.appendSystemPrompt; + if (!this.structuredOutput) { + return this.appendSystemPrompt; + } + + const structuredOutputPrompt = [ + `When you are ready to provide the final answer, call the ${ToolNames.STRUCTURED_OUTPUT} tool exactly once.`, + 'Do not answer the final result in natural language.', + 'The tool arguments must match the JSON Schema supplied by the caller.', + ].join(' '); + + return this.appendSystemPrompt + ? `${this.appendSystemPrompt}\n\n${structuredOutputPrompt}` + : structuredOutputPrompt; } /** @deprecated Use getPermissionsAllow() instead. */ @@ -2857,6 +2881,23 @@ export class Config { } }; + const registerStructuredOutputTool = async (): Promise => { + if (!this.structuredOutput) { + return; + } + if (registry.getAllToolNames().includes(ToolNames.STRUCTURED_OUTPUT)) { + throw new Error( + `Tool name "${ToolNames.STRUCTURED_OUTPUT}" is reserved for structured output.`, + ); + } + registry.registerFactory(ToolNames.STRUCTURED_OUTPUT, async () => { + const { StructuredOutputTool } = await import( + '../tools/structured-output.js' + ); + return new StructuredOutputTool(this.structuredOutput!.schema); + }); + }; + if (this.getBareMode()) { await registerLazy(ToolNames.READ_FILE, async () => { const { ReadFileTool } = await import('../tools/read-file.js'); @@ -2870,6 +2911,7 @@ export class Config { const { ShellTool } = await import('../tools/shell.js'); return new ShellTool(this); }); + await registerStructuredOutputTool(); this.debugLogger.debug( `ToolRegistry created: ${JSON.stringify(registry.getAllToolNames())} (${registry.getAllToolNames().length} tools)`, ); @@ -3005,6 +3047,7 @@ export class Config { if (!options?.skipDiscovery) { await registry.discoverAllTools(); } + await registerStructuredOutputTool(); this.debugLogger.debug( `ToolRegistry created: ${JSON.stringify(registry.getAllToolNames())} (${registry.getAllToolNames().length} tools)`, ); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 0a4f2013d3f..36146ccbb62 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -2066,6 +2066,9 @@ export class CoreToolScheduler { ...('modelOverride' in toolResult ? { modelOverride: toolResult.modelOverride } : {}), + ...(toolResult.terminalResult + ? { terminalResult: toolResult.terminalResult } + : {}), }; this.setStatusInternal(callId, 'success', successResponse); } else { diff --git a/packages/core/src/core/permissionFlow.test.ts b/packages/core/src/core/permissionFlow.test.ts index 2715a0afeca..035a4518367 100644 --- a/packages/core/src/core/permissionFlow.test.ts +++ b/packages/core/src/core/permissionFlow.test.ts @@ -24,6 +24,7 @@ const mockConfig = (overrides: Partial = {}): Config => getPermissionManager: vi.fn().mockReturnValue(null), getTargetDir: vi.fn().mockReturnValue('/test'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getStructuredOutput: vi.fn().mockReturnValue(undefined), ...overrides, }) as unknown as Config; @@ -84,6 +85,62 @@ describe('evaluatePermissionFlow', () => { expect(result.denyMessage).toContain('Matching deny rule'); }); + it('should always allow the structured output terminal tool', async () => { + const mockPm = { + hasRelevantRules: vi.fn().mockReturnValue(true), + evaluate: vi.fn().mockResolvedValue('deny'), + findMatchingDenyRule: vi.fn().mockReturnValue('structured_output'), + hasMatchingAskRule: vi.fn().mockReturnValue(false), + }; + + const invocation = mockInvocation({ + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + }); + + const result = await evaluatePermissionFlow( + mockConfig({ + getPermissionManager: vi.fn().mockReturnValue(mockPm), + getStructuredOutput: vi.fn().mockReturnValue({ + schema: { type: 'object' }, + maxRetries: 5, + }), + }), + invocation, + ToolNames.STRUCTURED_OUTPUT, + { ok: true }, + ); + + expect(result.finalPermission).toBe('allow'); + expect(result.denyMessage).toBeUndefined(); + expect(mockPm.evaluate).not.toHaveBeenCalled(); + }); + + it('should apply normal permission rules for structured_output when structured output is disabled', async () => { + const mockPm = { + hasRelevantRules: vi.fn().mockReturnValue(true), + evaluate: vi.fn().mockResolvedValue('deny'), + findMatchingDenyRule: vi.fn().mockReturnValue('structured_output'), + hasMatchingAskRule: vi.fn().mockReturnValue(false), + }; + + const invocation = mockInvocation({ + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + }); + + const result = await evaluatePermissionFlow( + mockConfig({ + getPermissionManager: vi.fn().mockReturnValue(mockPm), + }), + invocation, + ToolNames.STRUCTURED_OUTPUT, + { ok: true }, + ); + + expect(result.finalPermission).toBe('deny'); + expect(result.denyMessage).toContain('denied by permission rules'); + expect(mockPm.evaluate).toHaveBeenCalled(); + }); + it('should return ask permission when PM has no relevant rules', async () => { const mockPm = { hasRelevantRules: vi.fn().mockReturnValue(false), diff --git a/packages/core/src/core/permissionFlow.ts b/packages/core/src/core/permissionFlow.ts index 96e6b225f00..dcbd2d130a7 100644 --- a/packages/core/src/core/permissionFlow.ts +++ b/packages/core/src/core/permissionFlow.ts @@ -69,6 +69,18 @@ export async function evaluatePermissionFlow( toolParams, config.getTargetDir?.() ?? '', ); + + if ( + toolName === ToolNames.STRUCTURED_OUTPUT && + config.getStructuredOutput?.() + ) { + return { + finalPermission: 'allow', + pmForcedAsk: false, + pmCtx, + }; + } + const { finalPermission, pmForcedAsk } = await evaluatePermissionRules( pm, defaultPermission, diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 34f78bcd063..bc2e98e07b1 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -113,6 +113,10 @@ export interface ToolCallResponseInfo { errorType: ToolErrorType | undefined; contentLength?: number; modelOverride?: string; + terminalResult?: { + kind: 'structured_output'; + data: unknown; + }; } export interface ServerToolCallConfirmationDetails { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 16b2d63d397..9c09b921074 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,6 +110,11 @@ export type { ShellToolInvocation, } from './tools/shell.js'; export type { SkillTool, SkillParams } from './tools/skill.js'; +export type { + StructuredOutputTool, + StructuredOutputParams, + StructuredOutputToolResult, +} from './tools/structured-output.js'; export type { AgentTool, AgentParams } from './tools/agent/agent.js'; export type { TodoWriteTool, diff --git a/packages/core/src/tools/structured-output.test.ts b/packages/core/src/tools/structured-output.test.ts new file mode 100644 index 00000000000..fd90640b688 --- /dev/null +++ b/packages/core/src/tools/structured-output.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { StructuredOutputTool } from './structured-output.js'; +import { ToolNames } from './tool-names.js'; + +const schema = { + type: 'object', + properties: { + summary: { type: 'string' }, + count: { type: 'integer' }, + }, + required: ['summary', 'count'], + additionalProperties: false, +}; + +describe('StructuredOutputTool', () => { + it('returns a terminal structured output payload for valid input', async () => { + const tool = new StructuredOutputTool(schema); + const invocation = tool.build({ summary: 'done', count: 2 }); + const result = await invocation.execute(new AbortController().signal); + + expect(tool.name).toBe(ToolNames.STRUCTURED_OUTPUT); + expect(result.error).toBeUndefined(); + expect(result.terminalResult).toEqual({ + kind: 'structured_output', + data: { summary: 'done', count: 2 }, + }); + }); + + it('rejects invalid input without coercing values', () => { + const tool = new StructuredOutputTool(schema); + + expect(() => tool.build({ summary: 'done', count: '2' })).toThrow( + /must be integer/, + ); + }); +}); diff --git a/packages/core/src/tools/structured-output.ts b/packages/core/src/tools/structured-output.ts new file mode 100644 index 00000000000..bc2e35427dd --- /dev/null +++ b/packages/core/src/tools/structured-output.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ValidateFunction } from 'ajv'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { + BaseToolInvocation, + DeclarativeTool, + Kind, + type ToolInvocation, + type ToolResult, + type ToolResultDisplay, +} from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; + +export type StructuredOutputParams = Record; + +export interface StructuredOutputTerminalResult { + kind: 'structured_output'; + data: unknown; +} + +export interface StructuredOutputToolResult extends ToolResult { + terminalResult: StructuredOutputTerminalResult; +} + +class StructuredOutputInvocation extends BaseToolInvocation< + StructuredOutputParams, + StructuredOutputToolResult +> { + getDescription(): string { + return 'Provide final structured output.'; + } + + async execute( + _signal: AbortSignal, + _updateOutput?: (output: ToolResultDisplay) => void, + ): Promise { + return { + llmContent: 'Structured output provided successfully.', + returnDisplay: '', + terminalResult: { + kind: 'structured_output', + data: this.params, + }, + }; + } +} + +export class StructuredOutputTool extends DeclarativeTool< + StructuredOutputParams, + StructuredOutputToolResult +> { + private readonly validator: ValidateFunction; + + constructor(parameterSchema: Record) { + super( + ToolNames.STRUCTURED_OUTPUT, + ToolDisplayNames.STRUCTURED_OUTPUT, + 'Call this tool exactly once to provide the final response as structured JSON matching the requested schema.', + Kind.Other, + parameterSchema, + false, + false, + ); + this.validator = SchemaValidator.compileStrict(parameterSchema); + } + + build( + params: StructuredOutputParams, + ): ToolInvocation { + const valid = this.validator(params); + if (!valid) { + throw new Error( + `Output does not match required JSON Schema: ${SchemaValidator.errorsText( + this.validator, + )}`, + ); + } + return new StructuredOutputInvocation(params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index c0c7ec4dee9..b2ffd39828f 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -39,6 +39,7 @@ export const ToolNames = { TASK_STOP: 'task_stop', SEND_MESSAGE: 'send_message', MONITOR: 'monitor', + STRUCTURED_OUTPUT: 'structured_output', } as const; /** @@ -68,6 +69,7 @@ export const ToolDisplayNames = { TASK_STOP: 'TaskStop', SEND_MESSAGE: 'SendMessage', MONITOR: 'Monitor', + STRUCTURED_OUTPUT: 'StructuredOutput', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts index 244642e8305..7e61c226ead 100644 --- a/packages/core/src/tools/tools.test.ts +++ b/packages/core/src/tools/tools.test.ts @@ -118,6 +118,10 @@ describe('DeclarativeTool', () => { }); describe('hasCycleInSchema', () => { + it('should detect a root hash self-reference cycle', () => { + expect(hasCycleInSchema({ $ref: '#' })).toBe(true); + }); + it('should detect a simple direct cycle', () => { const schema = { properties: { @@ -183,6 +187,24 @@ describe('hasCycleInSchema', () => { expect(hasCycleInSchema(schema)).toBe(true); }); + it('should detect cycles through escaped JSON Pointer segments', () => { + const schema = { + type: 'object', + properties: { + root: { $ref: '#/definitions/node~1one' }, + }, + definitions: { + 'node/one': { + type: 'object', + properties: { + child: { $ref: '#/definitions/node~1one' }, + }, + }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(true); + }); + it('should not detect a cycle in a valid schema', () => { const schema = { type: 'object', diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 04f3a055cd4..09cf3b12201 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -408,6 +408,15 @@ export interface ToolResult { * turns within the same agentic loop. */ modelOverride?: string; + + /** + * Machine-readable terminal payloads that should end the surrounding + * non-interactive loop instead of being treated as display text. + */ + terminalResult?: { + kind: 'structured_output'; + data: unknown; + }; } /** @@ -416,13 +425,18 @@ export interface ToolResult { * @returns `true` if a cycle is detected, `false` otherwise. */ export function hasCycleInSchema(schema: object): boolean { + function decodeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); + } + function resolveRef(ref: string): object | null { if (!ref.startsWith('#/')) { return null; } const path = ref.substring(2).split('/'); let current: unknown = schema; - for (const segment of path) { + for (const rawSegment of path) { + const segment = decodeJsonPointerSegment(rawSegment); if ( typeof current !== 'object' || current === null || @@ -455,8 +469,8 @@ export function hasCycleInSchema(schema: object): boolean { if ('$ref' in node && typeof node.$ref === 'string') { const ref = node.$ref; - if (ref === '#/' || pathRefs.has(ref)) { - // A ref to just '#/' is always a cycle. + if (ref === '#' || ref === '#/' || pathRefs.has(ref)) { + // A ref to the root is always a cycle. return true; // Cycle detected! } if (visitedRefs.has(ref)) { diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 673f5b4104c..14a30b57240 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import AjvPkg, { type AnySchema, type Ajv } from 'ajv'; +import AjvPkg, { type AnySchema, type Ajv, type ValidateFunction } from 'ajv'; // Ajv2020 is the documented way to use draft-2020-12: https://ajv.js.org/json-schema.html#draft-2020-12 // eslint-disable-next-line import/no-internal-modules import Ajv2020Pkg from 'ajv/dist/2020.js'; @@ -64,6 +64,34 @@ function getValidator(schema: AnySchema): Ajv { * Supports both draft-07 (default) and draft-2020-12 schemas. */ export class SchemaValidator { + /** + * Compiles a schema and throws on compile failure. Unlike validate(), this + * never silently skips invalid schemas. + */ + static compileStrict(schema: unknown): ValidateFunction { + const anySchema = schema as AnySchema; + const validator = getValidator(anySchema); + return validator.compile(anySchema); + } + + static errorsText(validate: ValidateFunction, dataVar = 'params'): string { + const anySchema = validate.schema as AnySchema; + const validator = getValidator(anySchema); + return validator.errorsText(validate.errors, { dataVar }); + } + + static validateStrict( + schema: unknown | undefined, + data: unknown, + ): string | null { + if (!schema) { + return null; + } + const validate = SchemaValidator.compileStrict(schema); + const valid = validate(data); + return valid ? null : SchemaValidator.errorsText(validate); + } + /** * Returns null if the data conforms to the schema described by schema (or if schema * is null). Otherwise, returns a string describing the error. diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java index 58889630b2f..b48832f07ef 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java @@ -69,6 +69,11 @@ public class SDKResultMessage extends MessageBase { */ @JSONField(name = "permission_denials") private List permissionDenials; + /** + * Structured output payload, present when --json-schema is used. + */ + @JSONField(name = "structured_output") + private Object structuredOutput; /** * Error information. */ @@ -262,6 +267,24 @@ public void setPermissionDenials(List permissionDenials) { this.permissionDenials = permissionDenials; } + /** + * Gets the structured output payload. + * + * @return The structured output payload + */ + public Object getStructuredOutput() { + return structuredOutput; + } + + /** + * Sets the structured output payload. + * + * @param structuredOutput The structured output payload + */ + public void setStructuredOutput(Object structuredOutput) { + this.structuredOutput = structuredOutput; + } + /** * Gets the error information. * diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts index 07d54a1c7c5..33c658bd946 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts @@ -143,6 +143,7 @@ export interface SDKResultMessageSuccess { duration_api_ms: number; num_turns: number; result: string; + structured_output?: unknown; usage: ExtendedUsage; modelUsage?: Record; permission_denials: CLIPermissionDenial[]; @@ -151,7 +152,10 @@ export interface SDKResultMessageSuccess { export interface SDKResultMessageError { type: 'result'; - subtype: 'error_max_turns' | 'error_during_execution'; + subtype: + | 'error_max_turns' + | 'error_during_execution' + | 'error_max_structured_output_retries'; uuid: string; session_id: string; is_error: true; diff --git a/packages/sdk-python/src/qwen_code_sdk/protocol.py b/packages/sdk-python/src/qwen_code_sdk/protocol.py index 7e5e50b701f..88049472830 100644 --- a/packages/sdk-python/src/qwen_code_sdk/protocol.py +++ b/packages/sdk-python/src/qwen_code_sdk/protocol.py @@ -132,6 +132,7 @@ class SDKResultMessageSuccess(TypedDict): duration_api_ms: int num_turns: int result: str + structured_output: NotRequired[Any] usage: ExtendedUsage permission_denials: list[CLIPermissionDenial] @@ -143,7 +144,11 @@ class ResultErrorObject(TypedDict): class SDKResultMessageError(TypedDict): type: Literal["result"] - subtype: Literal["error_max_turns", "error_during_execution"] + subtype: Literal[ + "error_max_turns", + "error_during_execution", + "error_max_structured_output_retries", + ] uuid: str session_id: str is_error: Literal[True] diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 4b289acf434..55366143013 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -143,6 +143,7 @@ export interface SDKResultMessageSuccess { duration_api_ms: number; num_turns: number; result: string; + structured_output?: unknown; usage: ExtendedUsage; modelUsage?: Record; permission_denials: CLIPermissionDenial[]; @@ -151,7 +152,10 @@ export interface SDKResultMessageSuccess { export interface SDKResultMessageError { type: 'result'; - subtype: 'error_max_turns' | 'error_during_execution'; + subtype: + | 'error_max_turns' + | 'error_during_execution' + | 'error_max_structured_output_retries'; uuid: string; session_id: string; is_error: true;