UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

407 lines • 18.9 kB
"use strict"; /** * nativeOutputFormat — the ONE builder of the JSON Schema that * `.outputSchema(parser, { strategy: 'native' })` sends as the provider's * structured-output format (Anthropic's `output_config.format`). * * Pattern: pure transform over plain JSON, run once at build * (`AgentBuilder · resolveOutputEnforcement`); no scope, no I/O. * Role: core/ agent leaf. Its input is the SAME schema the `'tool-forced'` * strategy puts on its synthetic tool (`outputEnforcement.ts · * resolveJsonSchema`: the `jsonSchema` option, else the parser's own * `toJsonSchema()`), and the SAME parser judges every answer after — * so the wire copy may be looser than the contract, never different * from it. * * THE RULES, from Anthropic's structured-outputs page ("JSON Schema * limitations", fetched 2026-10-14 — * https://platform.claude.com/docs/en/build-with-claude/structured-outputs), * and the shape of the SDK's own transform (`@anthropic-ai/sdk` · * `transformJSONSchema`, which moves what the API does not take into the * description): * - KEPT: `type`, `properties`, `required`, `items`, `anyOf`, `allOf`, * local `$ref` with `$defs` / `definitions`, `enum` and `const` of * scalars, `default`, `description`, `title`, the ten supported string * `format`s, `minItems` of 0 or 1, and a `pattern` with no backreference, * lookaround or word boundary. `oneOf` is sent as `anyOf`. * - CLOSED: every object gets `additionalProperties: false` — the only value * the API takes. * - MOVED INTO THE DESCRIPTION, on the wire copy only: every constraint the * API does not take (`minimum`, `maximum`, `multipleOf`, `minLength`, * `maxLength`, `minItems` above 1 — sent as 1 —, `maxItems`, an * unsupported `format` or `pattern`, …). The model still reads them; the * parser still enforces them. * - REFUSED BY NAME (`NativeOutputSchemaError`): what the API cannot express * — a recursive schema, an external or dangling `$ref` (checked on the * source AND on the wire copy, whose renamed paths a reference could * miss), an `enum` or `const` holding an object or array, `allOf` beside * a `$ref`, a node with no type (an open `unknown` value), and an object * with open keys (`additionalProperties` given a schema, `true` or `{}` * on an object naming no properties, a bare `{ type: 'object' }`, or * `patternProperties`: closing it would make every map empty while the * parser accepted it), and object intersections through `allOf` (closing * each branch can make the intersection impossible). * - DROPPED: the meta keys `$schema`, `$id`, `$comment`, `$anchor`. * * THE RESERVED `_findings` KEY. With `.findings()` armed, the instruction tells * the model it may carry its standings on a JSON answer as a top-level * `_findings.previous`. Under `additionalProperties: false` a key the schema * does not name cannot be written at all, so the answer standings would stop * without a sound. Armed, the root gains the optional `_findings` property * the served contract allows (`findings/reserved.ts · findingsAnswerSchema`) — * unless the author's schema names its own `_findings`, which wins; a root * that is not an object cannot carry it and is refused. Unarmed, the schema * is the author's alone. * * @example * nativeOutputFormat( * { type: 'object', properties: { n: { type: 'integer', minimum: 1 } }, required: ['n'] }, * { findings: false }, * ); * // { type: 'json_schema', schema: { type: 'object', * // properties: { n: { type: 'integer', description: '{minimum: 1}' } }, * // required: ['n'], additionalProperties: false } } */ Object.defineProperty(exports, "__esModule", { value: true }); exports.nativeOutputFormat = exports.NativeOutputSchemaError = void 0; const reserved_js_1 = require("./findings/reserved.js"); const types_js_1 = require("./findings/types.js"); /** A schema the provider's structured output cannot express, named. */ class NativeOutputSchemaError extends Error { /** JSON-pointer-like path of the node refused (`'#'` is the root). */ path; constructor(path, reason) { super(`AgentBuilder.outputSchema: strategy 'native' sends the schema as Anthropic's ` + `output_config.format, and the node at ${path} cannot be expressed there: ${reason} ` + `Change the schema, or use { strategy: 'instruct', retries: N }, which takes any ` + `parser and judges the same contract.`); this.name = 'NativeOutputSchemaError'; this.path = path; } } exports.NativeOutputSchemaError = NativeOutputSchemaError; const SUPPORTED_FORMATS = new Set([ 'date-time', 'time', 'date', 'duration', 'email', 'hostname', 'uri', 'ipv4', 'ipv6', 'uuid', ]); const META_KEYS = new Set(['$schema', '$id', '$comment', '$anchor']); /** Annotations kept as they are (the docs list `default`; the rest are words). */ const KEPT_AS_IS = new Set(['description', 'title', 'default', 'required']); function isObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isObjectNode(node) { const type = node.type; return (type === 'object' || (Array.isArray(type) && type.includes('object')) || isObject(node.properties)); } /** Object alternatives participate in the same intersection as their parent. * Do not infer a merged property set: closing its branches independently can * exclude keys another branch requires. Scalar intersections remain expressible. */ function hasObjectAlternative(node) { if (!isObject(node)) return false; return (isObjectNode(node) || ['allOf', 'anyOf', 'oneOf'].some((key) => { const alternatives = node[key]; return Array.isArray(alternatives) && alternatives.some(hasObjectAlternative); })); } /** A pattern the API's regex subset refuses: a backreference, lookaround or word boundary. */ function unsupportedPattern(pattern) { for (let i = 0; i < pattern.length; i++) { const ch = pattern[i]; if (ch === '\\') { const next = pattern[i + 1]; if (next !== undefined && /[1-9kbB]/.test(next)) return true; i++; continue; } if (ch === '(' && pattern[i + 1] === '?') { const rest = pattern.slice(i + 2, i + 4); if (rest.startsWith('=') || rest.startsWith('!') || rest === '<=' || rest === '<!') return true; } } return false; } function deepFreeze(value) { if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) deepFreeze(child); } return value; } // ─── Refusals that need the whole schema: $ref resolution ───────────── function resolvePointer(root, ref) { if (ref === '#') return root; if (!ref.startsWith('#/')) return undefined; let node = root; for (const raw of ref.slice(2).split('/')) { let key; try { key = decodeURIComponent(raw).replace(/~1/g, '/').replace(/~0/g, '~'); } catch { return undefined; // a malformed escape points at nothing — refused by name below } if (typeof node !== 'object' || node === null) return undefined; node = node[key]; } return node; } /** * Refuse an external, dangling or recursive `$ref`. A depth-first walk that * expands each local reference with the chain of references being expanded * (`open`); meeting one of them again is recursion. A reference fully * expanded once without meeting itself is `settled` and not walked again. */ function assertReferencesExpressible(root, copy) { const settled = new Set(); const open = new Set(); const walk = (node, path) => { if (Array.isArray(node)) { node.forEach((child, i) => walk(child, `${path}/${i}`)); return; } if (!isObject(node)) return; const ref = node.$ref; if (typeof ref === 'string') { if (!ref.startsWith('#')) { throw new NativeOutputSchemaError(path, `'$ref': '${ref}' is an external reference, and only references inside the ` + `schema ('#/$defs/…') are supported.`); } if (open.has(ref)) { throw new NativeOutputSchemaError(path, `'$ref': '${ref}' refers back into a definition it is part of — a recursive ` + `schema, which is not supported.`); } if (!settled.has(ref)) { const target = resolvePointer(root, ref); if (!isObject(target)) { throw new NativeOutputSchemaError(path, copy === 'source' ? `'$ref': '${ref}' points at nothing in this schema.` : `'$ref': '${ref}' points into a part of the schema the wire copy does not ` + `keep at that path (oneOf is sent as anyOf; keywords the API does not take ` + `move into the description). Point it at a $defs entry.`); } open.add(ref); walk(target, ref); open.delete(ref); settled.add(ref); } } // Definitions are walked too, referenced or not: a definition that // reaches itself is recursive either way, and an unreferenced one is // still sent. for (const [key, child] of Object.entries(node)) { if (key !== '$ref') walk(child, `${path}/${key}`); } }; open.add('#'); walk(root, '#'); } // ─── The node transform ─────────────────────────────────────────────── function describe(node, moved) { const entries = Object.entries(moved); if (entries.length === 0) return; const words = `{${entries.map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}}`; const existing = typeof node.description === 'string' ? node.description : undefined; node.description = existing ? `${existing}\n\n${words}` : words; } function transformNode(node, path) { if (!isObject(node)) { throw new NativeOutputSchemaError(path, 'a schema must be an object, and this one is not.'); } const has = (key) => Object.prototype.hasOwnProperty.call(node, key); if (!has('type') && !has('anyOf') && !has('oneOf') && !has('allOf') && !has('$ref') && !has('enum') && !has('const')) { throw new NativeOutputSchemaError(path, 'it has no type — an open value (z.unknown(), z.any(), {}) is any JSON at all, which ' + 'the structured output cannot decode without recursion. Give it a type.'); } if (Array.isArray(node.enum) && node.enum.some((v) => typeof v === 'object' && v !== null)) { throw new NativeOutputSchemaError(path, 'its enum holds an object or an array; enum values must be strings, numbers, ' + 'booleans or null.'); } if (typeof node.const === 'object' && node.const !== null) { throw new NativeOutputSchemaError(path, 'its const is an object or an array; a const must be a string, number, boolean or null.'); } if (has('allOf') && Array.isArray(node.allOf) && node.allOf.some((s) => isObject(s) && has$ref(s))) { throw new NativeOutputSchemaError(path, 'allOf with a $ref inside it is not supported.'); } if (Array.isArray(node.allOf) && node.allOf.length > 0 && hasObjectAlternative(node)) { throw new NativeOutputSchemaError(path, 'allOf with object schemas cannot be safely closed: additionalProperties: false on ' + 'one branch can exclude properties required by another. Declare the object properties ' + 'in a single schema instead of intersecting object shapes.'); } // Open keys: a map whose entries the API could never produce once closed. // A value schema for the extra keys, a pattern for them, or an object that // names no properties and does not say it is closed (`{ type: 'object' }`, // `additionalProperties: true` or `{}` — zod's `z.record(…)` emits these). const namesNoProperties = !isObject(node.properties) || Object.keys(node.properties).length === 0; if (has('patternProperties') || (isObject(node.additionalProperties) && Object.keys(node.additionalProperties).length > 0) || (isObjectNode(node) && namesNoProperties && node.additionalProperties !== false)) { throw new NativeOutputSchemaError(path, 'it is an object with open keys (a record or map: additionalProperties given a schema ' + 'or left open, or patternProperties). The API takes only additionalProperties: false, ' + 'which would make every such map empty while the parser accepted it. Name the keys as ' + 'properties.'); } // A reference stands alone, as the SDK's transform sends it: its target // carries the shape, and siblings beside a `$ref` are not part of the subset // — except the definitions it may point into (a root `$ref` beside its // `definitions`, as zod-to-json-schema writes with a name). if (has('$ref')) { const ref = { $ref: node.$ref }; for (const key of ['$defs', 'definitions']) { if (isObject(node[key])) ref[key] = transformDefinitions(node[key], `${path}/${key}`); } return ref; } const out = {}; const moved = {}; for (const [key, value] of Object.entries(node)) { if (META_KEYS.has(key)) continue; switch (key) { case 'type': case 'enum': case 'const': out[key] = value; break; case 'properties': if (isObject(value)) { out.properties = Object.fromEntries(Object.entries(value).map(([name, child]) => [ name, transformNode(child, `${path}/properties/${name}`), ])); } else moved[key] = value; break; case 'items': if (isObject(value)) out.items = transformNode(value, `${path}/items`); else moved[key] = value; break; case 'anyOf': case 'oneOf': case 'allOf': if (Array.isArray(value)) { const target = key === 'allOf' ? 'allOf' : 'anyOf'; out[target] = value.map((child, i) => transformNode(child, `${path}/${key}/${i}`)); } else moved[key] = value; break; case '$defs': case 'definitions': if (isObject(value)) out[key] = transformDefinitions(value, `${path}/${key}`); else moved[key] = value; break; case 'format': if (SUPPORTED_FORMATS.has(value)) out.format = value; else moved[key] = value; break; case 'pattern': if (typeof value === 'string' && !unsupportedPattern(value)) out.pattern = value; else moved[key] = value; break; case 'minItems': if (value === 0 || value === 1) out.minItems = value; else { moved[key] = value; if (typeof value === 'number' && value > 1) out.minItems = 1; } break; case 'additionalProperties': // `true`, `{}` or `false`: closed below. A schema was refused above. break; default: if (KEPT_AS_IS.has(key)) out[key] = value; else moved[key] = value; } } if (isObjectNode(node)) { if (out.properties === undefined) out.properties = {}; out.additionalProperties = false; } describe(out, moved); return out; } function transformDefinitions(defs, path) { return Object.fromEntries(Object.entries(defs).map(([name, child]) => [name, transformNode(child, `${path}/${name}`)])); } function has$ref(node) { return Object.prototype.hasOwnProperty.call(node, '$ref'); } // ─── The one entry point ────────────────────────────────────────────── /** * The structured-output format for `jsonSchema`, made API-compatible as the * file header lists, with the reserved `_findings` property when the findings * ledger is armed. Deep-frozen; the input is never edited. * * @throws NativeOutputSchemaError naming the node the API cannot express. */ function nativeOutputFormat(jsonSchema, arms) { let source = jsonSchema; if (arms.findings) { if (!isObjectNode(jsonSchema)) { throw new NativeOutputSchemaError('#', `.findings() lets a JSON answer carry its standings as a top-level ` + `'${types_js_1.RESERVED_ANSWER_KEY}' property, and this root is not an object schema with ` + `properties, so the structured output would refuse the key and the standings would ` + `stop. Give the answer an object root.`); } const properties = isObject(jsonSchema.properties) ? jsonSchema.properties : {}; if (!Object.prototype.hasOwnProperty.call(properties, types_js_1.RESERVED_ANSWER_KEY)) { source = { ...jsonSchema, properties: { ...properties, [types_js_1.RESERVED_ANSWER_KEY]: (0, reserved_js_1.findingsAnswerSchema)() }, }; } } assertReferencesExpressible(source, 'source'); const schema = transformNode(source, '#'); // And again on what is SENT: a reference into a path the copy renamed or // moved would dangle on the wire, a 400 on every call. assertReferencesExpressible(schema, 'wire'); return deepFreeze({ type: 'json_schema', schema }); } exports.nativeOutputFormat = nativeOutputFormat; //# sourceMappingURL=nativeOutputFormat.js.map