UNPKG

agentfootprint

Version:

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

156 lines • 8.67 kB
/** * 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. */ import type { AgentRunCheckpoint } from '../core/runCheckpoint.js'; import type { Runner } from '../core/runner.js'; import { type CrossSiteOptions } from './doorGuard.js'; import type { ProtocolAdapter, StreamRunContext, ThreadBinder } from './protocolAdapter.js'; import { type RequestSealer } from './requestSealing.js'; import type { SessionLifecycle } from './types.js'; /** The default body ceiling — 1 MiB, the same as `nodeHost`. */ export declare const DEFAULT_STREAM_MAX_BODY_BYTES = 1048576; /** The default keep-alive interval: an SSE comment every 15 s of silence. */ export declare const DEFAULT_STREAM_HEARTBEAT_MS = 15000; /** * The runner one request runs — an `Agent`, an `LLMCall`, a composition. An * Agent's `checkpoint()` is the conversation a session store keeps; a runner * without one answers each request's message on its own. */ export type StreamRunner = Pick<Runner, 'on' | 'run' | 'resume'> & { checkpoint?(): AgentRunCheckpoint | undefined; shutdown?(options?: { readonly stop?: boolean; }): Promise<void>; }; /** Options for {@link streamHandler}. */ export interface StreamHandlerOptions<TRequest, TEvent> extends CrossSiteOptions { /** * Builds a FRESH runner for every request — called once per request, with * what that request's runner is built with ({@link StreamRunContext}: the * tools the caller runs, the values the app's validators accepted, the bound * conversation). A runner is never shared between requests; one handed back * twice is refused. */ readonly agent: (run: StreamRunContext) => StreamRunner; /** The wire protocol — `agUi()` from `agentfootprint/ag-ui`. */ readonly adapter: ProtocolAdapter<TRequest, TEvent>; /** * The app's port that binds the conversation a request names to the caller * the app authenticates ({@link ThreadBinder}). Without it every request is * a conversation of one message: no session, no identity, nothing kept. */ readonly threads?: ThreadBinder; /** * Where a bound conversation is kept between requests — what was said (an * Agent's `checkpoint()`) and a run paused on an answer — under the key * `threads` binds (`memorySessions()`, `sqliteSessions()`, or yours). Needs * `threads`: nothing is ever kept under the id a request names. */ readonly sessions?: SessionLifecycle; /** * Seal the credential a request carries for its run ({@link RequestSealer}) — * a gateway's per-request token in a header, read by a `requestCredentials` * provider for the tools that declare a `needs` for it. Called once per * request that runs, after the thread is bound and the turn is planned, with * the request's headers; its handle goes to THAT request's * `run()` / `resume()` and nowhere else — never a frame, a stored session or * a checkpoint. A handle returned twice is refused; a sealer that throws * refuses the request in fixed words (`ERR_REQUEST_NOT_SEALED`, 400). * Without it, no run carries a request. */ readonly sealRequest?: RequestSealer; /** The largest request body read, in bytes. Default {@link DEFAULT_STREAM_MAX_BODY_BYTES}. */ readonly maxBodyBytes?: number; /** * An SSE comment (`: ping`) every this many milliseconds, so a proxy does not * cut a stream that is quiet while the model thinks. Default * {@link DEFAULT_STREAM_HEARTBEAT_MS}; `0` turns it off. */ readonly heartbeatMs?: number; /** Names this handler in refusals. Default `'<adapter name> stream'`. */ readonly name?: string; } /** A Fetch-API handler: one request in, one streamed response out. */ export type StreamHandler = (request: Request) => Promise<Response>; /** * 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(), * }); * ``` */ export declare function streamHandler<TRequest, TEvent>(options: StreamHandlerOptions<TRequest, TEvent>): StreamHandler; //# sourceMappingURL=streamHandler.d.ts.map