Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0a2e390
feat(tools): add ToolSearch for on-demand loading of deferred tool sc…
Apr 24, 2026
f22d164
feat(cli): add --json-schema for structured output in headless mode
Apr 24, 2026
a17a357
fix(tools): tighten ToolSearch schema + match invocation signature
wenshao May 8, 2026
1fa1d75
fix(tools,cli): surface ToolSearch reveal failures + dedupe revealed …
wenshao May 8, 2026
e8712eb
fix(cli): honor process.exitCode in headless main exit
wenshao May 8, 2026
88b10c2
test(cli): add integration tests for --json-schema and ToolSearch
wenshao May 8, 2026
3872656
fix(cli,core): tighten --json-schema validation
wenshao May 8, 2026
6153efb
fix(tools): roll back ToolSearch reveals when setTools() throws
wenshao May 8, 2026
dbc6714
test(cli): unit-cover --json-schema runtime branches
wenshao May 8, 2026
210ec2b
fix(cli,core): support type-union arrays in --json-schema
wenshao May 8, 2026
7f235c2
fix(tools): cap select: mode in ToolSearch by max_results
wenshao May 8, 2026
c24c1e5
fix(tools): treat null GeminiClient like setTools() failure in ToolSe…
wenshao May 8, 2026
21c48e9
fix(cli): use boolean sentinel for structured_output submission
wenshao May 8, 2026
ea3ab0c
fix(cli): finish structured_output sentinel cleanup + reject stream-j…
wenshao May 8, 2026
b680acc
fix(prompt): harden deferred-tools section against MCP description in…
wenshao May 8, 2026
9c031da
fix(core): scope --json-schema strictness so spec-valid schemas pass
wenshao May 8, 2026
2213e6e
fix(cli): allow --json-schema with stdin-piped prompt
wenshao May 8, 2026
093b5f8
fix(cli,tools): short-circuit after structured_output + tighten ToolS…
wenshao May 8, 2026
5efc328
fix(prompt,tools): escape backticks in tool names + report select: tr…
wenshao May 8, 2026
e39948e
fix(prompt): JSON-quote tool names instead of incomplete backtick escape
wenshao May 8, 2026
33a946a
fix(tools): escape `<` in ToolSearch schema blocks to prevent wrapper…
wenshao May 8, 2026
9588231
fix: address #3589 wave 2 — Critical reveal/race + revealed-set hygiene
wenshao May 8, 2026
11ccadd
fix(tools,cli): select: quote-strip + import order
wenshao May 8, 2026
1ae9b4a
fix(tools,cli): isolate ensureTool failures + enrich --json-schema error
wenshao May 9, 2026
b8ff450
test(core): cover startChat deferred-tool branches
wenshao May 9, 2026
883a332
test(tools): pin MCP `__` suffix already scores as exact (12), not su…
wenshao May 9, 2026
1884c97
test(cli,core): cover --json-schema pre-scan + resetChat reveal cleanup
wenshao May 9, 2026
e1b75f6
docs(tools): clarify ToolSearch description — fetch decl, callable ne…
wenshao May 9, 2026
bb39976
docs(cli): correct pre-scan comment — siblings are skipped, not synth…
wenshao May 9, 2026
e7a0da9
Merge remote-tracking branch 'origin/main' into x3
wenshao May 9, 2026
1d97d7a
fix(tools,cli): scope ToolSearch reveal/setTools to deferred + drop d…
wenshao May 9, 2026
ca4835c
test(cli): pin contextCommand passes includeDeferred to getFunctionDe…
wenshao May 9, 2026
b3027a5
fix(tools): surface ToolSearch ensureTool/setTools failures to stderr
wenshao May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
isBareMode,
isToolEnabled,
type MCPServerConfig,
SchemaValidator,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
Expand Down Expand Up @@ -164,9 +165,75 @@ export interface CliArgs {
channel: string | undefined;
jsonFd?: number | undefined;
jsonFile?: string | undefined;
jsonSchema?: string | undefined;
inputFile?: string | undefined;
}

