@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
574 lines • 25.6 kB
JavaScript
/** The ids of an AG-UI run this consumer invented, when the send did not name a thread. */
function newId() {
return globalThis.crypto.randomUUID();
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function defaultRefusal(response) {
try {
const body = (await response.json());
if (typeof body.message === 'string' && body.message.length > 0)
return new Error(body.message);
}
catch {
// not JSON: fall through to the status
}
return new Error(`The agent refused the request (${response.status}).`);
}
/** The provider name `agUiAdapter()` reads a `file` handle to one of its own uploads by. */
const AG_UI_STAGED_MEDIA_PROVIDER = 'agora';
function mediaKind(contentType) {
if (contentType?.startsWith('image/'))
return 'image';
if (contentType?.startsWith('audio/'))
return 'audio';
if (contentType?.startsWith('video/'))
return 'video';
return 'document';
}
/** The body's message, and its staged uploads as media parts by `file` handle. */
function defaultContent(body, files) {
const message = typeof body.message === 'string' ? body.message : '';
const refs = Array.isArray(body.attachments)
? body.attachments.flatMap((ref) => isRecord(ref) && typeof ref.mediaId === 'string' && ref.mediaId.length > 0
? [ref.mediaId]
: [])
: [];
if (refs.length === 0)
return message;
const parts = message.length > 0 ? [{ type: 'text', text: message }] : [];
for (const mediaId of refs) {
const file = files.find((each) => each.mediaId === mediaId);
parts.push({
type: mediaKind(file?.contentType),
source: {
type: 'file',
provider: AG_UI_STAGED_MEDIA_PROVIDER,
value: mediaId,
...(file?.contentType !== undefined ? { mimeType: file.contentType } : {}),
},
...(file?.name !== undefined ? { metadata: { filename: file.name } } : {}),
});
}
return parts;
}
function defaultForwardedProps(body) {
const out = {};
for (const key of ['pageContext', 'agent', 'model', 'persona', 'uiCapabilities']) {
if (body[key] !== undefined && body[key] !== null)
out[key] = body[key];
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Start a turn against an AG-UI producer; the stream comes back in this library's framing. */
export async function agUiChatStream(request, options) {
const body = request.body;
const threadId = typeof body.threadId === 'string' ? body.threadId : newId();
const files = request.files ?? [];
let forwarded = (options.forwardedProps ?? defaultForwardedProps)(body);
// Not a body field the app shapes: without it the producer would answer the question again.
if (body.regenerate === true && (forwarded === undefined || isRecord(forwarded))) {
forwarded = { ...forwarded, regenerate: true };
}
const input = {
threadId,
runId: newId(),
protocolVersion: '1.0',
messages: [
{
id: newId(),
role: 'user',
content: (options.content ?? defaultContent)(body, files),
},
],
...(forwarded !== undefined ? { forwardedProps: forwarded } : {}),
};
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
const response = await doFetch(options.url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
...options.headers,
...request.headers,
},
body: JSON.stringify(input),
...(request.signal !== undefined ? { signal: request.signal } : {}),
});
if (!response.ok || response.body === null) {
throw await (options.refusal ?? defaultRefusal)(response);
}
const runId = response.headers.get('x-agent-run-id') ?? undefined;
return {
body: reframeAgUiStream(response.body, { threadId }),
threadId,
...(runId !== undefined ? { runId } : {}),
};
}
/**
* Re-frame an AG-UI event stream (SSE, one event per `data:`) in this library's stream protocol:
* `event: meta`, `data:` frames of {@link AgentStreamEvent}, then `event: done` or `event: error`.
* When the producer numbers its events with the run's own sequence (SSE `id:`, as `agUiAdapter()`
* does), that number is passed on as `id:`-only blocks — the cursor the transport re-attaches to
* the native stream with (`chat/:runId/stream?after=`). Exported for a backend that fetches the
* stream itself.
*/
export function reframeAgUiStream(source, ids) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const reframer = createReframer(ids.threadId);
const reader = source.getReader();
let buffer = '';
return new ReadableStream({
async pull(controller) {
for (;;) {
const { value, done } = await reader.read();
if (done) {
// A stream that stopped without a terminal event is a truncated run: end it plainly, and
// the transport reads the stored thread for the rest.
const rest = reframer.end();
if (rest.length > 0)
controller.enqueue(encoder.encode(rest));
controller.close();
return;
}
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n');
let out = '';
let separator = buffer.indexOf('\n\n');
while (separator !== -1) {
const block = buffer.slice(0, separator);
buffer = buffer.slice(separator + 2);
separator = buffer.indexOf('\n\n');
const lines = block.split('\n');
const idLine = lines.find((line) => line.startsWith('id:'));
const id = idLine === undefined ? Number.NaN : Number(idLine.slice(3).trim());
const data = lines
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n');
if (data.length === 0)
continue;
let event;
try {
event = JSON.parse(data);
}
catch {
continue;
}
if (isRecord(event) && typeof event.type === 'string') {
out += reframer.push(event, Number.isSafeInteger(id) && id > 0 ? id : undefined);
}
if (reframer.closed)
break;
}
if (out.length > 0)
controller.enqueue(encoder.encode(out));
if (reframer.closed) {
void reader.cancel().catch(() => undefined);
controller.close();
return;
}
if (out.length > 0)
return;
}
},
cancel(reason) {
return reader.cancel(reason).catch(() => undefined);
},
});
}
function createReframer(threadId) {
const activities = new Map();
let metaSent = false;
const tools = new Map();
// `STEP_FINISHED` is held until the next event: the producer's `agora.step-usage` right after it
// carries what the step spent.
let stepFinished = false;
// Interrupts the stream already carried as an approval card or a question set (by call id, and
// the question sets themselves for one that belongs to no call).
const carried = new Set();
const carriedRequests = new Set();
/** A text decision on an action proposal (no run): written at the run's end, with its text. */
let decision;
const encodeFrame = (streamEvent) => `data: ${JSON.stringify(streamEvent)}\n\n`;
const takeStep = (extra = {}) => {
if (!stepFinished)
return '';
stepFinished = false;
return encodeFrame({ kind: 'step-finish', ...extra });
};
/**
* The run's sequence number of the events being read, and the last one written out as a cursor.
* Several AG-UI events stand for one event of the run, so the cursor (an `id:`-only SSE block the
* transport records) moves only once every event of it was re-framed — re-attaching `?after=` it
* then neither skips nor repeats anything. Frames carry no `id:` of their own: a count of
* re-framed frames is not the run's sequence.
*/
let current;
let written;
const cursor = () => {
if (current === undefined || current === written)
return '';
written = current;
return `${takeStep()}id: ${current}\n\n`;
};
const state = {
closed: false,
end() {
if (state.closed)
return '';
return `${takeStep()}${cursor()}`;
},
push(event, id) {
if (state.closed)
return '';
let out = '';
if (id !== undefined) {
if (current !== undefined && id > current)
out += cursor();
current = id;
}
const frame = (streamEvent) => {
out += encodeFrame(streamEvent);
};
const meta = (runId, thread) => {
if (metaSent)
return;
metaSent = true;
out += `event: meta\ndata: ${JSON.stringify({
runId: typeof runId === 'string' ? runId : '',
threadId: typeof thread === 'string' ? thread : threadId,
})}\n\n`;
};
const flushStep = (extra = {}) => {
out += takeStep(extra);
};
if (stepFinished &&
event.type === 'CUSTOM' &&
event.name === 'agora.step-usage' &&
isRecord(event.value)) {
const value = event.value;
flushStep({
...(isRecord(value.usage) ? { usage: value.usage } : {}),
...(typeof value.costUsd === 'number' || value.costUsd === null
? { costUsd: value.costUsd }
: {}),
...(typeof value.reasoningMs === 'number' ? { reasoningMs: value.reasoningMs } : {}),
...(typeof value.model === 'string' ? { model: value.model } : {}),
});
return out;
}
flushStep();
switch (event.type) {
case 'RUN_STARTED':
// `agora.run` (next) names the library's own run; wait a beat for it only if it follows
// at once — otherwise the protocol's own ids do.
pendingStart = { runId: event.runId, threadId: event.threadId };
return out;
case 'CUSTOM': {
const value = event.value;
if (event.name === 'agora.run' && isRecord(value)) {
meta(value.runId, pendingStart?.threadId ?? value.threadId);
pendingStart = undefined;
return out;
}
flushStart();
if (!isRecord(value))
return out;
if (event.name === 'agora.ui')
frame({ kind: 'ui', ...value });
else if (event.name === 'agora.title' && typeof value.title === 'string') {
frame({ kind: 'title', title: value.title });
}
else if (event.name === 'agora.queue')
frame({ kind: 'queue', ...value });
else if (event.name === 'agora.approval-requested') {
if (typeof value.id === 'string')
carried.add(value.id);
frame({ ...value, kind: 'approval-requested' });
}
else if (event.name === 'agora.elicitation') {
// The question block answers it by id (the call that asked); a producer that does not
// say which leaves it to the interrupt.
if (typeof value.id === 'string' && isRecord(value.request)) {
carried.add(value.id);
carriedRequests.add(JSON.stringify(value.request));
frame({ kind: 'elicitation', id: value.id, request: value.request });
}
}
else if (event.name === 'agora.approval-settled') {
frame({ ...value, kind: 'approval-settled' });
}
else if (event.name === 'agora.action-proposal-decision') {
decision = {
threadId: value.threadId,
proposalDecision: value.proposalDecision,
text: '',
};
}
return out;
}
default:
flushStart();
}
switch (event.type) {
case 'TEXT_MESSAGE_CONTENT':
case 'TEXT_MESSAGE_CHUNK':
if (decision !== undefined && typeof event.delta === 'string') {
decision.text += event.delta;
}
if (typeof event.delta === 'string' && event.delta.length > 0) {
frame({ kind: 'text', text: event.delta });
}
break;
case 'REASONING_MESSAGE_CONTENT':
case 'REASONING_MESSAGE_CHUNK':
if (typeof event.delta === 'string' && event.delta.length > 0) {
frame({ kind: 'reasoning', text: event.delta });
}
break;
case 'STEP_STARTED':
frame({ kind: 'step-start' });
break;
case 'STEP_FINISHED':
stepFinished = true;
break;
case 'TOOL_CALL_START':
case 'TOOL_CALL_CHUNK': {
const id = String(event.toolCallId ?? '');
if (id === '')
break;
if (!tools.has(id)) {
const name = typeof event.toolCallName === 'string' ? event.toolCallName : '';
const toolKind = isRecord(event.metadata) && event.metadata['agora.toolKind'] === 'action'
? 'action'
: 'read';
tools.set(id, { name, args: '', toolKind });
frame({ kind: 'tool-input-start', id, name, toolKind });
}
if (event.type === 'TOOL_CALL_CHUNK' && typeof event.delta === 'string') {
tools.get(id).args += event.delta;
frame({ kind: 'tool-input-delta', id, delta: event.delta });
}
break;
}
case 'TOOL_CALL_ARGS': {
const call = tools.get(String(event.toolCallId));
if (call === undefined || typeof event.delta !== 'string')
break;
call.args += event.delta;
frame({ kind: 'tool-input-delta', id: String(event.toolCallId), delta: event.delta });
break;
}
case 'TOOL_CALL_END': {
const id = String(event.toolCallId);
const call = tools.get(id);
if (call === undefined)
break;
frame({
kind: 'tool-input-available',
id,
name: call.name,
input: parseJson(call.args, {}),
toolKind: call.toolKind,
});
break;
}
case 'TOOL_CALL_RESULT': {
const id = String(event.toolCallId);
const content = contentText(event.content);
const outcome = isRecord(event.metadata) ? event.metadata['agora.outcome'] : undefined;
if (outcome === 'error')
frame({ kind: 'tool-output-error', id, error: content });
else if (outcome === 'denied')
frame({ kind: 'tool-output-denied', id, reason: content });
else
frame({ kind: 'tool-output', id, output: parseJson(content, content) });
break;
}
case 'ACTIVITY_SNAPSHOT':
case 'ACTIVITY_DELTA': {
const messageId = typeof event.messageId === 'string' ? event.messageId : '';
if (messageId === '')
break;
const known = activities.get(messageId);
const activityType = typeof event.activityType === 'string'
? event.activityType
: (known?.activityType ?? '');
let content;
if (event.type === 'ACTIVITY_SNAPSHOT') {
// `replace: false` leaves an existing activity as it stands
if (known !== undefined && event.replace === false)
break;
content = isRecord(event.content) ? event.content : {};
}
else {
// a delta for an activity nothing created is skipped, never fatal
if (known === undefined || !Array.isArray(event.patch))
break;
content = applyPatch(known.content, event.patch);
if (content === undefined)
break;
}
activities.set(messageId, { activityType, content });
frame({
kind: 'ui',
id: `activity:${messageId}`,
component: 'AgUiActivity',
props: { activityType, content },
});
break;
}
case 'RUN_ERROR':
state.closed = true;
out += `event: error\ndata: ${JSON.stringify({
code: typeof event.code === 'string' ? event.code : 'run_failed',
message: typeof event.message === 'string' ? event.message : 'The run failed.',
})}\n\n`;
break;
case 'RUN_FINISHED': {
const outcome = isRecord(event.outcome) ? event.outcome : undefined;
if (decision !== undefined) {
// What a native text decision answers with: a `data-proposal-decision` part.
frame({ kind: 'proposal-decision', ...decision });
decision = undefined;
}
if (outcome?.type === 'cancelled')
frame({ kind: 'cancelled' });
if (outcome?.type === 'interrupt' && Array.isArray(outcome.interrupts)) {
// What the stream already showed as an approval card or a question block is answered
// there; the rest is the app's to render.
const interrupts = outcome.interrupts.filter((interrupt) => !isCarried(interrupt));
if (interrupts.length === 0) {
// Parked on what the chat shows: the run is not over. End without `done`, at the
// cursor, so the transport re-attaches on the native route and the rest of the run
// (after a decision over the `tool-call` routes) streams into the same message.
out += cursor();
state.closed = true;
break;
}
frame({
kind: 'ui',
id: `ag-ui:interrupt:${String(event.runId ?? '')}`,
component: 'AgUiInterrupt',
props: { interrupts },
});
}
out += cursor();
state.closed = true;
out += 'event: done\ndata: {}\n\n';
break;
}
default:
// Everything else (message and span brackets, state, activity, raw) carries nothing this
// framing does not already say.
break;
}
return out;
function isCarried(interrupt) {
if (!isRecord(interrupt))
return false;
if (typeof interrupt.toolCallId === 'string' && carried.has(interrupt.toolCallId)) {
return true;
}
const request = isRecord(interrupt.metadata)
? interrupt.metadata['agora.request']
: undefined;
return request !== undefined && carriedRequests.has(JSON.stringify(request));
}
function flushStart() {
if (pendingStart !== undefined) {
meta(pendingStart.runId, pendingStart.threadId);
pendingStart = undefined;
}
}
},
};
let pendingStart;
return state;
}
function parseJson(text, fallback) {
if (text.trim().length === 0)
return fallback;
try {
return JSON.parse(text);
}
catch {
return fallback;
}
}
/** A result's content as text: a string, or its text parts in order (other parts carry no text). */
function contentText(content) {
if (typeof content === 'string')
return content;
if (!Array.isArray(content))
return '';
return content
.filter((part) => isRecord(part) && part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text)
.join('');
}
/**
* RFC 6902 on a copy: `add`, `replace`, `remove` (and `test`, which fails the patch). `undefined`
* when the patch does not apply — the activity then keeps its last good content until a snapshot.
*/
function applyPatch(target, patch) {
const doc = structuredClone(target);
for (const operation of patch) {
if (!isRecord(operation) || typeof operation.path !== 'string')
return undefined;
const tokens = operation.path
.split('/')
.slice(1)
.map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~'));
const key = tokens.pop();
if (key === undefined)
return undefined;
let parent = doc;
for (const token of tokens) {
parent = Array.isArray(parent)
? parent[Number(token)]
: isRecord(parent)
? parent[token]
: undefined;
if (parent === undefined)
return undefined;
}
if (Array.isArray(parent)) {
const index = key === '-' ? parent.length : Number(key);
if (!Number.isInteger(index) || index < 0 || index > parent.length)
return undefined;
if (operation.op === 'add')
parent.splice(index, 0, operation.value);
else if (operation.op === 'replace' && index < parent.length)
parent[index] = operation.value;
else if (operation.op === 'remove' && index < parent.length)
parent.splice(index, 1);
else if (operation.op === 'test') {
if (JSON.stringify(parent[index]) !== JSON.stringify(operation.value))
return undefined;
}
else
return undefined;
}
else if (isRecord(parent)) {
if (operation.op === 'add' || (operation.op === 'replace' && key in parent)) {
parent[key] = operation.value;
}
else if (operation.op === 'remove' && key in parent)
delete parent[key];
else if (operation.op === 'test') {
if (JSON.stringify(parent[key]) !== JSON.stringify(operation.value))
return undefined;
}
else
return undefined;
}
else
return undefined;
}
return doc;
}
//# sourceMappingURL=ag-ui-backend.js.map