@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
273 lines • 11.8 kB
JavaScript
/**
* Client-side counterpart to {@link import('../sse.js').AgentSseEncoder}: parses the provider's SSE
* back into typed frames the browser can render. Kept framework-agnostic (no React, no Adonis) so
* any consumer — a React hook, a Vue composable, a plain fetch loop — decodes the wire format the
* same way, and so the envelope stays owned by the package that emits it.
*
* Wire format — the agent stream protocol shared with `@dudousxd/nestjs-agent`:
* - `event: meta\ndata: {runId,threadId}` — sent once, first, before any token
* - `id: <seq>\ndata: <AgentStreamEvent>` — one event per frame (`{"kind":"text","text":…}`,
* `ui`, `elicitation`, `approval-requested`, reasoning, tool calls, steps, title, …)
* - `event: error\ndata: {code,message}` — the run failed (terminal)
* - `event: done\ndata: {}` — the run's stream finished
*/
/**
* Parses one raw SSE frame (the text between `\n\n` separators, without them) into `{event, data}`.
* A frame with no `data:` line (e.g. a `:` keep-alive comment) returns `null`.
*/
export function parseSseEvent(frame) {
let event = 'message';
let id;
const dataLines = [];
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice('event:'.length).trim();
}
else if (line.startsWith('data:')) {
dataLines.push(line.slice('data:'.length).trim());
}
else if (line.startsWith('id:')) {
id = line.slice('id:'.length).trim();
}
}
if (dataLines.length === 0) {
return null;
}
return { event, data: dataLines.join('\n'), ...(id !== undefined ? { id } : {}) };
}
/**
* Decodes a parsed {@link SseEvent} into a typed {@link ChatFrame}, or `null` when the payload is
* malformed (non-JSON, or missing required fields) so callers can skip it without crashing.
*/
export function decodeFrame(event) {
if (event.event === 'done') {
return { type: 'done' };
}
if (event.event === 'meta') {
try {
const parsed = JSON.parse(event.data);
return {
type: 'meta',
...(typeof parsed?.runId === 'string' ? { runId: parsed.runId } : {}),
...(typeof parsed?.threadId === 'string' ? { threadId: parsed.threadId } : {}),
};
}
catch {
return null;
}
}
if (event.event === 'error') {
try {
const parsed = JSON.parse(event.data);
return {
type: 'error',
code: typeof parsed?.code === 'string' ? parsed.code : 'run_failed',
message: typeof parsed?.message === 'string' ? parsed.message : 'The run failed.',
};
}
catch {
return { type: 'error', code: 'run_failed', message: 'The run failed.' };
}
}
// Default event: one `AgentStreamEvent` (`{ kind, … }`).
try {
const parsed = JSON.parse(event.data);
if (typeof parsed?.kind !== 'string') {
return null;
}
return decodeAgentEvent(parsed);
}
catch {
return null;
}
}
/**
* One frame of the agent stream protocol as a {@link ChatFrame}. The kinds this client folds into a
* message or acts on (text, ui, elicitation, approval-requested) get their own frame; every other
* kind (reasoning, tool calls, steps, title, …) arrives as `{ type: 'event' }`, which
* {@link foldPart} ignores — but for a `step-start`, see there — and `onFrame` still sees.
*/
function decodeAgentEvent(event) {
if (event.kind === 'text') {
return typeof event.text === 'string' && event.text.length > 0
? { type: 'text', delta: event.text }
: null;
}
if (event.kind === 'ui' && typeof event.component === 'string') {
return {
type: 'component',
name: event.component,
data: event.props,
...(typeof event.id === 'string' ? { id: event.id } : {}),
...(typeof event.version === 'number' ? { version: event.version } : {}),
...(typeof event.fallbackText === 'string' ? { fallbackText: event.fallbackText } : {}),
...(event.componentVersions !== null &&
typeof event.componentVersions === 'object' &&
!Array.isArray(event.componentVersions)
? { componentVersions: event.componentVersions }
: {}),
...(event.partial === true ? { partial: true } : {}),
};
}
if (event.kind === 'elicitation' && typeof event.id === 'string') {
if (typeof event.runId !== 'string')
return { type: 'event', event };
return {
type: 'elicitation',
runId: event.runId,
toolCallId: event.id,
request: event.request,
};
}
if (event.kind === 'approval-requested' &&
typeof event.id === 'string' &&
typeof event.runId === 'string' &&
typeof event.toolName === 'string') {
const confirmation = decodeConfirmation(event.confirmation);
return {
type: 'approval',
...(event.target &&
typeof event.target === 'object' &&
Reflect.get(event.target, 'kind') === 'proposal' &&
typeof Reflect.get(event.target, 'proposalId') === 'string'
? {
target: {
kind: 'proposal',
proposalId: Reflect.get(event.target, 'proposalId'),
},
}
: {}),
runId: event.runId,
toolCallId: event.id,
toolName: event.toolName,
input: event.input,
...(confirmation !== undefined ? { confirmation } : {}),
...(typeof event.approver === 'string' ? { approver: event.approver } : {}),
...(typeof event.expiresAt === 'string' ? { expiresAt: event.expiresAt } : {}),
...(typeof event.reason === 'string' ? { reason: event.reason } : {}),
};
}
return { type: 'event', event };
}
/**
* Folds a renderable {@link ChatFrame} (text or component) into the message's parts, concatenating
* consecutive text deltas into the trailing text part and appending components in order. A
* `step-start` closes the text before it with a paragraph break ({@link STEP_SEPARATOR}), so two
* model steps never run into each other.
* `meta`/`elicitation`/`approval`/`done` are control frames and are ignored here — a form to put to
* the user is not a part of the assistant's message. Returns a new array (never mutates the input).
*/
export function foldPart(parts, frame) {
if (frame.type === 'component') {
const part = {
type: 'component',
name: frame.name,
data: frame.data,
...(frame.id !== undefined ? { id: frame.id } : {}),
...(frame.version !== undefined ? { version: frame.version } : {}),
...(frame.fallbackText !== undefined ? { fallbackText: frame.fallbackText } : {}),
...(frame.componentVersions !== undefined
? { componentVersions: frame.componentVersions }
: {}),
...(frame.partial === true ? { partial: true } : {}),
};
// A repeat id (agent protocol `ui`) replaces the component in place, never adds a second one.
const at = frame.id === undefined
? -1
: parts.findIndex((each) => each.type === 'component' && each.id === frame.id);
// A withdrawn preview (partial, nothing to draw) removes the component it previewed.
if (frame.partial === true && isEmptyRecord(frame.data)) {
return at === -1 ? parts : parts.filter((_, index) => index !== at);
}
return at === -1 ? [...parts, part] : parts.map((each, index) => (index === at ? part : each));
}
if (frame.type === 'text') {
const last = parts[parts.length - 1];
if (last && last.type === 'text') {
return [...parts.slice(0, -1), { type: 'text', text: last.text + frame.delta }];
}
return [...parts, { type: 'text', text: frame.delta }];
}
if (frame.type === 'event' && frame.event.kind === 'step-start') {
// A new model step. What it says is a new paragraph, not the continuation of a sentence: the
// step before it ended to call tools, so its text stops wherever the model stopped ("…before
// answering.") and the next one would otherwise be glued to it ("…before answering.Let me…").
// Only text the message already ends with is closed — a step that opens after a component, or
// first, has nothing to be separated from.
const last = parts[parts.length - 1];
if (last &&
last.type === 'text' &&
last.text.trim().length > 0 &&
!last.text.endsWith('\n\n')) {
return [...parts.slice(0, -1), { type: 'text', text: `${last.text}${STEP_SEPARATOR}` }];
}
}
return parts;
}
/** What separates the text of two consecutive model steps in one message: a paragraph break. */
export const STEP_SEPARATOR = '\n\n';
/**
* Reads a byte {@link ReadableStream} (a `fetch` `response.body`) as a sequence of raw SSE events,
* splitting on the `\n\n` frame separator and buffering partial frames across chunks. Yields only
* frames that carry a `data:` payload (keep-alives are dropped).
*/
export async function* readSseStream(body) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
for (;;) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex !== -1) {
const frame = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
const parsed = parseSseEvent(frame);
if (parsed) {
yield parsed;
}
separatorIndex = buffer.indexOf('\n\n');
}
}
}
finally {
reader.releaseLock();
}
}
/** Optional presentation metadata is accepted only when every supplied field has its declared type. */
function decodeConfirmation(value) {
if (typeof value !== 'object' ||
value === null ||
!('title' in value) ||
typeof value.title !== 'string' ||
!('verb' in value) ||
typeof value.verb !== 'string')
return undefined;
if ('detail' in value && typeof value.detail !== 'string')
return undefined;
if ('link' in value && typeof value.link !== 'string')
return undefined;
return {
title: value.title,
verb: value.verb,
...('detail' in value && typeof value.detail === 'string' ? { detail: value.detail } : {}),
...('link' in value && typeof value.link === 'string' ? { link: value.link } : {}),
};
}
function isEmptyRecord(value) {
return (typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0);
}
/** The parts with every preview left standing removed — what a finished stream keeps. */
export function settleParts(parts) {
return parts.some((part) => part.type === 'component' && part.partial === true)
? parts.filter((part) => !(part.type === 'component' && part.partial === true))
: parts;
}
//# sourceMappingURL=sse.js.map