/**
* Resolves the `--json-schema` argument into a parsed JSON Schema object.
*
* Accepts either a JSON literal or `@path/to/schema.json`. Fails fast with a
* FatalConfigError if the input can't be read/parsed/compiled — invalid
* schemas should not silently skip validation at runtime.
*/
export function resolveJsonSchemaArg(
Comment thread
wenshao marked this conversation as resolved.
raw: string | undefined,
): Record<string, unknown> | undefined {
if (raw === undefined) {
return undefined;
}
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new FatalConfigError('--json-schema cannot be empty.');
}

let payload: string;
if (trimmed.startsWith('@')) {
const resolvedPath = resolvePath(trimmed.slice(1));
try {
payload = fs.readFileSync(resolvedPath, 'utf8');
} catch (err) {
throw new FatalConfigError(
Comment thread
wenshao marked this conversation as resolved.
`--json-schema could not read "${resolvedPath}": ${
err instanceof Error ? err.message : String(err)
}`,
);
}
} else {
payload = trimmed;
}

let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch (err) {
throw new FatalConfigError(
`--json-schema is not valid JSON: ${
err instanceof Error ? err.message : String(err)
}`,
);
}

if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new FatalConfigError(
'--json-schema must be a JSON object describing a schema.',
);
}

// Ajv compile-time validation. SchemaValidator.validate is deliberately
// lenient at runtime (falls back to no-op on compile failure to support
// exotic MCP schemas) — but `--json-schema` is explicit user intent, so
// surface a bad schema here rather than letting it silently no-op later.
const compileError = SchemaValidator.compileStrict(parsed);
if (compileError) {
throw new FatalConfigError(
`--json-schema is not a valid JSON Schema: ${compileError}`,
);
}

return parsed as Record<string, unknown>;
Comment thread
wenshao marked this conversation as resolved.
}

function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
Expand Down Expand Up @@ -470,6 +537,14 @@ export async function parseArguments(): Promise<CliArgs> {
'File path for structured JSON event output (dual output mode). ' +
'Can be a regular file, FIFO (named pipe), or /dev/fd/N.',
})
.option('json-schema', {
type: 'string',
description:
'JSON Schema that the model\'s final output must conform to ' +
'(headless mode only). Accepts a JSON literal or "@path/to/schema.json". ' +
'Registers a synthetic `structured_output` tool; the session ends on ' +
'the first valid call.',
})
.option('input-file', {
type: 'string',
description:
Expand Down Expand Up @@ -603,6 +678,19 @@ export async function parseArguments(): Promise<CliArgs> {
if (argv['jsonFd'] != null && argv['jsonFile'] != null) {
return '--json-fd and --json-file are mutually exclusive. Use one or the other.';
}
if (argv['jsonSchema']) {
if (argv['promptInteractive']) {
return '--json-schema cannot be used with --prompt-interactive (-i); structured output only terminates the non-interactive flow.';
}
const hasPrompt = !!argv['prompt'];
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (!hasPrompt && !hasPositionalQuery) {
return '--json-schema only applies to non-interactive mode; pass a prompt via -p or as a positional argument.';
Comment thread
wenshao marked this conversation as resolved.
Outdated
}
}
Comment thread
wenshao marked this conversation as resolved.
return true;
}),
)
Expand Down Expand Up @@ -1316,6 +1404,7 @@ export async function loadCliConfig(
// absent.
jsonFd: argv.jsonFd,
jsonFile: argv.jsonFile ?? settings.dualOutput?.jsonFile,
jsonSchema: resolveJsonSchemaArg(argv.jsonSchema),
inputFile: argv.inputFile ?? settings.dualOutput?.inputFile,
// Precedence: explicit CLI flag > settings file > default(true).
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/config/jsonSchemaArg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { resolveJsonSchemaArg } from './config.js';

describe('resolveJsonSchemaArg', () => {
it('returns undefined when the arg is absent', () => {
expect(resolveJsonSchemaArg(undefined)).toBeUndefined();
});

it('parses an inline JSON literal into a schema object', () => {
const schema = resolveJsonSchemaArg(
'{"type":"object","properties":{"summary":{"type":"string"}}}',
);
expect(schema).toEqual({
type: 'object',
properties: { summary: { type: 'string' } },
});
});

it('reads schema from disk via @path syntax', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-schema-'));
const file = path.join(tmp, 'schema.json');
fs.writeFileSync(file, '{"type":"object"}');
try {
const schema = resolveJsonSchemaArg(`@${file}`);
expect(schema).toEqual({ type: 'object' });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it('throws on empty string', () => {
expect(() => resolveJsonSchemaArg(' ')).toThrow(/cannot be empty/);
});

it('throws on invalid JSON', () => {
expect(() => resolveJsonSchemaArg('{not json}')).toThrow(
/not valid JSON/,
);
});

it('throws when the parsed value is not an object', () => {
expect(() => resolveJsonSchemaArg('[]')).toThrow(/must be a JSON object/);
expect(() => resolveJsonSchemaArg('"just a string"')).toThrow(
/must be a JSON object/,
);
});

it('throws when the referenced file does not exist', () => {
expect(() =>
resolveJsonSchemaArg('@/this/path/does/not/exist.json'),
).toThrow(/could not read/);
});

it('throws when schema is syntactically JSON but invalid as a JSON Schema', () => {
// `type` must be a string or array, not a number.
expect(() => resolveJsonSchemaArg('{"type": 42}')).toThrow(
/not a valid JSON Schema/,
);
});

it('accepts a minimal empty-object schema', () => {
// `{}` is a valid schema that accepts anything.
expect(resolveJsonSchemaArg('{}')).toEqual({});
});

it('accepts a draft-2020-12 schema', () => {
const schema = resolveJsonSchemaArg(
'{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}',
);
expect(schema).toBeDefined();
});
});
37 changes: 37 additions & 0 deletions packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,43 @@ describe('BaseJsonOutputAdapter', () => {
expect(result.result).toBe('');
}
});

it('includes structured_result and JSON-stringifies result when structuredResult is provided', () => {
const payload = { summary: 'hi', score: 42 };
const options: ResultOptions = {
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
structuredResult: payload,
};

const result = adapter.exposeBuildResultMessage(options);

if (!result.is_error) {
expect(result.result).toBe(JSON.stringify(payload));
expect(
(result as unknown as { structured_result?: unknown })
.structured_result,
).toEqual(payload);
}
});

it('omits structured_result when structuredResult is undefined (back-compat)', () => {
const options: ResultOptions = {
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
};

const result = adapter.exposeBuildResultMessage(options);

expect(
(result as unknown as { structured_result?: unknown })
.structured_result,
).toBeUndefined();
});
});

