UNPKG

@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
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