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