@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
523 lines • 24.3 kB
JavaScript
import { negotiateCatalog } from './capabilities.js';
import { standaloneComponents, toolNameFor, } from './catalog.js';
import { channelCatalog, channelInstructions, resolveGenuiChannel, } from './channels.js';
import { partialTree } from './progressive.js';
import { sandboxCallExample, sandboxPolicyOf } from './sandbox.js';
import { formatIssues, isStandardSchema, toJsonSchema, validateProps, } from './schema.js';
import { catalogToModelText } from './text.js';
import { GENUI_TREE_COMPONENT, treeJsonSchema, validateTree, } from './tree.js';
/** The default name of the generic show tool ({@link GenuiToolsOptions.showTool}). */
export const GENUI_SHOW_TOOL = 'ui__show';
/**
* Tools that let a model push catalog components into the conversation as `ui` frames. Each call
* validates its input against the catalog (a refused call reaches the model as a tool error it can
* fix) and then pushes through `ctx.emitUi`, which streams the frame live and persists it on the
* assistant message.
*/
export function genuiTools(catalog, options = {}) {
// Without `channels`, the one top-level mode. With them, every mode some channel runs in: each
// tool is then offered only on the channels of its mode.
const modes = new Set([options.mode === 'per-component' ? 'per-component' : 'tree']);
for (const name of Object.keys(options.channels ?? {}))
modes.add(channelFor(options, name).mode);
const tools = [];
if (modes.has('tree')) {
tools.push(treeTool(catalog, options));
// `componentTools`: a flat tool per named component, offered where the tree is.
for (const { component, prefix } of ownTools(catalog, options)) {
tools.push(componentTool(catalog, component, { ...options, namePrefix: prefix }, 'tree'));
}
}
if (modes.has('per-component')) {
for (const component of standaloneComponents(catalog)) {
tools.push(componentTool(catalog, component, options));
}
}
if (options.showTool !== undefined && options.showTool !== false) {
tools.push(showTool(catalog, options));
}
return tools;
}
/** The {@link GenuiToolsOptions.componentTools} of a tree-mode set, checked against the catalog. */
function ownTools(catalog, options) {
const prefix = options.componentToolPrefix ?? 'ui__';
return (options.componentTools ?? []).map((name) => {
const component = catalog.get(name);
if (component === undefined || component.internal === true || component.children === true) {
throw new Error(`genui componentTools: "${name}" is not a model-facing component without children`);
}
return { component, prefix, name: toolNameFor(component.name, prefix) };
});
}
function scopeOf(input) {
return {
actor: input.actor,
...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
...(input.actor.tenantRef !== undefined ? { tenant: input.actor.tenantRef } : {}),
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.channel !== undefined ? { channel: input.channel } : {}),
};
}
/** The turn's channel with every default applied. */
function channelFor(options, channel) {
return resolveGenuiChannel({
...(options.mode !== undefined ? { mode: options.mode } : {}),
...(options.streaming !== undefined ? { streaming: options.streaming } : {}),
...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}),
}, options.channels, channel);
}
/** Is a tool of `mode` offered on this turn's channel? Always, without `channels`. */
function offeredOn(options, channel, mode) {
if (options.channels === undefined)
return true;
const resolved = channelFor(options, channel).mode;
return mode === 'show' ? resolved !== 'text' : resolved === mode;
}
const NOT_ON_CHANNEL = {
available: false,
description: 'Not available on this channel — do not call this tool.',
};
/** Words for the model about the turn's channel, after a tool's own description. */
function withChannel(description, options, channel) {
if (options.channels === undefined)
return description;
const extra = channelInstructions(channelFor(options, channel));
return extra === undefined ? description : `${description}\n${extra}`;
}
async function catalogFor(catalog, options, scope) {
const resolved = options.resolveCatalog === undefined ? catalog : await options.resolveCatalog(scope);
return options.channels === undefined
? resolved
: channelCatalog(resolved, channelFor(options, scope.channel));
}
/** The ability a UI tool declares, from {@link GenuiToolsOptions.ability}. */
export function genuiToolAbility(ability, tool) {
if (ability === undefined)
return undefined;
if (typeof ability === 'string')
return ability;
if (typeof ability === 'function')
return ability(tool);
return ability[tool.name];
}
function common(options, tool, mode) {
const ability = genuiToolAbility(options.ability, tool);
const telemetry = options.telemetry;
return {
...(options.roles !== undefined ? { roles: options.roles } : {}),
...(ability !== undefined ? { ability } : {}),
...(options.terminal === true ? { terminal: true } : {}),
genui: {
mode,
...(telemetry === false
? { telemetry: false }
: telemetry?.samplePayloads === true
? { telemetry: { samplePayloads: true } }
: {}),
},
};
}
/**
* A component's {@link ComponentDefinition.check}: errors refuse the call (thrown, the model gets
* them), warnings come back to be handed over with the result.
*/
async function runChecks(definition, props, where = '') {
if (definition.check === undefined)
return { errors: [], warnings: [] };
const found = await definition.check(props);
const prefix = where === '' ? '' : `${where} `;
return {
errors: found.filter((each) => each.severity === 'error').map((each) => prefix + each.message),
warnings: found
.filter((each) => each.severity === 'warning')
.map((each) => prefix + each.message),
};
}
/** At most three problems — the first ones are what the model fixes first. */
function codeError(component, errors) {
const shown = errors.slice(0, 3).join('; ');
const more = errors.length > 3 ? ` (+${errors.length - 3} more)` : '';
return new Error(`invalid ${component} code: ${shown}${more}`);
}
function componentTool(catalog, component, options,
/** The mode whose channels offer it: `tree` for a {@link GenuiToolsOptions.componentTools} tool. */
offeredIn = 'per-component') {
const presentation = options.presentation?.(component.name) ?? {
label: component.title,
running: `Showing ${lowerFirst(component.title)}`,
done: `Showed ${lowerFirst(component.title)}`,
result: { kind: 'elsewhere' },
};
const describeFor = (definition) => {
const example = sandboxCallExample(definition);
return `Show the user a ${definition.title} (${definition.name}). ${definition.description}${example !== undefined ? `\n${example}` : ''}`;
};
const dynamic = options.resolveCatalog !== undefined;
const mode = sandboxPolicyOf(component) !== undefined ? 'sandbox' : 'per-component';
// With a resolver the component's schema may differ per request (a tenant's version of it), so
// the registry lets any object through and `execute` validates against the resolved definition.
const inputSchema = dynamic
? permissiveSchema(toJsonSchema(component.props) ?? { type: 'object' })
: isStandardSchema(component.props)
? component.props
: jsonStandardSchema(component.props, (value) => catalog.validator.validate(component.props, value));
const handler = {
async execute(input, ctx) {
const resolved = await catalogFor(catalog, options, scopeOf(ctx));
const definition = modelComponent(resolved, component.name);
if (definition === undefined) {
throw new Error(`component "${component.name}" is not available here`);
}
// Static handlers receive the registry's parsed output; dynamic handlers parse the
// request-specific input here. Never run an input transformation on portable output.
const validated = await validateProps(dynamic ? definition.props : (definition.outputProps ?? definition.props), input, resolved.validator);
if (!validated.ok) {
throw new Error(`invalid ${component.name} props: ${formatIssues(validated.issues)}`);
}
const props = validated.value;
const checked = await runChecks(definition, props);
if (checked.errors.length > 0)
throw codeError(component.name, checked.errors);
return withWarnings(await push(ctx, component.name, props, definition.version), checked.warnings);
},
};
handler.describe = async (scope) => {
if (!offeredOn(options, scope.channel, offeredIn))
return NOT_ON_CHANNEL;
const resolved = negotiateCatalog(await catalogFor(catalog, options, scopeOf(scope)), scope.uiCapabilities);
const definition = modelComponent(resolved, component.name);
if (definition === undefined) {
return {
available: false,
description: `Not available in this conversation — do not call this tool. (${component.name})`,
};
}
return {
description: withChannel(describeFor(definition), options, scope.channel),
inputSchema: permissiveSchema(toJsonSchema(definition.props) ?? { type: 'object' }),
};
};
if (component.partialProps !== undefined) {
// A component that says what of its half-written props may be shown (the sandbox) is drawn
// while the model writes the call: as a one-node tree, the shape every partial frame takes.
handler.previewInput = async (scope) => {
const resolved = negotiateCatalog(await catalogFor(catalog, options, scopeOf(scope)), scope.uiCapabilities);
const definition = modelComponent(resolved, component.name);
if (definition?.partialProps === undefined)
return undefined;
const trim = definition.partialProps;
return {
...(options.streamingThrottleMs !== undefined
? { throttleMs: options.streamingThrottleMs }
: {}),
render(input) {
const value = input.value;
if (typeof value !== 'object' || value === null || Array.isArray(value))
return undefined;
const props = input.done
? value
: trim(value, input);
return {
component: GENUI_TREE_COMPONENT,
props: {
root: {
id: 'root',
type: component.name,
props,
...(input.done ? {} : { incomplete: true }),
},
},
version: 1,
};
},
};
};
}
return {
spec: {
name: toolNameFor(component.name, options.namePrefix),
kind: 'read',
description: describeFor(component),
inputSchema,
presentation,
...common(options, {
name: toolNameFor(component.name, options.namePrefix),
component: component.name,
}, mode),
},
handler,
};
}
function treeTool(catalog, options) {
const name = options.treeToolName ?? 'ui__render';
const presentation = options.presentation?.(name) ?? {
label: 'Answer',
running: 'Laying out the answer',
done: 'Laid out the answer',
result: { kind: 'elsewhere' },
};
const own = ownTools(catalog, options);
const describeFor = (resolved) => {
const offered = own.filter(({ component }) => resolved.get(component.name) !== undefined);
return [
options.treeInstructions ?? 'Render a rich UI to present the answer to the user.',
...(offered.length > 0
? [
`On its own (not inside a layout), a ${offered
.map(({ component, name }) => `${component.name} has its own tool, \`${name}\``)
.join('; a ')}, taking its props directly — call that instead.`,
]
: []),
catalogToModelText(resolved, { mode: 'tree' }),
].join('\n');
};
const dynamic = options.resolveCatalog !== undefined;
const treeSchemaOptions = options.treeSchema !== undefined ? { schema: options.treeSchema } : {};
const handler = {
async execute(input, ctx) {
const resolved = await catalogFor(catalog, options, scopeOf(ctx));
const result = await validateTree(resolved, input, options.treeLimits, dynamic ? 'input' : 'output');
if (!result.ok) {
throw new Error(`invalid UI tree: ${formatIssues(result.issues)}`);
}
const root = result.value;
const checked = await checkTree(resolved, root);
if (checked.errors.length > 0)
throw codeError('UI tree', checked.errors);
// A tree of one standalone component is that component: pushed as its `ui__show_*` tool
// would push it, so a client (or a channel's `renderComponent`) sees no difference.
const single = resolved.get(root.type);
if (root.children === undefined && single !== undefined && single.children !== true) {
return withWarnings(await push(ctx, root.type, root.props, single.version), checked.warnings);
}
return withWarnings(await push(ctx, GENUI_TREE_COMPONENT, { root }), checked.warnings);
},
};
handler.describe = async (scope) => {
if (!offeredOn(options, scope.channel, 'tree'))
return NOT_ON_CHANNEL;
const resolved = negotiateCatalog(await catalogFor(catalog, options, scopeOf(scope)), scope.uiCapabilities);
return {
available: resolved.modelComponents().length > 0,
description: withChannel(describeFor(resolved), options, scope.channel),
inputSchema: permissiveSchema(treeJsonSchema(resolved, treeSchemaOptions)),
};
};
handler.previewInput = async (scope) => {
// What THIS client draws: a node it cannot would make the final push degrade to text, so the
// preview stops there instead of showing a layout the message will not have.
if (!offeredOn(options, scope.channel, 'tree'))
return undefined;
const resolved = negotiateCatalog(await catalogFor(catalog, options, scopeOf(scope)), scope.uiCapabilities);
const streaming = (options.channels === undefined
? options.streaming
: channelFor(options, scope.channel).streaming) ?? 'complete';
// Nothing streams partial: the tree appears when the call has run, as it always did.
if (!resolved.modelComponents().some((each) => (each.streaming ?? streaming) === 'partial')) {
return undefined;
}
return {
...(options.streamingThrottleMs !== undefined
? { throttleMs: options.streamingThrottleMs }
: {}),
render(input) {
const tree = partialTree(resolved, input, {
streaming,
...(options.treeLimits !== undefined ? { limits: options.treeLimits } : {}),
});
if (tree === null)
return null;
if (tree.root === null)
return undefined;
return { component: GENUI_TREE_COMPONENT, props: { root: tree.root }, version: 1 };
},
};
};
return {
spec: {
name,
kind: 'read',
description: describeFor(catalog),
inputSchema: dynamic
? permissiveSchema(treeJsonSchema(catalog, treeSchemaOptions))
: asyncJsonStandardSchema(treeJsonSchema(catalog, treeSchemaOptions), async (value) => {
const result = await validateTree(catalog, value, options.treeLimits, 'input');
return result.ok ? { value: result.value } : { issues: result.issues };
}),
presentation,
...common(options, { name }, 'tree'),
},
handler,
};
}
/** Every node's {@link ComponentDefinition.check}, addressed by its path when it is not the root. */
async function checkTree(catalog, root) {
const errors = [];
const warnings = [];
const visit = async (node, path) => {
const definition = catalog.get(node.type);
if (definition !== undefined) {
const found = await runChecks(definition, node.props, path);
errors.push(...found.errors);
warnings.push(...found.warnings);
}
for (const [index, child] of (node.children ?? []).entries()) {
await visit(child, `${path === '' ? '' : `${path}.`}children[${index}]`);
}
};
await visit(root, '');
return { errors, warnings };
}
function withWarnings(output, warnings) {
return warnings.length > 0 ? { ...output, warnings: warnings.slice(0, 5) } : output;
}
/** The show tool's input: a component name (one of the catalog's) and its props. */
export function showToolJsonSchema(catalog) {
return {
type: 'object',
properties: {
component: {
type: 'string',
enum: standaloneComponents(catalog).map((component) => component.name),
description: 'Component name from the catalog',
},
props: {
type: 'object',
description: "The component's props, as the catalog describes them",
},
},
required: ['component', 'props'],
};
}
async function validateShow(catalog, input, phase = 'input') {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return { ok: false, issues: [{ path: [], message: 'must be { component, props }' }] };
}
const { component, props } = input;
if (typeof component !== 'string') {
return { ok: false, issues: [{ path: ['component'], message: 'must be a component name' }] };
}
const definition = modelComponent(catalog, component);
if (definition === undefined || definition.children === true) {
const allowed = standaloneComponents(catalog)
.map((each) => each.name)
.join(', ');
return {
ok: false,
issues: [
{ path: ['component'], message: `unknown component "${component}" (allowed: ${allowed})` },
],
};
}
const validated = await validateProps(phase === 'input' ? definition.props : (definition.outputProps ?? definition.props), props ?? {}, catalog.validator);
if (!validated.ok) {
return {
ok: false,
issues: validated.issues.map((issue) => ({ ...issue, path: ['props', ...issue.path] })),
};
}
return { ok: true, definition, props: validated.value };
}
function showTool(catalog, options) {
const name = typeof options.showTool === 'string' ? options.showTool : GENUI_SHOW_TOOL;
const presentation = options.presentation?.(name) ?? {
label: 'Show',
running: 'Preparing a view',
done: 'Showed a view',
result: { kind: 'elsewhere' },
};
const describeFor = (resolved) => [
options.showInstructions ?? 'Show the user one component from the catalog.',
catalogToModelText(resolved, { mode: 'show' }),
].join('\n');
const dynamic = options.resolveCatalog !== undefined;
const handler = {
async execute(input, ctx) {
const resolved = await catalogFor(catalog, options, scopeOf(ctx));
const result = await validateShow(resolved, input, dynamic ? 'input' : 'output');
if (!result.ok) {
throw new Error(`invalid ${name} call: ${formatIssues(result.issues)}`);
}
const checked = await runChecks(result.definition, result.props);
if (checked.errors.length > 0)
throw codeError(result.definition.name, checked.errors);
return withWarnings(await push(ctx, result.definition.name, result.props, result.definition.version), checked.warnings);
},
};
handler.describe = async (scope) => {
if (!offeredOn(options, scope.channel, 'show'))
return NOT_ON_CHANNEL;
const resolved = negotiateCatalog(await catalogFor(catalog, options, scopeOf(scope)), scope.uiCapabilities);
return {
available: standaloneComponents(resolved).length > 0,
description: withChannel(describeFor(resolved), options, scope.channel),
inputSchema: permissiveSchema(showToolJsonSchema(resolved)),
};
};
return {
spec: {
name,
kind: 'read',
description: describeFor(catalog),
inputSchema: dynamic
? permissiveSchema(showToolJsonSchema(catalog))
: asyncJsonStandardSchema(showToolJsonSchema(catalog), async (value) => {
const result = await validateShow(catalog, value);
return result.ok
? { value: { component: result.definition.name, props: result.props } }
: { issues: result.issues };
}),
presentation,
...common(options, { name }, 'show'),
},
handler,
};
}
function modelComponent(catalog, name) {
const definition = catalog.get(name);
return definition === undefined || definition.internal === true ? undefined : definition;
}
async function push(ctx, component, props, version) {
const { id } = await ctx.emitUi(component, props, version !== undefined ? { version } : {});
return { shown: component, id };
}
function lowerFirst(text) {
return text.length > 0 ? `${text[0]?.toLowerCase()}${text.slice(1)}` : text;
}
/**
* A JSON Schema presented as a Standard Schema WITH the Standard JSON Schema extension, so the lib
* validates tool input through `~standard.validate` and the AI SDK adapter hands the model the
* schema itself (it reads `~standard.jsonSchema.input`).
*/
export function jsonStandardSchema(schema, validate) {
return asyncJsonStandardSchema(schema, (value) => {
const issues = validate(value);
return issues.length > 0 ? { issues } : { value };
});
}
/** Shows the model `schema`, accepts any value: validation happens in `execute`, per request. */
function permissiveSchema(schema) {
return asyncJsonStandardSchema(schema, (value) => ({ value }));
}
function asyncJsonStandardSchema(schema, validate) {
const toResult = (outcome) => outcome.issues !== undefined
? {
issues: outcome.issues.map((issue) => ({ message: issue.message, path: issue.path })),
}
: { value: outcome.value };
return {
'~standard': {
version: 1,
vendor: 'adonis-agora-agent-genui',
validate(value) {
const outcome = validate(value);
return outcome instanceof Promise ? outcome.then(toResult) : toResult(outcome);
},
jsonSchema: {
input: () => schema,
output: () => schema,
},
},
};
}
//# sourceMappingURL=tools.js.map