@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
321 lines • 14.2 kB
JavaScript
import { randomUUID } from 'node:crypto';
/** A refusal of an upload request, carrying the HTTP status the route answers with. */
export class MediaUploadRefusedError extends Error {
status;
constructor(status, message) {
super(message);
this.status = status;
this.name = 'MediaUploadRefusedError';
}
}
export const DEFAULT_AGENT_MEDIA_COLLECTION = 'agent-attachments';
export const DEFAULT_AGENT_MEDIA_OWNER_TYPE = 'agent-actor';
export const DEFAULT_AGENT_MEDIA_TUS_BASE_PATH = '/media/uploads/tus';
const DEFAULT_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024;
const DEFAULT_ALLOWED_CONTENT_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/csv',
];
/**
* Chat attachments stored as media rows in their own collection, one owner per actor, on any Drive
* disk — S3, GCS, R2, the local filesystem. Uploads land at
* `<ownerType>/<actor>/<collection>/<id>/<file>`; a send names them by id and {@link resolve} mints
* the url the model fetches: the caller's own `resolveUrl`, a public url, a signed url, or — on a
* disk that cannot sign — the bytes inline as a `data:` url (logged once: fine for dev, not for
* production).
*/
export class MediaAttachmentStaging {
deps;
options;
collection;
ownerType;
maxBytes;
allowed;
now;
newId;
warnedInline = false;
constructor(deps, options = {}) {
this.deps = deps;
this.options = options;
this.collection = options.collection ?? DEFAULT_AGENT_MEDIA_COLLECTION;
this.ownerType = options.ownerType ?? DEFAULT_AGENT_MEDIA_OWNER_TYPE;
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.allowed = options.allowedContentTypes ?? DEFAULT_ALLOWED_CONTENT_TYPES;
this.now = options.clock ?? (() => new Date());
this.newId = options.idGenerator ?? (() => randomUUID());
}
describe() {
return {
maxBytes: this.maxBytes,
allowedContentTypes: this.allowed,
upload: this.deps.uploads ? 'resumable' : 'multipart',
};
}
/**
* Open a tus session for one file, owned by `actor`. Everything is validated here, before any
* byte moves. The record exists from now on, `pending` until {@link completeUpload} sees the bytes.
*/
async beginUpload(input) {
const uploads = this.deps.uploads;
if (!uploads) {
throw new MediaUploadRefusedError(501, 'Resumable attachment uploads need `uploads.resumable` in config/media.ts.');
}
this.validate(input.filename, input.contentType, input.size);
const id = this.newId();
const fileName = safeFileName(input.filename);
const path = this.pathFor(input.actor, id, fileName);
const session = await uploads.createUpload({
disk: this.diskName,
key: path,
size: input.size,
contentType: input.contentType,
metadata: { agentMediaId: id },
});
await this.saveRecord({
id,
actor: input.actor,
fileName,
mimeType: input.contentType,
size: input.size,
path,
state: { status: 'pending', uploadId: session.id },
});
const base = (this.options.tusBasePath ?? DEFAULT_AGENT_MEDIA_TUS_BASE_PATH).replace(/\/+$/, '');
return { mediaId: id, uploadId: session.id, location: `${base}/${session.id}` };
}
/**
* Confirm the bytes of a {@link beginUpload} landed, and whole. `null` for an unknown id or one
* that is not `actor`'s; `409` while the bytes are still missing; `422` (and the record dropped)
* when what arrived is not the size that was declared. Idempotent on a ready record.
*/
async completeUpload(input) {
const record = await this.ownRecord(input.mediaId, input.actor);
if (record === null)
return null;
const ready = await this.ensureReady(record);
if (ready === 'missing') {
throw new MediaUploadRefusedError(409, `upload ${input.mediaId} has not finished`);
}
if (ready === 'mismatch') {
await this.remove(record.id);
throw new MediaUploadRefusedError(422, `upload ${input.mediaId} does not match its declared size`);
}
return this.toAttachment(ready, { forModel: false });
}
/** The actor drops one of their own attachments — aborting its upload if still in flight. */
async discard(input) {
const record = await this.ownRecord(input.mediaId, input.actor);
if (record === null)
return false;
await this.remove(record.id);
return true;
}
get diskName() {
return this.options.disk ?? this.deps.storage.defaultDisk;
}
async stage(input) {
const id = this.newId();
const fileName = safeFileName(input.filename);
const path = this.pathFor(input.actor, id, fileName);
await this.deps.storage
.disk(this.diskName)
.put(path, input.data, { contentType: input.contentType });
const record = await this.saveRecord({
id,
actor: input.actor,
fileName,
mimeType: input.contentType,
size: input.sizeBytes,
path,
state: { status: 'ready' },
});
return this.toAttachment(record, { forModel: false });
}
/**
* The attachment for `mediaId`, or `null` when it is unknown or this actor may not use it. The
* actor may use media they own, and media a message in one of THEIR threads already carries (a
* fork, a regenerate) — never anything else, unless `canAccess` says so.
*/
async resolve(input) {
const record = await this.deps.store.find(input.mediaId);
if (record === null || !this.isOurs(record))
return null;
if (!(await this.canUse(record, input.actor)))
return null;
// A resumable upload whose bytes have not (fully) arrived is not attachable yet.
const ready = await this.ensureReady(record);
if (typeof ready === 'string')
return null;
return this.toAttachment(ready, { forModel: true });
}
/**
* The actor's staged attachments, newest first: their own media rows in this store's collection —
* sent or not, and a resumable upload still in flight too (it is theirs, and it is staged). Metadata
* only; a url is only ever minted by {@link resolve}.
*/
async list(input) {
const listByOwner = this.deps.store.listByOwner?.bind(this.deps.store);
if (listByOwner === undefined) {
throw new MediaUploadRefusedError(501, 'Listing attachments needs a media store that can enumerate an owner (listByOwner) — upgrade @adonis-agora/media.');
}
const records = await listByOwner(this.ownerType, input.actor.id, this.collection);
const entries = records
.filter((record) => this.isOurs(record) && record.ownerId === input.actor.id)
.map((record) => ({
mediaId: record.id,
name: record.fileName,
contentType: record.mimeType,
sizeBytes: record.size,
createdAt: new Date(record.createdAt).toISOString(),
}))
.filter((entry) => input.stagedBefore === undefined || entry.createdAt < input.stagedBefore)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return input.limit === undefined ? entries : entries.slice(0, input.limit);
}
async canUse(record, actor) {
const allowed = await this.defaultAccess(record, actor);
const custom = this.options.canAccess;
return custom === undefined ? allowed : (await custom({ record, actor, allowed })) === true;
}
async defaultAccess(record, actor) {
if (record.ownerId === actor.id)
return true;
const referenced = this.deps.agentStore?.referencedMediaIds?.bind(this.deps.agentStore);
if (referenced === undefined)
return false;
return (await referenced(actor.id, [record.id])).includes(record.id);
}
/** Delete an upload and its row. */
async remove(mediaId) {
const record = await this.deps.store.find(mediaId);
if (record === null || !this.isOurs(record))
return;
const state = stateOf(record);
if (state.status === 'pending' && state.uploadId !== undefined) {
await this.deps.uploads?.abort(state.uploadId).catch(() => undefined);
}
await this.deps.storage
.disk(record.disk)
.delete(record.path)
.catch(() => undefined);
await this.deps.store.delete(record.id);
}
validate(filename, contentType, size) {
if (typeof filename !== 'string' || filename.trim().length === 0) {
throw new MediaUploadRefusedError(400, 'filename is required');
}
if (typeof contentType !== 'string' || !this.allowed.includes(contentType)) {
throw new MediaUploadRefusedError(415, `content type "${String(contentType)}" is not allowed (allowed: ${this.allowed.join(', ')})`);
}
if (typeof size !== 'number' || !Number.isInteger(size) || size <= 0) {
throw new MediaUploadRefusedError(400, 'size must be a positive integer byte count');
}
if (size > this.maxBytes) {
throw new MediaUploadRefusedError(413, `file exceeds the ${this.maxBytes}-byte limit`);
}
}
pathFor(actor, id, fileName) {
return `${this.ownerType}/${encodeURIComponent(actor.id)}/${this.collection}/${id}/${fileName}`;
}
async saveRecord(input) {
const timestamp = this.now();
return this.deps.store.save({
id: input.id,
ownerType: this.ownerType,
ownerId: input.actor.id,
collection: this.collection,
name: stripExtension(input.fileName),
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
disk: this.diskName,
path: input.path,
order: await this.deps.store.nextOrder(this.ownerType, input.actor.id, this.collection),
// A multipart upload is ready the moment it is stored, and carries no state — the same row
// shape as before resumable uploads existed.
customProperties: input.state.status === 'ready' ? {} : { agentAttachment: input.state },
conversions: {},
createdAt: timestamp,
updatedAt: timestamp,
});
}
async ownRecord(mediaId, actor) {
const record = await this.deps.store.find(mediaId);
if (record === null || !this.isOurs(record) || record.ownerId !== actor.id)
return null;
return record;
}
/**
* A ready record as-is; a pending one promoted to ready once its bytes are on the disk at exactly
* the declared size. `'missing'` while they are not; `'mismatch'` when they arrived at another size.
*/
async ensureReady(record) {
if (stateOf(record).status === 'ready')
return record;
const disk = this.deps.storage.disk(record.disk);
if (disk.exists === undefined || disk.getMetaData === undefined)
return 'missing';
if (!(await disk.exists(record.path)))
return 'missing';
const { contentLength } = await disk.getMetaData(record.path);
if (contentLength !== record.size || contentLength > this.maxBytes)
return 'mismatch';
const { agentAttachment: _done, ...customProperties } = record.customProperties;
return this.deps.store.save({ ...record, customProperties, updatedAt: this.now() });
}
isOurs(record) {
return record.ownerType === this.ownerType && record.collection === this.collection;
}
async toAttachment(record, { forModel }) {
const base = { mediaId: record.id, contentType: record.mimeType, name: record.fileName };
const disk = this.deps.storage.disk(record.disk);
if (this.options.resolveUrl !== undefined) {
return { ...base, url: await this.options.resolveUrl(record, { disk }) };
}
if (this.options.visibility === 'public') {
return { ...base, url: await disk.getUrl(record.path) };
}
try {
const url = await disk.getSignedUrl(record.path, {
expiresIn: this.options.urlExpiresInSeconds ?? DEFAULT_URL_TTL_SECONDS,
contentType: record.mimeType,
});
return { ...base, url };
}
catch {
// A disk that cannot sign (the local filesystem without a signing route).
}
if (!forModel)
return { ...base, url: '' };
if (!this.warnedInline) {
this.warnedInline = true;
console.warn(`[-agora/agent] disk "${record.disk}" cannot sign urls: attachments reach the model inline (data: urls, persisted with each message). Use a signing disk or resolveUrl in production.`);
}
const bytes = await disk.getBytes(record.path);
return {
...base,
url: `data:${record.mimeType};base64,${Buffer.from(bytes).toString('base64')}`,
};
}
}
function stateOf(record) {
const state = record.customProperties.agentAttachment;
return state ?? { status: 'ready' };
}
function safeFileName(filename) {
const last = filename.split(/[\\/]/).pop() ?? '';
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point.
const cleaned = last.replace(/[\u0000-\u001f\u007f]/g, '').trim();
return cleaned === '' || cleaned === '.' || cleaned === '..' ? 'file' : cleaned;
}
function stripExtension(fileName) {
const dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.slice(0, dot) : fileName;
}
//# sourceMappingURL=media-attachment-staging.js.map