SPB Git

Repository Public

schema.ts 12.7 KB 264 lines typescript

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
Files main
Menu

Theme