@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
292 lines • 14.8 kB
JavaScript
import { decodeInterruptId } from '../ag-ui/core/interrupt-id.js';
import { agUiEvents } from '../ag-ui/stream.js';
import { uiActionText } from '../genui/actions.js';
import { validateUiCapabilities } from '../genui/capabilities.js';
import { A2UI_APPROVE_ACTION, A2UI_REJECT_ACTION, A2uiProjector, a2uiThreadReplay, negotiateA2uiCatalog, readA2uiAction, readA2uiClientCapabilities, } from './core.js';
const VIA = 'a2ui';
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** A field of the body, or of its A2A-style `metadata` (where A2UI puts client capabilities). */
function bodyField(body, key) {
if (body[key] !== undefined)
return body[key];
return isRecord(body.metadata) ? body.metadata[key] : undefined;
}
/** `a2uiClientDataModel` (`{ version, surfaces: { <surfaceId>: model } }`): its surfaces, or why not. */
function readDataModel(raw, maxBytes) {
if (raw === undefined)
return undefined;
if (!isRecord(raw) || !isRecord(raw.surfaces)) {
return 'a2uiClientDataModel carries { version, surfaces }';
}
let size;
try {
size = new TextEncoder().encode(JSON.stringify(raw.surfaces)).length;
}
catch {
return 'a2uiClientDataModel must be JSON';
}
if (size > maxBytes) {
return `a2uiClientDataModel may be at most ${maxBytes} bytes (this one is ${size})`;
}
return Object.keys(raw.surfaces).length > 0 ? raw.surfaces : undefined;
}
/**
* Serve the agent as an A2UI (https://a2ui.org, v0.9) agent: `POST <path>/a2ui` answers with the
* run as a JSON Lines stream of A2UI server-to-client messages — `createSurface`,
* `updateComponents`, `updateDataModel`, `deleteSurface` — that any A2UI renderer draws.
*
* ```ts
* import { a2uiAdapter } from '@adonis-agora/agent/a2ui'
*
* export default defineConfig({ adapters: [a2uiAdapter()] })
* ```
*
* The body is `{ threadId?, message?, action?, uiCapabilities?, a2uiClientCapabilities?,
* a2uiClientDataModel?, agent? }` (the two A2UI fields also read from `metadata`, A2A-style):
* - `message` — the user's text;
* - `action` — an A2UI client message (`{ version: 'v0.9', action: { name, surfaceId,
* sourceComponentId, timestamp, context } }`, or v0.8's `{ userAction }`): it becomes the turn
* (`uiActionText`). `agora.approve` / `agora.reject` with `context.interruptId` decide the
* approval a previous stream ended on, and stream the rest of that run;
* - `threadId` — continue that thread (its owner is checked), or start one under that id;
* - `a2uiClientCapabilities` — the catalogs the client draws (`{ 'v0.9': { supportedCatalogIds } }`,
* what `MessageProcessor.getRendererCapabilities()` builds): the basic catalog is sent under the
* id the client lists for it;
* - `a2uiClientDataModel` — the data models of surfaces created with `sendDataModel`, handed to the
* prompt builder as `pageContext.a2uiDataModel`.
*
* `GET <path>/a2ui/threads/:threadId` answers a stored thread as `{ threadId, entries }`
* ({@link a2uiThreadReplay}): what a client draws again after a reload.
*
* Response headers name the library's ids: `X-Agent-Thread-Id`, `X-Agent-Run-Id`. Authenticated and
* owner-scoped exactly as `chat` is.
*/
export function a2uiAdapter(options = {}) {
const path = (options.path ?? 'a2ui').replace(/^\/+|\/+$/g, '');
const { path: _path, quietMs, maxActionBytes, maxDataModelBytes, ...configured } = options;
return {
name: 'a2ui',
mount(host) {
const { service } = host;
host.get(`${path}/threads/:threadId`, async (ctx) => {
const query = ctx.request.qs();
const agentName = typeof query.agent === 'string' && query.agent.length > 0 ? query.agent : undefined;
const actor = await host.resolveActor(ctx, agentName ?? host.defaultAgentName);
if (actor === null)
return;
const threadId = String(ctx.params.threadId);
const owner = await service.threadOwner(threadId);
if (owner === null)
return ctx.response.json({ threadId, entries: [] });
if (!(await host.assertOwner(ctx, actor, owner, 'thread')))
return;
const thread = await service.getThread(threadId, actor);
const supported = typeof query.catalog === 'string'
? [query.catalog]
: Array.isArray(query.catalog)
? query.catalog.filter((id) => typeof id === 'string')
: undefined;
return ctx.response.json({
threadId,
entries: a2uiThreadReplay(thread?.messages ?? [], {
...negotiateA2uiCatalog(configured, supported),
...(configured.catalog === undefined && host.genuiCatalog !== undefined
? { catalog: host.genuiCatalog }
: {}),
}),
});
});
host.post(path, async (ctx) => {
const body = ctx.request.body();
const agentName = typeof body.agent === 'string' && body.agent.length > 0 ? body.agent : undefined;
const actor = await host.resolveActor(ctx, agentName ?? host.defaultAgentName);
if (actor === null)
return;
const projection = negotiateA2uiCatalog(configured, readA2uiClientCapabilities(bodyField(body, 'a2uiClientCapabilities')));
const dataModel = readDataModel(bodyField(body, 'a2uiClientDataModel'), maxDataModelBytes ?? 32768);
if (typeof dataModel === 'string') {
return ctx.response.badRequest({ message: dataModel, code: 'invalid_data_model' });
}
let action;
if (body.action !== undefined) {
const read = isRecord(body.action)
? readA2uiAction(body.action, {
...(maxActionBytes !== undefined ? { maxBytes: maxActionBytes } : {}),
})
: 'action must be an A2UI action message';
if (typeof read === 'string') {
return ctx.response.badRequest({ message: read, code: 'invalid_action' });
}
action = read;
}
// Deciding the approval a stream ended on: settle it, then stream the rest of the run.
if (action?.name === A2UI_APPROVE_ACTION || action?.name === A2UI_REJECT_ACTION) {
const interruptId = action.context.interruptId;
const address = typeof interruptId === 'string' ? decodeInterruptId(interruptId) : undefined;
if (address === undefined || address === null || address.kind === 'elicitation') {
return ctx.response.badRequest({
message: 'an approval action carries the interruptId it decides',
code: 'invalid_action',
});
}
// An independent proposal: decided through the proposal service (its own run is not
// waiting), answered with the configured reply.
if (address.kind === 'proposal') {
if (address.proposalId === undefined || address.threadId === undefined) {
return ctx.response.badRequest({
message: 'the interruptId names no proposal',
code: 'invalid_action',
});
}
const decision = action.name === A2UI_APPROVE_ACTION ? 'approved' : 'rejected';
let reply;
try {
const result = await service.decideActionProposal(actor, address.threadId, address.proposalId, decision, {}, VIA);
reply = service.actionProposalReply(result, decision);
}
catch (error) {
if (host.refuseSend(ctx, error))
return;
throw error;
}
writeHead(ctx, { 'X-Agent-Thread-Id': address.threadId });
const projector = new A2uiProjector(projection);
const messageId = `proposal-${address.proposalId}`;
for (const event of [
{ type: 'TEXT_MESSAGE_START', messageId, role: 'assistant' },
{ type: 'TEXT_MESSAGE_CONTENT', messageId, delta: reply },
]) {
for (const message of projector.project(event)) {
ctx.response.response.write(`${JSON.stringify(message)}\n`);
}
}
ctx.response.response.end();
return;
}
if (!(await host.mayDecide(ctx, actor, address.parked, address.toolCallId)))
return;
try {
if (action.name === A2UI_APPROVE_ACTION) {
await service.approve(address.parked, address.toolCallId, {
executedByRef: actor.id,
via: VIA,
});
}
else {
await service.reject(address.parked, address.toolCallId, undefined, {
executedByRef: actor.id,
via: VIA,
});
}
}
catch (error) {
host.conflictOnMismatch(ctx, error);
return;
}
await stream(ctx, host, address.stream, {
skip: address.position,
answered: [address.toolCallId],
projection,
quietMs,
});
return;
}
const message = typeof body.message === 'string' ? body.message : '';
const text = action !== undefined ? uiActionText(action) : message;
if (text.trim().length === 0) {
return ctx.response.badRequest({
message: 'send a message or an action',
code: 'no_user_message',
});
}
const threadId = typeof body.threadId === 'string' && body.threadId.length > 0 ? body.threadId : undefined;
const owner = threadId !== undefined ? await service.threadOwner(threadId) : null;
if (owner !== null && !(await host.assertOwner(ctx, actor, owner, 'thread')))
return;
let started;
try {
started = await service.chat({
actor,
message: text,
...(threadId === undefined
? {}
: owner !== null
? { threadId }
: { newThreadId: threadId }),
...(agentName !== undefined ? { agentName } : {}),
// An A2UI client draws like a web page: no channel stamped, which `turnChannel` reads as `web`.
...(dataModel !== undefined ? { pageContext: { a2uiDataModel: dataModel } } : {}),
...(Object.hasOwn(body, 'uiCapabilities')
? { uiCapabilities: validateUiCapabilities(body.uiCapabilities) }
: {}),
});
}
catch (error) {
if (host.refuseSend(ctx, error))
return;
throw error;
}
await stream(ctx, host, started.runId, {
threadId: started.threadId,
projection,
quietMs,
});
});
},
};
}
/** Write one run as A2UI JSON Lines, until it ends or stops to ask. */
async function stream(ctx, host, runId, options) {
const raw = ctx.response.response;
writeHead(ctx, {
'X-Agent-Run-Id': runId,
...(options.threadId !== undefined ? { 'X-Agent-Thread-Id': options.threadId } : {}),
});
const projector = new A2uiProjector({
...options.projection,
...(options.projection.catalog === undefined && host.genuiCatalog !== undefined
? { catalog: host.genuiCatalog }
: {}),
});
const write = (messages) => {
for (const message of messages)
raw.write(`${JSON.stringify(message)}\n`);
};
try {
for await (const event of agUiEvents(host.service.subscribe(runId), {
threadId: options.threadId ?? runId,
runId,
streamRunId: runId,
...(options.threadId !== undefined ? { streamThreadId: options.threadId } : {}),
...(options.skip !== undefined ? { skip: options.skip } : {}),
...(options.answered !== undefined ? { answered: options.answered } : {}),
...(options.quietMs !== undefined ? { quietMs: options.quietMs } : {}),
})) {
write(projector.project(event));
}
}
catch {
write(projector.project({ type: 'RUN_ERROR', message: 'The run could not be followed.' }));
}
raw.end();
}
/** Open the JSON Lines response, carrying over the headers already set on the context (cookies). */
function writeHead(ctx, extra) {
const raw = ctx.response.response;
const headers = {
'Content-Type': 'application/jsonl; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...extra,
};
const pending = ctx.response.getHeaders();
for (const [name, value] of Object.entries(pending)) {
if (value !== undefined && !(name in headers))
raw.setHeader(name, value);
}
raw.writeHead(200, headers);
}
//# sourceMappingURL=adapter.js.map