UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

435 lines • 19.7 kB
import { readErrorAnswer, readErrorResponse, reportHttpError, } from './http-error.js'; /** * Thrown by {@link AgentClient} on a non-2xx response. Carries the HTTP `status` so callers can * branch (e.g. 403 → "not your thread", 429 → quota) instead of string-matching a generic Error, * and what the server said: `message` is the body's `message` when it sent one (so a hook that * shows `error.message` shows the server's words), `code` its machine-readable `code`, and `body` * the whole answer, parsed when it is JSON. */ export class AgentHttpError extends Error { status; method; path; /** The answer's body: parsed JSON, else its text; `undefined` when empty. */ body; /** The body's `code` (`quota_exceeded`, …), when the server sent one. */ code; constructor(status, method, path, statusText, answer = { body: undefined, message: undefined, code: undefined }) { super(answer.message ?? `Agent request failed: ${method} ${path} → ${status} ${statusText}`); this.status = status; this.method = method; this.path = path; this.name = 'AgentHttpError'; this.body = answer.body; this.code = answer.code; } /** Read a refused `response`'s body into an error. */ static async from(response, method, path) { return new AgentHttpError(response.status, method, path, response.statusText, await readErrorResponse(response)); } } /** `'/api/agent'` from `'api/agent'`, `'/api/agent/'`, …; `''` for an empty path. */ export function normalizeAgentPath(path) { const trimmed = (path ?? 'agent').replace(/^\/+|\/+$/g, ''); return trimmed === '' ? '' : `/${trimmed}`; } const HEADER_RUN_ID = 'x-agent-run-id'; const HEADER_THREAD_ID = 'x-agent-thread-id'; /** * Framework-agnostic REST client for the Adonis agent endpoints — the default {@link AgentBackend}. * Used by `useAgentChat`, but standalone-usable (vanilla fetch, no React). */ export class AgentClient { options; constructor(options = {}) { this.options = options; } /** `POST <path>/chat` → the turn's SSE stream. Throws {@link AgentHttpError} on a non-2xx. */ async openChatStream(request) { const response = await this.fetchImpl()(`${this.root()}/chat`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...(await this.resolveHeaders()), ...request.headers, }, body: JSON.stringify(request.body), ...this.credentials(), ...(request.signal !== undefined ? { signal: request.signal } : {}), }); if (response.status === 202) { // Queued behind a turn already running on the thread: JSON, not a stream. return { body: emptyStream(), queued: (await response.json()) }; } if (response.ok && response.headers.get('content-type')?.includes('application/json')) { const decision = (await response.json()); if (decision.proposalDecision !== undefined && typeof decision.threadId === 'string') return { body: emptyStream(), threadId: decision.threadId, proposalDecision: decision }; throw new Error('Unexpected JSON chat response'); } if (!response.ok || !response.body) { throw await this.failure(response, 'POST', `${this.agentPath()}/chat`); } return streamResponse(response); } /** * `POST <path>/chat` for a message that should wait in the thread's queue — the body carries * `mode: 'queue'` (or `'interrupt'`), which the server always answers with `202` JSON. */ enqueueMessage(request) { return this.request('POST', '/chat', { mode: 'queue', ...request.body, }); } /** `GET <path>/threads/:id/queue` — the thread's waiting messages, and whether it drains. */ getQueue(threadId) { return this.request('GET', `/threads/${encodeURIComponent(threadId)}/queue`); } /** `PATCH <path>/queue/:messageId` — change a waiting message's text/attachments, or move it. */ updateQueuedMessage(messageId, update) { return this.request('PATCH', `/queue/${encodeURIComponent(messageId)}`, update); } /** `DELETE <path>/queue/:messageId`. */ removeQueuedMessage(messageId) { return this.request('DELETE', `/queue/${encodeURIComponent(messageId)}`); } /** * `POST <path>/queue/:messageId/interrupt` — run a waiting message now, cancelling the running * turn for it. `runId` when nothing was running and it started; else `interrupting`. */ interruptQueuedMessage(messageId) { return this.request('POST', `/queue/${encodeURIComponent(messageId)}/interrupt`); } /** `DELETE <path>/threads/:id/queue` — drop every waiting message. */ clearQueue(threadId) { return this.request('DELETE', `/threads/${encodeURIComponent(threadId)}/queue`); } /** `POST <path>/threads/:id/queue/resume` — lift a pause; `runId` when the head started. */ resumeQueue(threadId) { return this.request('POST', `/threads/${encodeURIComponent(threadId)}/queue/resume`); } /** * `GET <path>/chat/:runId/stream[?after=<seq>]` → the run's SSE stream, or `null` when nothing is * streaming under that id (404). */ async resumeChatStream(request) { const path = `/chat/${encodeURIComponent(request.runId)}/stream`; const query = request.after !== undefined && request.after > 0 ? `?after=${request.after}` : ''; const response = await this.fetchImpl()(`${this.root()}${path}${query}`, { method: 'GET', headers: { accept: 'text/event-stream', ...(await this.resolveHeaders()), ...request.headers, }, ...this.credentials(), ...(request.signal !== undefined ? { signal: request.signal } : {}), }); if (response.status === 404) { return null; } if (!response.ok || !response.body) { throw await this.failure(response, 'GET', `${this.agentPath()}${path}`); } return streamResponse(response); } /** * Rate a message (`'up'`/`'down'`, optional comment) or clear its rating (`value: null`). Answers * the stored rating. */ setMessageFeedback(messageId, input) { return this.request('POST', `/messages/${encodeURIComponent(messageId)}/feedback`, input); } listThreads() { return this.request('GET', '/threads'); } /** * The skills this caller can invoke right now, scope-resolved — the same list, built by the same * call, that the model is offered, so what a user can type after a `/` and what the agent can * reach cannot drift apart. `threadId` reaches the host's own resolver, which may scope a skill to * one conversation; omitted, the server reads it as a brand-new thread. */ listSkills(threadId) { const query = threadId === undefined ? '' : `?threadId=${encodeURIComponent(threadId)}`; return this.request('GET', `/skills${query}`); } /** * The tools this caller can reach through `agent` (the default agent when omitted), each with the * server-declared `presentation` a chat narrates it by — the same list the model is offered. * Prefer {@link useToolCatalog}, which fetches it once and shares it. */ listTools(agent) { const query = agent === undefined ? '' : `?agent=${encodeURIComponent(agent)}`; return this.request('GET', `/tools${query}`); } getThread(id) { return this.request('GET', `/threads/${encodeURIComponent(id)}`); } deleteThread(id) { return this.request('DELETE', `/threads/${encodeURIComponent(id)}`); } forkFromMessage(threadId, messageId) { return this.request('POST', `/threads/${encodeURIComponent(threadId)}/fork-from/${encodeURIComponent(messageId)}`); } /** General `PATCH <path>/threads/:threadId` — title and/or the thread's pinned default agent. */ updateThread(id, patch) { return this.request('PATCH', `/threads/${encodeURIComponent(id)}`, patch); } /** * Uploads a file (image/PDF) for a vision-capable model turn. Multipart, field name `file` — * mirrors the backend's `POST <path>/attachments`. The returned {@link MessageAttachment} is * what a caller then rides on `sendMessage({ text }, { body: { attachments: [...] } })`. */ async uploadAttachment(file, options = {}) { const upload = this.options.attachments?.upload; if (upload !== undefined) { return upload(file, options, this.connection()); } // `fetch` cannot observe an upload's progress; XHR can. Only when someone is listening, and // never when a `fetch` was injected (tests, non-browser runtimes). if (options.onProgress !== undefined && this.options.fetch === undefined && typeof XMLHttpRequest !== 'undefined') { return this.uploadWithProgress(file, options); } const formData = new FormData(); formData.append('file', file); const response = await this.fetchImpl()(`${this.root()}/attachments`, { method: 'POST', headers: { accept: 'application/json', ...(await this.resolveHeaders()), }, body: formData, ...this.credentials(), ...(options.signal !== undefined ? { signal: options.signal } : {}), }); const attachment = await this.handleResponse(response, 'POST', `${this.agentPath()}/attachments`); options.onProgress?.(1); return attachment; } async uploadWithProgress(file, { signal, onProgress }) { const headers = { accept: 'application/json', ...(await this.resolveHeaders()), }; const url = `${this.root()}/attachments`; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url); for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value); // Same-origin requests carry cookies regardless; this is the cross-origin opt-in. xhr.withCredentials = this.options.credentials === 'include'; xhr.upload.onprogress = (event) => { if (event.lengthComputable && event.total > 0) onProgress?.(event.loaded / event.total); }; xhr.onload = () => { if (xhr.status < 200 || xhr.status >= 300) { const error = new AgentHttpError(xhr.status, 'POST', `${this.agentPath()}/attachments`, xhr.statusText, readErrorAnswer(xhr.responseText)); reportHttpError(this.options.onHttpError, error); reject(error); return; } onProgress?.(1); resolve(JSON.parse(xhr.responseText)); }; xhr.onerror = () => reject(new TypeError('Network error while uploading the attachment')); xhr.onabort = () => reject(new DOMException('The upload was aborted', 'AbortError')); if (signal !== undefined) { if (signal.aborted) { reject(new DOMException('The upload was aborted', 'AbortError')); return; } signal.addEventListener('abort', () => xhr.abort(), { once: true }); } const formData = new FormData(); formData.append('file', file); xhr.send(formData); }); } promoteThread(id) { return this.request('POST', `/threads/${encodeURIComponent(id)}/promote`); } truncateFromMessage(threadId, messageId) { return this.request('DELETE', `/threads/${encodeURIComponent(threadId)}/from/${encodeURIComponent(messageId)}`); } /** `GET <path>/models?agent=` — the models this caller may pick, grouped by provider. */ listModels(agent) { const query = agent === undefined ? '' : `?agent=${encodeURIComponent(agent)}`; return this.request('GET', `/models${query}`); } /** `GET <path>/agents` — the registered agents, the default one flagged. */ listAgents() { return this.request('GET', '/agents'); } /** `GET <path>/config` — attachment limits and upload mode, and which features are on. */ getConfig() { return this.request('GET', '/config'); } /** `GET <path>/quota` — the caller's budget windows and the one blocking sends, if any. */ getQuota() { return this.request('GET', '/quota'); } cancelStream(runId) { return this.request('POST', `/chat/${encodeURIComponent(runId)}/cancel`); } /** * `remember` approves later calls of the same tool in the same thread; `via` names the surface * the decision came through (the server records `'web'` when omitted). */ async listActionProposals({ threadId }) { const proposals = new Map(); let after; for (;;) { const route = `/threads/${encodeURIComponent(threadId)}/action-proposals${after === undefined ? '' : `?after=${encodeURIComponent(JSON.stringify(after))}`}`; const response = await this.requestResponse('GET', route); const page = await this.handleResponse(response, 'GET', `${this.agentPath()}${route}`); if (!Array.isArray(page)) throw new TypeError('Invalid action proposal page'); for (const proposal of page) proposals.set(proposal.id, proposal); const header = response.headers.get('X-Action-Proposals-Next'); if (header === null) return [...proposals.values()]; let next; try { next = JSON.parse(decodeURIComponent(header)); } catch { throw new TypeError('Invalid action proposal cursor'); } if (typeof next !== 'object' || next === null || !('createdAt' in next) || typeof next.createdAt !== 'number' || !Number.isSafeInteger(next.createdAt) || !('id' in next) || typeof next.id !== 'string' || next.id.length === 0 || next.id.length > 255 || (after !== undefined && (next.createdAt < after.createdAt || (next.createdAt === after.createdAt && next.id <= after.id)))) throw new TypeError('Invalid or non-advancing action proposal cursor'); after = { createdAt: next.createdAt, id: next.id }; } } approveActionProposal({ threadId, proposalId, remember, }) { return this.request('POST', `/threads/${encodeURIComponent(threadId)}/action-proposals/${encodeURIComponent(proposalId)}/approve`, remember === undefined ? {} : { remember }); } rejectActionProposal({ threadId, proposalId, reason, }) { return this.request('POST', `/threads/${encodeURIComponent(threadId)}/action-proposals/${encodeURIComponent(proposalId)}/reject`, reason === undefined ? {} : { reason }); } approveToolCall(input) { return this.request('POST', '/tool-call/approve', input); } rejectToolCall(input) { return this.request('POST', '/tool-call/reject', input); } /** * Settle a parked question set. `answers` is questionId → chosen option values; a question left * out takes the pre-picked default the request carried, resolved server-side against the request * the run already holds. Omit the whole object and the user has confirmed every pre-picked * answer — which is the point of the surface, so it is a valid submission rather than a blank. * `via` names the surface the answer came through (the server records `'web'` when omitted). */ answerToolCall(input) { return this.request('POST', '/tool-call/answer', input); } /** * Decline to answer and let the agent proceed on its own pre-picked values. Lands on the same * values a confirmation would, and persists differently on purpose — only one of them is * evidence the user chose them. */ skipToolCall(input) { return this.request('POST', '/tool-call/skip', input); } fetchImpl() { return this.options.fetch ?? globalThis.fetch; } /** This client's connection, for an {@link AttachmentUploadStrategy}. */ connection() { return { baseUrl: this.baseUrl(), path: this.agentPath(), headers: () => this.resolveHeaders(), fetch: this.fetchImpl(), ...this.credentials(), ...(this.options.onHttpError !== undefined ? { onHttpError: this.options.onHttpError } : {}), }; } baseUrl() { return (this.options.baseUrl ?? '').replace(/\/+$/, ''); } agentPath() { return normalizeAgentPath(this.options.path); } /** Origin + agent path: what every route hangs off. */ root() { return `${this.baseUrl()}${this.agentPath()}`; } async resolveHeaders() { const dynamic = (await this.options.getHeaders?.()) ?? {}; return { ...this.options.headers, ...dynamic }; } credentials() { return this.options.credentials !== undefined ? { credentials: this.options.credentials } : {}; } async request(method, route, body) { const response = await this.requestResponse(method, route, body); return this.handleResponse(response, method, `${this.agentPath()}${route}`); } async requestResponse(method, route, body) { const path = `${this.agentPath()}${route}`; const response = await this.fetchImpl()(`${this.baseUrl()}${path}`, { method, headers: { accept: 'application/json', ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(await this.resolveHeaders()), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), ...this.credentials(), }); return response; } /** The error for a refused `response`, already reported to `onHttpError`. */ async failure(response, method, path) { const error = await AgentHttpError.from(response, method, path); reportHttpError(this.options.onHttpError, error); return error; } async handleResponse(response, method, path) { if (!response.ok) { throw await this.failure(response, method, path); } if (response.status === 204) return undefined; const text = await response.text(); if (!text) return undefined; return JSON.parse(text); } } function emptyStream() { return new ReadableStream({ start(controller) { controller.close(); }, }); } function streamResponse(response) { const body = response.body; const runId = response.headers?.get(HEADER_RUN_ID) ?? undefined; const threadId = response.headers?.get(HEADER_THREAD_ID) ?? undefined; return { body, ...(runId ? { runId } : {}), ...(threadId ? { threadId } : {}), }; } //# sourceMappingURL=client.js.map