agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
364 lines (363 loc) • 19 kB
TypeScript
/**
* adapters/hosting/agentcore — AWS Bedrock **AgentCore Runtime** adapters for
* the two hosting ports.
*
* import { agentCoreRuntimeHost, agentCoreSessions } from 'agentfootprint/hosting';
* import { standingAgent } from 'agentfootprint/hosting';
*
* const handle = await standingAgent({
* agent,
* host: agentCoreRuntimeHost(),
* sessions: agentCoreSessions({ store: 'session-storage' }),
* });
*
* ── What this file actually is ───────────────────────────────────────────────
* Vendor paths, a header name, and two JSON body shapes. That is the whole
* adapter, and it is the claim the hosting ports were designed to make: a
* container runtime's contract is a CONFIGURATION of HTTP work that already
* exists, not a second implementation of it. Nothing here reaches into the
* ports, and nothing here needed the ports to change.
*
* AgentCore Runtime is a **container contract**: an ARM64 image serving HTTP on
* `0.0.0.0:8080` —
*
* POST /invocations JSON `{ "prompt": "..." }` → JSON `{ "response", "status" }`
* GET /ping → `{ "status": "Healthy", "time_of_last_update": <unix seconds> }`
* GET /ws a bidirectional WebSocket, on the SAME port
*
* and the caller's conversation arrives in the
* `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header rather than in the body,
* which is the one thing paths-and-bodies configuration alone could not
* express before this release. Its sibling
* `X-Amzn-Bedrock-AgentCore-Runtime-User-Id` carries WHO is calling (9.12.0) —
* two headers, two different facts, and the second is why a served run can name
* an actor in its audit trail without anybody configuring one.
*
* ── The second door ──────────────────────────────────────────────────────────
* `/ws` is this runtime's answer for a caller that cannot host an inbound
* endpoint — a browser, most obviously — and so dials out and parks a channel
* instead. It is the same container and the same port, which is exactly why the
* two doors here share one socket.
*
* Its wire facts are ADAPTER facts and live nowhere but this file: 32KB frames,
* a 15-minute idle ceiling, the bearer credential carried as a
* `Sec-WebSocket-Protocol` offer because a browser's WebSocket API cannot set a
* header, and session affinity that has to be readable from the query string
* for the same reason. The port knows none of it — it is handed a session id
* and a header bag, the same two things every other transport hands it.
*
* ── Verification status, stated plainly ──────────────────────────────────────
* `agentCoreRuntimeHost` is **plain HTTP and is really verified**: it runs the
* same host conformance suite as `nodeHost`, over a real socket, in
* `test/hosting/host-contract.test.ts`. There is no AWS SDK on its path.
*
* `agentCoreSessions({ store: 'memory' })` is **contract-mapped and
* injection-tested**: its AgentCore Memory calls are exercised through the
* `_client` seam, never against AWS. Confirm the command and field names
* against your installed `@aws-sdk/client-bedrock-agentcore` before you rely
* on it.
*
* Both modes have now been exercised against the real service by a production
* integration, and the `'memory'` mode came back with a defect no injected fake
* could have shown: given an OBJECT as an event's blob, the service stores its
* own host language's `toString()` of it and returns a string that is not JSON
* and cannot be decoded. This shim writes JSON text for exactly that reason.
* Envelopes written by any build before 7.22.1 are unrecoverable — the mangling
* is lossy, so there is nothing to migrate, and the honest response is a loud
* refusal rather than a silent fresh start.
*
* Pattern: Adapter (GoF). Role: outer ring. The file-backed session store uses
* `node:fs` and nothing else; the event-backed one lazy-loads the AWS SDK, so
* importing this module costs zero peer-dep load.
*/
/// <reference types="node" />
import { type CrossSiteOptions } from '../../hosting/doorGuard.js';
import type { ConversationHandshake, HandshakeFacts, HttpHost, HttpWire } from '../../hosting/httpHost.js';
import type { CheckpointEnvelope, SessionLifecycle } from '../../hosting/types.js';
/**
* Options for {@link agentCoreRuntimeHost}.
*
* The three cross-site fields are the door guard every `httpHost` keeps, with
* this adapter's defaults INVERTED — off unless set. The runtime's front door
* is the only way to this port and demands a credential no web page holds (a
* signed request, or the bearer a browser offers on `/ws`), and whether that
* front door forwards `Origin` to the container at all is still unverified —
* so a default Origin rule could refuse the documented browser path while
* protecting nothing (see `platformFrontedDoor` in `src/hosting/doorGuard.ts`).
* Set any of them to enforce it, exactly as on every other door. On a
* LOOPBACK `hostname` (a laptop — no platform fronts it) every plain-host
* default applies instead, the loopback-only `allowedHosts` included; with the
* rules off the host says so once at boot. The session-id bound applies
* regardless: the runtime's own session header is bounded like any other.
*/
export interface AgentCoreRuntimeHostOptions extends CrossSiteOptions {
/**
* Port to bind. Default `8080` — the port the container contract specifies.
* Pass `0` in tests to take an ephemeral one.
*/
readonly port?: number;
/**
* Interface to bind. Default `'0.0.0.0'`, which the contract requires: bind
* to loopback inside the container and the runtime's health probe cannot
* reach you.
*/
readonly hostname?: string;
/**
* Report `'HealthyBusy'` instead of `'Healthy'` on the health path.
*
* A function, not a flag, because busy is a live fact about the process, not
* a setting: the runtime reads it on every probe to decide whether to send
* you more work. Omit it and the host reports `'Healthy'`, which is the
* honest answer for an agent that answers synchronously.
*/
readonly busy?: () => boolean;
/**
* A `node:http` server **you** own, already listening. Given one, this
* adapter attaches `/invocations` and `/ping` to it instead of binding a
* socket of its own — which is the only way to serve something else on the
* same port, and a container gets one port.
*
* The case this exists for: a container that must also answer a WebSocket
* upgrade beside the runtime's two routes. Add your `'upgrade'` listener to
* the server, listen on 8080 yourself, and attach the agent to it.
*
* Every law is `httpHost`'s: unmatched paths are YOURS (this adapter writes
* no 404 on a server it does not own), and `close()` detaches and drains
* without closing your socket. `port` and `hostname` are refused alongside
* it — a server you own already has an address.
*
* @example
* const server = createServer();
* server.on('upgrade', handleWebSocket);
* await new Promise<void>((r) => server.listen(8080, '0.0.0.0', r));
* const handle = await standingAgent({
* agent,
* host: agentCoreRuntimeHost({ server }),
* sessions: agentCoreSessions({ store: 'session-storage' }),
* });
*/
readonly server?: import('node:http').Server;
/**
* Path that takes a conversation upgrade. Default `'/ws'` — the runtime's
* own second door, beside `/invocations` on the same port.
*
* You do not need `server` for this: both doors share one socket by
* construction, which is what the single-port container required.
*/
readonly conversationPath?: string;
/**
* 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`, and the cheaper half of it when all you need is a
* route of your own: the host binds the container's one port as usual and
* hands you everything it does not answer. The runtime's own three paths
* never reach it, a throw inside it is that request's 500, and passing it
* beside `server` is refused by name — there, unmatched paths already reach
* your own listeners.
*
* @example A diagnostic route inside the container, on the one port it has
* agentCoreRuntimeHost({
* onUnhandled: (req, res) => {
* res.writeHead(req.url === '/debug/trace' ? 200 : 404, {
* 'content-type': 'application/json',
* });
* res.end(JSON.stringify(req.url === '/debug/trace' ? lastTrace : { error: 'no route' }));
* },
* });
*/
readonly onUnhandled?: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void;
}
/**
* The AgentCore Runtime contract as an {@link HttpWire}.
*
* Exported so the body shapes are inspectable and testable without binding a
* socket, and so a deployment that must serve the same bodies from somewhere
* else can reuse them by name.
*/
export declare function agentCoreRuntimeWire(busy?: () => boolean): HttpWire;
/**
* The `/ws` handshake, in this runtime's spelling: **header-or-query session
* affinity, and the bearer subprotocol mapped into headers.**
*
* ── Why the query string is read at all ──────────────────────────────────────
* The same header carries the session on `/invocations`, and it is preferred
* here too. But the caller this door exists for is a browser, and the browser
* WebSocket API cannot set a header — so a session id has nowhere to travel
* except the URL. Both the runtime's header name and the plain `sessionId` are
* accepted as query parameters, case-insensitively; **the header wins** when
* both arrive, so a caller that sets both is never surprised by which one the
* server preferred. That is the same precedence rule the request dialect uses.
*
* ── Why the credential becomes a header ──────────────────────────────────────
* A bearer token offered as a subprotocol is this runtime's spelling of
* `Authorization`, and a port field spelled the way one vendor spells it is how
* a port stops being one. So it lands in `headers.authorization` as
* `Bearer <token>` — the vocabulary every other transport already uses, with
* the vendor's base64url wrapper already undone — and the raw
* `sec-websocket-protocol` header is left in place, so an application that
* reads the offer itself still can. **Nothing here authenticates anything**:
* the port never proves who is calling, and a token that arrived is a claim,
* exactly like the session id beside it.
*
* ── When it refuses ──────────────────────────────────────────────────────────
* Two shapes throw rather than degrade, and both throws end this one upgrade
* with a message naming the reason: a dotted value that is not valid base64url
* (see {@link BEARER_SUBPROTOCOL}), and a dotted value offered without the
* sentinel beside it. The alternative — mapping a credential nobody can read,
* or echoing the token back to the client — is the failure shape this adapter
* is built to refuse.
*
* Exported by name so the mapping is inspectable and testable without binding a
* socket, the same way the body shapes are.
*/
export declare function readAgentCoreConversation(facts: HandshakeFacts): ConversationHandshake;
/**
* An `AgentHost` that speaks AgentCore Runtime's container contract.
*
* Passes the same conformance suite as `nodeHost` — it is the same HTTP host
* with this runtime's two paths, its header, and its two body shapes.
*
* @example The container's entry point
* const handle = await standingAgent({
* agent,
* host: agentCoreRuntimeHost(),
* sessions: agentCoreSessions({ store: 'session-storage' }),
* });
* process.on('SIGTERM', () => void handle.close());
*/
export declare function agentCoreRuntimeHost(options?: AgentCoreRuntimeHostOptions): HttpHost;
/**
* Where {@link agentCoreSessions} keeps a conversation between requests.
*
* - `'session-storage'` — a JSON file under the container's own storage. The
* runtime keeps that storage for the life of a session, INCLUDING across a
* stop/resume of the container, so a conversation survives the thing most
* likely to interrupt it. It does not survive the session ending.
* - `'memory'` — one AgentCore Memory event per persist. Outlives the session,
* the container and the deployment; costs an API call per turn and the
* `@aws-sdk/client-bedrock-agentcore` peer dependency.
*
* Chosen at construction, never per call: a store that silently changed where
* it wrote would be a store you cannot reason about after an incident.
*/
export type AgentCoreSessionStore = 'session-storage' | 'memory';
/** The default file the `'session-storage'` mode writes to. */
export declare const DEFAULT_SESSION_STORAGE_PATH = "/tmp/agentcore-session";
/** Options for the file-backed mode. */
export interface AgentCoreFileSessionsOptions {
readonly store: 'session-storage';
/**
* Where to write. Default {@link DEFAULT_SESSION_STORAGE_PATH}. One file
* holds every session this container has seen, keyed by session id — the
* runtime already gives each session its own storage, so the keying is
* belt-and-braces for the case where it does not.
*/
readonly path?: string;
}
/** One AgentCore Memory event, as this adapter cares about it. */
export interface AgentCoreSessionEvent {
/** Server-assigned event id. */
readonly eventId: string;
/**
* The envelope decoded from the event's blob payload.
*
* `null` means the event carried **no blob at all** — nothing here ever
* claimed to be a session, which is an absence and hydrates as "no
* conversation".
*
* A blob that IS present but could not be decoded travels here **as-is**, so
* the shared reading law refuses it by name rather than this adapter quietly
* calling a conversation that exists an absent one. Those are different facts
* and only one of them is safe to answer with a fresh start.
*/
readonly envelope: unknown;
}
/**
* The minimal AgentCore Memory surface the session store calls. The real SDK is
* adapted to this shape in one function below; tests inject a fake via
* `_client` and never touch AWS.
*/
export interface AgentCoreSessionClientLike {
/**
* Append one envelope as an event (the server assigns the event id).
*
* The envelope arrives as an OBJECT; how it reaches the wire is the
* implementation's business. The shipped shim writes it as JSON text, because
* this service returns an object blob back as its own host language's
* `toString()` of it — see `createSessionClient`.
*/
createEvent(input: {
memoryId: string;
actorId: string;
sessionId: string;
envelope: CheckpointEnvelope;
}): Promise<void>;
/** The session's events, newest first — the adapter reads only the newest. */
listEvents(input: {
memoryId: string;
actorId: string;
sessionId: string;
maxResults?: number;
}): Promise<{
events: readonly AgentCoreSessionEvent[];
}>;
}
/** Options for the event-backed mode. */
export interface AgentCoreMemorySessionsOptions {
readonly store: 'memory';
/** AgentCore Memory ARN or id. Required. */
readonly memoryId: string;
/** AWS region, when the adapter constructs the SDK client itself. */
readonly region?: string;
/**
* The AgentCore `actorId` these conversations belong to. Default
* `'afp-standing-agent'`. One actor per deployed agent is the usual shape.
*/
readonly actorId?: string;
/** Pre-built client, to share one SDK config across the host app. */
readonly client?: AgentCoreSessionClientLike;
/** @internal Test injection — skips the SDK require entirely. */
readonly _client?: AgentCoreSessionClientLike;
/** @internal Test injection — the AWS SDK module, to exercise the real shim with a fake SDK. */
readonly _sdk?: BedrockAgentCoreSessionSdkModule;
}
/** Options for {@link agentCoreSessions}. */
export type AgentCoreSessionsOptions = AgentCoreFileSessionsOptions | AgentCoreMemorySessionsOptions;
/**
* A `SessionLifecycle` backed by AgentCore, with the checkpoint's home chosen
* at construction.
*
* Both modes store the SAME `CheckpointEnvelope` the port defines — a
* conversation or a paused run — and both refuse an unknown `format` by name
* through the shared `checkEnvelope`: a session written by a newer runtime is
* refused, never half-restored. That law is inherited, not re-implemented, and
* so is its other half: a stored session that is present but **unreadable** is
* refused by name too (`UnreadableEnvelopeError`), never answered with a fresh
* start. Only a session that was never written hydrates as `undefined`.
*
* **The two modes differ on retention (9.42.0), and the difference is
* reported rather than smoothed over.** `'session-storage'` owns its file, so
* it implements the port's optional `retention()` as a sweep you call.
* `'memory'` appends events to a service whose expiry belongs to the memory
* resource an operator configured, and this shim has no delete on its surface
* — so it implements no retention member at all, and `sessionRetention()`
* refuses BY NAME rather than reporting a sweep that would delete nothing.
*
* @example Survive a stop/resume, no AWS SDK required
* agentCoreSessions({ store: 'session-storage' });
*
* @example Outlive the session entirely
* agentCoreSessions({ store: 'memory', memoryId: process.env.MEMORY_ID!, region: 'us-west-2' });
*/
export declare function agentCoreSessions(options: AgentCoreSessionsOptions): SessionLifecycle;
/** The slice of `@aws-sdk/client-bedrock-agentcore` this shim touches. */
export interface BedrockAgentCoreSessionSdkModule {
readonly BedrockAgentCoreClient?: new (config: {
region?: string;
}) => {
send(cmd: unknown): Promise<unknown>;
};
readonly CreateEventCommand?: new (input: unknown) => unknown;
readonly ListEventsCommand?: new (input: unknown) => unknown;
}