UNPKG

agentfootprint

Version:

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

639 lines • 31 kB
"use strict"; /** * streamHandler — one run per request, streamed in a wire protocol, as a * framework-agnostic Fetch-API handler: `(request: Request) => Promise<Response>`. * * Pattern: Template method over a {@link ProtocolAdapter} (the protocol is a * strategy; AG-UI is one). Everything HTTP happens here once — the * door guard, the body ceiling, the JSON parse, binding the * conversation to its caller, keeping it between requests, the client * hanging up, a failure that is never the process's — so an adapter * only reads its request, plans the turn and translates. * Role: the host for a protocol whose response is a STREAM OF EVENTS rather * than text pieces (`httpHost`'s port). It mounts in anything that * speaks `Request`/`Response` — a framework's route handler, an edge * or serverless runtime, or Node through any fetch adapter. * Deliberately names no vendor, like every file in this folder. * * THE REQUEST IS UNTRUSTED. A body is what anybody could send. It decides * nothing the server owns: * - WHO is calling and WHICH conversation is theirs come from the app's * {@link ThreadBinder} (`threads`), from the request's headers — the id a * request names is format-checked caller data and never a key; * - WHAT WAS SAID BEFORE comes from the server: the session store * (`sessions`), or the agent's own memory keyed by the bound session; * - WHAT A PAUSED RUN WAITS ON is the stored run's own record — an answer is * matched to it by the adapter, never taken on the request's word; * - identity and redaction settings never come from a request. A credential * the request CARRIES (a gateway's per-request token) reaches its own run * only through the app's `sealRequest`, sealed: never a frame, a stored * session or a checkpoint. * * NOTHING IS SHARED BETWEEN REQUESTS. Every request builds a FRESH runner * (`agent`, a factory): footprintjs runs one executor per run and a runner * holds one run's context, so a request can neither see another's events nor * leave anything behind for one — no shared listener, recorder, buffer, tool * session or frame count (each stream numbers its own frames, and hands the * number to the framing: `SseFramePosition`). A factory that hands back a * runner it handed back before is refused; so is a `sealRequest` that hands * back a sealed request twice. The handler's own state is KEYS, never content: * the bound conversations with a turn in flight and who holds each (a second * turn on one conversation is refused rather than racing the first for the * stored state), and the runners and sealed requests already used (by * identity, held weakly). * * ONE TURN PER THREAD IS A PER-PROCESS GUARD. Two instances of the handler * (several serverless instances, several workers) do not see each other's * turns: route a thread to one instance (sticky sessions), or a double-sent * answer can resume one paused run twice and a later persist can overwrite an * earlier one. The session store port has no compare-and-set yet. A consent * decision itself is used once per LEDGER: the agents every instance builds * share one only if they are given one (`decisionLedger`). * * The laws it keeps from `httpHost`: * - The door guard runs first, on the headers alone (`doorGuard.ts`): a Host * not configured (421), a browser origin not allowed (403), a POST that * does not say it is JSON (415). A refused body is never read. * - A body over `maxBodyBytes` is refused at the byte that crosses it (413). * - A refusal answers with its own status and code (`refusalStatus.ts`); * anything else is a 500 that names nothing the caller sent and no thrown * message. * - Nothing in a request's lifecycle is the process's failure: every step is * caught, and a stream always ends. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.streamHandler = exports.DEFAULT_STREAM_HEARTBEAT_MS = exports.DEFAULT_STREAM_MAX_BODY_BYTES = void 0; const eventMeta_js_1 = require("../bridge/eventMeta.js"); const pause_js_1 = require("../core/pause.js"); const wireJson_js_1 = require("../lib/wireJson.js"); const answeredPause_js_1 = require("./answeredPause.js"); const doorGuard_js_1 = require("./doorGuard.js"); const envelope_js_1 = require("./envelope.js"); const errors_js_1 = require("./errors.js"); const pendingAsk_js_1 = require("./pendingAsk.js"); const refusalStatus_js_1 = require("./refusalStatus.js"); const types_js_1 = require("../answer-validation/types.js"); const requestSealing_js_1 = require("./requestSealing.js"); /** The default body ceiling — 1 MiB, the same as `nodeHost`. */ exports.DEFAULT_STREAM_MAX_BODY_BYTES = 1_048_576; /** The default keep-alive interval: an SSE comment every 15 s of silence. */ exports.DEFAULT_STREAM_HEARTBEAT_MS = 15_000; /** * Serve runs in `adapter`'s protocol: POST a protocol request, read a stream * of the protocol's events. * * @throws TypeError at construction for options that cannot be served, and * when the adapter's framing cannot be built (an optional peer missing) — * refused at boot, never mid-request. * * @example A route handler that speaks `Request`/`Response` * ```ts * import { memorySessions, streamHandler } from 'agentfootprint/hosting'; * import { agUi } from 'agentfootprint/ag-ui'; * * export const POST = streamHandler({ * agent: ({ tools }) => buildAgent(tools), // a fresh agent per request * adapter: agUi(), * threads: { bind: ({ conversation, headers }) => bindToUser(conversation, headers) }, * sessions: memorySessions(), * }); * ``` */ function streamHandler(options) { const { adapter, threads, sessions } = options; const name = options.name ?? `${adapter.name} stream`; if (typeof options.agent !== 'function') { throw new TypeError(`[hosting] '${name}': agent must be a function that builds a FRESH runner for every ` + `request — a runner holds one run's context, and one shared between requests would ` + `hand one caller's run to another.`); } if (sessions !== undefined && threads === undefined) { throw new TypeError(`[hosting] '${name}': sessions needs threads. A conversation is kept under the key the ` + `app binds to its caller (threads.bind), never under the id a request names.`); } const limit = positive(options.maxBodyBytes, exports.DEFAULT_STREAM_MAX_BODY_BYTES, 'maxBodyBytes', name); const heartbeatMs = options.heartbeatMs ?? exports.DEFAULT_STREAM_HEARTBEAT_MS; if (!Number.isFinite(heartbeatMs) || heartbeatMs < 0) { throw new TypeError(`[hosting] '${name}': heartbeatMs must be 0 (off) or a positive number.`); } const guard = (0, doorGuard_js_1.doorGuard)({ ...options, name }); const framing = adapter.framing(); const seal = (0, requestSealing_js_1.requestSealing)(options.sealRequest, name); const bytes = new TextEncoder(); /** * Bound conversations with a turn in flight — the key, and who holds it (so * anybody else hears "not found", never "busy"). */ const inFlight = new Map(); /** Runners already used, by identity — so a factory that shares one is caught. */ const used = new WeakSet(); /** One answer for "none", "another's" and "nobody's" — a caller learns no ids. */ const notFound = () => new errors_js_1.WireRequestRefusal('ERR_SESSION_NOT_FOUND', `'${name}': that conversation is not one you can open.`, 404); /** Bind the conversation the request names to its caller — the app's port decides. */ async function bind(request, headers) { if (threads === undefined) return undefined; const thread = await threads.bind({ conversation: adapter.conversation(request), headers }); if (thread === null || typeof thread !== 'object' || typeof thread.sessionId !== 'string') { throw new TypeError('threads.bind returned no session'); } const bad = (0, doorGuard_js_1.checkSessionId)(thread.sessionId, name); if (bad !== undefined) throw bad; return thread; } /** * The stored conversation, when it is the caller's — refusing one that is not. * Asked BEFORE anything about the conversation's activity, so a caller it is * not learns "not found" and never "busy" — and never holds its turn. */ async function ownersOnly(thread) { if (sessions === undefined || thread === undefined) return undefined; await sessions.onWake?.(thread.sessionId, 'invoke'); const stored = await sessions.hydrate(thread.sessionId); // Whose conversation this is, before a line of it is read. One answer for // "none", "another's" and "nobody's", so a caller learns no ids. if (stored !== undefined && (0, envelope_js_1.envelopeOwner)(stored) !== thread.identity?.principal) throw notFound(); return stored; } /** * Read what the server holds for the bound conversation — under the turn's * claim. The one reading every door uses (`answeredPause.ts`): a pause still * waiting is offered to the adapter as awaiting; a pause whose decision a * resume already used is a conversation to continue, never offered again. */ async function hold(thread) { const stored = await ownersOnly(thread); if (stored === undefined) return { view: { continues: false } }; const { waiting: paused, prior, answered } = (0, answeredPause_js_1.readStoredSession)(stored); // A pause no answer can reach any more — paused before requests were // named, or its call changed after the ask — would hold the thread for // good: closed instead, and the conversation goes on (`settleClosed`, once // this request's runner is built: its ledger says whether the calls the // pause left open are recorded as not run or as not known). (An ask's // rules are the runner's, built later: a redeploy that changed them is // found when an answer is refused.) const reason = paused === undefined ? undefined : (0, answeredPause_js_1.unanswerablePause)(paused); if (paused !== undefined && reason !== undefined) { return { closed: { paused, reason }, view: { continues: true }, }; } if (paused !== undefined) { const record = paused.wire?.protocol === adapter.name ? paused.wire.record : undefined; return { paused, view: { continues: false, awaiting: { pending: paused.pending, ...(record !== undefined && { suspension: record }), }, }, }; } return { ...(prior !== undefined && { prior }), ...(answered !== undefined && { answered }), view: { continues: true }, }; } /** A fresh runner for this request — never one the factory handed out before. */ function build(context) { const runner = options.agent(context); if (runner === null || typeof runner !== 'object' || typeof runner.on !== 'function' || typeof runner.run !== 'function') { throw new TypeError('agent returned no runner'); } if (used.has(runner)) { throw new errors_js_1.WireRequestRefusal('ERR_RUNNER_REUSED', `'${name}': the agent factory returned a runner it returned before. Every ` + `request needs a FRESH runner — a shared one hands one caller's run to another.`, 500); } used.add(runner); return runner; } /** Keep what the run left behind, then say how it ended — kept FIRST, told after. */ async function keep(turn, out, recordRunId) { const { thread, runner, held, translation } = turn; const store = thread === undefined ? undefined : sessions; if ((0, pause_js_1.isPaused)(out)) { const awaiting = adapter.awaitsCaller?.(out.pauseData) === true ? 'caller' : 'person'; if (store !== undefined && thread !== undefined) { const record = translation.suspension?.(awaiting); const conversation = runner.checkpoint?.() ?? noConversation(turn, recordRunId, thread.identity); await store.persist(thread.sessionId, (0, envelope_js_1.toPausedEnvelope)({ checkpoint: out.checkpoint, conversation, pending: (0, pendingAsk_js_1.describePause)(out, thread.sessionId), ...(record !== undefined && { wire: { protocol: adapter.name, record } }), })); } return { kind: 'paused', awaiting }; } if (store !== undefined && thread !== undefined) { const conversation = runner.checkpoint?.(); if (conversation !== undefined) await store.persist(thread.sessionId, (0, envelope_js_1.toEnvelope)(conversation)); // A runner that keeps no conversation still clears the pause it continued // — or closed. else if (held.paused !== undefined || held.closed !== undefined) await store.persist(thread.sessionId, (0, envelope_js_1.toEnvelope)(noConversation(turn, recordRunId, thread.identity))); } return { kind: 'finished' }; } /** * Close the pause an answer could not reach (`Turn.closes`): the * conversation it goes on from replaces it in the store — the run ends * cancelled, nothing having run. */ async function keepClosed(turn) { const { thread, held } = turn; if (sessions !== undefined && thread !== undefined && held.prior !== undefined) await sessions.persist(thread.sessionId, (0, envelope_js_1.toEnvelope)(held.prior)); return { kind: 'cancelled' }; } /** Run the turn; every event the run serves goes through the translation; settle once. */ async function serve(turn, write, signal, release) { const { plan, held, thread, runner, translation, request } = turn; // A translation that throws is a fault in the adapter, never the run's: the // run goes on, and the stream still ends. const safely = (produce) => { try { write(produce()); } catch { // nothing more this request can be told } }; let runId; let off; let settlement; /** The answer was handed to the runner: from here its decision may be used. */ let answering = false; /** Opened as the answer is handed over (`answeredPause.ts`). */ let watch; try { // The runner is this request's alone; the run filter still holds a fact a // shared resource stamped for another run or session out of the stream. off = runner.on('*', (event) => { const named = event.meta?.runId; if (runId === undefined && typeof named === 'string' && named !== eventMeta_js_1.CONSUMER_SCOPE_RUN_ID) runId = named; if (!(0, eventMeta_js_1.eventBelongsToRun)(event.meta, { runId: runId ?? '', sessionId: thread?.sessionId })) return; safely(() => translation.event(event)); }); if (signal.aborted) throw new Error('the caller left before the run started'); if (turn.closes === true) { // An answer to a pause no answer can reach (`hold`): nothing runs. The // pause is closed in the store, and the run ends cancelled, so a // client still holding the question lets it go. settlement = await keepClosed(turn); } else { const runOptions = { signal, ...(thread !== undefined && { sessionId: thread.sessionId }), ...(thread?.identity !== undefined && { identity: thread.identity }), ...(request !== undefined && { request }), }; answering = plan.turn.kind === 'answer'; if (answering && held.paused !== undefined) watch = (0, answeredPause_js_1.watchAnswer)(held.paused); const out = plan.turn.kind === 'answer' ? await runner.resume(held.paused.checkpoint, plan.turn.answer, runOptions) : await runner.run((typeof runner.checkpoint === 'function' && held.prior !== undefined ? { message: plan.turn.message, continueFrom: held.prior } : { message: plan.turn.message }), runOptions); settlement = await keep(turn, out, runId); } } catch (err) { // An answer handed to the runner that then failed — in the run, or in // keeping its result: when its decision may have been used, the stored // pause is answered before the caller hears the end; refused for a // reason no decision can change, it is closed (`answeredPause.ts`). A // caller who left before the run started handed nothing over. let outcome = 'kept'; if (answering && held.paused !== undefined && thread !== undefined && sessions !== undefined) { outcome = await (0, answeredPause_js_1.afterFailedAnswer)({ store: sessions, sessionId: thread.sessionId, paused: held.paused, watch, answer: plan.turn.kind === 'answer' ? plan.turn.answer : undefined, error: err, runner, }).catch(() => 'kept'); } // A pause closed for good ends the run cancelled, as above: the client // lets the question go, and its next message continues the conversation. settlement = signal.aborted || outcome === 'closed' ? { kind: 'cancelled' } : failedSettlement(err); } finally { // The conversation is free BEFORE the caller hears the end — and before // anything else that could throw: a client that answers on RUN_FINISHED // must not find its own turn still running, ever. release(); watch?.release(); try { if (typeof off === 'function') off(); } catch { // a runner that cannot unsubscribe is thrown away with this request } } safely(() => translation.settle(settlement)); // Tool sessions this request's runner opened close with it; a strategy the // app shares between runners is drained, never stopped. void runner.shutdown?.({ stop: false })?.catch(() => undefined); } function respond(turn, request, release) { const abort = new AbortController(); const hangUp = () => abort.abort(); // A caller already gone never starts its run. if (request.signal?.aborted === true) abort.abort(); else request.signal?.addEventListener('abort', hangUp, { once: true }); let closed = false; let heartbeat; const stop = () => { closed = true; if (heartbeat !== undefined) clearInterval(heartbeat); request.signal?.removeEventListener('abort', hangUp); }; const body = new ReadableStream({ start(controller) { const send = (text) => { if (closed) return; try { controller.enqueue(bytes.encode(text)); } catch { stop(); // the stream went away under us } }; // This stream's own count — never the framing's, never another stream's. let written = 0; const write = (events) => { for (const event of events) { const frame = framing.frame(event, { sequence: written + 1 }); written += 1; send(frame); } }; if (heartbeatMs > 0) { heartbeat = setInterval(() => send(': ping\n\n'), heartbeatMs); heartbeat.unref?.(); } const end = () => { if (closed) return; stop(); try { controller.close(); } catch { // already closed by the reader } }; void serve(turn, write, abort.signal, release).then(end, end); }, cancel() { stop(); abort.abort(); }, }); return new Response(body, { status: 200, headers: { 'content-type': framing.contentType, 'cache-control': 'no-cache, no-transform', 'x-accel-buffering': 'no', }, }); } return async (request) => { /** The conversation this request holds while it is in flight — released exactly once. */ let claimed; const release = () => { if (claimed !== undefined) inFlight.delete(claimed); claimed = undefined; }; try { if (request.method !== 'POST') { return refusal(405, `[hosting] '${name}' answers POST only.`, 'ERR_METHOD_NOT_ALLOWED', { allow: 'POST', }); } // Frozen: the reader, the binder and the sealer each read the same headers. const headers = Object.freeze(headersOf(request)); const refused = guard.check({ method: request.method, headers }); if (refused !== undefined) return refusal(refused.status, refused.message, refused.code); const text = await readBody(request, limit, name); let body; try { body = text.trim() === '' ? {} : JSON.parse(text); } catch { return refusal(400, `[hosting] '${name}': the request body is not JSON.`, 'ERR_INVALID_JSON'); } const read = adapter.readRequest({ body, headers }); const thread = await bind(read, headers); if (thread !== undefined) { // Ownership first: a caller the conversation is not answered "not // found" whether or not a turn is running, and never claims it. await ownersOnly(thread); const principal = thread.identity?.principal; if (inFlight.has(thread.sessionId)) { // Even before a first turn has stored anything: the claimant is the owner. if (inFlight.get(thread.sessionId) !== principal) throw notFound(); return refusal(409, `[hosting] '${name}': a turn of that conversation is already in flight. Send the ` + `next turn when it ends.`, 'ERR_CONCURRENT_RUN'); } inFlight.set(thread.sessionId, principal); claimed = thread.sessionId; } const held = await hold(thread); let plan; let closes = false; try { plan = adapter.plan(read, held.view, { headers, ...(thread !== undefined && { thread }), keepsConversation: threads !== undefined, keepsPauses: threads !== undefined && sessions !== undefined, }); } catch (err) { // An answer with nothing pending, on a thread whose last pause a // resume already answered: say which (`answeredPause.ts`) — the // request was answered, and its outcome is not known here. const nothingPending = err?.code === 'ERR_NO_PENDING_ASK'; if (nothingPending && held.answered !== undefined) throw (0, answeredPause_js_1.answeredRefusal)(held.answered); if (!nothingPending || held.closed === undefined) throw err; // An answer to a pause no answer can reach: nothing runs (`serve`). plan = { turn: { kind: 'answer', answer: undefined } }; closes = true; } // Sealed only for a request that runs: every refusal is behind it, and an // answer that closes a pause runs nothing. const sealed = closes ? undefined : await seal?.({ headers }); const runner = build({ tools: plan.tools ?? [], accepted: plan.accepted ?? {}, ...(thread !== undefined && { thread }), }); const settled = await settleClosed(held, runner, sessions); const translation = adapter.open(read, plan.turn.kind === 'answer' ? held.view.awaiting?.suspension : undefined); // The stream owns the claim from here: released when the run ends. const owned = claimed; claimed = undefined; return respond({ plan, held: settled, ...(thread !== undefined && { thread }), runner, translation, ...(sealed !== undefined && { request: sealed }), ...(closes && { closes: true }), }, request, () => { if (owned !== undefined) inFlight.delete(owned); }); } catch (err) { return failure(err, name); } finally { release(); } }; } exports.streamHandler = streamHandler; /** * A held thread whose pause no answer can reach, with the conversation it goes * on from (`answeredPause.ts` · `closedConversation`) — settled against this * request's runner and the handler's session store, which together say * whether the calls the pause left open provably never ran. Any other held thread is handed back as it was. */ async function settleClosed(held, runner, store) { return held.closed === undefined ? held : { ...held, prior: await (0, answeredPause_js_1.closedConversation)(held.closed.paused, { runner, store }) }; } /** * A conversation record for a runner that keeps none (an `LLMCall`, a * composition): no history, only what a stored session needs — the run, the * message, and whose it is. */ function noConversation(turn, recordRunId, identity) { const message = turn.plan.turn.kind === 'message' ? turn.plan.turn.message : turn.held.paused?.conversation.originalInput.message ?? ''; return { version: 1, runId: recordRunId ?? 'unknown', history: [], lastCompletedIteration: 0, originalInput: { message }, checkpointedAt: Date.now(), ...(identity !== undefined && { identity }), }; } /** * How a rejected run settles: failed — naming a library refusal by its code * when the error's CLASS is one (never its words; a thrown value that only * claims the code is a failure like any other). */ function failedSettlement(err) { return err instanceof types_js_1.AnswerValidationError ? { kind: 'failed', refusal: 'ERR_ANSWER_VALIDATION' } : { kind: 'failed' }; } /** The refusal an error means — its own status and code, or a 500 that quotes nothing. */ function failure(err, name) { if (err instanceof errors_js_1.RequestTooLargeError) return refusal(413, err.message, err.code); if (err instanceof errors_js_1.WireRequestRefusal) return refusal(err.status, err.message, err.code); const code = err?.code; if (err instanceof Error && typeof code === 'string' && refusalStatus_js_1.REFUSAL_STATUS[code] !== undefined) { return refusal(refusalStatus_js_1.REFUSAL_STATUS[code], err.message, code); } // Never the thrown message: it is the author's note to their own logs. return refusal(500, `[hosting] '${name}' could not serve the request.`, 'ERR_INTERNAL'); } /** A JSON refusal: `{ error, code }` under the refusal's own status. */ function refusal(status, message, code, headers = {}) { return new Response((0, wireJson_js_1.toWireJson)({ error: message, code }), { status, headers: { 'content-type': 'application/json', ...headers }, }); } /** The request's headers, names lower-cased (the door guard's and an adapter's view). */ function headersOf(request) { const out = {}; request.headers.forEach((value, key) => { out[key.toLowerCase()] = value; }); return out; } /** The body as text, refused at the byte that crosses `limit`. */ async function readBody(request, limit, name) { const declared = Number(request.headers.get('content-length')); if (Number.isFinite(declared) && declared > limit) throw new errors_js_1.RequestTooLargeError(limit, name); if (request.body === null) return ''; const reader = request.body.getReader(); const chunks = []; let size = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > limit) { await reader.cancel().catch(() => undefined); throw new errors_js_1.RequestTooLargeError(limit, name); } chunks.push(value); } const all = new Uint8Array(size); let at = 0; for (const chunk of chunks) { all.set(chunk, at); at += chunk.byteLength; } return new TextDecoder().decode(all); } function positive(value, fallback, field, name) { if (value === undefined) return fallback; if (!Number.isInteger(value) || value <= 0) { throw new TypeError(`[hosting] '${name}': ${field} must be a positive whole number of bytes.`); } return value; } //# sourceMappingURL=streamHandler.js.map