/** * @factory/ai — tool-schema validation (ai-architecture §2.1 design notes). * * The gateway rejects tool schemas outside a strict JSON-Schema subset that * maps 1:1 to every provider dialect AND to the server-side Zod validators: * * - object root only * - types: `string` | `number` | `boolean` | `integer` | `array` | `object` * - allowed keywords: `type`, `properties`, `required`, `items`, `enum`, * `description` * - NOT allowed: `pattern`, `$ref`, `anyOf`, `oneOf`, `allOf`, `not`, * `additionalProperties` (bool), `minLength`/`maxLength`, etc. * * Pure + deterministic — the same schema object always yields the same verdict. */ const ALLOWED_TYPES = new Set(["string", "number", "boolean", "integer", "array", "object"]); const ALLOWED_KEYWORDS = new Set([ "type", "properties", "required", "items", "enum", "description", ]); export interface ToolValidationResult { ok: boolean; /** First problem found, if any (deterministic key order). */ error?: string; } /** Validate the `parameters` schema object of one {@link ToolSpec}. */ export function validateToolParameters(parameters: unknown, path = "$"): ToolValidationResult { if (typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) { return { ok: false, error: `${path}: parameters must be an object` }; } const schema = parameters as Record; for (const key of Object.keys(schema)) { if (!ALLOWED_KEYWORDS.has(key)) { return { ok: false, error: `${path}: keyword "${key}" is not in the allowed tool-schema subset` }; } } if (schema.type !== undefined) { const type = schema.type; if (typeof type !== "string" || !ALLOWED_TYPES.has(type)) { return { ok: false, error: `${path}: type must be one of ${[...ALLOWED_TYPES].join("|")}` }; } if (type === "array") { if (schema.items === undefined) { return { ok: false, error: `${path}: array type requires "items"` }; } const items = validateToolParameters(schema.items, `${path}.items`); if (!items.ok) return items; } if (type === "object") { if (schema.properties !== undefined) { if (typeof schema.properties !== "object" || schema.properties === null || Array.isArray(schema.properties)) { return { ok: false, error: `${path}.properties must be an object` }; } const props = schema.properties as Record; for (const name of Object.keys(props)) { const sub = validateToolParameters(props[name], `${path}.properties.${name}`); if (!sub.ok) return sub; } if (schema.required !== undefined) { if (!Array.isArray(schema.required) || schema.required.some((r) => typeof r !== "string")) { return { ok: false, error: `${path}.required must be an array of strings` }; } for (const req of schema.required as string[]) { if (!(req in props)) { return { ok: false, error: `${path}.required lists "${req}" which is not in properties` }; } } } } } if (schema.enum !== undefined) { if (!Array.isArray(schema.enum) || schema.enum.length === 0) { return { ok: false, error: `${path}.enum must be a non-empty array` }; } } } return { ok: true }; } /** Validate a whole tool list (names + parameters). */ export function validateTools(tools: readonly { name: string; description: string; parameters: unknown }[]): ToolValidationResult { const seen = new Set(); for (const tool of tools) { if (typeof tool.name !== "string" || !/^[A-Za-z0-9_-]+$/.test(tool.name)) { return { ok: false, error: `tool name "${String(tool.name)}" must match [A-Za-z0-9_-]+` }; } if (seen.has(tool.name)) { return { ok: false, error: `duplicate tool name "${tool.name}"` }; } seen.add(tool.name); if (typeof tool.description !== "string" || tool.description.length === 0) { return { ok: false, error: `tool "${tool.name}" requires a non-empty description` }; } const params = validateToolParameters(tool.parameters, `tool.${tool.name}.parameters`); if (!params.ok) return params; } return { ok: true }; }