Support durable assistant image attachments in Agent clients

This commit is contained in:
2026-10-20 14:14:09 -07:00
parent 7b48201e28
commit 489c347976
15 changed files with 227 additions and 40 deletions
+15 -2
View File
@@ -19,10 +19,23 @@ export function parseAgentAttachment(value: unknown): AgentAttachment {
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
}
export function agentAttachments(metadata: unknown): AgentAttachment[] {
export function agentAttachments(metadata: unknown, role = "user"): AgentAttachment[] {
if (role !== "user" && role !== "assistant") return [];
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
if (!Array.isArray(values) || values.length > 8) return [];
try { return values.map(parseAgentAttachment); } catch { return []; }
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 []; }
}
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
+1 -1
View File
@@ -20,7 +20,7 @@ export type AgentRoomPresenterOptions = {
typingDelayMs?: number;
clock?: Scheduler;
};
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
const quietTools = new Set(['user_message', 'output_image', 'notify_parent', 'clock_wait', 'event_decision']);
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
const entryId = (message: Message): bigint | undefined => {
+43 -7
View File
@@ -18,7 +18,7 @@ export type AgentMessagesPage = {
};
export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability; assistantImages?: { version: 1 } };
};
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
export type AgentRoomState = {
@@ -175,6 +175,8 @@ export class AgentRoomClient {
private readonly baseUrl: string;
private readonly fetcher: typeof globalThis.fetch;
private readonly token: () => string | null;
private activeDownloads = 0;
private readonly downloadWaiters = new Set<() => void>();
constructor(options: AgentRoomClientOptions = {}) {
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
@@ -223,12 +225,46 @@ export class AgentRoomClient {
}
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); }
const blob = await response.blob();
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete.");
return blob;
signal?.throwIfAborted();
while (this.activeDownloads >= 2) {
await new Promise<void>((resolve, reject) => {
const cleanup = () => { this.downloadWaiters.delete(wake); signal?.removeEventListener("abort", abort); };
const wake = () => { cleanup(); resolve(); };
const abort = () => { cleanup(); reject(signal?.reason || new Error("Download canceled.")); };
this.downloadWaiters.add(wake); signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
});
signal?.throwIfAborted();
}
this.activeDownloads++;
try {
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
if (!response.ok) { await response.body?.cancel(); throw new Error("Attachment download failed. Retry the download."); }
const reader = response.body?.getReader();
if (!reader) throw new Error("Attachment download was incomplete.");
const bytes = new Uint8Array(file.sizeBytes); let size = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
signal?.throwIfAborted();
if (size + value.length > bytes.length) throw new Error("Attachment download exceeds its size limit.");
bytes.set(value, size); size += value.length;
}
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
if (size !== file.sizeBytes) throw new Error("Attachment download was incomplete.");
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(n => n.toString(16).padStart(2, "0")).join("");
if (sha256 !== file.sha256) throw new Error("Attachment checksum did not match.");
if (file.kind === "image" && response.headers.get("content-type")?.split(";")[0].trim() !== file.mimeType)
throw new Error("Attachment image type did not match.");
signal?.throwIfAborted();
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
return new Blob([bytes], { type: response.headers.get("content-type") || "application/octet-stream" });
} finally {
this.activeDownloads--;
this.downloadWaiters.values().next().value?.();
}
}
websocketUrl(pageUrl: string): string {
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);