@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
364 lines • 14.9 kB
JavaScript
import { decodeFrame, foldPart, readSseStream, settleParts, } from './sse.js';
/**
* Thrown when a run's stream dropped and could not be resumed within the retry budget. Carries the
* partial parts received so far (and the ids) so a consumer can keep what streamed and surface a
* "couldn't finish, try again" state instead of losing the message.
*/
export class AgentChatDisconnectedError extends Error {
runId;
threadId;
parts;
constructor(runId, threadId, parts) {
super('The agent stream dropped and could not be resumed.');
this.name = 'AgentChatDisconnectedError';
this.runId = runId;
this.threadId = threadId;
this.parts = parts;
}
}
/**
* The server refused the request before any stream started — a non-2xx answer. `message` is the
* server's own words (`{ message, code? }`, the body every refusal answers), `code` its
* machine-readable reason (`quota_exceeded`, `model_not_allowed`, `thread_required`, …) and `body`
* the parsed answer, or `undefined` when it was not JSON.
*/
export class AgentChatHttpError extends Error {
status;
code;
body;
constructor(status, message, code, body) {
super(message);
this.name = 'AgentChatHttpError';
this.status = status;
this.code = code;
this.body = body;
}
}
/** Read a refused response into an {@link AgentChatHttpError}, keeping the server's words. */
async function httpError(response) {
let body;
try {
body = await response.json();
}
catch {
body = undefined;
}
const record = (typeof body === 'object' && body !== null ? body : {});
const message = typeof record.message === 'string' && record.message.length > 0
? record.message
: `Failed to start agent chat (HTTP ${response.status}).`;
return new AgentChatHttpError(response.status, message, typeof record.code === 'string' ? record.code : undefined, body);
}
/**
* The server ended the run with `event: error` (agent protocol). Not a dropped connection: the run
* is over, so it is surfaced rather than re-attached. `code` is the server's (`quota_exceeded`,
* `output_rejected`, `structured_output_invalid`, `run_failed`).
*/
export class AgentChatStreamError extends Error {
code;
constructor(code, message) {
super(message);
this.name = 'AgentChatStreamError';
this.code = code;
}
}
const DEFAULT_MAX_ATTEMPTS = 6;
function defaultBackoff(attempt) {
return Math.min(400 * attempt, 3000);
}
/** An `AbortError`-shaped error without depending on `DOMException` (kept Node/browser portable). */
function abortError() {
const error = new Error('Aborted');
error.name = 'AbortError';
return error;
}
function delay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(abortError());
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(abortError());
}, { once: true });
});
}
/**
* `promise`, or an `AbortError` as soon as `signal` aborts — even when whatever produced the promise
* never looks at the signal. A host `fetch` may await something of its own before it reaches the
* network (a token refresh, a session read); aborting must still settle the call, or `send` stays
* pending forever and a "Stop" button cannot stop it.
*/
function untilAborted(promise, signal) {
if (!signal)
return promise;
if (signal.aborted)
return Promise.reject(abortError());
let onAbort;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(abortError());
signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([promise, aborted]).finally(() => {
if (onAbort)
signal.removeEventListener('abort', onAbort);
});
}
export function createAgentChatClient(options = {}) {
const basePath = (options.basePath ?? '/agent').replace(/\/$/, '');
const rawFetch = options.fetch ?? globalThis.fetch;
// Every request honours its `signal` end to end, whatever the injected `fetch` does with it.
const fetchImpl = (input, init) => untilAborted(rawFetch(input, init), init.signal);
const resumeCfg = options.resume;
const maxAttempts = resumeCfg === false ? 0 : (resumeCfg?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
const backoff = (resumeCfg === false ? undefined : resumeCfg?.backoffMs) ?? defaultBackoff;
function headers(base) {
return { ...base, ...(options.getHeaders?.() ?? {}) };
}
/**
* Drains one SSE stream, folding renderable frames into a fresh parts array (a re-attach replays
* from the start, so rebuilding from `[]` yields the complete message without duplicating). Returns
* whether the stream ended with `done` (the run finished) vs. was cut short.
*/
async function consume(body, sink) {
let parts = [];
for await (const event of readSseStream(body)) {
const frame = decodeFrame(event);
if (!frame) {
continue;
}
sink.handlers.onFrame?.(frame);
if (frame.type === 'done') {
// A preview its call never replaced (the run was cancelled mid-layout) is not the answer.
const settled = settleParts(parts);
if (settled !== parts)
sink.onParts(settled);
return true;
}
if (frame.type === 'error') {
throw new AgentChatStreamError(frame.code, frame.message);
}
if (frame.type === 'meta') {
if (frame.runId) {
sink.handlers.onRunId?.(frame.runId);
}
if (frame.threadId) {
sink.handlers.onThreadId?.(frame.threadId);
}
continue;
}
parts = foldPart(parts, frame);
sink.onParts(parts);
}
return false;
}
/**
* Runs the re-attach loop from a known `runId` until the run emits `done` or the retry budget is
* exhausted. Shared by `send` (after the POST drops) and `resume` (from a cold start).
*/
async function reattach(runId, signal, sink) {
let attempt = 0;
let sawDone = false;
while (!sawDone && !signal?.aborted && attempt < maxAttempts) {
attempt += 1;
await delay(backoff(attempt), signal);
try {
const response = await fetchImpl(`${basePath}/chat/${runId}/stream`, {
method: 'GET',
credentials: 'include',
...(signal ? { signal } : {}),
headers: headers({ Accept: 'text/event-stream' }),
});
if (!response.ok || !response.body) {
continue;
}
attempt = 0; // Reconnected: reset the budget; only the next drop counts again.
sawDone = await consume(response.body, sink);
}
catch (error) {
if (signal?.aborted || error instanceof AgentChatStreamError) {
throw error;
}
// Dropped again — loop retries until the budget is spent.
}
}
return sawDone;
}
async function send(sendOptions) {
const { body, signal } = sendOptions;
let runId;
let threadId;
let lastParts = [];
const handlers = {
...sendOptions,
onRunId: (id) => {
runId = id;
sendOptions.onRunId?.(id);
},
onThreadId: (id) => {
threadId = id;
sendOptions.onThreadId?.(id);
},
};
const onParts = (parts) => {
lastParts = parts;
sendOptions.onParts?.(parts);
};
const sink = { onParts, handlers };
const response = await fetchImpl(`${basePath}/chat`, {
method: 'POST',
credentials: 'include',
...(signal ? { signal } : {}),
headers: headers({ 'Content-Type': 'application/json', Accept: 'text/event-stream' }),
body: JSON.stringify(body),
});
if (!response.ok) {
throw await httpError(response);
}
if (response.status === 202) {
// The thread already has a turn running: the message waits in its queue, and nothing streams.
const queued = (await response.json());
if (typeof queued.threadId === 'string') {
sendOptions.onThreadId?.(queued.threadId);
}
return {
parts: [],
queued,
...(typeof queued.threadId === 'string' ? { threadId: queued.threadId } : {}),
...(typeof queued.runId === 'string' ? { runId: queued.runId } : {}),
};
}
if (response.headers.get('Content-Type')?.includes('application/json')) {
const receipt = (await response.json());
if (typeof receipt.threadId !== 'string' ||
typeof receipt.text !== 'string' ||
!receipt.proposalDecision)
throw new Error('Invalid proposal decision receipt');
handlers.onThreadId?.(receipt.threadId);
const parts = receipt.text ? [{ type: 'text', text: receipt.text }] : [];
onParts(parts);
return { threadId: receipt.threadId, proposalDecision: receipt.proposalDecision, parts };
}
if (!response.body) {
throw new Error(`Failed to start agent chat (HTTP ${response.status}): no response body.`);
}
// The provider sets these before any byte; capture the runId now so a mid-stream drop (before the
// `meta` frame) can still re-attach.
const headerRunId = response.headers.get('X-Agent-Run-Id');
if (headerRunId) {
runId = headerRunId;
sendOptions.onRunId?.(headerRunId);
}
const headerThreadId = response.headers.get('X-Agent-Thread-Id');
if (headerThreadId) {
threadId = headerThreadId;
sendOptions.onThreadId?.(headerThreadId);
}
let sawDone;
try {
sawDone = await consume(response.body, sink);
}
catch (error) {
if (signal?.aborted || error instanceof AgentChatStreamError) {
throw error;
}
sawDone = false; // Fall through to the re-attach loop instead of surfacing the drop as an error.
}
if (!sawDone && runId) {
sawDone = await reattach(runId, signal, sink);
}
if (!sawDone) {
throw new AgentChatDisconnectedError(runId, threadId, lastParts);
}
return {
parts: lastParts,
...(runId !== undefined ? { runId } : {}),
...(threadId !== undefined ? { threadId } : {}),
};
}
async function resume(runId, resumeOptions = {}) {
let threadId;
let lastParts = [];
const handlers = {
...resumeOptions,
onThreadId: (id) => {
threadId = id;
resumeOptions.onThreadId?.(id);
},
};
const onParts = (parts) => {
lastParts = parts;
resumeOptions.onParts?.(parts);
};
const sawDone = await reattach(runId, resumeOptions.signal, { onParts, handlers });
if (!sawDone) {
throw new AgentChatDisconnectedError(runId, threadId, lastParts);
}
return {
runId,
parts: lastParts,
...(threadId !== undefined ? { threadId } : {}),
};
}
async function proposalRequest(path, init) {
const response = await fetchImpl(`${basePath}/threads/${path}`, {
...init,
credentials: 'include',
headers: headers({ 'Content-Type': 'application/json' }),
});
if (!response.ok)
throw await httpError(response);
return (await response.json());
}
return {
send,
resume,
listActionProposals: async (threadId, signal) => {
const items = [];
const seen = new Set();
let cursor;
for (let page = 0; page < 1000; page++) {
const response = await fetchImpl(`${basePath}/threads/${encodeURIComponent(threadId)}/action-proposals${cursor ? `?after=${encodeURIComponent(cursor)}` : ''}`, {
method: 'GET',
credentials: 'include',
headers: headers({}),
...(signal ? { signal } : {}),
});
if (!response.ok)
throw await httpError(response);
const rows = await response.json();
if (!Array.isArray(rows))
throw new Error('Invalid action proposal list');
items.push(...rows);
const next = response.headers.get('X-Action-Proposals-Next');
if (!next)
return items;
let parsed;
try {
cursor = decodeURIComponent(next);
parsed = JSON.parse(cursor);
if (!parsed ||
typeof parsed !== 'object' ||
Object.keys(parsed).some((key) => key !== 'createdAt' && key !== 'id') ||
!Number.isSafeInteger(parsed.createdAt) ||
typeof parsed.id !== 'string' ||
!parsed.id ||
parsed.id.length > 255)
throw new Error('cursor');
}
catch {
throw new Error('Invalid action proposal cursor');
}
const fingerprint = JSON.stringify([parsed.createdAt, parsed.id]);
if (seen.has(fingerprint))
throw new Error('Repeated action proposal cursor');
seen.add(fingerprint);
}
throw new Error('Action proposal pagination limit exceeded');
},
decideActionProposal: (threadId, proposalId, decision, command = {}, signal) => proposalRequest(`${encodeURIComponent(threadId)}/action-proposals/${encodeURIComponent(proposalId)}/${decision === 'approved' ? 'approve' : 'reject'}`, { method: 'POST', body: JSON.stringify(command), ...(signal ? { signal } : {}) }),
};
}
//# sourceMappingURL=chat-client.js.map