UNPKG

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