import type { Message, MessageAttachment } from '../../types/message/index.js' /** * Where an attachment's bytes live when the message does not carry them. * * Every attachment was inline base64 on the message. That is fine for one * screenshot and wrong for everything else it implies: the bytes are copied * into the turn's durable transcript, into every checkpoint, into every * compaction pass that walks the history, and — because a conversation * resends its history — into every subsequent request. A 4 MB PDF attached * once is 4 MB in the transcript and 4 MB on the wire per turn for the rest * of the turn. * * So a message may carry a REFERENCE instead. The kernel treats `ref` as * opaque: this seam says nothing about whether it is a hash, a path, or a * URL, because the store that minted it is the only thing that can answer. * A content-addressed store gets deduplication for free and this interface * neither requires nor prevents that. */ /** An attachment whose bytes are held by a store. */ export interface StoredAttachment { readonly type: 'stored' /** * Opaque to the kernel, meaningful to the store that minted it. * * Never parsed here. A ref that the kernel could interpret is a ref the * kernel could construct, and a model-authored one would then be a path * into whatever the store can reach. */ readonly ref: string /** * What the provider is told this is. * * Declared on the message rather than read from the store, because it * decides which content block gets built and that decision has to be * makeable without a round trip. `resolveAttachment` checks it against * what the store reports and refuses a mismatch. */ readonly mediaType: string /** Which kind of block to build once the bytes arrive. */ readonly kind: 'image' | 'document' /** Shown to the model, for a document it can refer to by name. */ readonly name?: string /** See {@link DocumentAttachment.citations}. Ignored for an image. */ readonly citations?: boolean } export interface StoredBytes { readonly data: string readonly mediaType: string } /** Authority owned by the public operation resolving stored bytes. */ export interface AttachmentOperationOptions { /** * A pre-aborted signal starts no store work. Implementations should use it * to stop their own I/O; the SDK also stops awaiting a store that ignores it. */ readonly signal?: AbortSignal } /** Policy owned by the operation that materializes one or more references. */ export interface AttachmentResolutionOptions extends AttachmentOperationOptions { /** * Maximum wall-clock time for the complete materialization phase. * * Defaults to one minute. `0` retains the prior unbounded wait. The SDK * still races the wait itself, so a custom store cannot defeat this bound * by ignoring the signal it receives. */ readonly timeoutMs?: number } /** One minute is long enough for remote stores without letting a turn wedge forever. */ export const DEFAULT_ATTACHMENT_RESOLVE_TIMEOUT_MS = 60_000 const MAX_TIMER_DELAY_MS = 2_147_483_647 export interface AttachmentStore { /** * Take bytes, return a ref. * * `mediaType` is stored alongside, so `get` can report what it holds and * a caller can be caught claiming something else. */ put(bytes: StoredBytes): Promise /** `undefined` for a ref this store does not hold. */ get(ref: string, options?: AttachmentOperationOptions): Promise } /** A reference nothing could resolve. */ export class AttachmentNotFoundError extends Error { readonly details: { ref: string } constructor(details: { ref: string }) { super(`No attachment for ref "${details.ref}".`) this.name = 'AttachmentNotFoundError' this.details = details } } /** A message that carries a ref, in a turn with nowhere to resolve it. */ export class NoAttachmentStoreError extends Error { readonly details: { ref: string } constructor(details: { ref: string }) { super( `A message carries a stored attachment ("${details.ref}") but this turn has no attachment store.`, ) this.name = 'NoAttachmentStoreError' this.details = details } } /** A store whose bytes are not what the message said they were. */ export class AttachmentMediaTypeMismatchError extends Error { readonly details: { ref: string; declared: string; stored: string } constructor(details: { ref: string; declared: string; stored: string }) { super( `Attachment "${details.ref}" was declared ${details.declared} but the store holds ${details.stored}.`, ) this.name = 'AttachmentMediaTypeMismatchError' this.details = details } } /** A store did not settle the materialization phase within its declared bound. */ export class AttachmentResolutionTimeoutError extends Error { readonly details: { timeoutMs: number } constructor(details: { timeoutMs: number }) { super(`Stored attachment resolution timed out after ${details.timeoutMs}ms.`) this.name = 'AttachmentResolutionTimeoutError' this.details = details } } export const isStoredAttachment = ( attachment: MessageAttachment, ): attachment is MessageAttachment & StoredAttachment => (attachment as { type?: string }).type === 'stored' /** * Turn a stored attachment into an inline one, or refuse. * * Every failure here REFUSES rather than dropping the attachment. A message * that quietly lost its image is a model answering a question about a * picture it never saw, confidently, and nothing in the transcript says * why — the worst available outcome, and the reason none of these three * branches returns the message unchanged. */ export async function resolveAttachment( attachment: MessageAttachment, store: AttachmentStore | undefined, options: AttachmentResolutionOptions = {}, ): Promise { options.signal?.throwIfAborted() const timeoutMs = resolveAttachmentTimeoutMs(options.timeoutMs) if (!isStoredAttachment(attachment)) return attachment return await withAttachmentDeadline(options.signal, timeoutMs, async (signal) => resolveAttachmentWithSignal(attachment, store, signal), ) } async function resolveAttachmentWithSignal( attachment: MessageAttachment & StoredAttachment, store: AttachmentStore | undefined, signal: AbortSignal | undefined, ): Promise { signal?.throwIfAborted() if (!store) throw new NoAttachmentStoreError({ ref: attachment.ref }) const bytes = await awaitStoreRead(signal, () => signal ? store.get(attachment.ref, { signal }) : store.get(attachment.ref), ) // A store completion can win its promise reaction and remove the abort // listener immediately before an already-queued abort. Publication is a // second authority boundary, so fence it independently. signal?.throwIfAborted() if (!bytes) throw new AttachmentNotFoundError({ ref: attachment.ref }) if (bytes.mediaType !== attachment.mediaType) { // The declared type decides which content block is built and what the // provider is told the bytes are. A store holding a PDF under a ref // declared `image/png` means one of the two is wrong, and guessing // which sends the provider bytes it cannot read while telling it // otherwise. throw new AttachmentMediaTypeMismatchError({ ref: attachment.ref, declared: attachment.mediaType, stored: bytes.mediaType, }) } if (attachment.kind === 'document') { return { type: 'document', data: bytes.data, mediaType: bytes.mediaType, ...(attachment.name === undefined ? {} : { name: attachment.name }), ...(attachment.citations === undefined ? {} : { citations: attachment.citations }), } } return { type: 'image', data: bytes.data, mediaType: bytes.mediaType } } /** * Resolve every stored attachment on every message. * * Returns the SAME array when nothing was stored, so the common case costs * one scan and no allocation — and so a caller cannot tell resolved * messages from unresolved ones by identity and get it wrong. */ export async function resolveAttachments( messages: readonly Message[], store: AttachmentStore | undefined, options: AttachmentResolutionOptions = {}, ): Promise { options.signal?.throwIfAborted() const timeoutMs = resolveAttachmentTimeoutMs(options.timeoutMs) // `Message` rather than a structural constraint: only some members of // the union carry `attachments`, and a structural bound over a union // like that is not assignable in either direction. Naming the real type // costs one import this module already had. const has = (message: Message): readonly MessageAttachment[] | undefined => (message as { attachments?: readonly MessageAttachment[] }).attachments if (!messages.some((m) => has(m)?.some(isStoredAttachment))) return messages return await withAttachmentDeadline(options.signal, timeoutMs, async (signal) => Promise.all( messages.map(async (message) => { const attachments = has(message) if (!attachments?.some(isStoredAttachment)) return message return { ...message, attachments: await Promise.all( attachments.map((attachment) => isStoredAttachment(attachment) ? resolveAttachmentWithSignal(attachment, store, signal) : attachment, ), ), } }), ), ) } function resolveAttachmentTimeoutMs(value: number | undefined): number { const resolved = value ?? DEFAULT_ATTACHMENT_RESOLVE_TIMEOUT_MS if (!Number.isInteger(resolved) || resolved < 0 || resolved > MAX_TIMER_DELAY_MS) { throw new RangeError( `attachmentResolveTimeoutMs must be an integer from 0 to ${MAX_TIMER_DELAY_MS}; received ${String(resolved)}`, ) } return resolved } /** * Own one timer for the whole parallel materialization phase. * * The caller's signal is an input, never the controller we abort. Whichever * cause wins is latched by `AbortSignal.any`, and every store wait races that * fused signal independently through `awaitStoreRead` below. */ async function withAttachmentDeadline( upstream: AbortSignal | undefined, timeoutMs: number, start: (signal: AbortSignal | undefined) => Promise, ): Promise { upstream?.throwIfAborted() if (timeoutMs === 0) return await start(upstream) const timeout = new AbortController() const signal = upstream ? AbortSignal.any([upstream, timeout.signal]) : timeout.signal const timer = setTimeout( () => timeout.abort(new AttachmentResolutionTimeoutError({ timeoutMs })), timeoutMs, ) try { return await start(signal) } finally { clearTimeout(timer) } } /** * Await a host store without handing it ownership of run cancellation. * * Forwarding the signal is cooperative cleanup; this race is the public * liveness boundary when a remote or custom store ignores it. The first cause * wins, and late store completion has no route back into the returned value. */ async function awaitStoreRead( signal: AbortSignal | undefined, start: () => Promise, ): Promise { if (!signal) return await start() signal.throwIfAborted() return new Promise((resolve, reject) => { let settled = false const cleanup = (): void => signal.removeEventListener('abort', onAbort) const rejectOnce = (reason: unknown): void => { if (settled) return settled = true cleanup() reject(reason) } const resolveOnce = (value: T): void => { if (settled) return settled = true cleanup() resolve(value) } const onAbort = (): void => rejectOnce(signal.reason) signal.addEventListener('abort', onAbort, { once: true }) try { Promise.resolve(start()).then(resolveOnce, rejectOnce) } catch (error) { rejectOnce(error) } }) }