@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
432 lines • 18.7 kB
JavaScript
import { toJsonSchema, validateProps, } from './schema.js';
/**
* The `ui` frame component a tree-mode tool pushes: `{ component: GENUI_TREE_COMPONENT, props: {
* root } }`. Not a valid component name, so it can never collide with a catalog entry.
*/
export const GENUI_TREE_COMPONENT = 'genui:tree';
/**
* Validate a model-composed tree against the catalog: every node's `type` must be a model-facing
* component, its props must pass that component's schema, and only components declared with
* `children: true` may have children. Returns the tree with each node's props as its schema
* produced them (defaults applied by a Standard Schema, for instance).
*
* Lenient where what the model meant is unambiguous ({@link normalizeElement}):
* - a `children` array, a node's `props` — or the whole tree — sent as a JSON STRING (sometimes
* with a stray trailing `}`, or `>` for a key's `:` — {@link repairKeyArrows}) is parsed back;
* - a node that left out its `type` (`{ props: { html, css } }`, or the bare props `{ html, css }`)
* is the ONE component whose props schema those keys fit — every key one of its properties,
* every required one present; with two candidates (or none) it is still refused;
* - a node whose props sit beside its `type` (`{ type: 'Sandbox', html }`) gets them as `props`;
* - a whole element wrapped in one more key (`{ props: { type, props } }`, `{ root: "{…}" }`) is
* unwrapped when what it wraps names a component, and a component named under `component` or
* `root` instead of `type` is read from there.
*/
export async function validateTree(catalog, tree, limits = {}, phase = 'output') {
const maxDepth = limits.maxDepth ?? 12;
const maxNodes = limits.maxNodes ?? 200;
const issues = [];
let nodes = 0;
const visit = async (node, path, depth) => {
nodes += 1;
if (nodes > maxNodes) {
if (nodes === maxNodes + 1) {
issues.push({ path, message: `the tree has more than ${maxNodes} elements` });
}
return undefined;
}
if (depth > maxDepth) {
issues.push({ path, message: `the tree is nested deeper than ${maxDepth} levels` });
return undefined;
}
if (typeof node !== 'object' || node === null || Array.isArray(node)) {
issues.push({ path, message: 'must be an element object { type, props, children? }' });
return undefined;
}
const element = normalizeElement(catalog, node);
if (typeof element.type !== 'string') {
// Models that write a big component (a sandbox) tend to drop the envelope: say what it is.
issues.push({
path: [...path, 'type'],
message: 'must be a component name: every element is { "type": "<component>", "props": { … }, "children"?: [ … ] }, with the props inside "props"',
});
return undefined;
}
const definition = catalog.get(element.type);
if (definition === undefined || definition.internal === true) {
issues.push({
path: [...path, 'type'],
message: `unknown component "${element.type}" (allowed: ${catalog
.modelComponents()
.map((component) => component.name)
.join(', ')})`,
});
return undefined;
}
const validated = phase === 'input'
? (await validateProps(definition.props, element.props ?? {}, catalog.validator))
: await catalog.validate(element.type, element.props ?? {});
let props = {};
if (!validated.ok) {
for (const issue of validated.issues) {
issues.push({ path: [...path, 'props', ...issue.path], message: issue.message });
}
}
else {
props = validated.value;
}
const rawChildren = parseChildrenIfStringified(element.children);
if (rawChildren === undefined || rawChildren === null) {
return { type: element.type, props };
}
if (!Array.isArray(rawChildren)) {
issues.push({
path: [...path, 'children'],
message: 'must be an array of elements (a literal array, not a string)',
});
return undefined;
}
if (rawChildren.length > 0 && definition.children !== true) {
issues.push({
path: [...path, 'children'],
message: `${element.type} does not take children`,
});
return undefined;
}
const children = [];
for (const [index, child] of rawChildren.entries()) {
const next = await visit(child, [...path, 'children', index], depth + 1);
if (next !== undefined) {
children.push(next);
}
}
return children.length > 0
? { type: element.type, props, children }
: { type: element.type, props };
};
const root = await visit(parseRootIfStringified(tree), [], 1);
return issues.length > 0 || root === undefined
? { ok: false, issues: issues.length > 0 ? issues : [{ path: [], message: 'is invalid' }] }
: { ok: true, value: root };
}
/**
* The JSON Schema of a tree-mode tool's input. Whatever it says, {@link validateTree} remains the
* check every call goes through: a provider is free to ignore the schema.
*
* Strict (the default), it is shaped for what the providers accept in tool parameters:
* - the root is a plain `type: 'object'` — OpenAI and Anthropic both refuse a union at the top
* level of a tool's parameters — whose `type` lists every component and whose `props` is any of
* their props schemas;
* - every `children` item is `{ $ref: '#/$defs/node' }`, an `anyOf` of one variant per component
* (`type` a one-value `enum`, `props` that component's schema, `children` only where taken,
* `additionalProperties: false`);
* - a component whose props schema is not self-contained (it carries its own `$ref`, `$defs` or
* `definitions`, as a recursive Zod schema does) is described as `{ type: 'object' }`, since its
* references would not resolve once embedded; `$schema` is dropped.
*/
export function treeJsonSchema(catalog, options = {}) {
const components = catalog.modelComponents();
const names = components.map((component) => component.name);
if (options.schema === 'loose') {
return {
type: 'object',
properties: {
type: { type: 'string', enum: names, description: 'Component name from the catalog' },
props: {
type: 'object',
description: "The component's props, as the catalog describes them",
},
children: {
type: 'array',
items: { $ref: '#' },
description: 'Nested elements, only for components that take children. A literal JSON array, never a JSON-stringified one.',
},
},
required: ['type', 'props'],
};
}
const defs = {};
const childrenSchema = {
type: 'array',
items: { $ref: '#/$defs/node' },
description: 'Nested elements. A literal JSON array, never a JSON-stringified one.',
};
for (const component of components) {
defs[`props_${component.name}`] = embeddableProps(toJsonSchema(component.props));
defs[`node_${component.name}`] = {
type: 'object',
properties: {
type: { type: 'string', enum: [component.name] },
props: { $ref: `#/$defs/props_${component.name}` },
...(component.children === true ? { children: childrenSchema } : {}),
},
required: ['type', 'props'],
additionalProperties: false,
};
}
defs.node = {
anyOf: components.map((component) => ({ $ref: `#/$defs/node_${component.name}` })),
};
return {
type: 'object',
properties: {
type: {
type: 'string',
enum: names,
description: 'Component name from the catalog. A single component (no children) is a valid tree.',
},
props: {
description: "The component's props: the schema of the component named by `type`",
anyOf: components.map((component) => ({ $ref: `#/$defs/props_${component.name}` })),
},
children: {
...childrenSchema,
description: 'Nested elements, only for components that take children. A literal JSON array, never a JSON-stringified one.',
},
},
required: ['type', 'props'],
additionalProperties: false,
$defs: defs,
};
}
/** A props schema that stands on its own once embedded in the tree's `$defs`, else a plain object. */
function embeddableProps(schema) {
if (schema === undefined || typeof schema !== 'object' || schema === null) {
return { type: 'object' };
}
const references = (value) => {
if (Array.isArray(value))
return value.some(references);
if (typeof value !== 'object' || value === null)
return false;
return Object.entries(value).some(([key, nested]) => key === '$ref' || key === '$defs' || key === 'definitions' || references(nested));
};
if (references(schema))
return { type: 'object' };
const { $schema: _schema, ...rest } = schema;
return rest;
}
/** Convert a tree to a json-render flat spec (ids are `el-0`, `el-1`, … in depth-first order). */
export function treeToFlatSpec(root) {
const elements = {};
let next = 0;
const visit = (node) => {
const id = `el-${next++}`;
const entry = { type: node.type, props: node.props, children: [] };
elements[id] = entry;
for (const child of node.children ?? []) {
entry.children.push(visit(child));
}
return id;
};
return { root: visit(root), elements };
}
/**
* A node as the model plainly meant it (see {@link validateTree}): `props` parsed from a string,
* props written beside `type` moved under it, and a missing `type` inferred from the props when
* exactly one component's schema fits them. Anything else is returned as it came, to be refused.
*/
export function normalizeElement(catalog, node, depth = 0) {
if (typeof node.type === 'string') {
const { type, props: rawProps, children, ...rest } = node;
const props = parseObjectIfStringified(rawProps);
// `{ type: 'Sandbox', html, css }`: the props written beside the type.
if (props === undefined && Object.keys(rest).length > 0) {
return { type, props: rest, ...(children !== undefined ? { children } : {}) };
}
return props === rawProps ? node : { ...node, props };
}
if (node.type !== undefined || depth > 3)
return node;
// The component named under another key: `{ root: 'Sandbox', props }`, or the show tool's
// `{ component: 'Sandbox', props }`.
for (const key of TYPE_ALIASES) {
const named = node[key];
if (typeof named === 'string' && isComponentName(catalog, named) && 'props' in node) {
const { [key]: _named, ...others } = node;
return normalizeElement(catalog, { ...others, type: named }, depth + 1);
}
}
// A whole element (or its bare props) wrapped in one more key — `{ props: { type, props } }`,
// `{ root: "{…}" }`, `{ props: { html, css } }` — stray keys beside it left behind.
for (const key of WRAPPER_KEYS) {
const inner = parseObjectIfStringified(node[key]);
if (!isPlainObject(inner))
continue;
const unwrapped = normalizeElement(catalog, inner, depth + 1);
if (!isComponentName(catalog, unwrapped.type))
continue;
return node.children !== undefined && unwrapped.children === undefined
? { ...unwrapped, children: node.children }
: unwrapped;
}
// The bare props themselves: `{ html, css }`.
const { children, ...props } = node;
const inferred = inferComponent(catalog, props);
return inferred === undefined
? node
: { type: inferred, props, ...(children !== undefined ? { children } : {}) };
}
/**
* A tree-mode call's arguments as the model meant them — the leniency {@link validateTree} applies
* (a stringified tree, a dropped envelope, a wrapper key; see {@link normalizeElement}), all the way
* down, WITHOUT validating anything. For a client that draws a call from its arguments (CopilotKit
* renders tool calls, an OpenUI artifact renderer reads them), so it draws the element the server
* accepted rather than nothing. Safe on half-streamed arguments: what cannot be read yet is left as
* it came.
*/
export function normalizeTreeInput(catalog, input) {
const visit = (node, depth) => {
if (depth > 32)
return node;
const value = depth === 0 ? parseRootIfStringified(node) : node;
if (!isPlainObject(value))
return value;
const element = normalizeElement(catalog, value);
const children = parseChildrenIfStringified(element.children);
return Array.isArray(children)
? { ...element, children: children.map((child) => visit(child, depth + 1)) }
: element;
};
return visit(input, 0);
}
/** Keys a model wraps a whole element in: `{ props: <element> }`, `{ root: <element> }`. */
const WRAPPER_KEYS = ['props', 'root', 'tree', 'element', 'ui'];
/** Keys a model names the component under instead of `type`. */
const TYPE_ALIASES = ['component', 'root'];
function isComponentName(catalog, name) {
if (typeof name !== 'string')
return false;
const definition = catalog.get(name);
return definition !== undefined && definition.internal !== true;
}
/** The one model-facing component whose props schema `props` fits by its keys, if exactly one. */
function inferComponent(catalog, props) {
const keys = Object.keys(props);
if (keys.length === 0)
return undefined;
const fits = catalog.modelComponents().filter((component) => {
const schema = toJsonSchema(component.props);
const properties = schema?.properties;
if (properties === undefined || typeof properties !== 'object')
return false;
const required = Array.isArray(schema?.required) ? schema.required : [];
// A schema with nothing required fits too much ({ title } is half the catalog).
if (required.length === 0)
return false;
return (keys.every((key) => Object.hasOwn(properties, key)) &&
required.every((key) => Object.hasOwn(props, key)));
});
return fits.length === 1 ? fits[0]?.name : undefined;
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseObjectIfStringified(value) {
if (typeof value !== 'string')
return value;
const trimmed = value.trim();
if (!trimmed.startsWith('{'))
return value;
const parsed = leadingJson(trimmed);
return isPlainObject(parsed) ? parsed : value;
}
function parseChildrenIfStringified(value) {
if (typeof value !== 'string')
return value;
const trimmed = value.trim();
if (!trimmed.startsWith('['))
return value;
return leadingJsonArray(trimmed) ?? value;
}
/** A whole tree sent as a JSON string (`"{\"type\": …}"`): the element it spells. */
function parseRootIfStringified(value) {
if (typeof value !== 'string')
return value;
const trimmed = value.trim();
if (!trimmed.startsWith('{'))
return value;
const parsed = leadingJson(trimmed) ?? leadingJson(repairKeyArrows(trimmed));
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : value;
}
/**
* Arguments a model wrote with `>` for the `:` after a key (`{"title">"Calc"}`) — seen from
* Anthropic models on long tool calls; the provider then hands over the text it could not parse.
* Outside a string a `>` is never JSON, so one right after a closing quote can only be that colon.
*/
function repairKeyArrows(text) {
let out = '';
let inString = false;
let escaped = false;
let afterString = false;
for (const char of text) {
if (inString) {
out += char;
if (escaped)
escaped = false;
else if (char === '\\')
escaped = true;
else if (char === '"') {
inString = false;
afterString = true;
}
continue;
}
if (char === '"') {
inString = true;
afterString = false;
}
else if (char === '>' && afterString) {
out += ':';
afterString = false;
continue;
}
else if (char !== ' ' && char !== '\n' && char !== '\t' && char !== '\r') {
afterString = false;
}
out += char;
}
return out;
}
/**
* The array `text` opens with, ignoring what follows: a model that stringifies `children` tends to
* carry the enclosing object's `}` along, which `JSON.parse` refuses whole.
*/
function leadingJsonArray(text) {
const parsed = leadingJson(text);
return Array.isArray(parsed) ? parsed : undefined;
}
/** The JSON value (an array or object) `text` opens with, ignoring what follows. */
function leadingJson(text) {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped)
escaped = false;
else if (char === '\\')
escaped = true;
else if (char === '"')
inString = false;
continue;
}
if (char === '"')
inString = true;
else if (char === '[' || char === '{')
depth += 1;
else if (char === ']' || char === '}') {
depth -= 1;
if (depth === 0) {
try {
return JSON.parse(text.slice(0, index + 1));
}
catch {
return undefined;
}
}
}
}
return undefined;
}
//# sourceMappingURL=tree.js.map