feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s

This commit is contained in:
2026-10-14 23:36:38 -07:00
parent b9a6f5dd7d
commit 7510603b77
14 changed files with 335 additions and 32 deletions
+88
View File
@@ -0,0 +1,88 @@
import { convert } from 'html-to-text';
import { marked } from 'marked';
import { z } from 'zod';
import { Cursor, parseSafe } from './agent-push-contract.js';
export const MAX_PREVIEW_BYTES = 3000;
export const APNS_PAYLOAD_BYTES = 4096;
// RFC 8291: 4096 bytes less the 86-byte header, delimiter and 16-byte tag.
export const WEB_PUSH_PAYLOAD_BYTES = 3993;
const FALLBACK = 'You have a new message.';
export type PublicMessage = { conversationId: string; messageId: string; cursor: string };
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() }) });
/** Resolve only an already eligible candidate from one immutable root public entry. */
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
return async candidate => {
if (!upstream.apiKey || !Cursor.safeParse(candidate.cursor).success || Number(candidate.cursor) < 1
|| !(candidate.messageId === `outbound:${candidate.cursor}` || new RegExp(`^entry:${candidate.cursor}:\\d+$`).test(candidate.messageId))) {
throw new Error('Public Agent message unavailable');
}
const url = new URL('/api/conversations/root/messages', upstream.baseUrl);
url.searchParams.set('after', String(Number(candidate.cursor) - 1));
url.searchParams.set('limit', '1'); url.searchParams.set('includeTools', 'false');
const response = await fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' },
signal: AbortSignal.timeout(10_000), redirect: 'error' });
if (!response.ok) { await response.body?.cancel(); throw new Error('Public Agent message unavailable'); }
const reader = response.body?.getReader();
if (!reader) throw new Error('Public Agent message unavailable');
const chunks: Uint8Array[] = []; let length = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
length += value.length;
if (length > 512 * 1024) throw new Error('Public Agent message unavailable');
chunks.push(value);
}
} finally { await reader.cancel(); }
const page = parseSafe(Page, JSON.parse(Buffer.concat(chunks).toString('utf8')));
if (page.conversationId !== candidate.conversationId || page.cursor !== candidate.cursor || page.beforeCursor !== candidate.cursor) {
throw new Error('Public Agent message unavailable');
}
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.
return message.data.content;
};
}
export function previewText(message: string): string {
const html = marked.parse(message, { async: false });
const text = convert(html, { wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'imageAlt' },
...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map(selector => ({ selector, options: { uppercase: false } })),
{ selector: 'hr', format: 'skip' },
],
formatters: { imageAlt: (element: { attribs: { alt?: string } }, _walk: unknown, builder: { addInline: (text: string) => void }) => builder.addInline(element.attribs.alt || '') },
});
// A string body for native OS display; normalize whitespace/control characters and malformed UTF-16.
return Buffer.from(text).toString('utf8').replace(/[\u0000-\u001f\u007f-\u009f\s]+/gu, ' ').trim();
}
/** Budget the complete serialized UTF-8 payload, including JSON escaping and identity overhead. */
export function previewPayload(message: string | undefined, payload: (body: string) => unknown, maxBytes: number): Buffer {
const text = (message === undefined ? '' : previewText(message)) || FALLBACK;
const budget = Math.min(MAX_PREVIEW_BYTES, maxBytes - Buffer.byteLength(JSON.stringify(payload(''))));
if (budget < 3) throw new Error('Agent push identity exceeds payload budget');
let body = text;
if (Buffer.byteLength(JSON.stringify(text)) - 2 > budget) {
body = ''; let bytes = 0;
for (const character of text) {
const size = Buffer.byteLength(JSON.stringify(character)) - 2;
if (bytes + size > budget - 3) break;
body += character; bytes += size;
}
body = body.trimEnd() + '…';
}
const result = Buffer.from(JSON.stringify(payload(body)));
if (result.length > maxBytes) throw new Error('Agent push exceeds payload budget');
return result;
}
+7 -4
View File
@@ -6,12 +6,13 @@ import { isIP } from 'node:net';
import webpush from 'web-push';
import type { PushConfig } from './agent-push-config.js';
import { validatePushEndpoint, type Identity, type Registration } from './agent-push-contract.js';
import { APNS_PAYLOAD_BYTES, previewPayload, WEB_PUSH_PAYLOAD_BYTES } from './agent-push-preview.js';
export type SendResult = { outcome: 'accepted' | 'retry' | 'invalid' | 'failed' | 'ambiguous'; retryAfterMs?: number };
export type ProviderRequest = { endpoint: string; headers: Record<string, string | number>; body: Buffer };
export type ProviderResponse = { status: number; body?: string; retryAfter?: string };
export type RequestTransport = (request: ProviderRequest) => Promise<ProviderResponse>;
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number) => Promise<SendResult>;
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number, messageText?: string) => Promise<SendResult>;
function boundedRetry(value?: string): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
@@ -104,7 +105,7 @@ export const webRequest = createWebRequest();
export function createPushSender(config: PushConfig, adapters: { apns?: RequestTransport; web?: RequestTransport } = {}): PushSend {
let jwt: { value: string; issuedAt: number } | undefined;
return async (registration, identity, expiresAt, now) => {
return async (registration, identity, expiresAt, now, messageText) => {
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
try {
if (registration.transport === 'apns') {
@@ -117,7 +118,8 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now };
}
const clear = identity.kind === 'read_state';
const body = Buffer.from(JSON.stringify({ aps: clear ? { 'content-available': 1 } : { alert: { title: 'Sybil Agent', body: 'You have a new message.' }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }));
const body = clear ? Buffer.from(JSON.stringify({ aps: { 'content-available': 1 }, ...identity }))
: previewPayload(messageText, body => ({ aps: { alert: { title: 'Sybil Agent', body }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }), APNS_PAYLOAD_BYTES);
const response = await (adapters.apns || apnsRequest)({
endpoint: `https://${registration.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'}/3/device/${registration.token}`,
headers: { authorization: `bearer ${jwt.value}`, 'apns-topic': credential.topic, 'apns-push-type': clear ? 'background' : 'alert',
@@ -130,10 +132,11 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
// Safari requires visible pushes; there is deliberately no Web Push read-clear transport.
if (!config.web || identity.kind !== 'message') return { outcome: 'failed' };
validatePushEndpoint(registration.subscription.endpoint);
const payload = JSON.stringify({ ...identity, title: 'Sybil Agent', body: 'You have a new message.', url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` });
const payload = previewPayload(messageText, body => ({ ...identity, title: 'Sybil Agent', body, url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` }), WEB_PUSH_PAYLOAD_BYTES);
const details = webpush.generateRequestDetails(registration.subscription, payload, { vapidDetails: config.web,
contentEncoding: 'aes128gcm', TTL: Math.max(1, Math.min(60, Math.floor((expiresAt - now) / 1000))), urgency: 'normal',
topic: createHash('sha256').update(identity.sourceId).digest('base64url').slice(0,32) });
if (!details.body || details.body.length > 4096) return { outcome: 'failed' };
const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! });
return classify(response, false);
} catch {
+24 -7
View File
@@ -8,10 +8,11 @@ import { loadPushConfig } from './agent-push-config.js';
import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, NotificationContext, PresenceBody, PUSH_POLICY,
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
import { createPushSender, type PushSend } from './agent-push-transports.js';
import { createMessageText, type MessageText } from './agent-push-preview.js';
type Tx = Prisma.TransactionClient;
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; send: PushSend; now?: () => number;
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
broadcast?: (state: ReadState) => void; warmupMs?: number };
const date = (ms: number) => new Date(ms);
const activeStatuses = ['pending', 'intent'];
@@ -28,6 +29,7 @@ export class AgentPushCoordinator {
private running = false;
private timer?: ReturnType<typeof setInterval>;
private stopped = false;
private observedRoot?: { sourceId: string; conversationId: string };
constructor(readonly options: Options) {
this.epoch = ownerEpoch(options.config.adminToken || 'disabled');
this.now = options.now || Date.now;
@@ -261,6 +263,7 @@ export class AgentPushCoordinator {
return changedRead;
});
if (read) this.options.broadcast?.(read);
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
return context.hasMore;
}
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
@@ -278,8 +281,8 @@ export class AgentPushCoordinator {
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
}
private async dispatch(id: string) {
const claim = await this.options.db.$transaction(async tx => {
private async claim(id: string, commit: boolean) {
return this.options.db.$transaction(async tx => {
const now = this.now();
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
@@ -313,17 +316,31 @@ export class AgentPushCoordinator {
await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { enabled: false } });
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'expired_subscription' } }); return;
}
const identity: Identity = row.kind === 'read_state' ? { version: 1, sourceId: row.sourceId, kind: 'read_state', readCursor: read.cursor }
: { version: 1, sourceId: row.sourceId, kind: 'message', messageId: row.messageId, cursor: row.cursor };
if (!commit) return { row, registration, identity, now };
// Durable crossing intent is committed BEFORE invoking an external adapter. A crash here is ambiguous, never replayed.
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'intent', attempts: { increment: 1 }, outcome: null } });
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { lockUntil: date(now + 30_000) } });
if (row.kind === 'read_state') await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { lastClearAt: date(now) } });
const identity: Identity = row.kind === 'read_state' ? { version: 1, sourceId: row.sourceId, kind: 'read_state', readCursor: read.cursor }
: { version: 1, sourceId: row.sourceId, kind: 'message', messageId: row.messageId, cursor: row.cursor };
return { row, registration, identity, now };
});
}
private async dispatch(id: string) {
// Only resolve text for an otherwise dispatchable candidate, outside the DB transaction.
// Recheck source/read/presence and every claim condition after the bounded public lookup.
const ready = await this.claim(id, false);
if (!ready) return;
let messageText: string | undefined;
if (ready.identity.kind === 'message') {
if (this.observedRoot?.sourceId !== ready.identity.sourceId) throw new Error('Public Agent source unavailable');
messageText = await this.options.messageText({ ...ready.identity, conversationId: this.observedRoot.conversationId });
if (await this.observe()) return;
}
const claim = await this.claim(id, true);
if (!claim) return;
let result;
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now); }
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now, messageText); }
catch { result = { outcome: 'ambiguous' as const }; }
await this.options.db.$transaction(async tx => {
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
@@ -398,7 +415,7 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
if (probe && (!sourceId || context.hasMore || context.candidates.length > 0
|| (context.sourceId === sourceId && Number(context.cursor) >= Number(after)))) throw new Error('Runtime cursor unavailable');
return context;
}, send: createPushSender(config), ...overrides });
}, messageText: createMessageText(upstream), send: createPushSender(config), ...overrides });
const auth = (req: FastifyRequest) => { if (!env.ADMIN_TOKEN) throw fail(503, 'Agent push requires authenticated access'); requireAdmin(req); };
const safe = <T>(fn: () => Promise<T>): Promise<T> => fn().catch(error => { if (error?.statusCode) throw error; throw fail(503, 'Agent push is unavailable'); });
const routeOptions = { bodyLimit: 16 * 1024 };