UNPKG

@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
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