agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
260 lines (259 loc) • 13.7 kB
TypeScript
/**
* hosting/nodeHost — the plain HTTP adapter, built on `node:http` and nothing
* else.
*
* const host = nodeHost({ port: 8080 });
* const handle = await host.serve(async (request, reply) => {
* reply.complete(await answer(request.input));
* });
*
* Two routes: `POST /invoke` takes `{ input, sessionId?, decision? }` and
* answers `{ output }` — or `{ awaiting }` with a **202** when the run stopped
* to ask a person something, which a later `POST` carrying `decision` continues;
* `GET /health` answers `{ status: 'ok' }`. The same `POST` also carries the
* artifact wire operations (9.23.0): `{ op: 'artifact-head', ref, sessionId }`
* answers `{ artifact: { ref, meta } }` and `{ op: 'artifact-get', … }` adds
* `data` — a screen redeeming a claim ticket under the same session identity
* the conversation's own requests present. Both paths are
* options, because the paths are the part most likely to be dictated to you by
* whatever is in front of the process — a load balancer, a container contract,
* a colleague's convention. A path is a deployment detail, so it is a knob
* here and absent from the port entirely.
*
* **The socket can be yours.** Pass `server` — a `node:http` server you
* created and listened on — and this adapter attaches its two routes to it
* instead of binding one, so a WebSocket upgrade or your own routes can share
* the port. `close()` then detaches and drains, and your socket stays up.
*
* **Or the socket can be the host's and the routes yours.** Pass `onUnhandled`
* and every path this adapter does not own is handed to your code instead of
* collecting its 404 — the same one port, bound by the host rather than by you.
* The two are opposite ends of one seam and are refused together.
*
* **There is a third door.** `serveConversations(handler)` takes WebSocket
* upgrades on `/conversation` and hands each one to your handler as a
* `HostConversation` — a channel that stays open, for the callers that cannot
* host an inbound endpoint and dial out instead. It shares this host's socket
* with `/invoke` and `/health`, needs nothing installed, and declares what it
* caps.
*
* **Streaming is the caller's choice, not the server's.** Send
* `Accept: text/event-stream` and the reply is Server-Sent Events, one `chunk`
* event per `reply.emit(...)` then a final `complete`. Send anything else and
* the same handler produces one JSON body — it emits into a buffer that the
* completion settles. The handler cannot tell the difference and does not need
* to, which is the property `capabilities` exists to describe.
*
* Pattern: Adapter. What is specific to THIS adapter is its two paths and its
* JSON dialect (`jsonWire` below) — nothing else. The HTTP work itself lives in
* `httpHost.ts` and is shared with every other HTTP adapter, so two of them can
* never quietly drift apart on what `close()` drains or what a handler that
* throws does. `types.ts` knows none of it either way.
*/
/// <reference types="node" />
import type { HostSignInOptions } from './signin/types.js';
import type { CrossSiteOptions } from './doorGuard.js';
import { type HttpHost, type HttpHostHandle, type HttpWire } from './httpHost.js';
import type { ConversationLimits } from './types.js';
/**
* Options for {@link nodeHost}.
*
* The three cross-site fields — `allowedOrigins`, `allowedHosts`,
* `requireJsonContentType` — are `httpHost`'s own door guard, on both of this
* adapter's doors, with the safe defaults: a request that changes something
* must say it is JSON, a browser may drive the door only from its own host,
* and with `allowedHosts` unset the host says once at boot that it answers any
* name. Behind a proxy, list every name the door is reached by — the library
* never redirects a short name to a canonical one; that is the proxy's job.
*
* @example A door on a company network, reached by one name
* nodeHost({ port: 8080, allowedHosts: ['bot.corp.example'] });
*/
export interface NodeHostOptions extends CrossSiteOptions {
/** Port to bind. Default `8080`. Pass `0` for an ephemeral port. Refused alongside `server`. */
readonly port?: number;
/** Interface to bind. Default `'0.0.0.0'`. Refused alongside `server`. */
readonly hostname?: string;
/** Path that takes a request. Default `'/invoke'`. */
readonly invokePath?: string;
/** Path that answers a health probe. Default `'/health'`. */
readonly healthPath?: string;
/**
* Path that takes a conversation upgrade. Default `'/conversation'` — this
* adapter's own word, chosen the way its other two paths were, and named for
* what it carries rather than for what any runtime calls it.
*/
readonly conversationPath?: string;
/**
* What the conversation door caps. Defaults to one mebibyte per frame and one
* mebibyte held before a handler subscribes — declared, so
* `host.conversationLimits` always reports what is really enforced. This
* adapter declares no `idleMs`: it does not idle a conversation out, and
* reporting a ceiling it neither imposes nor sits behind would be inventing
* a fact.
*/
readonly conversationLimits?: ConversationLimits;
/**
* A `node:http` server **you** own, already listening. Given one, this
* adapter attaches its two routes to it instead of binding a socket of its
* own — the only way to serve something else (a WebSocket upgrade, routes
* that were there first) on the same port, which matters when the thing
* running your process gives it exactly one.
*
* You keep `listen()` and you keep the shutdown; `close()` detaches this
* host and drains what it was already serving. On a server you own the host
* never writes a 404 either: a path it does not own is yours to answer. The
* full set of laws is on `httpHost`'s own `server` option, which this is.
*/
readonly server?: import('node:http').Server;
/**
* Carry a sign-in cookie — see `HttpHostOptions.signIn`. The cookie is
* stripped from every header bag handed onward and only its key goes on;
* conversation handshakes are verified before the 101. Absent, nothing
* changes.
*/
readonly signIn?: HostSignInOptions;
/**
* Answer a request whose path this adapter does not own — your code, on this
* host's socket, instead of its 404.
*
* The inverse of `server`: there you own the socket and lend the host two
* paths; here the host owns the socket and lends you every path it does not
* answer. `/invoke`, `/health` and `/conversation` never reach it, a throw
* inside it is that request's 500, and passing it beside `server` is refused
* by name. The full set of laws is on `httpHost`'s own `onUnhandled`, which
* this is.
*
* @example A diagnostic route beside the agent, on one port
* nodeHost({
* port: 8080,
* onUnhandled: (req, res) => {
* if (req.url === '/debug/trace') {
* res.writeHead(200, { 'content-type': 'application/json' });
* res.end(JSON.stringify(lastTrace));
* return;
* }
* res.writeHead(404, { 'content-type': 'application/json' });
* res.end('{"error":"no such route"}');
* },
* });
*/
readonly onUnhandled?: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void;
/**
* Which header carries the session id. Default `'x-session-id'` — the header
* this adapter has always read, now named so a deployment can change it.
*
* The pairing on the client side is one line:
* `browserSessionId()` from the main barrel mints the id, keeps it, and hands
* it back on every call.
*/
readonly sessionHeader?: string;
/**
* Read the session from a cookie of this name, and ISSUE one (HttpOnly,
* SameSite=Lax) when the request carried none — a durable per-browser
* conversation with no client code at all.
*
* Off by default. The trade-offs are real and are stated in full on
* {@link JsonWireOptions.sessionCookie} — read them before turning it on;
* the short version is that a cookie is browser affinity and never identity,
* and that no `Secure` flag is set because this host cannot know whether it
* is behind TLS.
*/
readonly sessionCookie?: string;
/**
* Ceiling on a request body, in bytes. Default {@link DEFAULT_NODE_MAX_BODY_BYTES}
* (one mebibyte) — the same number the hosted-runtime adapters default to.
*
* `httpHost` itself keeps no default (a number chosen there would be
* inherited by every adapter built on it); this adapter chooses one, because
* without it a caller with no credentials could make the door buffer and
* parse a body of any size before a single rule about it — the session-id
* bound included — could say no. A body over the line is refused with
* `ERR_REQUEST_TOO_LARGE` (413) at the byte that crossed it. One mebibyte is
* about a quarter of a million tokens of input, more than any context window
* serves in one message; raise it if this deployment genuinely carries more.
*/
readonly maxBodyBytes?: number;
}
/** {@link NodeHostOptions.maxBodyBytes}'s default: one mebibyte. */
export declare const DEFAULT_NODE_MAX_BODY_BYTES = 1048576;
/**
* What {@link nodeHost}'s `serve()` resolves to — a `HostHandle` that also
* reports the `url` and `port` it actually bound, which is the only way to find
* out when you asked for port `0`.
*/
export type NodeHostHandle = HttpHostHandle;
/** `AgentHost` narrowed to this adapter's handle. */
export type NodeHost = HttpHost;
/** The header this dialect reads a session id from when nobody says otherwise. */
export declare const DEFAULT_SESSION_HEADER = "x-session-id";
/** What {@link jsonWireWith} lets a deployment re-decide about sessions. */
export interface JsonWireOptions {
/**
* Which header carries the session id. Default `'x-session-id'`, matched
* case-insensitively (headers reach a wire already lower-cased).
*
* Change it when something in front of the process already stamps a
* conversation id under its own name — a gateway, a CDN, a corporate proxy.
* The JSON body's `sessionId` still wins over it, unchanged.
*/
readonly sessionHeader?: string;
/**
* Read the session from a COOKIE of this name, and issue one when the request
* did not carry it. Off by default.
*
* ── What it buys ─────────────────────────────────────────────────────────
* A browser gets a durable conversation with **no client code at all**: the
* first request comes back with `Set-Cookie`, and every later request carries
* it automatically. Nothing to mint, nothing to store, nothing to remember to
* send.
*
* ── What it costs, stated rather than discovered ─────────────────────────
* - **It is browser affinity, not identity.** The same laws as
* {@link HostRequest.sessionId}: a cookie is client data, anyone can send
* any value, and nothing here signs or verifies it. Authenticate the
* caller by your own means before serving the session they claimed.
* - **No `Secure` flag is set**, because this host cannot know whether it is
* behind TLS — it is routinely terminated at a proxy and answers plain
* HTTP itself. On the public internet, add `Secure` at your proxy or
* terminate TLS here; a session cookie on plain HTTP travels in the clear.
* - `HttpOnly` and `SameSite=Lax` ARE set: script on the page cannot read
* the id, and a cross-site POST does not carry it. `HttpOnly` also means
* your own front-end code cannot read it — if the page needs the id in
* hand, use `browserSessionId()` and the header instead.
* - **It follows the browser, not the person.** One profile is one
* conversation; two devices are two, and a shared machine is one.
* - A caller that already sent a session (body, header, or an existing
* cookie) is never issued a second one — two competing handles for one
* conversation is worse than none.
*/
readonly sessionCookie?: string;
}
/**
* Build this adapter's JSON dialect: `{ input, sessionId? }` in, `{ output }`
* out, `{ status: 'ok', uptimeMs }` on the health path — with the session
* plumbing a deployment gets to re-decide.
*
* Exported by name so a deployment that has to keep these exact bodies while
* changing something else about the host reuses them rather than retyping them
* and getting one field subtly wrong.
*/
export declare function jsonWireWith(options?: JsonWireOptions): HttpWire;
/**
* This adapter's own JSON dialect with its defaults: the session on
* `x-session-id`, no cookie.
*/
export declare const jsonWire: HttpWire;
/**
* An HTTP host for one handler.
*
* @example
* const handle = await nodeHost({ port: 0 }).serve(handler);
* await fetch(`${handle.url}/invoke`, {
* method: 'POST',
* headers: { 'content-type': 'application/json' },
* body: JSON.stringify({ input: 'hello', sessionId: 'c-1' }),
* });
* await handle.close();
*/
export declare function nodeHost(options?: NodeHostOptions): NodeHost;