/** * A2A SSE Streaming Support * * Provides SSE event formatting for A2A `message/stream` responses. * Features: heartbeat (15s), chunk emission, metadata final event, cancellation. */ import type { A2ATask } from "./taskManager"; export interface SSEChunkEvent { jsonrpc: "2.0"; method: "message/stream"; params: { task: { id: string; state: string }; chunk?: { type: string; content: string }; metadata?: Record; }; } /** * Format an SSE event line. */ export function formatSSE(event: SSEChunkEvent): string { return `data: ${JSON.stringify(event)}\n\n`; } /** * Create a chunk event for streaming text content. */ export function createChunkEvent(taskId: string, content: string): string { return formatSSE({ jsonrpc: "2.0", method: "message/stream", params: { task: { id: taskId, state: "working" }, chunk: { type: "text", content }, }, }); } /** * Create the final completion event with metadata. */ export function createCompletionEvent(taskId: string, metadata: Record): string { return formatSSE({ jsonrpc: "2.0", method: "message/stream", params: { task: { id: taskId, state: "completed" }, metadata, }, }); } /** * Create a heartbeat event to keep the connection alive. */ export function createHeartbeat(taskId: string): string { return `: heartbeat ${new Date().toISOString()}\n\n`; } /** * Create a failure event. */ export function createFailureEvent(taskId: string, error: string): string { return formatSSE({ jsonrpc: "2.0", method: "message/stream", params: { task: { id: taskId, state: "failed" }, metadata: { error }, }, }); } /** * SSE response headers for A2A streaming. */ export const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", } as const; /** * Create a streaming SSE handler that wraps a fetch-based LLM call. * Returns a ReadableStream suitable for a Response object. */ export function createA2AStream( task: A2ATask, executeSkill: ( task: A2ATask ) => Promise<{ artifacts: Array<{ content: string }>; metadata: Record }>, abortSignal?: AbortSignal, lifecycle?: { onStart?: () => void; onEnd?: () => void; } ): ReadableStream { const encoder = new TextEncoder(); return new ReadableStream({ async start(controller) { lifecycle?.onStart?.(); // Heartbeat interval const heartbeatInterval = setInterval(() => { try { controller.enqueue(encoder.encode(createHeartbeat(task.id))); } catch { /* stream closed */ } }, 15_000); try { // Check for cancellation if (abortSignal?.aborted) { controller.enqueue(encoder.encode(createFailureEvent(task.id, "Cancelled"))); controller.close(); return; } // Execute the skill const result = await executeSkill(task); // Emit content as chunks (simulated streaming for non-streaming skills) for (const artifact of result.artifacts) { if (abortSignal?.aborted) break; controller.enqueue(encoder.encode(createChunkEvent(task.id, artifact.content))); } if (abortSignal?.aborted) { controller.enqueue(encoder.encode(createFailureEvent(task.id, "Cancelled"))); return; } // Emit completion with metadata controller.enqueue(encoder.encode(createCompletionEvent(task.id, result.metadata))); } catch (err) { const msg = err instanceof Error ? err.message : String(err); controller.enqueue(encoder.encode(createFailureEvent(task.id, msg))); } finally { clearInterval(heartbeatInterval); lifecycle?.onEnd?.(); controller.close(); } }, }); }