2026-10-14 17:25:52 -07:00
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
export type AgentAttachment = {
id : string ; kind : "image" | "text" | "file" ; filename : string ; mimeType : string ;
sizeBytes : number ; sha256 : string ; workspacePath? : string ;
};
export type AgentAttachmentCapability = {
version : 1 ; maxCount : number ; maxTotalBytes : number ; maxImageBytes : number ; maxFileBytes : number ;
imageMimeTypes : string []; quotaBytes : number ; usedBytes : number ; remainingBytes : number ;
maxConcurrentUploads : number ; maxConcurrentDecodes : number ;
};
export function parseAgentAttachment ( value : unknown ) : AgentAttachment {
const item = value as AgentAttachment | null ;
if ( ! item || ! /^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ . test ( item . id )
|| ! [ "image" , "text" , "file" ]. includes ( item . kind ) || typeof item . filename !== "string" || ! item . filename
|| typeof item . mimeType !== "string" || ! Number . isSafeInteger ( item . sizeBytes ) || item . sizeBytes < 0 || item . sizeBytes > 8 * 1024 * 1024
|| typeof item . sha256 !== "string" || ! /^[a-f0-9]{64}$/ . test ( item . sha256 )
|| ( item . workspacePath !== undefined && ( typeof item . workspacePath !== "string" || ! item . workspacePath . startsWith ( "/" ))))
throw new Error ( "Agent returned an invalid attachment descriptor." );
return { id : item.id , kind : item.kind , filename : item.filename , mimeType : item.mimeType , sizeBytes : item.sizeBytes , sha256 : item.sha256 ,
...( item . workspacePath ? { workspacePath : item.workspacePath } : {}) };
}
2026-10-20 14:14:09 -07:00
export function agentAttachments ( metadata : unknown , role = "user" ) : AgentAttachment [] {
if ( role !== "user" && role !== "assistant" ) return [];
2026-10-14 17:25:52 -07:00
const values = ( metadata as { agentAttachments? : unknown } | null ) ? . agentAttachments ;
if ( ! Array . isArray ( values ) || values . length > 8 ) return [];
2026-10-20 14:14:09 -07:00
try {
const files = values . map ( value => {
// Source paths are not part of the assistant output contract, even if an
// older/malformed server supplies one. Only the ID authorizes retrieval.
const file = parseAgentAttachment ( role === "assistant" ? { ... value , workspacePath : undefined } : value );
if ( role === "assistant" && ( file . kind !== "image" || ! [ "image/png" , "image/jpeg" , "image/gif" , "image/webp" ]. includes ( file . mimeType )
|| file . sizeBytes > 6 * 1024 * 1024 || file . filename . length > 255 || /[\\/\u0000-\u001f\u007f]/ . test ( file . filename )))
throw new Error ( "Invalid assistant image." );
return file ;
});
if ( new Set ( files . map ( file => file . id )). size !== files . length || files . reduce (( total , file ) => total + file . sizeBytes , 0 ) > 32 * 1024 * 1024 ) return [];
return files ;
} catch { return []; }
2026-10-14 17:25:52 -07:00
}
export function attachmentCaption ( content : string , files : AgentAttachment []) : string {
return content . trim () ? content : `Attached: ${ files . map ( file => file . filename ). join ( ", " ) } .` ;
}
export function attachmentSize ( bytes : number ) : string { return bytes < 1024 ? ` ${ bytes } B` : bytes < 1024 ** 2 ? ` ${ Math . ceil ( bytes / 1024 ) } KiB` : ` ${ ( bytes / 1024 ** 2 ). toFixed ( 1 ) } MiB` ; }
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1" ;
export type AgentUpload = {
uploadId : string ; filename : string ; sizeBytes : number ; mimeType : string ; sha256? : string ; file? : File ;
status : "waiting" | "uploading" | "ready" | "failed" | "reselect" ; attachment? : AgentAttachment ; error? : string ;
};
export type AgentUploadStorage = { read () : unknown ; write ( rows : Omit < AgentUpload , "file" >[]) : void };
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
export function createAgentUploadQueue ( options : {
capability () : AgentAttachmentCapability | undefined ;
upload ( file : File , uploadId : string , signal : AbortSignal ) : Promise < AgentAttachment >;
changed () : void ; storage? : AgentUploadStorage ; newId ?: () => string ;
}) {
const rows = new Map < string , AgentUpload >();
let running : { id : string ; controller : AbortController } | undefined , disposed = false ;
const persist = () => options . storage ? . write ([... rows . values ()]. map (({ file : _file , ... row }) => ({ ... row })));
const publish = () => { if ( ! disposed ) options . changed (); };
const save = () => { if ( disposed ) return ; try { persist (); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish (); };
const restored = options . storage ? . read ();
if ( Array . isArray ( restored )) for ( const value of restored . slice ( 0 , 8 )) {
if ( ! value || typeof value . uploadId !== "string" || ! /^[a-f0-9-]{36}$/i . test ( value . uploadId )
|| typeof value . filename !== "string" || typeof value . mimeType !== "string" || ! Number . isSafeInteger ( value . sizeBytes ) || value . sizeBytes < 0 || value . sizeBytes > 8 * 1024 * 1024 ) continue ;
try {
const attachment = value . attachment ? parseAgentAttachment ( value . attachment ) : undefined ;
rows . set ( value . uploadId , { uploadId : value.uploadId , filename : value.filename , sizeBytes : value.sizeBytes , mimeType : value.mimeType ,
...( typeof value . sha256 === "string" && /^[a-f0-9]{64}$/ . test ( value . sha256 ) ? { sha256 : value.sha256 } : {}),
...( attachment ? { attachment , status : "ready" } : { status : "reselect" , error : "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
}
const digest = async ( file : File ) => [... new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , await file . arrayBuffer ()))]. map ( n => n . toString ( 16 ). padStart ( 2 , "0" )). join ( "" );
const pump = () => {
if ( disposed || running ) return ;
const row = [... rows . values ()]. find ( row => row . status === "waiting" && row . file );
if ( ! row ) return ;
const controller = new AbortController (); running = { id : row.uploadId , controller }; row . status = "uploading" ; publish ();
void ( async () => {
row . sha256 ||= await digest ( row . file ! );
if ( rows . get ( row . uploadId ) !== row || controller . signal . aborted ) return ;
// Persist the upload identity AND original byte hash before any network request.
persist ();
const value = await options . upload ( row . file ! , row . uploadId , controller . signal );
if ( rows . get ( row . uploadId ) !== row || controller . signal . aborted ) return ;
const attachment = parseAgentAttachment ( value );
if ( attachment . sha256 !== row . sha256 || attachment . sizeBytes !== row . sizeBytes || attachment . filename !== row . filename . normalize ( "NFC" ))
throw new Error ( "The uploaded file receipt does not match this file. Retry to confirm." );
row . attachment = attachment ; row . status = "ready" ; delete row . error ;
})(). catch ( error => {
if ( rows . get ( row . uploadId ) !== row || controller . signal . aborted ) return ;
row . status = "failed" ; row . error = error instanceof Error ? error.message : String ( error );
}). finally (() => { running = undefined ; save (); pump (); });
};
return {
add ( files : Iterable < File >) {
const cap = options . capability (); if ( ! cap || cap . version !== 1 ) throw new Error ( "Attachments are not available on this Agent." );
const selected = Array . from ( files );
if ( rows . size + selected . length > Math . min ( 8 , cap . maxCount )) throw new Error ( "Attach at most 8 files per message." );
if ([... rows . values ()]. reduce (( n , r ) => n + r . sizeBytes , 0 ) + selected . reduce (( n , f ) => n + f . size , 0 ) > Math . min ( 32 * 1024 * 1024 , cap . maxTotalBytes ))
throw new Error ( "Attachments exceed 32 MiB per message." );
for ( const file of selected ) {
const image = cap . imageMimeTypes . includes ( file . type );
const limit = image ? Math . min ( 6 * 1024 * 1024 , cap . maxImageBytes ) : Math . min ( 8 * 1024 * 1024 , cap . maxFileBytes );
if ( file . size > limit ) throw new Error ( ` ${ file . name } exceeds the ${ image ? "6" : "8" } MiB file limit.` );
}
const added = selected . map ( file => ({ uploadId : ( options . newId || (() => crypto . randomUUID ()))(), file , filename : file.name , sizeBytes : file.size , mimeType : file.type , status : "waiting" as const }));
for ( const row of added ) rows . set ( row . uploadId , row );
try { persist (); } catch ( error ) { for ( const row of added ) rows . delete ( row . uploadId ); throw error ; }
publish (); pump ();
},
async reselect ( id : string , file : File ) {
const row = rows . get ( id ); if ( ! row || row . status !== "reselect" ) return ;
if ( file . name !== row . filename || file . size !== row . sizeBytes || file . type !== row . mimeType ) throw new Error ( "Choose the same filename, type and size to resume this upload." );
const hash = await digest ( file );
if ( row . sha256 && row . sha256 !== hash ) throw new Error ( "The selected file has different contents. Choose the original file or remove this attachment." );
if ( rows . get ( id ) !== row ) return ;
row . sha256 = hash ; row . file = file ; row . status = "waiting" ; delete row . error ; persist (); publish (); pump ();
},
remove ( id : string ) { const row = rows . get ( id ); rows . delete ( id ); try { persist (); } catch ( error ) { if ( row ) rows . set ( id , row ); throw error ; } if ( running ? . id === id ) running . controller . abort (); publish (); },
retry ( id : string ) { const row = rows . get ( id ); if ( row ? . status === "failed" ) { row . status = row . file ? "waiting" : "reselect" ; delete row . error ; save (); pump (); } },
snapshot () : AgentUpload [] { return [... rows . values ()]. map ( row => ({ ... row })); },
ready () : AgentAttachment [] {
if ([... rows . values ()]. some ( row => row . status !== "ready" )) throw new Error ( "Wait for uploads, reselect or retry failed files, or remove them before sending." );
return [... rows . values ()]. map ( row => row . attachment ! );
},
clear() { rows . clear (); running ? . controller . abort (); save (); },
dispose() { disposed = true ; running ? . controller . abort (); try { persist (); } catch { /* Existing stored retry IDs remain usable. */ } },
};
}