UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

225 lines • 9.13 kB
"use strict"; /** * stream — agent events → Server-Sent Events helpers. * * Pattern: Adapter (event stream → SSE wire format). * Role: Outer ring. Subscribes to a `Runner`'s `EventDispatcher` * and yields SSE-formatted strings. Drop into any HTTP * framework that accepts an async iterable response body * (Fetch Response, Express res.write, Hono streaming, etc.). * Emits: N/A — observes only. * * Not an import path of its own since 9.0.0. This is the implementation barrel * behind `agentfootprint/observe`, which re-exports every name here — same * symbols, one door. Import from the door. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.encodeSSE = exports.SSEFormatter = exports.toSSE = exports.agentfootprintSse = void 0; const runnerLive_js_1 = require("../core/runnerLive.js"); const wireJson_js_1 = require("../lib/wireJson.js"); /** * agentfootprint's own framing — `event: <type>` then `data: <json>` — the * frames `toSSE` writes by default, byte for byte as before the framing seam * existed. Every payload goes through the wire rule (`lib/wireJson.ts`). */ exports.agentfootprintSse = { contentType: 'text/event-stream', frame: (event) => encodeSSE(event.type, event), }; /** * Subscribe to a runner's `EventDispatcher` and yield SSE-formatted * strings until the run completes. */ async function* toSSE(runner, options = {}) { const filter = options.filter; const format = options.format ?? 'full'; const heartbeatMs = options.heartbeatMs ?? 0; const framing = framingOf(options); // Pull the dispatcher off the runner. RunnerBase exposes it as // protected — we cast to access. No public dispatcher() method // exists ; runners forward .on/.off via their public API. const dispatcher = runner.dispatcher; // Bounded queue: events drained as the consumer iterates. const queue = []; let waiter = null; let done = false; const wakeup = () => { if (waiter) { const w = waiter; waiter = null; w.resolve(); } }; // Every event — and so every event the consumer's `filter` sees — is the // RECORD: served under the run's redaction policy like every other // listener's. 'text' then streams the REPLY to the person who asked, the // caller's own answer, which a policy never masks: each token's text is // taken from the run's real-value path (`core/runnerLive.ts` · // `runnerLive`) — matched to its served event by the token's address (its // stage, iteration and index, none of which a policy selects), so a served // token that never arrives cannot shift the ones after it — and never // handed to consumer code. A runner that is not a RunnerBase has no // redaction and no live taps: its events are the record and the reply at once. const live = format === 'text' ? (0, runnerLive_js_1.runnerLive)(runner) : undefined; const replies = new Map(); const offReplies = live?.onRealEvent((event) => { if (event.type !== 'agentfootprint.stream.token') return; const content = event.payload?.content; const key = tokenAddress(event); const waiting = replies.get(key); const text = typeof content === 'string' ? content : ''; if (waiting === undefined) replies.set(key, [text]); else waiting.push(text); }); const replyFor = (event) => { const key = tokenAddress(event); const waiting = replies.get(key); const text = waiting?.shift(); if (waiting !== undefined && waiting.length === 0) replies.delete(key); return text; }; const listener = (event) => { // Taken before the filter, so a filtered-out token still consumes its text. const reply = live !== undefined && event.type === 'agentfootprint.stream.token' ? replyFor(event) : undefined; if (filter && !filter(event)) return; queue.push(reply !== undefined ? { type: event.type, payload: { content: reply } } : event); wakeup(); // `agent.turn_end` (or composition exit on the outermost runner) // ends the stream naturally; the consumer's `for await` finishes // when the iterator returns. if (event.type === 'agentfootprint.agent.turn_end' || event.type === 'agentfootprint.error.fatal') { done = true; wakeup(); } }; const offRecord = dispatcher.on('*', listener); const unsub = () => { offRecord(); offReplies?.(); }; let heartbeat; if (heartbeatMs > 0) { heartbeat = setInterval(() => { queue.push({ type: '__heartbeat' }); wakeup(); }, heartbeatMs); } /** Frames written to THIS stream so far — the {@link SseFramePosition} of the next one. */ let written = 0; try { while (!done || queue.length > 0) { while (queue.length > 0) { // queue.length > 0 guards the shift; result is defined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = queue.shift(); if (event.type === '__heartbeat') { yield ': ping\n\n'; continue; } if (format === 'text') { if (event.type === 'agentfootprint.stream.token') { const payload = event.payload; if (payload?.content) yield payload.content; } } else { const frame = framing.frame(event, { sequence: written + 1 }); written += 1; yield frame; } } if (done) break; await new Promise((resolve) => { waiter = { resolve }; }); } } finally { unsub(); if (heartbeat) clearInterval(heartbeat); } } exports.toSSE = toSSE; /** * Class form for consumers who prefer `new SSEFormatter(runner).stream()`. * Identical behavior to `toSSE(runner)` — pick by preference. */ class SSEFormatter { runner; options; constructor(runner, options = {}) { this.runner = runner; this.options = options; } /** Async iterable of SSE chunks. Consume with `for await`. */ stream() { return toSSE(this.runner, this.options); } } exports.SSEFormatter = SSEFormatter; /** * Format any JSON-able payload as a single SSE event chunk. * * Useful for app-level events outside the runner's typed registry * (auth/error frames, app-state echoes). Most consumers won't need this. */ function encodeSSE(eventName, payload) { // The wire rule (`lib/wireJson.ts`): an Error in a payload streams as its // name, message, code and cause — never its custom properties or stack. const json = (0, wireJson_js_1.toWireJson)(payload); // Escape newlines inside JSON (rare with stringify) so the data field // stays single-line. SSE's data: lines can be repeated, but the // canonical encoder keeps it simple. return `event: ${eventName}\ndata: ${json}\n\n`; } exports.encodeSSE = encodeSSE; /** * The framing `toSSE` writes with: the one the caller passed, else the default * — {@link agentfootprintSse}, or, when only `eventName` was given, the same * frames under the caller's names. A `framing` beside `eventName` or beside * `format: 'text'` is refused by name: each would silently ignore the other. */ function framingOf(options) { const { framing, eventName } = options; if (framing === undefined) { if (eventName === undefined) return exports.agentfootprintSse; return { contentType: exports.agentfootprintSse.contentType, frame: (e) => encodeSSE(eventName(e), e) }; } if (eventName !== undefined) { throw new TypeError('toSSE: `framing` and `eventName` were both given. `eventName` names the event line of ' + 'the default framing; a framing of your own writes its own names — pass one or the other.'); } if (options.format === 'text') { throw new TypeError("toSSE: `framing` was given with `format: 'text'`, which writes the reply's text and no " + 'frames. Drop `framing`, or use the default `format`.'); } return framing; } /** * A token event's address — the stage that emitted it, its iteration and its * index: the same on the real-value path and on its served twin, and never a * name a redaction policy selects. `toSSE`'s 'text' pairing key. */ function tokenAddress(event) { const payload = event.payload; return [ event.meta?.runtimeStageId ?? '', String(payload?.iteration ?? ''), String(payload?.tokenIndex ?? ''), ].join('\u001f'); } //# sourceMappingURL=stream.js.map