agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
219 lines • 8.82 kB
JavaScript
/**
* 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.
*/
import { runnerLive } from '../core/runnerLive.js';
import { toWireJson } from '../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`).
*/
export const 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.
*/
export 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' ? 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);
}
}
/**
* Class form for consumers who prefer `new SSEFormatter(runner).stream()`.
* Identical behavior to `toSSE(runner)` — pick by preference.
*/
export 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);
}
}
/**
* 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.
*/
export 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 = 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`;
}
/**
* 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 agentfootprintSse;
return { contentType: 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