UNPKG

@adonis-agora/agent

Version:

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

135 lines • 5.82 kB
import { normalizeAgentPath } from '../client.js'; import { readErrorResponse, reportHttpError, } from '../http-error.js'; import { streamChunks } from './tus.js'; /** * Resumable attachment uploads through `@adonis-agora/media`, in one line: * * ```tsx * <AgentProvider attachments={{ upload: mediaAttachments() }}> * ``` * * Reuses the client's own connection (origin, path, headers, credentials), so it needs no * configuration. Also takes `new AgentClient({ attachments: { upload: mediaAttachments() } })`. */ export function mediaAttachments(options = {}) { return (file, uploadOptions, connection) => createMediaUpload({ ...options, baseUrl: connection.baseUrl, path: connection.path, getHeaders: connection.headers, fetch: connection.fetch, ...(connection.credentials !== undefined ? { credentials: connection.credentials } : {}), ...(connection.onHttpError !== undefined ? { onHttpError: connection.onHttpError } : {}), })(file, uploadOptions); } /** * A non-2xx answer from the agent's upload routes. Carries what `AgentHttpError` does — `status` * (413 too large, 415 type refused, 404 not yours), the server's `message`, `code` and `body` — * as its own class because this subpath is bundled apart from the root entry, where an * `instanceof AgentHttpError` would not match a copy. Both satisfy `AgentRequestError`. */ export class MediaUploadError extends Error { status; method; path; body; code; constructor(status, method, path, statusText, answer = { body: undefined, message: undefined, code: undefined }) { super(answer.message ?? `Attachment upload failed: ${method} ${path} → ${status} ${statusText}`); this.status = status; this.method = method; this.path = path; this.name = 'MediaUploadError'; this.body = answer.body; this.code = answer.code; } } /** * A resumable `upload` for `useAttachments`, backed by `@adonis-agora/media` on the server * (`MediaAttachmentStaging` from `@adonis-agora/agent/media`): * * 1. `POST <path>/attachments/uploads` — the agent validates the file and opens a tus session the * actor owns; * 2. the bytes stream to the media adapter’s own tus endpoint in chunks (`streamChunks`), reporting * progress and honouring the abort signal; * 3. `POST <path>/attachments/uploads/:mediaId/complete` — the agent confirms the bytes landed and * answers the `MessageAttachment` the turn will reference by `mediaId`. * * An aborted or failed upload is discarded server-side (`DELETE`), best effort. */ export function createMediaUpload(options = {}) { const origin = (options.baseUrl ?? '').replace(/\/$/, ''); const base = `${origin}${normalizeAgentPath(options.path)}/attachments/uploads`; const baseFetch = () => options.fetch ?? fetch; // Every request — the agent's and media's tus PATCHes — rides the same credentials mode. const fetchWithCredentials = (input, init) => baseFetch()(input, { ...init, ...(options.credentials !== undefined ? { credentials: options.credentials } : {}), }); const headers = async () => ({ ...options.headers, ...((await options.getHeaders?.()) ?? {}), }); const discard = (mediaId) => { void (async () => { await fetchWithCredentials(`${base}/${encodeURIComponent(mediaId)}`, { method: 'DELETE', headers: await headers(), }); })().catch(() => undefined); }; const call = async (method, url, body) => { const response = await fetchWithCredentials(url, { method, headers: { accept: 'application/json', ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(await headers()), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); if (!response.ok) { const error = new MediaUploadError(response.status, method, url, response.statusText, await readErrorResponse(response)); reportHttpError(options.onHttpError, error); throw error; } return (await response.json()); }; return async (file, { signal, onProgress } = {}) => { signal?.throwIfAborted(); const begun = await call('POST', base, { filename: file.name, contentType: file.type, size: file.size, }); const onAbort = () => discard(begun.mediaId); signal?.addEventListener('abort', onAbort, { once: true }); try { const location = /^https?:\/\//.test(begun.location) ? begun.location : `${origin}${begun.location}`; await streamChunks(location, file, { resume: false, fetchImpl: fetchWithCredentials, getHeaders: headers, ...(options.chunkSize !== undefined ? { chunkSize: options.chunkSize } : {}), ...(options.retries !== undefined ? { retries: options.retries } : {}), ...(signal !== undefined ? { signal } : {}), onProgress: (sent, total) => onProgress?.(total > 0 ? sent / total : 1), }); signal?.throwIfAborted(); const attachment = await call('POST', `${base}/${encodeURIComponent(begun.mediaId)}/complete`); onProgress?.(1); return attachment; } catch (error) { if (!signal?.aborted) discard(begun.mediaId); throw error; } finally { signal?.removeEventListener('abort', onAbort); } }; } //# sourceMappingURL=index.js.map