Repository Public
1/**2 * JSON Schema helpers for strict tool use on both vendors.3 *4 * Strict mode (Anthropic `strict: true`, OpenAI `strict: true`) accepts a subset of JSON Schema:5 * every object needs `additionalProperties: false`; OpenAI additionally requires every property to be6 * listed in `required` (optional fields become nullable); numeric/string/array constraints, `default`7 * and recursive schemas are not supported. `toStrictSchema()` converts a zod-derived schema8 * (`z.toJSONSchema(...)`) into that subset, deterministically and idempotently. The adapters call it9 * themselves, so a ToolDef does not have to be pre-normalised.10 *11 * Constraints for the tools workstream:12 * - Optional properties are sent as REQUIRED + NULLABLE; the model emits `null` for "absent". The adapters13 * undo this on the way back (`stripNullOptionals`), so zod `.optional()` fields validate unchanged.14 * - Dropped keywords (`minLength`, `maximum`, `default`, …) are NOT enforced by the model: keep validating15 * tool input with zod before running a tool.16 * - Not representable: `z.record()` / open objects (`additionalProperties` other than false) and recursive17 * schemas. An open object becomes an object with no properties.18 */1920type Json = Record<string, unknown>;2122const isObj = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);2324const DROPPED_KEYWORDS = new Set([25 '$schema', '$id', '$comment', 'default', 'examples', 'title',26 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',27 'minLength', 'maxLength', 'minItems', 'maxItems', 'uniqueItems', 'contains', 'minContains', 'maxContains',28 'minProperties', 'maxProperties', 'patternProperties', 'propertyNames', 'unevaluatedProperties',29 'unevaluatedItems', 'dependentRequired', 'dependentSchemas', 'if', 'then', 'else', 'not',30 'readOnly', 'writeOnly', 'deprecated', 'contentEncoding', 'contentMediaType',31]);32const SUPPORTED_FORMATS = new Set(['date-time', 'time', 'date', 'duration', 'email', 'hostname', 'uri', 'ipv4', 'ipv6', 'uuid']);3334function allowsNull(schema: Json): boolean {35 const t = schema.type;36 if (t === 'null' || (Array.isArray(t) && t.includes('null'))) return true;37 if (schema.const === null) return true;38 if (Array.isArray(schema.enum) && schema.enum.includes(null) && t === undefined) return true;39 const alts = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : null;40 return alts ? alts.some((s) => isObj(s) && allowsNull(s)) : false;41}4243/**44 * Nullable form accepted by both vendors in strict mode: `anyOf: [<schema>, {type:'null'}]`. (A type array45 * such as `['string','null']` next to an `enum` is rejected by Anthropic: "Enum value … does not match46 * declared type" — verified live on 2026-11-10.)47 */48function makeNullable(schema: Json): Json {49 if (allowsNull(schema)) return schema;50 const { description, ...rest } = schema;51 const head = description === undefined ? {} : { description };52 if (Array.isArray(rest.anyOf) && Object.keys(rest).length === 1) return { ...head, anyOf: [...rest.anyOf, { type: 'null' }] };53 return { ...head, anyOf: [rest, { type: 'null' }] };54}5556function strictNode(node: unknown): unknown {57 if (Array.isArray(node)) return node.map(strictNode);58 if (!isObj(node)) return node;59 const out: Json = {};60 for (const [key, value] of Object.entries(node)) {61 if (DROPPED_KEYWORDS.has(key)) continue;62 switch (key) {63 case 'properties':64 case 'required':65 case 'additionalProperties':66 break; // rebuilt below67 case 'format':68 if (typeof value === 'string' && SUPPORTED_FORMATS.has(value)) out.format = value;69 break;70 case 'oneOf': // not in the strict subset; anyOf is the supported spelling71 case 'anyOf':72 case 'allOf':73 out[key === 'oneOf' ? 'anyOf' : key] = Array.isArray(value) ? value.map(strictNode) : value;74 break;75 case 'items':76 case 'prefixItems':77 out[key] = strictNode(value);78 break;79 case '$defs':80 case 'definitions':81 out[key] = isObj(value) ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, strictNode(v)])) : value;82 break;83 default:84 out[key] = value; // type, description, enum, const, $ref, pattern, …85 }86 }87 const types = Array.isArray(node.type) ? node.type : [node.type];88 if (types.includes('object') || isObj(node.properties)) {89 const props = isObj(node.properties) ? node.properties : {};90 const wasRequired = new Set(Array.isArray(node.required) ? node.required.filter((r): r is string => typeof r === 'string') : []);91 const strictProps: Json = {};92 for (const [name, sub] of Object.entries(props)) {93 const s = strictNode(sub);94 strictProps[name] = wasRequired.has(name) || !isObj(s) ? s : makeNullable(s);95 }96 if (out.type === undefined) out.type = 'object';97 out.properties = strictProps;98 out.required = Object.keys(strictProps);99 out.additionalProperties = false;100 }101 // `type: ['string','null']` (what zod emits for `.nullable()`) → the anyOf spelling, see makeNullable().102 if (Array.isArray(out.type) && out.type.includes('null') && out.type.length > 1) {103 const rest = out.type.filter((t) => t !== 'null');104 const { description, ...base } = out;105 base.type = rest.length === 1 ? rest[0] : rest;106 if (Array.isArray(base.enum)) base.enum = base.enum.filter((v) => v !== null);107 return { ...(description === undefined ? {} : { description }), anyOf: [base, { type: 'null' }] };108 }109 return out;110}111112/** Converts a JSON Schema (e.g. `z.toJSONSchema(zodType)`) into the strict-tool subset. Idempotent. */113export function toStrictSchema(schema: Record<string, unknown>): Record<string, unknown> {114 const out = strictNode(schema) as Json;115 if (out.type !== 'object') {116 // Tool input must be an object on both vendors.117 return { type: 'object', properties: {}, required: [], additionalProperties: false };118 }119 return out;120}121122/**123 * Inverse of the optional→nullable rewrite: removes `null` values for properties that the ORIGINAL schema124 * declares optional and non-nullable, so the engine's zod validation sees "absent" rather than `null`.125 */126export function stripNullOptionals(originalSchema: unknown, value: unknown): unknown {127 if (!isObj(originalSchema)) return value;128 if (Array.isArray(value)) {129 return isObj(originalSchema.items) ? value.map((v) => stripNullOptionals(originalSchema.items, v)) : value;130 }131 if (!isObj(value) || !isObj(originalSchema.properties)) return value;132 const required = new Set(Array.isArray(originalSchema.required) ? originalSchema.required : []);133 const out: Json = {};134 for (const [key, v] of Object.entries(value)) {135 const sub = originalSchema.properties[key];136 if (v === null && isObj(sub) && !required.has(key) && !allowsNull(sub)) continue;137 out[key] = stripNullOptionals(sub, v);138 }139 return out;140}141142// ---------------------------------------------------------------------------------------------143// Minimal structural validation of tool input against the strict schema that was sent. With eager input144// streaming the API no longer validates tool input, and the SDK's tolerant partial-JSON parser can return a145// silently truncated object, so the adapter checks the parsed input before handing it to the engine.146// Deliberately lenient: unknown keywords and unresolvable `$ref`s pass.147148function typeOf(v: unknown): string {149 if (v === null) return 'null';150 if (Array.isArray(v)) return 'array';151 if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';152 return typeof v;153}154155function resolveRef(root: Json, ref: unknown): Json | null {156 if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;157 let cur: unknown = root;158 for (const part of ref.slice(2).split('/')) {159 if (!isObj(cur)) return null;160 cur = cur[part.replace(/~1/g, '/').replace(/~0/g, '~')];161 }162 return isObj(cur) ? cur : null;163}164165function check(root: Json, schema: unknown, value: unknown, path: string, depth: number): string | null {166 if (!isObj(schema) || depth > 64) return null;167 if ('$ref' in schema) {168 const target = resolveRef(root, schema.$ref);169 return target ? check(root, target, value, path, depth + 1) : null;170 }171 if (Array.isArray(schema.anyOf)) {172 const ok = schema.anyOf.some((s) => check(root, s, value, path, depth + 1) === null);173 if (!ok) return `${path || '(root)'}: matches none of the allowed shapes`;174 }175 if (Array.isArray(schema.allOf)) {176 for (const s of schema.allOf) {177 const e = check(root, s, value, path, depth + 1);178 if (e) return e;179 }180 }181 if ('const' in schema && JSON.stringify(schema.const) !== JSON.stringify(value)) return `${path || '(root)'}: unexpected value`;182 if (Array.isArray(schema.enum) && !schema.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) {183 return `${path || '(root)'}: value is not one of the allowed values`;184 }185 if (schema.type !== undefined) {186 const allowed = Array.isArray(schema.type) ? schema.type : [schema.type];187 const actual = typeOf(value);188 if (!allowed.includes(actual) && !(actual === 'integer' && allowed.includes('number'))) {189 return `${path || '(root)'}: expected ${allowed.join(' | ')}, got ${actual}`;190 }191 }192 if (isObj(value) && isObj(schema.properties)) {193 for (const name of Array.isArray(schema.required) ? schema.required : []) {194 if (typeof name === 'string' && !(name in value)) return `${path ? `${path}.` : ''}${name}: missing`;195 }196 for (const [name, v] of Object.entries(value)) {197 const sub = schema.properties[name];198 if (sub === undefined) {199 if (schema.additionalProperties === false) return `${path ? `${path}.` : ''}${name}: unknown property`;200 continue;201 }202 const e = check(root, sub, v, path ? `${path}.${name}` : name, depth + 1);203 if (e) return e;204 }205 }206 if (Array.isArray(value) && isObj(schema.items)) {207 for (let i = 0; i < value.length; i++) {208 const e = check(root, schema.items, value[i], `${path}[${i}]`, depth + 1);209 if (e) return e;210 }211 }212 return null;213}214215/** Returns null when `value` conforms to `schema`, else a short description of the first mismatch. */216export function validateToolInput(schema: Record<string, unknown>, value: unknown): string | null {217 return check(schema, schema, value, '', 0);218}219220// ---------------------------------------------------------------------------------------------221// Invalid tool input. A `tool_call` block whose input could not be trusted carries a marker STRING instead of222// an object: any zod object schema rejects it, so a tool can never run on it even if the engine does not look223// for the marker. `invalidToolInputMessage()` gives the text to return to the model as an error tool result.224225export const INVALID_TOOL_INPUT_PREFIX = '__AOS_INVALID_TOOL_INPUT__ ';226const MAX_RAW_CHARS = 4_000;227228export interface InvalidToolInput {229 /** `invalid_json`: unparseable arguments · `truncated`: cut off by the output-token limit · `schema`: parsed but does not match the tool schema. */230 reason: 'invalid_json' | 'truncated' | 'schema';231 detail: string;232 /** The input as received (truncated to 4 000 chars). */233 raw: string;234}235236export function markInvalidToolInput(info: InvalidToolInput): string {237 const raw = info.raw.length > MAX_RAW_CHARS ? `${info.raw.slice(0, MAX_RAW_CHARS)}…` : info.raw;238 return INVALID_TOOL_INPUT_PREFIX + JSON.stringify({ reason: info.reason, detail: info.detail, raw });239}240241export function parseInvalidToolInput(input: unknown): InvalidToolInput | null {242 if (typeof input !== 'string' || !input.startsWith(INVALID_TOOL_INPUT_PREFIX)) return null;243 try {244 const v: unknown = JSON.parse(input.slice(INVALID_TOOL_INPUT_PREFIX.length));245 if (isObj(v) && (v.reason === 'invalid_json' || v.reason === 'truncated' || v.reason === 'schema')246 && typeof v.detail === 'string' && typeof v.raw === 'string') {247 return { reason: v.reason, detail: v.detail, raw: v.raw };248 }249 } catch { /* fall through */ }250 return { reason: 'invalid_json', detail: 'unreadable marker', raw: '' };251}252253/** Model-facing text for the error tool result of an invalid tool call, or null if the input is not a marker. */254export function invalidToolInputMessage(input: unknown): string | null {255 const info = parseInvalidToolInput(input);256 if (!info) return null;257 const why = info.reason === 'truncated'258 ? 'The tool input was cut off by the output-token limit, so the tool was NOT run. Retry with a smaller input (for example write the content in several smaller calls).'259 : info.reason === 'schema'260 ? `The tool input does not match the tool's schema (${info.detail}), so the tool was NOT run. Send the call again with valid input.`261 : 'The tool input was not valid JSON, so the tool was NOT run. Send the call again with valid JSON input.';262 return `${why}\n${JSON.stringify({ INVALID_JSON: info.raw })}`;263}264