@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
556 lines • 24.1 kB
JavaScript
import { a2uiActivityEvent } from '../../a2ui/core.js';
import { resolveToolConfirmation } from '../../tool-presentation.js';
import { encodeInterruptId } from './interrupt-id.js';
import { AG_UI_CUSTOM, AG_UI_PROTOCOL_VERSION, } from './types.js';
const APPROVAL_SCHEMA = {
type: 'object',
properties: {
approved: { type: 'boolean' },
reason: { type: 'string' },
remember: { type: 'boolean' },
},
required: ['approved'],
};
/** The answer an elicitation expects: `{ answers: { <questionId>: string[] } }`. */
function elicitationSchema(request) {
const properties = {};
for (const question of request.questions) {
const options = question.options?.map((option) => option.value);
properties[question.id] = {
type: 'array',
title: question.prompt,
...(question.description !== undefined ? { description: question.description } : {}),
items: options !== undefined && options.length > 0 && question.allowFreeText !== true
? { type: 'string', enum: options }
: { type: 'string' },
...(question.multiple === true ? {} : { maxItems: 1 }),
...(question.defaults !== undefined ? { default: question.defaults } : {}),
};
}
return {
type: 'object',
properties: { answers: { type: 'object', properties } },
required: ['answers'],
};
}
function text(value) {
if (typeof value === 'string')
return value;
const json = JSON.stringify(value);
return json === undefined ? '' : json;
}
/**
* Projects one library run's stream ({@link AgUiSourceFrame}s, in buffer order) onto AG-UI 1.0 events.
*
* A pure state machine: the same frames always give the same events, which is what lets a resuming
* request — served by any replica — replay the run's buffer, skip what the interrupted AG-UI run
* already delivered, and continue. It writes nothing itself; {@link agUiEvents} drives it and
* decides when the run has stopped to ask.
*
* What maps where:
* - `text` → `TEXT_MESSAGE_*` (one message per stretch of text);
* - `reasoning` → a reasoning span with one reasoning message;
* - `step-start`/`step-finish` → `STEP_STARTED`/`STEP_FINISHED`, the step's usage folded into the
* run's `usage`;
* - `tool-input-*` → `TOOL_CALL_START`/`ARGS`/`END` (the call's kind as `metadata['agora.toolKind']`);
* `tool-output*` → `TOOL_CALL_RESULT`;
* - an approval or a question set the run parks on → an `Interrupt` on the closing `RUN_FINISHED`;
* - `cancelled` → the cancelled outcome; an error frame → `RUN_ERROR`;
* - generative UI, title, queue and the native approval/elicitation frames → `CUSTOM` events named
* in {@link AG_UI_CUSTOM}, for what AG-UI has no event for.
*
* Not mapped: sub-agent attribution (`subagentRunId`) — a delegated agent's frames are projected
* flat, as the protocol allows of a producer that does not attribute.
*/
export class AgUiEncoder {
options;
position = 0;
started = false;
closed = false;
cancelled = false;
crossed = false;
messageSeq = 0;
stepSeq = 0;
openText;
openReasoning;
/** The last text message of the current step: a tool call attaches to it. */
stepMessage;
openSteps = [];
calls = new Map();
pending = new Map();
usage = new Map();
constructor(options) {
this.options = options;
}
/** How many frames of the library stream this encoder has consumed. */
get consumed() {
return this.position;
}
/** The run is waiting on at least one approval or question set nobody has settled yet. */
get waiting() {
return this.pending.size > 0;
}
/**
* Is anything still in flight that is NOT waiting on a person — a tool call announced and neither
* answered nor parked? While there is, more frames are coming without anyone's help.
*/
get busy() {
for (const [id, call] of this.calls) {
if (!call.answered && !this.pending.has(id))
return true;
}
return false;
}
/** `RUN_STARTED`, and the library's own ids for the run. Call once, before the first frame. */
start() {
if (this.started)
return [];
this.started = true;
return [
{
type: 'RUN_STARTED',
threadId: this.options.threadId,
runId: this.options.runId,
protocolVersion: AG_UI_PROTOCOL_VERSION,
},
{
type: 'CUSTOM',
name: AG_UI_CUSTOM.run,
value: {
runId: this.options.streamRunId,
threadId: this.options.streamThreadId ?? this.options.threadId,
},
},
];
}
/** The AG-UI events the next frame of the library stream stands for (possibly none). */
encode(frame) {
const position = this.position;
this.position += 1;
if (this.closed)
return [];
const skip = this.options.skip ?? 0;
if (!this.crossed && position >= skip)
this.crossBoundary();
const events = this.project(frame, position);
// A frame an earlier AG-UI run already delivered still moves the state (what is open, what is
// pending; NOT what was spent — usage is per run) but writes nothing. The last of them is the
// boundary: crossed at once, not when the next frame happens to arrive, because a run that was
// just answered may take a moment to write one.
if (position < skip) {
if (this.position >= skip)
this.crossBoundary();
return [];
}
return events;
}
/**
* The replayed frames are behind us: from here on events are written. The AG-UI run that delivered
* them closed everything it had open before it finished, so this run starts with nothing open, and
* what the resuming request answered is no longer waiting.
*/
crossBoundary() {
this.crossed = true;
this.openText = undefined;
this.openReasoning = undefined;
this.stepMessage = undefined;
this.openSteps.length = 0;
for (const call of this.calls.values()) {
call.started = false;
call.ended = true;
}
for (const id of this.options.answered ?? [])
this.pending.delete(id);
}
get replaying() {
return this.position <= (this.options.skip ?? 0);
}
project(event, position) {
switch (event.kind) {
case 'error': {
this.closed = true;
const usage = this.usageEntries();
return [
{
type: 'RUN_ERROR',
message: event.message,
code: event.code,
...(usage.length > 0 ? { usage } : {}),
},
];
}
case 'ui': {
const a2ui = this.options.a2ui === undefined
? null
: a2uiActivityEvent({
id: event.id ?? `ui:${position}`,
component: event.component,
props: event.props,
...(event.partial === true ? { partial: true } : {}),
}, this.options.a2ui);
return [
this.custom(AG_UI_CUSTOM.ui, {
id: event.id ?? `ui:${position}`,
component: event.component,
props: event.props,
...(event.version !== undefined ? { version: event.version } : {}),
// What a client that cannot draw the component shows instead.
...(event.fallbackText !== undefined ? { fallbackText: event.fallbackText } : {}),
...(event.componentVersions !== undefined
? { componentVersions: event.componentVersions }
: {}),
...(event.toolCallId !== undefined ? { toolCallId: event.toolCallId } : {}),
// A preview of a call's streaming input: a repeat of this id replaces it in place.
...(event.partial === true ? { partial: true } : {}),
}),
...(a2ui !== null ? [a2ui] : []),
];
}
case 'approval-requested': {
const call = this.calls.get(event.id);
const parked = event.runId ?? this.options.streamRunId;
const toolName = event.toolName ?? call?.name ?? '';
const input = event.input ?? call?.input ?? null;
// An independent proposal: decided through the proposal service, not by signalling the run.
const proposal = event.target?.kind === 'proposal' ? event.target : undefined;
this.pending.set(event.id, {
kind: proposal !== undefined ? 'proposal' : 'approval',
parked,
toolCallId: event.id,
...(proposal !== undefined ? { proposalId: proposal.proposalId } : {}),
interrupt: {
reason: 'tool_approval',
// The tool's own wording ("Refund order #7?"), as the CUSTOM event's `confirmation`;
// it outranks a free-form `reason`. A tool that declared none reads as its name in
// words ("Refund order?"), never as the raw identifier.
message: event.confirmation?.title ??
event.reason ??
resolveToolConfirmation(toolName, input).title,
toolCallId: event.id,
responseSchema: APPROVAL_SCHEMA,
...(event.expiresAt !== undefined ? { expiresAt: event.expiresAt } : {}),
metadata: {
'agora.toolName': toolName,
'agora.input': input,
'agora.approver': event.approver,
...(event.confirmation !== undefined
? { 'agora.confirmation': event.confirmation }
: {}),
...(event.target !== undefined ? { 'agora.target': event.target } : {}),
},
},
});
return [
this.custom(AG_UI_CUSTOM.approvalRequested, {
id: event.id,
runId: parked,
toolName,
input,
approver: event.approver,
...(event.target !== undefined ? { target: event.target } : {}),
...(event.confirmation !== undefined ? { confirmation: event.confirmation } : {}),
...(event.expiresAt !== undefined ? { expiresAt: event.expiresAt } : {}),
...(event.reason !== undefined ? { reason: event.reason } : {}),
}),
];
}
case 'elicitation': {
const parked = event.runId ?? this.options.streamRunId;
this.pending.set(event.id, {
kind: 'elicitation',
parked,
toolCallId: event.id,
interrupt: {
reason: 'input_required',
message: event.request.preamble ?? event.request.questions[0]?.prompt ?? 'Input required',
// An intake's question set belongs to no tool call the stream announced.
...(this.calls.has(event.id) ? { toolCallId: event.id } : {}),
responseSchema: elicitationSchema(event.request),
metadata: { 'agora.request': event.request },
},
});
return [
this.custom(AG_UI_CUSTOM.elicitation, {
id: event.id,
runId: parked,
request: event.request,
}),
];
}
case 'text':
return event.text.length > 0 ? this.textDelta(event.text) : [];
case 'reasoning':
return event.text.length > 0 ? this.reasoningDelta(event.text) : [];
case 'step-start': {
this.stepSeq += 1;
const stepName = `step-${this.stepSeq}`;
this.openSteps.push(stepName);
this.stepMessage = undefined;
return [...this.closeStreams(), { type: 'STEP_STARTED', stepName }];
}
case 'step-finish': {
const out = this.closeStreams();
if (!this.replaying)
this.addUsage(event.usage, event.model);
const stepName = this.openSteps.pop();
if (stepName !== undefined)
out.push({ type: 'STEP_FINISHED', stepName });
if (event.usage !== undefined || event.costUsd !== undefined) {
out.push(this.custom(AG_UI_CUSTOM.stepUsage, {
...(event.usage !== undefined ? { usage: event.usage } : {}),
...(event.costUsd !== undefined ? { costUsd: event.costUsd } : {}),
...(event.reasoningMs !== undefined ? { reasoningMs: event.reasoningMs } : {}),
...(typeof event.model === 'string' ? { model: event.model } : {}),
}));
}
return out;
}
case 'tool-input-start': {
if (this.calls.has(event.id))
return [];
const out = this.closeStreams();
this.calls.set(event.id, {
name: event.name,
started: true,
ended: false,
sawDelta: false,
answered: false,
});
out.push(this.callStart(event.id, event.name, event.toolKind));
return out;
}
case 'tool-input-delta': {
const call = this.calls.get(event.id);
if (call === undefined || call.ended || event.delta.length === 0)
return [];
call.sawDelta = true;
return [{ type: 'TOOL_CALL_ARGS', toolCallId: event.id, delta: event.delta }];
}
case 'tool-input-available': {
const known = this.calls.get(event.id);
if (known?.ended === true)
return [];
const out = this.closeStreams();
const call = known ?? {
name: event.name,
started: false,
ended: false,
sawDelta: false,
answered: false,
};
if (!call.started) {
call.started = true;
out.push(this.callStart(event.id, event.name, event.toolKind));
}
if (!call.sawDelta) {
out.push({
type: 'TOOL_CALL_ARGS',
toolCallId: event.id,
delta: text(event.input ?? {}),
});
}
call.ended = true;
call.input = event.input;
this.calls.set(event.id, call);
out.push({ type: 'TOOL_CALL_END', toolCallId: event.id });
return out;
}
case 'tool-output':
return this.result(event.id, text(event.output));
case 'tool-output-error':
return this.result(event.id, event.error, { 'agora.outcome': 'error' });
case 'tool-output-denied':
return this.result(event.id, event.reason ?? 'denied', { 'agora.outcome': 'denied' });
case 'approval-settled':
this.pending.delete(event.id);
return [this.custom(AG_UI_CUSTOM.approvalSettled, event)];
case 'title':
return [this.custom(AG_UI_CUSTOM.title, { title: event.title })];
case 'queue':
return [
this.custom(AG_UI_CUSTOM.queue, {
queue: event.queue,
...(event.started !== undefined ? { started: event.started } : {}),
}),
];
case 'cancelled':
this.cancelled = true;
return [];
default:
// A kind newer than this encoder: tolerated, as every reader of the stream must.
return [];
}
}
/**
* The events that end the AG-UI run: whatever is still open is closed first, then `RUN_FINISHED`
* with how it ended — interrupted when the run is waiting on someone, cancelled when it was
* stopped, success otherwise. Nothing after a `RUN_ERROR`. `ended` says the library run's stream
* itself ended, rather than this reader having stopped following it.
*/
finish(ended = false) {
if (this.closed)
return [];
this.closed = true;
if (!this.crossed)
this.crossBoundary();
// A library run that ended is waiting on no one, whatever it asked along the way.
if (ended)
this.pending.clear();
const out = this.start();
out.push(...this.closeStreams());
for (const [id, call] of this.calls) {
if (call.started && !call.ended) {
call.ended = true;
out.push({ type: 'TOOL_CALL_END', toolCallId: id });
}
}
while (this.openSteps.length > 0) {
out.push({ type: 'STEP_FINISHED', stepName: this.openSteps.pop() });
}
const usage = this.usageEntries();
const interrupts = this.cancelled ? [] : this.interrupts();
out.push({
type: 'RUN_FINISHED',
threadId: this.options.threadId,
runId: this.options.runId,
...(this.cancelled
? { outcome: { type: 'cancelled' } }
: interrupts.length > 0
? { outcome: { type: 'interrupt', interrupts } }
: {}),
...(usage.length > 0 ? { usage } : {}),
});
return out;
}
interrupts() {
return [...this.pending.values()].map((entry) => ({
id: encodeInterruptId({
kind: entry.kind,
parked: entry.parked,
stream: this.options.streamRunId,
toolCallId: entry.toolCallId,
position: this.position,
...(entry.proposalId !== undefined
? {
proposalId: entry.proposalId,
threadId: this.options.streamThreadId ?? this.options.threadId,
}
: {}),
}),
...entry.interrupt,
}));
}
custom(name, value) {
return { type: 'CUSTOM', name, value };
}
nextMessageId() {
this.messageSeq += 1;
return `${this.options.runId}:m${this.messageSeq}`;
}
textDelta(delta) {
const out = [];
if (this.openReasoning !== undefined)
out.push(...this.closeReasoning());
if (this.openText === undefined) {
this.openText = this.nextMessageId();
this.stepMessage = this.openText;
out.push({ type: 'TEXT_MESSAGE_START', messageId: this.openText, role: 'assistant' });
}
out.push({ type: 'TEXT_MESSAGE_CONTENT', messageId: this.openText, delta });
return out;
}
reasoningDelta(delta) {
const out = [];
if (this.openText !== undefined)
out.push(...this.closeText());
if (this.openReasoning === undefined) {
this.openReasoning = this.nextMessageId();
out.push({ type: 'REASONING_START', messageId: this.openReasoning }, { type: 'REASONING_MESSAGE_START', messageId: this.openReasoning, role: 'reasoning' });
}
out.push({ type: 'REASONING_MESSAGE_CONTENT', messageId: this.openReasoning, delta });
return out;
}
closeText() {
if (this.openText === undefined)
return [];
const messageId = this.openText;
this.openText = undefined;
return [{ type: 'TEXT_MESSAGE_END', messageId }];
}
closeReasoning() {
if (this.openReasoning === undefined)
return [];
const messageId = this.openReasoning;
this.openReasoning = undefined;
return [
{ type: 'REASONING_MESSAGE_END', messageId },
{ type: 'REASONING_END', messageId },
];
}
closeStreams() {
return [...this.closeReasoning(), ...this.closeText()];
}
callStart(id, name, kind) {
return {
type: 'TOOL_CALL_START',
toolCallId: id,
toolCallName: name,
...(this.stepMessage !== undefined ? { parentMessageId: this.stepMessage } : {}),
// Whether the call reads or acts — AG-UI has no field for it; a consumer of this family
// renders an action differently (and may be asked to approve it).
...(kind !== undefined ? { metadata: { 'agora.toolKind': kind } } : {}),
};
}
result(id, content, metadata) {
const call = this.calls.get(id);
if (call !== undefined)
call.answered = true;
else {
this.calls.set(id, {
name: '',
started: false,
ended: true,
sawDelta: false,
answered: true,
});
}
this.pending.delete(id);
return [
...this.closeStreams(),
{
type: 'TOOL_CALL_RESULT',
messageId: `${this.options.runId}:tool:${id}`,
toolCallId: id,
content,
role: 'tool',
...(metadata !== undefined ? { metadata } : {}),
},
];
}
addUsage(usage, model) {
if (usage === undefined)
return;
const name = typeof model === 'string' && model.length > 0 ? model : this.options.model;
const key = name ?? '';
const entry = this.usage.get(key) ?? (name !== undefined ? { model: name } : {});
// The library's accounting is already the protocol's: input and output are totals, cache and
// reasoning counts are parts of them. A count the provider did not report stays absent.
entry.inputTokens = (entry.inputTokens ?? 0) + usage.inputTokens;
entry.outputTokens = (entry.outputTokens ?? 0) + usage.outputTokens;
entry.totalTokens = entry.inputTokens + entry.outputTokens;
if (usage.cacheReadTokens !== undefined) {
entry.cachedInputTokens = (entry.cachedInputTokens ?? 0) + usage.cacheReadTokens;
}
if (usage.cacheWriteTokens !== undefined) {
entry.cacheWriteInputTokens = (entry.cacheWriteInputTokens ?? 0) + usage.cacheWriteTokens;
}
if (usage.reasoningTokens !== undefined) {
entry.reasoningTokens = (entry.reasoningTokens ?? 0) + usage.reasoningTokens;
}
this.usage.set(key, entry);
}
usageEntries() {
return [...this.usage.values()];
}
}
//# sourceMappingURL=encoder.js.map