agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
236 lines • 12.9 kB
JavaScript
/**
* 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.
*/
import { artifactWireBody, readArtifactWireOp } from './artifactWire.js';
import { readSessionWireOp, sessionWireBody } from './sessionWire.js';
import { headerValue, httpHost, } from './httpHost.js';
/** {@link NodeHostOptions.maxBodyBytes}'s default: one mebibyte. */
export const DEFAULT_NODE_MAX_BODY_BYTES = 1_048_576;
const HOST_NAME = 'nodeHost';
/** The header this dialect reads a session id from when nobody says otherwise. */
export const DEFAULT_SESSION_HEADER = 'x-session-id';
/**
* 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 function jsonWireWith(options = {}) {
const sessionHeader = options.sessionHeader ?? DEFAULT_SESSION_HEADER;
const sessionCookie = options.sessionCookie;
return {
readRequest(facts) {
const input = typeof facts.body.input === 'string' ? facts.body.input : '';
// sessionId is caller data whichever way it arrives; the body wins so a
// caller that sets both is not surprised by which one the server preferred.
// The cookie is last, because a caller that named a session explicitly
// meant that one — the cookie is what the BROWSER remembered, and the
// explicit value is what this request said.
const fromBody = typeof facts.body.sessionId === 'string' ? facts.body.sessionId : undefined;
const fromHeader = headerValue(facts, sessionHeader);
const fromCookie = sessionCookie !== undefined ? cookieValue(facts.headers.cookie, sessionCookie) : undefined;
const carried = fromBody ?? fromHeader ?? fromCookie;
// Issued ONLY in cookie mode, and only when this request carried no
// session at all. A caller holding a session already has a conversation;
// handing it a second handle would split it in two.
const minted = sessionCookie !== undefined && carried === undefined ? mintSessionId() : undefined;
const sessionId = carried ?? minted;
// Read as-is and never coerced: `decision` is a person's answer to whatever
// a tool asked, and this dialect does not get to decide what that looks
// like. Its PRESENCE is the whole signal.
const decision = facts.body.decision;
// The artifact wire operations (9.23.0): a body naming `op` redeems a
// claim ticket instead of starting a turn. One reader owns the grammar;
// an op this dialect does not speak refuses there rather than quietly
// becoming a conversation turn. The session rides body/header/cookie
// above, exactly as every other request's does — the resolution is
// governed by that same session identity.
const artifact = readArtifactWireOp(facts.body);
// The session-history operations (9.26.0), read by their own grammar
// owner. Same law as the artifact ops beside them: a body naming `op`
// never quietly becomes a conversation turn, and neither reader claims
// the other's names.
const session = readSessionWireOp(facts.body);
return {
input,
...(sessionId !== undefined && { sessionId }),
...(decision !== undefined && { decision }),
...(artifact !== undefined && { artifact }),
...(session !== undefined && { session }),
...(minted !== undefined &&
sessionCookie !== undefined && {
responseHeaders: { 'set-cookie': sessionCookieHeader(sessionCookie, minted) },
}),
};
},
health: (uptimeMs) => ({ status: 'ok', uptimeMs }),
output: (output) => ({ output }),
failure: (error, code) => ({ error, ...(code !== undefined && { code }) }),
chunk: (text) => ({ text }),
awaiting: (pending) => ({ awaiting: pending }),
artifact: (result) => artifactWireBody(result),
sessions: (result) => sessionWireBody(result),
readConversation(facts) {
// A handshake has no body, so this dialect names the three places a
// session id can be: the header a server-side caller sets, the cookie the
// browser sends on its own (when cookie mode is on), and the query a
// BROWSER has to use because the WebSocket API gives it no way to set a
// header. Header wins, so a caller that sets both is not surprised by
// which one the server preferred — the same rule the request dialect uses
// for body-vs-header.
//
// Nothing is MINTED here, in either mode: the 101 this door writes is not
// a reply a wire composes, so a session issued on a handshake could never
// reach the client and would be a conversation nobody could resume.
const sessionId = headerValue(facts, sessionHeader) ??
(sessionCookie !== undefined
? cookieValue(facts.headers.cookie, sessionCookie)
: undefined) ??
facts.query.get('sessionId') ??
undefined;
return { ...(sessionId !== undefined && sessionId.length > 0 && { sessionId }) };
},
};
}
/**
* This adapter's own JSON dialect with its defaults: the session on
* `x-session-id`, no cookie.
*/
export const jsonWire = jsonWireWith();
/**
* One cookie's value out of a `Cookie` header, or `undefined`.
*
* Deliberately small: it splits on `;`, takes the first `=`, and trims. It does
* not decode, because a session id this library minted is URL-safe and a value
* somebody else's system minted is theirs to interpret — guessing at an
* encoding is how a session id silently becomes a different string.
*/
function cookieValue(header, name) {
if (header === undefined || header.length === 0)
return undefined;
for (const pair of header.split(';')) {
const at = pair.indexOf('=');
if (at < 0)
continue;
if (pair.slice(0, at).trim() !== name)
continue;
const value = pair.slice(at + 1).trim();
return value.length > 0 ? value : undefined;
}
return undefined;
}
/** The `Set-Cookie` this dialect issues. See {@link JsonWireOptions.sessionCookie}. */
function sessionCookieHeader(name, value) {
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax`;
}
/** A session id for a caller that carried none. Node 20+ has global `crypto`. */
function mintSessionId() {
return globalThis.crypto.randomUUID();
}
/**
* 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 function nodeHost(options = {}) {
// The shared default instance when nothing about sessions was re-decided, so
// a host built with no options is the same object graph it always was.
const wire = options.sessionHeader === undefined && options.sessionCookie === undefined
? jsonWire
: jsonWireWith({
...(options.sessionHeader !== undefined && { sessionHeader: options.sessionHeader }),
...(options.sessionCookie !== undefined && { sessionCookie: options.sessionCookie }),
});
return httpHost({
name: HOST_NAME,
wire,
// Chosen, not inherited — see the test in this folder that greps for one
// particular runtime's container-contract path literal.
invokePath: options.invokePath ?? '/invoke',
healthPath: options.healthPath ?? '/health',
conversationPath: options.conversationPath ?? '/conversation',
...(options.conversationLimits !== undefined && {
conversationLimits: options.conversationLimits,
}),
// Passed through as given, never defaulted: with a caller-owned server
// there is no port to default, and a port passed WITH one is a caller who
// believes something untrue about where their agent answers — `httpHost`
// refuses that pair by name rather than picking a winner.
...(options.port !== undefined && { port: options.port }),
...(options.hostname !== undefined && { hostname: options.hostname }),
...(options.server !== undefined && { server: options.server }),
// Passed through as given for the same reason: with a caller-owned server
// this hook is refused BY NAME rather than dropped, so a caller who asked
// for both learns which one this host cannot honour.
...(options.onUnhandled !== undefined && { onUnhandled: options.onUnhandled }),
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_NODE_MAX_BODY_BYTES,
// The door guard's three rules, as given — unset keeps `httpHost`'s safe
// defaults, which is the whole point of not restating them here.
...(options.allowedOrigins !== undefined && { allowedOrigins: options.allowedOrigins }),
...(options.allowedHosts !== undefined && { allowedHosts: options.allowedHosts }),
...(options.requireJsonContentType !== undefined && {
requireJsonContentType: options.requireJsonContentType,
}),
...(options.signIn !== undefined && { signIn: options.signIn }),
});
}
//# sourceMappingURL=nodeHost.js.map