mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
Some checks failed
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Sync cua-driver to Aliyun OSS / Mirror cua-driver binaries to Aliyun OSS (push) Has been cancelled
* fix(core): coerce numeric string params in SchemaValidator for MCP tools
LLMs frequently emit numeric parameters as strings (e.g. `{"depth": "3"}`
instead of `{"depth": 3}`), which strict MCP servers like Playwright reject
with schema validation errors ("params/depth must be number"). The
SchemaValidator already coerces boolean strings and stringified JSON but
was missing numeric coercion.
Add `fixNumericValues()` that converts string values to integers/numbers
when the schema expects integer/number but does not accept string,
consistent with the existing `fixBooleanValues` pattern.
Co-Authored-By: Qwen-Coder (Qwen Code) <noreply@qwen.ai>
* fix(core): prevent silent truncation in numeric string coercion
- Skip coercion for decimal strings when schema only accepts integer
- Prefer parseFloat when number type is accepted (fixes anyOf[integer, number])
- Add tests for integer-only decimal rejection, anyOf coercion, and array elements
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(core): coerce whole-number decimal strings for integer schemas
Replace the syntactic `hasDecimal` guard with a semantic `num % 1 !== 0`
check so whole-number decimal strings ("3.0", "-10.0") coerce to integers
while genuine non-integers ("5.5") are still left for the LLM to
self-correct. Python-based models commonly serialize float(3) as "3.0".
Co-Authored-By: Qwen-Coder <noreply@qwenlm.ai>
---------
Co-authored-by: Qwen-Coder (Qwen Code) <noreply@qwen.ai>
Co-authored-by: Qwen-Coder <noreply@qwenlm.ai>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import AjvPkg, { type AnySchema, type Ajv } 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';
|
|
import * as addFormats from 'ajv-formats';
|
|
import { createDebugLogger } from './debugLogger.js';
|
|
|
|
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const AjvClass = (AjvPkg as any).default || AjvPkg;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const Ajv2020Class = (Ajv2020Pkg as any).default || Ajv2020Pkg;
|
|
|
|
const debugLogger = createDebugLogger('SchemaValidator');
|
|
|
|
const ajvOptions = {
|
|
// See: https://ajv.js.org/options.html#strict-mode-options
|
|
// strictSchema defaults to true and prevents use of JSON schemas that
|
|
// include unrecognized keywords. The JSON schema spec specifically allows
|
|
// for the use of non-standard keywords and the spec-compliant behavior
|
|
// is to ignore those keywords. Note that setting this to false also
|
|
// allows use of non-standard or custom formats (the unknown format value
|
|
// will be logged but the schema will still be considered valid).
|
|
strictSchema: false,
|
|
};
|
|
|
|
// Draft-07 validator (default)
|
|
const ajvDefault: Ajv = new AjvClass(ajvOptions);
|
|
|
|
// Draft-2020-12 validator for MCP servers using rmcp
|
|
const ajv2020: Ajv = new Ajv2020Class(ajvOptions);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const addFormatsFunc = (addFormats as any).default || addFormats;
|
|
addFormatsFunc(ajvDefault);
|
|
addFormatsFunc(ajv2020);
|
|
|
|
// Canonical draft-2020-12 meta-schema URI (used by rmcp MCP servers).
|
|
// JSON Schema authors commonly include both `…/schema` and `…/schema#`
|
|
// — the trailing `#` is an empty fragment and points at the same
|
|
// document. Normalize before comparing so either form selects ajv2020.
|
|
const DRAFT_2020_12_SCHEMA = 'https://json-schema.org/draft/2020-12/schema';
|
|
|
|
function isDraft2020Uri(uri: unknown): boolean {
|
|
if (typeof uri !== 'string') return false;
|
|
const normalized = uri.endsWith('#') ? uri.slice(0, -1) : uri;
|
|
return normalized === DRAFT_2020_12_SCHEMA;
|
|
}
|
|
|
|
/**
|
|
* Returns the appropriate validator based on schema's $schema field.
|
|
*/
|
|
function getValidator(schema: AnySchema): Ajv {
|
|
if (
|
|
typeof schema === 'object' &&
|
|
schema !== null &&
|
|
'$schema' in schema &&
|
|
isDraft2020Uri(schema.$schema)
|
|
) {
|
|
return ajv2020;
|
|
}
|
|
return ajvDefault;
|
|
}
|
|
|
|
/**
|
|
* Simple utility to validate objects against JSON Schemas.
|
|
* Supports both draft-07 (default) and draft-2020-12 schemas.
|
|
*/
|
|
export class SchemaValidator {
|
|
/**
|
|
* Strictly compiles a schema. Returns an error message if the schema is
|
|
* malformed or uses unsupported draft/features for our Ajv configuration
|
|
* (see {@link getValidator} — `$schema` selects between draft-07 and
|
|
* draft-2020-12; anything else falls through to draft-07's compiler).
|
|
* Returns null on success. Unlike {@link validate}, this does NOT
|
|
* silently skip on compile failure — callers (e.g. the CLI's
|
|
* `--json-schema` parser) need to surface invalid schemas instead of
|
|
* letting them no-op at runtime.
|
|
*/
|
|
static compileStrict(schema: unknown): string | null {
|
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
return 'schema must be a JSON object';
|
|
}
|
|
// Use a dedicated Ajv with `strictSchema: true` so typos like
|
|
// `propertees` raise instead of being silently ignored. The shared
|
|
// ajvDefault/ajv2020 instances run with `strictSchema: false` so
|
|
// unknown MCP keywords don't break runtime validation — that
|
|
// leniency is wrong for explicit user-supplied schemas where
|
|
// `compileStrict` is exactly the surface meant to surface mistakes.
|
|
//
|
|
// We deliberately do NOT pass `strict: true` (which would also
|
|
// enable `strictRequired`, `strictTypes`, etc): those rules go
|
|
// beyond JSON Schema validity and would reject spec-valid schemas
|
|
// like `{type:'object', required:['x']}` (no matching `properties`)
|
|
// or anything using a custom `format`. Keep typo detection;
|
|
// tolerate the looser-but-still-spec-valid patterns users actually
|
|
// ship in `--json-schema`.
|
|
const strictOptions = {
|
|
strictSchema: true, // catches unknown keywords (typos)
|
|
strictRequired: false, // allow `required` without `properties`
|
|
strictTypes: false, // allow inferred / partial type info
|
|
validateFormats: false, // unknown `format` values don't fail
|
|
allowUnionTypes: true, // type: ["a","b"]
|
|
};
|
|
const strictAjv: Ajv = isDraft2020Uri(
|
|
(schema as { $schema?: unknown }).$schema,
|
|
)
|
|
? new Ajv2020Class(strictOptions)
|
|
: new AjvClass(strictOptions);
|
|
addFormatsFunc(strictAjv);
|
|
try {
|
|
strictAjv.compile(schema as AnySchema);
|
|
return null;
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns null if the data conforms to the schema described by schema (or if schema
|
|
* is null). Otherwise, returns a string describing the error.
|
|
*/
|
|
static validate(schema: unknown | undefined, data: unknown): string | null {
|
|
if (!schema) {
|
|
return null;
|
|
}
|
|
if (typeof data !== 'object' || data === null) {
|
|
return 'Value of params must be an object';
|
|
}
|
|
|
|
const anySchema = schema as AnySchema;
|
|
const validator = getValidator(anySchema);
|
|
|
|
// Try to compile and validate; skip validation if schema can't be compiled.
|
|
// This handles schemas using JSON Schema versions AJV doesn't support
|
|
// (e.g., draft-2019-09, future versions).
|
|
// This matches LenientJsonSchemaValidator behavior in mcp-client.ts.
|
|
let validate;
|
|
try {
|
|
validate = validator.compile(anySchema);
|
|
} catch (error) {
|
|
// Schema compilation failed (unsupported version, invalid $ref, etc.)
|
|
// Skip validation rather than blocking tool usage.
|
|
debugLogger.warn(
|
|
`Failed to compile schema (${
|
|
(schema as Record<string, unknown>)?.['$schema'] ?? '<no $schema>'
|
|
}): ${error instanceof Error ? error.message : String(error)}. ` +
|
|
'Skipping parameter validation.',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
let valid = validate(data);
|
|
if (!valid && validate.errors) {
|
|
// Coerce string boolean values ("true"/"false") to actual booleans
|
|
fixBooleanValues(
|
|
data as Record<string, unknown>,
|
|
anySchema as Record<string, unknown>,
|
|
);
|
|
// Coerce stringified JSON values (arrays/objects) back to their proper types.
|
|
// Some LLMs serialize complex values as strings when the schema uses
|
|
// anyOf/oneOf (e.g., '["url"]' instead of ["url"] for anyOf: [array, null]).
|
|
fixStringifiedJsonValues(
|
|
data as Record<string, unknown>,
|
|
anySchema as Record<string, unknown>,
|
|
);
|
|
// Coerce numeric strings ("3", "5.0") to actual numbers when the schema
|
|
// expects integer/number. LLMs frequently emit numeric parameters as
|
|
// strings which strict MCP servers (e.g. Playwright) reject.
|
|
fixNumericValues(
|
|
data as Record<string, unknown>,
|
|
anySchema as Record<string, unknown>,
|
|
);
|
|
|
|
valid = validate(data);
|
|
if (!valid && validate.errors) {
|
|
return validator.errorsText(validate.errors, { dataVar: 'params' });
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Coerces string boolean values to actual booleans.
|
|
* This handles cases where LLMs return "true"/"false" strings instead of boolean values,
|
|
* which is common with self-hosted LLMs.
|
|
*
|
|
* Converts:
|
|
* - "true", "True", "TRUE" -> true
|
|
* - "false", "False", "FALSE" -> false
|
|
*/
|
|
/**
|
|
* Returns the set of JSON Schema types that a property accepts,
|
|
* considering `type`, `anyOf`, and `oneOf` keywords.
|
|
*/
|
|
function getAcceptedTypes(
|
|
propSchema: Record<string, unknown>,
|
|
): Set<string> | null {
|
|
const types = new Set<string>();
|
|
|
|
if (typeof propSchema['type'] === 'string') {
|
|
types.add(propSchema['type'] as string);
|
|
} else if (Array.isArray(propSchema['type'])) {
|
|
for (const t of propSchema['type'] as string[]) {
|
|
types.add(t);
|
|
}
|
|
}
|
|
|
|
for (const keyword of ['anyOf', 'oneOf']) {
|
|
const variants = propSchema[keyword];
|
|
if (Array.isArray(variants)) {
|
|
for (const variant of variants as Array<Record<string, unknown>>) {
|
|
if (typeof variant['type'] === 'string') {
|
|
types.add(variant['type'] as string);
|
|
} else if (Array.isArray(variant['type'])) {
|
|
for (const t of variant['type'] as string[]) {
|
|
types.add(t);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return types.size > 0 ? types : null;
|
|
}
|
|
|
|
/**
|
|
* Coerces stringified JSON values back to their proper types.
|
|
* Some LLMs serialize arrays/objects as JSON strings when the schema uses
|
|
* anyOf/oneOf with mixed types (e.g., `list[str] | None` in Python becomes
|
|
* `anyOf: [{type: "array"}, {type: "null"}]`). The model may return
|
|
* '["url"]' (a string) instead of ["url"] (an actual array).
|
|
*
|
|
* This function parses such strings back to their intended type when:
|
|
* 1. The value is a string starting with `[` or `{`
|
|
* 2. The schema accepts array or object but not string
|
|
* 3. The parsed result matches one of the accepted types
|
|
*/
|
|
function fixStringifiedJsonValues(
|
|
data: Record<string, unknown>,
|
|
schema: Record<string, unknown>,
|
|
) {
|
|
const properties = schema['properties'] as
|
|
| Record<string, Record<string, unknown>>
|
|
| undefined;
|
|
if (!properties) return;
|
|
|
|
for (const key of Object.keys(data)) {
|
|
const value = data[key];
|
|
const propSchema = properties[key];
|
|
if (!propSchema || typeof value !== 'string') continue;
|
|
|
|
const trimmed = value.trim();
|
|
if (
|
|
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
|
|
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|
|
) {
|
|
const accepted = getAcceptedTypes(propSchema);
|
|
if (!accepted) continue;
|
|
// Only coerce if the schema does NOT accept string — otherwise the
|
|
// string value may be intentional.
|
|
if (accepted.has('string')) continue;
|
|
if (!accepted.has('array') && !accepted.has('object')) continue;
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
const parsedType = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
if (accepted.has(parsedType)) {
|
|
data[key] = parsed;
|
|
}
|
|
} catch {
|
|
// Not valid JSON — leave the value unchanged
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Coerces string numeric values to actual numbers.
|
|
* LLMs frequently emit numeric parameters as strings (e.g. `{"depth": "3"}`)
|
|
* which strict MCP servers (e.g. Playwright) reject with schema validation
|
|
* errors like "params/depth must be number".
|
|
*
|
|
* Only coerces when:
|
|
* 1. The value is a string that looks like a clean number
|
|
* 2. The schema accepts integer/number but NOT string
|
|
*/
|
|
function fixNumericValues(
|
|
data: Record<string, unknown>,
|
|
schema: Record<string, unknown>,
|
|
) {
|
|
const properties = schema['properties'] as
|
|
| Record<string, Record<string, unknown>>
|
|
| undefined;
|
|
const items = schema['items'] as Record<string, unknown> | undefined;
|
|
|
|
for (const key of Object.keys(data)) {
|
|
if (!(key in data)) continue;
|
|
const value = data[key];
|
|
const childSchema = Array.isArray(data) ? items : properties?.[key];
|
|
|
|
if (typeof value === 'object' && value !== null) {
|
|
fixNumericValues(value as Record<string, unknown>, childSchema ?? {});
|
|
continue;
|
|
}
|
|
|
|
if (typeof value !== 'string') continue;
|
|
|
|
const accepted = childSchema ? getAcceptedTypes(childSchema) : null;
|
|
if (!accepted || accepted.has('string')) continue;
|
|
const wantsInteger = accepted.has('integer');
|
|
const wantsNumber = accepted.has('number');
|
|
if (!wantsInteger && !wantsNumber) continue;
|
|
|
|
const trimmed = value.trim();
|
|
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) continue;
|
|
|
|
const num = parseFloat(trimmed);
|
|
// Reject non-integer values (e.g. "5.5") for integer-only schemas so the
|
|
// LLM self-corrects. Whole-number decimals (e.g. "3.0") still coerce.
|
|
if (wantsInteger && !wantsNumber && num % 1 !== 0) continue;
|
|
|
|
const parsed = wantsNumber ? num : parseInt(trimmed, 10);
|
|
if (Number.isFinite(parsed)) {
|
|
data[key] = parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
function fixBooleanValues(
|
|
data: Record<string, unknown>,
|
|
schema?: Record<string, unknown>,
|
|
) {
|
|
const properties = schema?.['properties'] as
|
|
| Record<string, Record<string, unknown>>
|
|
| undefined;
|
|
const items = schema?.['items'] as Record<string, unknown> | undefined;
|
|
|
|
for (const key of Object.keys(data)) {
|
|
if (!(key in data)) continue;
|
|
const value = data[key];
|
|
// Array elements share the `items` schema; object fields use their
|
|
// per-property schema.
|
|
const childSchema = Array.isArray(data) ? items : properties?.[key];
|
|
|
|
if (typeof value === 'object' && value !== null) {
|
|
fixBooleanValues(value as Record<string, unknown>, childSchema);
|
|
continue;
|
|
}
|
|
|
|
if (typeof value !== 'string') continue;
|
|
|
|
// Only coerce when the field's schema explicitly types it as boolean (and
|
|
// does not also accept string). Without this guard a legitimate string
|
|
// value of "true"/"false" — e.g. an `old_string`/`content` argument that
|
|
// happens to be the text "true" — would be silently rewritten into a
|
|
// boolean, corrupting the tool call. Mirrors fixStringifiedJsonValues,
|
|
// which is already schema-aware for the same reason.
|
|
const accepted = childSchema ? getAcceptedTypes(childSchema) : null;
|
|
if (!accepted || accepted.has('string') || !accepted.has('boolean')) {
|
|
continue;
|
|
}
|
|
|
|
const lower = value.toLowerCase();
|
|
if (lower === 'true') {
|
|
data[key] = true;
|
|
} else if (lower === 'false') {
|
|
data[key] = false;
|
|
}
|
|
}
|
|
}
|