UNPKG

@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
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(`[@adonis-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