/** * Pure JSON-schema input validator for skill tool calls. * * Deliberate strict subset of JSON Schema: it covers only the keywords * actually used by `assistant/src/config/bundled-skills/**\/TOOLS.json` * (required / type / enum / items.type) plus unknown-key detection. Anything * else (`$ref`, `oneOf`, `anyOf`, `allOf`, `format`, `pattern`, `minimum`, * `maximum`, object-shape `additionalProperties`, etc.) is silently skipped so * a richer schema can never cause us to reject a legitimate call. * * Each error message is written to be agent-readable and self-correcting * (e.g. `surface_id is required`, `mode must be one of "replace", "append"`). */ import { parseJsonSafe } from "../util/json.js"; import { isPlainObject } from "../util/object.js"; export interface InputValidationSuccess { ok: true; } export interface InputValidationFailure { ok: false; /** Human-readable messages, one per problem. */ errors: string[]; } export type InputValidationResult = | InputValidationSuccess | InputValidationFailure; type SupportedType = | "string" | "number" | "integer" | "boolean" | "array" | "object"; const SUPPORTED_TYPES: ReadonlySet = new Set([ "string", "number", "integer", "boolean", "array", "object", ]); function matchesType(value: unknown, type: SupportedType): boolean { switch (type) { case "string": return typeof value === "string"; case "number": return typeof value === "number"; case "integer": return typeof value === "number" && Number.isInteger(value); case "boolean": return typeof value === "boolean"; case "array": return Array.isArray(value); case "object": return isPlainObject(value); } } function quoteList(values: readonly string[]): string { return values.map((v) => `"${v}"`).join(", "); } /** * Walk the properties a schema declares as `declaredType`, offering each * present value to `coerceValue`. A converter returns `{ value }` to replace * the input's value, or `undefined` to leave it alone. * * The shared traversal is what keeps the coercions below agreeing on the * details a caller depends on: union types (`["boolean", "null"]`) are skipped * rather than coerced, an absent property is never introduced, and the input * object is cloned lazily so an input with nothing to coerce comes back by * reference. * * Pure: returns a new object when a coercion applies, otherwise returns * `input` unchanged. Never mutates `input` or `schema`. */ function coercePropertiesOfType( input: Record, schema: Record | undefined, declaredType: string, coerceValue: ( value: unknown, subSchema: Record, ) => { value: unknown } | undefined, ): Record { if (!schema) { return input; } const properties = schema.properties; if (!isPlainObject(properties)) { return input; } let coerced: Record | undefined; for (const [key, rawSubSchema] of Object.entries(properties)) { if (!isPlainObject(rawSubSchema) || rawSubSchema.type !== declaredType) { continue; } const result = coerceValue(input[key], rawSubSchema); if (!result) { continue; } coerced ??= { ...input }; coerced[key] = result.value; } return coerced ?? input; } /** * Coerce string-encoded booleans (`"true"`/`"false"`) to real booleans for * properties the schema declares as `type: "boolean"`. * * Some providers' models serialize booleans as JSON strings. Rejecting those * loses the caller's intent: the model's typical recovery is to drop the field * and retry, at which point the field's default silently inverts what it asked * for (e.g. `app_create` with `auto_open: "false"` → retry omits the field → * default `true` opens a half-built app). Accepting the unambiguous string * forms preserves intent. * * Pure: returns a new object when a coercion applies, otherwise returns * `input` unchanged. Never mutates `input` or `schema`. */ export function coerceStringBooleans( input: Record, schema: Record | undefined, ): Record { return coercePropertiesOfType(input, schema, "boolean", (value) => { if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); if (normalized !== "true" && normalized !== "false") { return undefined; } return { value: normalized === "true" }; }); } /** * Coerce finite JSON numbers to strings for properties the schema declares as * `type: "string"`. * * Some providers' models emit numeric-looking string values as unquoted JSON * numbers (e.g. a phone number `15550100` instead of `"+15550100"`). * Plain `JSON.parse` yields a JS `Number`, which then fails the validator's * `typeof === "string"` check with a confusing "must be a string" error that * sends the model down a wrong retry path. The value's intent is unambiguous — * a string field received a number — so coerce it the same way we coerce * string-encoded booleans. The E.164 / format / enum checks downstream still * run on the coerced string, so a number missing a `+` prefix still gets a * self-correcting format error rather than a type error. * * Only finite numbers are coerced; `NaN`/`Infinity` (which can't come from * JSON.parse anyway) and non-number types are left untouched. Integers outside * the safe range are also left untouched: `JSON.parse` has already rounded them * (e.g. `12345678901234567890` → `12345678901234567000`), so `String()` would * emit a corrupted identifier. Leaving them un-coerced makes the validator * return a type error, prompting the model to retry with a quoted string that * preserves every digit. Pure: returns a new object when a coercion applies, * otherwise returns `input` unchanged. Never mutates `input` or `schema`. */ export function coerceStringNumbers( input: Record, schema: Record | undefined, ): Record { return coercePropertiesOfType(input, schema, "string", (value) => { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; } // An integer beyond the safe range was already rounded by JSON.parse; // coercing it would lock in a corrupted identifier. Skip it so validation // fails and the model retries with a lossless quoted string. if (Number.isInteger(value) && !Number.isSafeInteger(value)) { return undefined; } return { value: String(value) }; }); } /** * Repair the shapes a model sends for a property the schema declares as * `type: "array"`, when the intent is unambiguous. * * Two shapes dominate what models actually send, and neither is recoverable * from the model's side: it cannot see the difference between what it meant * and what arrived, so the observed recovery is to drop the field and retry. * That is how a skill gets scaffolded with no `activation_hints` (losing the * intent-routing signal that makes it findable later) or with its companion * `files` folded into the body instead. * * - **The JSON text of the array** (`"[\"a\",\"b\"]"`) rather than the array. * Decoded back into the array it spells. * - **A single element where a list was expected**: a bare phrase for a * string-item array (`avoid_when: "when the repo is dirty"`), or one object * for an object-item array. Wrapped into a one-element array. * * A string is wrapped only when the schema declares string items and the text * does not open a JSON structure it failed to close, and an object only when * every key it carries is a declared property of the item schema. A shape that * means something else (a truncated array, a map keyed by something the item * schema does not name) is left for the validator to reject rather than * silently reinterpreted. Per-element `items.type` checks still run on the * repaired array, so a genuinely wrong element keeps its own error, and each * element an array gains here is coerced against the item schema so a repaired * element cannot carry a shape a natively-sent one could not. * * Pure: returns a new object when a repair applies, otherwise returns `input` * unchanged. Never mutates `input` or `schema`. */ export function coerceArrayShapes( input: Record, schema: Record | undefined, ): Record { return coercePropertiesOfType(input, schema, "array", (value, subSchema) => { const items = isPlainObject(subSchema.items) ? subSchema.items : undefined; const itemType = typeof items?.type === "string" ? items.type : undefined; if (typeof value === "string") { const decoded = parseJsonSafe(value); if (Array.isArray(decoded)) { return { value: coerceElements(decoded, items) }; } if (itemType !== "string") { return undefined; } if (typeof decoded === "string" && decoded.trim()) { return { value: [decoded] }; } // Text that opens a JSON structure and then fails to parse is a // truncated or malformed array, not a phrase. Wrapping it would store // the broken JSON as if it were the element the caller wrote. const trimmed = value.trim(); if (!trimmed || trimmed.startsWith("[") || trimmed.startsWith("{")) { return undefined; } return { value: [value] }; } if ( itemType === "object" && isPlainObject(value) && matchesItemProperties(value, items) ) { return { value: coerceElements([value], items) }; } return undefined; }); } /** * Apply the scalar coercions to each object element against the item schema. * * Only top-level properties are coerced when a call arrives already in the * declared shape, because that is the only level the validator type-checks. * An array this function builds has no such level yet: its elements were text * a moment ago, and their properties carry whatever the model serialized, * including the string-encoded booleans this module exists to read. Coercing * them here keeps a repaired element from reaching an executor in a shape a * natively-sent one could not. */ function coerceElements( elements: unknown[], itemSchema: Record | undefined, ): unknown[] { if (!isPlainObject(itemSchema?.properties)) { return elements; } return elements.map((element) => isPlainObject(element) ? coerceStringNumbers( coerceStringBooleans(element, itemSchema), itemSchema, ) : element, ); } /** * Whether `value` reads as one element of `itemSchema`: every key it carries * is a property that schema declares. An object that fails this is some other * shape (most often a map keyed by data rather than by field name), which is * not a single element and must not be wrapped as one. */ function matchesItemProperties( value: Record, itemSchema: Record | undefined, ): boolean { const properties = itemSchema?.properties; if (!isPlainObject(properties)) { return false; } const keys = Object.keys(value); return keys.length > 0 && keys.every((key) => key in properties); } /** * Validate a tool input object against the (optional) JSON-schema definition * declared on the tool entry. Returns `{ ok: true }` if the input is valid (or * if there is nothing actionable to validate); otherwise returns a list of * human-readable error strings. * * Pure: never mutates `input` or `schema`, no I/O. */ export function validateInputAgainstSchema( _toolName: string, input: Record, schema: Record | undefined, ): InputValidationResult { // Skip when there's no schema or no properties block — matches today's // lenient behaviour for tools that declare only `{ type: "object" }`. if (!schema) { return { ok: true }; } const properties = schema.properties; if (!isPlainObject(properties)) { return { ok: true }; } const errors: string[] = []; const knownKeys = Object.keys(properties); const knownKeySet = new Set(knownKeys); // 1. Required fields — presence-only check per JSON Schema spec. // `required` only requires the property to be present; `null` is a valid // value when the schema allows it (e.g. `type: ["string", "null"]`). const required = schema.required; if (Array.isArray(required)) { for (const key of required) { if (typeof key !== "string") { continue; } if (!(key in input)) { errors.push(`${key} is required`); } } } // 2. Per-property checks: type, enum, items.type. for (const [key, rawSubSchema] of Object.entries(properties)) { if (!(key in input)) { continue; } const value = input[key]; // Skip type-checking for absent values; presence is enforced by the // `required` check above. Note: `null` IS a present value and is // type-checked below — only schemas that explicitly opt in to null via // a union type (`type: ["string","null"]`) bypass the check, handled // by the `Array.isArray(declaredType)` skip immediately below. if (value === undefined) { continue; } if (!isPlainObject(rawSubSchema)) { continue; } const declaredType = rawSubSchema.type; // Skip union types (e.g. `["string", "null"]`) — same lenient treatment // we give `oneOf`/`anyOf`/`$ref`. We only validate single-type schemas. if (Array.isArray(declaredType)) { continue; } if (typeof declaredType === "string" && SUPPORTED_TYPES.has(declaredType)) { const type = declaredType as SupportedType; if ( type === "array" && (typeof value === "string" || isPlainObject(value)) ) { errors.push( `${key} must be an array: pass a JSON array, not ${typeof value === "string" ? "a string" : "an object"}`, ); continue; } if (!matchesType(value, type)) { errors.push( type === "boolean" && typeof value === "string" ? `${key} must be a boolean — pass true or false as a JSON boolean, not a string` : `${key} must be ${typeArticle(type)} ${type}`, ); // No point checking enum/items if the base type is wrong. continue; } // 2a. Enum (string enums are the only shape used today). const enumValues = rawSubSchema.enum; if (Array.isArray(enumValues) && enumValues.length > 0) { if (!enumValues.includes(value)) { errors.push( `${key} must be one of ${quoteList(enumValues.map(String))}`, ); } } // 2b. Array items.type — per-element check. if (type === "array" && isPlainObject(rawSubSchema.items)) { const itemType = rawSubSchema.items.type; if ( typeof itemType === "string" && SUPPORTED_TYPES.has(itemType) && Array.isArray(value) ) { const itemTypeName = itemType as SupportedType; value.forEach((element, index) => { if (!matchesType(element, itemTypeName)) { errors.push( `${key}[${index}] must be ${typeArticle(itemTypeName)} ${itemTypeName}`, ); } }); } } } } // 3. Unknown keys — parity with the previous `validateNoUnknownParams`. const unknownKeys = Object.keys(input).filter((k) => !knownKeySet.has(k)); for (const key of unknownKeys) { errors.push( `Unknown parameter "${key}". Supported: ${quoteList(knownKeys)}`, ); } if (errors.length === 0) { return { ok: true }; } return { ok: false, errors }; } function typeArticle(type: SupportedType): "a" | "an" { // `integer`, `array`, `object` start with a vowel sound. return type === "integer" || type === "array" || type === "object" ? "an" : "a"; }