describe('startSubagentAssistantMessage', () => {
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export interface ResultOptions {
readonly stats?: SessionMetrics;
readonly summary?: string;
readonly subtype?: string;
/**
* Payload that the model submitted via the synthetic `structured_output`
* tool. When set, `result` is forced to the JSON-stringified form and a
* top-level `structured_result` field is added to the result message.
*/
readonly structuredResult?: unknown;
}

/**
Expand Down Expand Up @@ -1188,7 +1194,14 @@ export abstract class BaseJsonOutputAdapter {
error: { message: errorMessage },
};
} else {
const success: CLIResultMessageSuccess & { stats?: SessionMetrics } = {
const hasStructured = options.structuredResult !== undefined;
const finalResult = hasStructured
? JSON.stringify(options.structuredResult)
: resultText;
const success: CLIResultMessageSuccess & {
stats?: SessionMetrics;
structured_result?: unknown;
} = {
Comment thread
wenshao marked this conversation as resolved.
Outdated
type: 'result',
subtype:
(options.subtype as CLIResultMessageSuccess['subtype']) ?? 'success',
Expand All @@ -1198,14 +1211,17 @@ export abstract class BaseJsonOutputAdapter {
duration_ms: options.durationMs,
duration_api_ms: options.apiDurationMs,
num_turns: options.numTurns,
result: resultText,
result: finalResult,
usage,
permission_denials: [...this.permissionDenials],
};

if (options.stats) {
Comment thread
wenshao marked this conversation as resolved.
success.stats = options.stats;
}
if (hasStructured) {
success.structured_result = options.structuredResult;
}

return success;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ describe('runNonInteractive', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
getOutputFormat: vi.fn().mockReturnValue('text'),
getJsonSchema: vi.fn().mockReturnValue(undefined),
getFolderTrustFeature: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(false),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
Expand Down
Loading
Loading