Files
rea/src/contracts/inputSchemaPresentation.ts
T

533 lines
18 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import {
schemaArrays,
schemaChildren,
schemaMaps,
} from "./jsonSchemaKeywords.js";
const isObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const resolveReference = (
root: Readonly<Record<string, unknown>>,
reference: string,
): unknown => {
if (reference === "#") return root;
if (!reference.startsWith("#/")) return undefined;
let value: unknown = root;
for (const token of reference.slice(2).split("/")) {
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
if (!isObject(value) || !Object.hasOwn(value, key)) return undefined;
value = value[key];
}
return value;
};
const referencedDescription = (
root: Readonly<Record<string, unknown>>,
schema: Readonly<Record<string, unknown>>,
): string | undefined => {
let value: unknown = schema;
const active = new Set<string>();
while (isObject(value)) {
if (typeof value.description === "string") return value.description;
if (typeof value.$ref !== "string" || active.has(value.$ref))
return undefined;
active.add(value.$ref);
value = resolveReference(root, value.$ref);
}
return undefined;
};
const describeSchema = (
schema: unknown,
root: Readonly<Record<string, unknown>>,
describeProperty: (property: string) => string | undefined,
): unknown => {
if (!isObject(schema)) return schema;
return Object.fromEntries(
Object.entries(schema).map(([key, value]) => {
if (schemaMaps.has(key) && isObject(value))
return [
key,
Object.fromEntries(
Object.entries(value).map(([name, child]) => {
const described = describeSchema(child, root, describeProperty);
if (key !== "properties" || !isObject(described))
return [name, described];
const description =
referencedDescription(root, described) ??
describeProperty(name);
return [
name,
description === undefined
? described
: { ...described, description },
];
}),
),
];
if (schemaArrays.has(key) && Array.isArray(value))
return [
key,
value.map((child) => describeSchema(child, root, describeProperty)),
];
if (schemaChildren.has(key))
return [
key,
Array.isArray(value)
? value.map((child) =>
describeSchema(child, root, describeProperty),
)
: describeSchema(value, root, describeProperty),
];
// Annotation values and literal const/enum data are not schemas.
return [key, value];
}),
);
};
const objectBranch = (
root: Readonly<Record<string, unknown>>,
branch: unknown,
): Readonly<Record<string, unknown>> | undefined => {
// Reference siblings evaluate separately from the target in 2020-12, so a
// merged object could accept inputs the branch rejects.
const resolved =
isObject(branch) && typeof branch.$ref === "string"
? Object.keys(branch).length === 1
? resolveReference(root, branch.$ref)
: undefined
: branch;
return isObject(resolved) &&
resolved.type === "object" &&
isObject(resolved.properties)
? resolved
: undefined;
};
const requiredProperties = (
branch: Readonly<Record<string, unknown>>,
): readonly string[] =>
Array.isArray(branch.required)
? branch.required.filter((name) => typeof name === "string")
: [];
const sharedRequiredProperties = (
branches: readonly Readonly<Record<string, unknown>>[],
): readonly string[] => {
const [first = [], ...others] = branches.map(requiredProperties);
return first.filter((name) => others.every((names) => names.includes(name)));
};
const inputGroup = (branch: Readonly<Record<string, unknown>>): string => {
const required = requiredProperties(branch);
const optional = Object.keys(branch.properties ?? {})
.filter((name) => !required.includes(name))
.map((name) => `[${name}]`);
return [...required, ...optional].join(" + ");
};
type SchemaNormalization = {
readonly schema: unknown;
readonly changed: boolean;
};
const isBooleanLiteralSchema = (schema: unknown): boolean =>
isObject(schema) &&
(typeof schema.const === "boolean" ||
(Array.isArray(schema.enum) &&
schema.enum.length > 0 &&
schema.enum.every((value) => typeof value === "boolean")));
const isBooleanSchema = (schema: unknown): boolean =>
isObject(schema) &&
(schema.type === "boolean" || isBooleanLiteralSchema(schema));
const preserveSharedDefault = (
schema: Record<string, unknown>,
branches: readonly unknown[],
): void => {
if (schema.default !== undefined) return;
const defaults = branches
.filter(isObject)
.map((branch) => branch.default)
.filter((value) => value !== undefined);
if (
defaults.length > 0 &&
defaults.every((value) => isDeepStrictEqual(value, defaults[0]))
)
schema.default = defaults[0];
};
const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (!isObject(value)) return { schema: value, changed: false };
const node: Record<string, unknown> = { ...value };
let changed = false;
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries(
Object.entries(map).map(([name, child]) => {
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return [name, result.schema];
}),
);
const normalizeList = (list: readonly unknown[]) =>
list.map((child) => {
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return result;
});
for (const [key, child] of Object.entries(value)) {
if (schemaMaps.has(key) && isObject(child)) {
node[key] = normalizeMap(child);
} else if (
schemaArrays.has(key) &&
key !== "anyOf" &&
key !== "oneOf" &&
Array.isArray(child)
) {
node[key] = normalizeList(child).map((result) => result.schema);
} else if (
schemaChildren.has(key) &&
key !== "not" &&
key !== "if" &&
key !== "then" &&
key !== "else"
) {
const results = Array.isArray(child)
? normalizeList(child)
: [normalizeAdvertisedSchema(child)];
const nestedChanged = results.some((result) => result.changed);
if (
key === "contains" &&
nestedChanged &&
value.maxContains !== undefined
) {
delete node.contains;
delete node.minContains;
delete node.maxContains;
changed = true;
} else {
node[key] = Array.isArray(child)
? results.map((result) => result.schema)
: results[0]?.schema;
changed ||= nestedChanged;
}
}
}
if (typeof node.const === "boolean") {
delete node.const;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (
Array.isArray(node.enum) &&
node.enum.length > 0 &&
node.enum.every((item) => typeof item === "boolean")
) {
delete node.enum;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
const negated = Object.hasOwn(value, "not")
? normalizeAdvertisedSchema(value.not)
: undefined;
if (negated?.changed) {
// Widening a predicate under `not` narrows the containing schema.
changed = true;
delete node.not;
} else if (negated !== undefined) {
node.not = negated.schema;
}
const conditional = Object.hasOwn(value, "if")
? normalizeAdvertisedSchema(value.if)
: undefined;
const thenBranch = Object.hasOwn(value, "then")
? normalizeAdvertisedSchema(value.then)
: undefined;
const elseBranch = Object.hasOwn(value, "else")
? normalizeAdvertisedSchema(value.else)
: undefined;
if (conditional?.changed) {
// Changing the predicate can route canonical-valid inputs into a rejecting branch.
changed = true;
delete node.if;
delete node.then;
delete node.else;
} else {
if (conditional !== undefined) node.if = conditional.schema;
if (thenBranch !== undefined) {
node.then = thenBranch.schema;
changed ||= thenBranch.changed;
}
if (elseBranch !== undefined) {
node.else = elseBranch.schema;
changed ||= elseBranch.changed;
}
}
for (const combinator of ["anyOf", "oneOf"] as const) {
const branches = value[combinator];
if (!Array.isArray(branches)) continue;
const normalized = normalizeList(branches);
const schemas = normalized.map((result) => result.schema);
const branchesChanged = normalized.some((result) => result.changed);
if (combinator === "oneOf" && branchesChanged) {
const booleanLiteralsOnly = branches.every(isBooleanLiteralSchema);
if (booleanLiteralsOnly) {
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node.oneOf;
changed = true;
continue;
}
// `anyOf` retains branch guidance without allowing widened branches to
// reject inputs that were accepted by exactly one canonical branch.
delete node.oneOf;
if (Array.isArray(node.anyOf)) {
const existingAllOf = Array.isArray(node.allOf) ? node.allOf : [];
node.allOf = [...existingAllOf, { anyOf: schemas }];
} else {
node.anyOf = schemas;
}
changed = true;
continue;
}
if (
branchesChanged &&
schemas.length > 0 &&
schemas.every(isBooleanSchema)
) {
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node[combinator];
changed = true;
continue;
}
node[combinator] = schemas;
changed ||= branchesChanged;
}
return { schema: node, changed };
};
// Anthropic tool input schemas reject anyOf, oneOf, and allOf at the root, and
// clients then drop the tool or advertise it without parameters. Present a
// root union of strict object groups as one object whose properties are the
// union of the groups. The canonical Zod schema still enforces the exact group.
const flattenRootObjectUnion = (
root: Record<string, unknown>,
): Record<string, unknown> => {
const { anyOf, oneOf, ...rest } = root;
const alternatives = Array.isArray(anyOf) ? anyOf : oneOf;
if (
!Array.isArray(alternatives) ||
alternatives.length === 0 ||
root.properties !== undefined
)
return root;
const branches = alternatives.map((branch) => objectBranch(root, branch));
if (!branches.every((branch) => branch !== undefined)) return root;
const variants = new Map<string, unknown[]>();
for (const branch of branches)
for (const [name, schema] of Object.entries(branch.properties ?? {})) {
const known = variants.get(name) ?? [];
if (!known.some((seen) => isDeepStrictEqual(seen, schema)))
known.push(schema);
variants.set(name, known);
}
const required = sharedRequiredProperties(branches);
// A coarse property minimum rejects underspecified objects. Partner fields
// and exact group membership remain enforced by the canonical runtime parser.
const fewestRequired = Math.min(
...branches.map((branch) => requiredProperties(branch).length),
);
// Groups that differ only in property values add no caller guidance.
const groups = [...new Set(branches.map(inputGroup))];
const description = [
typeof rest.description === "string" ? rest.description : undefined,
groups.length > 1
? `Provide a complete input group: ${groups.join("; ")}.`
: undefined,
].filter((text) => text !== undefined);
const {
dependencies: _dependencies,
dependentRequired: _dependentRequired,
...presentation
} = rest;
return {
...presentation,
type: "object",
...(description.length > 0 ? { description: description.join(" ") } : {}),
properties: Object.fromEntries(
[...variants].map(([name, schemas]) => [
name,
schemas.length === 1 ? schemas[0] : { anyOf: schemas },
]),
),
...(required.length > 0 ? { required } : {}),
...(fewestRequired > 0 ? { minProperties: fewestRequired } : {}),
...(branches.every((branch) => branch.additionalProperties === false)
? { additionalProperties: false }
: {}),
};
};
/** Preserve opt-in discriminator relationships without a root union. */
const presentDiscriminatedRoot = (
root: Record<string, unknown>,
): Record<string, unknown> => {
const { "x-rea-root-discriminator": discriminator, ...canonical } = root;
const flattened = flattenRootObjectUnion(canonical);
if (typeof discriminator !== "string") return flattened;
const alternatives = canonical.anyOf ?? canonical.oneOf;
if (!Array.isArray(alternatives) || alternatives.length !== 2)
throw new TypeError(
"A presented root discriminator requires two object alternatives",
);
const branches = alternatives.map((alternative) => {
const branch = objectBranch(canonical, alternative);
if (branch === undefined || branch.additionalProperties !== false)
throw new TypeError(
"A presented root discriminator requires strict objects",
);
return branch;
});
const requiredBranch = branches.find((branch) =>
requiredProperties(branch).includes(discriminator),
);
const fallback = branches.find(
(branch) => !requiredProperties(branch).includes(discriminator),
);
const literal = (
branch: Readonly<Record<string, unknown>> | undefined,
): unknown => {
const properties = branch?.properties;
const schema = isObject(properties) ? properties[discriminator] : undefined;
return isObject(schema) ? schema.const : undefined;
};
const selected = literal(requiredBranch),
otherwise = literal(fallback);
if (
requiredBranch === undefined ||
fallback === undefined ||
typeof selected !== "string" ||
typeof otherwise !== "string" ||
selected === otherwise
)
throw new TypeError(
"A presented root discriminator requires distinct literals and one default alternative",
);
const properties = Object.fromEntries(
Object.entries(
isObject(flattened.properties) ? flattened.properties : {},
).map(([key, schema]) => {
if (!isObject(schema) || !Array.isArray(schema.anyOf))
return [key, schema];
const possible = schema.anyOf.filter(
(alternative) =>
alternative !== false &&
!(
isObject(alternative) &&
isObject(alternative.not) &&
Object.keys(alternative.not).length === 0
),
);
return [key, possible.length === 1 ? possible[0] : schema];
}),
);
const commonRequired = new Set(requiredProperties(flattened));
const constraints = (branch: Readonly<Record<string, unknown>>) => {
const own = isObject(branch.properties) ? branch.properties : {};
const differences = Object.fromEntries(
Object.entries(properties).flatMap(([key, schema]) => {
if (key === discriminator || isDeepStrictEqual(schema, own[key]))
return [];
return [[key, Object.hasOwn(own, key) ? own[key] : false]];
}),
);
const required = requiredProperties(branch).filter(
(key) => !commonRequired.has(key),
);
return {
...(Object.keys(differences).length === 0
? {}
: { properties: differences }),
...(required.length === 0 ? {} : { required }),
};
};
// The canonical branch predicates supply validation; omit generic group
// guidance that would describe the forbidden never-valued property as optional.
const { description: _description, ...presentation } = flattened;
return {
...presentation,
properties,
...(canonical.description === undefined
? {}
: { description: canonical.description }),
if: {
properties: { [discriminator]: { const: selected } },
required: [discriminator],
},
then: constraints(requiredBranch),
else: constraints(fallback),
};
};
/** Describe canonical input fields and present the root as one object. */
export const presentInputJsonSchema = (
root: Record<string, unknown>,
describeProperty: (property: string) => string | undefined,
): Record<string, unknown> => {
const flattened = presentDiscriminatedRoot(root);
const described = describeSchema(flattened, flattened, describeProperty);
if (!isObject(described))
return normalizeAdvertisedSchema(flattened).schema as Record<
string,
unknown
>;
const {
dependencies: _dependencies,
dependentRequired: _dependentRequired,
...presentation
} = described;
const rootDependencies = isObject(described.dependentRequired)
? described.dependentRequired
: isObject(described.dependencies)
? described.dependencies
: undefined;
const dependencyGuidance =
rootDependencies === undefined
? []
: Object.entries(rootDependencies).flatMap(([property, partners]) =>
Array.isArray(partners) &&
partners.every((partner) => typeof partner === "string")
? [
`When ${property} is provided, also provide ${partners.join(" and ")}.`,
]
: [],
);
if (dependencyGuidance.length === 0)
return normalizeAdvertisedSchema(presentation).schema as Record<
string,
unknown
>;
const description = [
typeof presentation.description === "string"
? presentation.description
: undefined,
...dependencyGuidance,
].filter((text) => text !== undefined);
return normalizeAdvertisedSchema({
...presentation,
description: description.join(" "),
}).schema as Record<string, unknown>;
};