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
+5 -4
View File
@@ -18,11 +18,12 @@ export type AgentProxyJson = (path: string, method?: string, body?: unknown, sig
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal, download = false) {
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
const value = descriptor.data as { capabilities?: { attachments?: { version?: number }; assistantImages?: { version?: number } } } | null;
if (descriptor.status !== 200 || !(value?.capabilities?.attachments?.version === 1
|| (download && value?.capabilities?.assistantImages?.version === 1)))
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
@@ -52,7 +53,7 @@ export async function registerAgentAttachmentRoutes(app: FastifyInstance, option
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal);
await requireAgentAttachments(options.proxyJson, controller.signal, !uploading);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
+6 -2
View File
@@ -14,7 +14,8 @@ export type MessageText = (message: PublicMessage) => Promise<string>;
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
messages: z.array(z.unknown()).max(1000) });
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe() }) });
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe(),
agentAttachments: z.unknown().optional() }) });
/** Resolve only an already eligible candidate from one immutable root public entry. */
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
@@ -47,7 +48,10 @@ export function createMessageText(upstream: { baseUrl: string; apiKey?: string }
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined;
if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable');
// Never inspect metadata, tool results, activity, reasoning blocks or linked resources for text.
// Attachment messages use the generic alert. Never expose captions, filenames,
// paths, image data or linked resources in an image notification.
if (message.data.metadata.agentAttachments !== undefined) return '';
// Never inspect tool results, activity, reasoning blocks or linked resources for text.
return message.data.content;
};
}
+3 -2
View File
@@ -43,8 +43,8 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
});
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
const descriptor = await response.json() as { capabilities?: { attachments?: unknown; assistantImages?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments || descriptor?.capabilities?.assistantImages) throw new Error('Private runtime');
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
};
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
@@ -53,6 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
redirect: 'error',
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
});