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
TypeScript
/**
* 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