/** * 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": }` * 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. */ /// import type { ConversationHandshake, HandshakeFacts, HttpHost, HttpWire } from '../../hosting/httpHost.js'; import type { CheckpointEnvelope, SessionLifecycle } from '../../hosting/types.js'; /** Options for {@link agentCoreRuntimeHost}. */ export interface AgentCoreRuntimeHostOptions { /** * 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((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 ` — 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; /** 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; }; readonly CreateEventCommand?: new (input: unknown) => unknown; readonly ListEventsCommand?: new (input: unknown) => unknown; }