UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

821 lines • 44.3 kB
/** * 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. */ import { artifactWireBody, readArtifactWireOp } from '../../hosting/artifactWire.js'; import { readSessionWireOp, sessionWireBody } from '../../hosting/sessionWire.js'; import { platformFrontedDoor } from '../../hosting/doorGuard.js'; import { checkEnvelope } from '../../hosting/envelope.js'; import { headerValue, httpHost } from '../../hosting/httpHost.js'; import { DEFAULT_SWEEP_LIMIT } from '../../hosting/types.js'; import { lazyRequire } from '../../lib/lazyRequire.js'; // ─── The runtime host ──────────────────────────────────────────────── const HOST_NAME = 'agentCoreRuntimeHost'; /** The runtime's container contract, as constants rather than as scattered literals. */ const INVOKE_PATH = '/invocations'; const HEALTH_PATH = '/ping'; const CONVERSATION_PATH = '/ws'; const RUNTIME_PORT = 8080; /** * The header the runtime puts the caller's conversation in. Matched * case-insensitively — HTTP header names are case-insensitive and a proxy in * front of the container is free to re-case them. */ const SESSION_HEADER = 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id'; /** * The header the runtime puts the caller's END USER in — the second half of * "who is this request for", and the documented inbound source for per-user * identity (9.12.0). * * **Both names are the SDK's, not a guess.** `InvokeAgentRuntimeRequest` binds * `runtimeSessionId` to {@link SESSION_HEADER} and `runtimeUserId` to this one, * and the SDK describes this one as "an identifier for the end user making the * request… passed through to the runtime container" — which is what makes the * container's side of it this adapter's business. Verified against a real * install of `@aws-sdk/client-bedrock-agentcore` before this shipped. * * Matched case-insensitively, through the same `headerValue` the session uses, * for the same reason: a proxy in front of the container re-cases freely, and * two header readers that disagree about casing is how one door sees a user the * other cannot. * * **What it is worth, plainly.** The runtime passes this through from its front * door, so its trustworthiness is that door's inbound-auth configuration — with * JWT auth in front, it is the authenticated caller; on a container somebody * exposed directly, it is a string anyone can send. It is read HERE and nowhere * else for exactly that reason: the generic JSON wire has no such door in front * of it, so it reads no such header. */ const USER_HEADER = 'X-Amzn-Bedrock-AgentCore-Runtime-User-Id'; /** * What the `/ws` door caps, as the runtime imposes it — **32KB per frame and a * 15-minute idle timeout.** * * Declared rather than worked around. A 32KB cap hidden inside auto-chunking * would have this adapter deciding, for every consumer at once, how a message * is split and how the far side knows the last piece landed; a 15-minute idle * answered with an invented heartbeat would put bytes on the wire that the * consumer's parser never agreed to. Both belong to the protocol above this * door, and both are actionable only if the number is visible. * * `maxFrameBytes` is enforced here, in both directions, because a frame past it * would be cut by the front door anyway and a refusal that names the ceiling * beats a truncation that does not. `idleMs` is REPORTED: the timeout belongs * to what sits in front of this container, and the honest thing is to say so * rather than to close a channel the runtime might have kept. */ const CONVERSATION_LIMITS = { maxFrameBytes: 32_768, idleMs: 900_000 }; /** * The subprotocol a browser offers to carry its bearer credential, because the * WebSocket API gives it no way to send an `Authorization` header. * * **The vendor spells it, so the vendor's spelling is the contract.** From * *Get started with bidirectional streaming using WebSocket* → "Browser * JavaScript client with OAuth": "The token must be base64url-encoded and * prefixed with `base64UrlBearerAuthorization.`, followed by the sentinel * subprotocol `base64UrlBearerAuthorization`." Its example offers the pair, in * this order: * * new WebSocket(url, [`base64UrlBearerAuthorization.${base64url}`, * 'base64UrlBearerAuthorization']) * * and the same page notes: "Subprotocols other than * `base64UrlBearerAuthorization` are not yet supported." * * Three things follow, and all three are the difference between a mapping and * a mapping that works: * * • **Only this word.** Until 7.27.1 this adapter looked for `bearer` and * `bearer.<token>` — spellings nobody documented and no browser can send * through this front door, which forwards no other subprotocol. A real * documented handshake matched neither and yielded `{}`: the credential * silently dropped. Invented spellings are gone rather than kept beside * the real one, because a door nobody can walk through should not be * advertised. A generic bearer-subprotocol mapping, if one is ever wanted * off this runtime, belongs to the generic wire and its own evidence. * * • **The value is encoded.** It is base64url (unpadded), so it is decoded * before it becomes `Bearer <jwt>`. A value that is not valid base64url * refuses the upgrade by name: a token that does not decode is not a * credential, and "authenticated with garbage" is the failure this library * exists to refuse. * * • **The echo is the sentinel.** RFC 6455 lets a server select only a * subprotocol the client OFFERED, so what comes back in the 101 is the * sentinel, in the client's exact spelling — never lower-cased, and never * the dotted value, because a credential in a response header is a * credential in every proxy log on the way home. Offer the dotted token * without the sentinel and the upgrade is refused: there is then nothing * safe to echo, and the browser's handshake fails either way. */ const BEARER_SUBPROTOCOL = 'base64UrlBearerAuthorization'; /** * 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 function agentCoreRuntimeWire(busy) { return { readRequest(facts) { // `prompt` is the field the runtime's own quickstart and this repo's // deploy template use. `input` is accepted too because the runtime passes // the payload through verbatim — it is the CALLER who picks the field — // and refusing a caller who used the port's own word would be a rule this // adapter invented rather than one the contract imposes. const prompt = facts.body.prompt ?? facts.body.input; const input = typeof prompt === 'string' ? prompt : ''; // The conversation id arrives in a header, never in the body. It is // caller-adjacent data, not identity — the port says so and it is just as // true here. const sessionId = headerValue(facts, SESSION_HEADER); // WHO the request is for — the other header the runtime forwards, and a // different fact from the session beside it: a session is a thread, this // is a person. Read as-is and absent when absent; the runtime sends it // only when the caller supplied one, and deriving a user from a session // id would put a thread's name in the actor's place. const userId = headerValue(facts, USER_HEADER); // A person's answer to a run that stopped to ask. Read as-is: what a // decision looks like is the tool author's business, not this dialect's. // Without it a deployment here could start turns but never finish one // that asked a question. const decision = facts.body.decision; // The artifact wire operations (9.23.0). The runtime passes the payload // through verbatim — the CALLER picks the fields — so the shared // `{ op, ref }` grammar reads here exactly as it does on `jsonWire`, // and a screen behind this runtime redeems tickets the same way. const artifact = readArtifactWireOp(facts.body); // The session-history operations (9.26.0). Same reasoning as the // artifact ops one line up: the runtime passes the payload through // verbatim, so the shared grammar reads here exactly as it does on // `jsonWire`, and a sidebar behind this runtime lists conversations the // same way. const session = readSessionWireOp(facts.body); return { input, ...(sessionId !== undefined && { sessionId }), ...(userId !== undefined && { userId }), ...(decision !== undefined && { decision }), ...(artifact !== undefined && { artifact }), ...(session !== undefined && { session }), }; }, // The runtime polls this to decide whether the container is ready and // whether to send it more work. `time_of_last_update` is unix SECONDS. health: () => ({ status: busy?.() === true ? 'HealthyBusy' : 'Healthy', time_of_last_update: Math.floor(Date.now() / 1000), }), output: (response) => ({ response, status: 'success' }), // The message only — never a stack. The runtime surfaces the status code; // the body is read by whoever called the agent. failure: (error, code) => ({ error, status: 'error', ...(code !== undefined && { code }) }), // A distinct field from `response` on purpose: a caller concatenating // stream frames must not be able to double-count the final answer by // reading the same key twice. chunk: (chunk) => ({ chunk }), // Unfinished work, in this dialect's own vocabulary. `status` is neither // 'success' nor 'error' because it is neither: the run stopped to ask a // person, it is stored, and a later call carrying `decision` finishes it. awaiting: (pending) => ({ awaiting: pending, status: 'awaiting' }), // A resolved claim ticket: the standard body, plus this dialect's own // `status` beside it — the same envelope rule its other replies follow. artifact: (result) => ({ ...artifactWireBody(result), status: 'success' }), // Resolved session history, in this dialect's own envelope — the same // spread-and-add rule its other replies follow. sessions: (result) => ({ ...sessionWireBody(result), status: 'success' }), readConversation: readAgentCoreConversation, }; } /** * 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 function readAgentCoreConversation(facts) { const sessionId = headerValue(facts, SESSION_HEADER) ?? queryValue(facts, SESSION_HEADER, 'sessionId'); const offered = bearerFromSubprotocols(facts.headers['sec-websocket-protocol']); return { ...(sessionId !== undefined && { sessionId }), ...(offered !== undefined && { headers: { authorization: `Bearer ${offered.token}` }, // Echoed so the client's handshake completes: the SENTINEL, in the exact // spelling the client offered it in — RFC 6455 lets a server select only // something the client offered. Never the dotted value: putting the // token in a response header would hand the credential to every proxy on // the way home. protocol: offered.echo, }), }; } /** A query parameter by any of several names, matched case-insensitively. */ function queryValue(facts, ...names) { const wanted = names.map((name) => name.toLowerCase()); for (const [key, value] of facts.query.entries()) { if (wanted.includes(key.toLowerCase()) && value.length > 0) return value; } return undefined; } /** * The decoded token, and the sentinel to echo, out of a `Sec-WebSocket-Protocol` * offer written in the vendor's documented scheme. See {@link BEARER_SUBPROTOCOL} * for the scheme and the quotes it comes from. * * `undefined` means no credential was offered — an absence, not a failure. * A credential that was offered and cannot be honoured throws instead. */ function bearerFromSubprotocols(offered) { if (!offered) return undefined; const parts = offered .split(',') .map((part) => part.trim()) .filter((part) => part.length > 0); const marker = BEARER_SUBPROTOCOL.toLowerCase(); const prefix = `${marker}.`; const dotted = parts.find((part) => part.toLowerCase().startsWith(prefix)); if (dotted === undefined) return undefined; // The sentinel is what may be echoed, so without it there is nothing this // door can answer with: echoing the dotted value would put the credential in // a response header, and echoing nothing fails the browser's handshake. // The vendor documents the pair — "prefixed with `base64UrlBearerAuthorization.`, // followed by the sentinel subprotocol `base64UrlBearerAuthorization`" — so a // lone dotted offer is a client that has not met the contract, and saying so // beats a connection that silently carries no credential. const sentinel = parts.find((part) => part.toLowerCase() === marker); if (sentinel === undefined) { throw new Error(`a '${BEARER_SUBPROTOCOL}.<token>' subprotocol was offered without the ` + `'${BEARER_SUBPROTOCOL}' sentinel beside it. AgentCore documents the pair, and the ` + 'sentinel is the only value this door can echo — the token must never travel back ' + `in a response header. Offer both: ['${BEARER_SUBPROTOCOL}.<base64url-token>', ` + `'${BEARER_SUBPROTOCOL}']`); } const encoded = dotted.slice(prefix.length); return { token: decodeBase64UrlToken(encoded), echo: sentinel }; } /** * The bearer token out of its base64url wrapper — or a refusal. * * Node decodes `'base64url'` leniently: invalid characters are skipped and a * truncated group yields whatever bytes it can, so decoding alone cannot tell a * token from a typo. The alphabet check and the re-encode round trip can, and * the failure they catch matters: a value that is not a token, mapped to * `authorization` anyway, is a request that looks authenticated and is not. */ function decodeBase64UrlToken(encoded) { const refuse = (why) => { throw new Error(`the '${BEARER_SUBPROTOCOL}.<token>' subprotocol carried a value that is not a ` + `base64url-encoded bearer token: ${why}. AgentCore's browser scheme base64url-encodes ` + 'the token (unpadded), and a value that does not decode is not a credential.'); }; if (encoded.length === 0) refuse('it is empty'); if (!/^[A-Za-z0-9_-]+$/.test(encoded)) refuse('it is not in the base64url alphabet'); const bytes = Buffer.from(encoded, 'base64url'); if (bytes.toString('base64url') !== encoded) refuse('it is not a whole base64url value'); const token = bytes.toString('utf8'); if (token.length === 0) refuse('it decodes to nothing'); // A bearer token is printable ASCII (RFC 6750 `b64token`). Anything else // decoded — a control character, a newline — is not a credential, and would // be going into a header value. // eslint-disable-next-line no-control-regex if (/[\x00-\x20\x7f]/.test(token)) refuse('it decodes to bytes that are not a bearer token'); return token; } /** * 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 function agentCoreRuntimeHost(options = {}) { return httpHost({ name: HOST_NAME, wire: agentCoreRuntimeWire(options.busy), invokePath: INVOKE_PATH, healthPath: HEALTH_PATH, conversationPath: options.conversationPath ?? CONVERSATION_PATH, conversationLimits: CONVERSATION_LIMITS, // With a caller-owned server this adapter binds nothing, so it must not // invent the contract's port either — the port is whatever the caller // listened on, and passing one anyway is refused by `httpHost` rather than // quietly ignored. An explicitly passed port still travels, so that refusal // reaches a caller who asked for both. ...(options.server !== undefined ? { server: options.server, ...(options.port !== undefined && { port: options.port }), ...(options.hostname !== undefined && { hostname: options.hostname }), } : { port: options.port ?? RUNTIME_PORT, hostname: options.hostname ?? '0.0.0.0' }), // Travels as given, including next to a `server` — where `httpHost` refuses // the pair by name rather than quietly dropping one half of what was asked // for. ...(options.onUnhandled !== undefined && { onUnhandled: options.onUnhandled }), // The platform fronts this port: browser rules off unless the deployment // sets them (see the options' doc) — except on a LOOPBACK bind, which no // platform fronts, where every plain-host default applies. ...platformFrontedDoor(options, options.server === undefined ? options.hostname : undefined), }); } /** The default file the `'session-storage'` mode writes to. */ export const DEFAULT_SESSION_STORAGE_PATH = '/tmp/agentcore-session'; /** * 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 function agentCoreSessions(options) { return options.store === 'memory' ? memoryEventSessions(options) : fileSessions(options); } function fileSessions(options) { const path = options.path ?? DEFAULT_SESSION_STORAGE_PATH; /** Write the whole file the way `persist` does — one temp file, one rename. */ async function writeFileAtomically(next) { const { writeFile, rename, mkdir } = await import('node:fs/promises'); const { dirname } = await import('node:path'); await mkdir(dirname(path), { recursive: true }).catch(() => undefined); // Write-then-rename: a container killed mid-write leaves the previous // conversations intact rather than a truncated file that refuses to parse. const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, JSON.stringify(next), 'utf8'); await rename(temporary, path); } async function readFile() { const { readFile: read } = await import('node:fs/promises'); let raw; try { raw = await read(path, 'utf8'); } catch { // No file yet is the ordinary first-request state, not an error. return { version: 1, sessions: {} }; } let parsed; try { parsed = JSON.parse(raw); } catch (err) { throw new TypeError(`[hosting] the session file at '${path}' is not JSON (${err.message}). ` + `Refusing rather than starting every conversation over silently — delete the file ` + `to start fresh, or point 'path' somewhere else.`); } const sessions = parsed?.sessions; return sessions && typeof sessions === 'object' ? { version: 1, sessions: sessions } : { version: 1, sessions: {} }; } return { async hydrate(sessionId) { const file = await readFile(); // `hasOwn`, not a bare lookup. A session id is caller data — on this // runtime it arrives in a header — and the sessions map is a plain object // parsed from JSON, so `file.sessions['constructor']` answers with // something off `Object.prototype` on a store that has never been // written to. That reached `checkEnvelope`, which refused BY NAME: an // empty store telling a caller their session "has a STORED conversation // this runtime cannot read", permanently, for any id that happens to name // a prototype member. An own-property test is the whole fix. const stored = Object.hasOwn(file.sessions, sessionId) ? file.sessions[sessionId] : undefined; if (stored === undefined) return undefined; // Validate HERE as well as in the composer, so a refusal points at the // store that produced the bytes rather than at whoever read them next. // `checkEnvelope` accepts either format: a store keeps sessions, and // whether a session is mid-conversation or mid-question is not its // business — only whether it can be read at all. return checkEnvelope(stored, sessionId); }, async persist(sessionId, envelope) { const file = await readFile(); await writeFileAtomically({ version: 1, sessions: { ...file.sessions, [sessionId]: envelope }, }); }, // ── Retention (9.42.0) ────────────────────────────────────────────── // // `'this-store'`: the conversations are one JSON file this container owns, // and nothing else is going to trim it. Worth having even though the // runtime reclaims a session's storage when the session ends — one file // holds every session this container has seen, so a long-lived container // accumulates conversations that ended hours ago, and `/tmp` filling up // inside a container is a failure that arrives as something else entirely. // // The sibling `{ store: 'memory' }` mode implements NO retention member, // and that absence is deliberate rather than unfinished: it appends events // to a service whose expiry belongs to the memory resource an operator // configured, and this shim's surface has no delete on it. Claiming a // sweep it cannot perform, or a backend policy nobody here has verified, // would be worse than the refusal an absent member produces. retention: () => ({ deletedBy: 'this-store', forgetOlderThan: async (before, sweepOptions) => { const limit = Math.max(1, Math.floor(sweepOptions?.limit ?? DEFAULT_SWEEP_LIMIT)); const file = await readFile(); const kept = {}; let forgotten = 0; let more = false; for (const [sessionId, envelope] of Object.entries(file.sessions)) { // A row this store cannot read the timestamp of is KEPT. A sweep // deletes on evidence; "I could not tell how old this is" is not // evidence, and the one mistake a retention job must not make is // deleting a conversation it did not understand. const savedAt = envelope?.savedAt; const expired = typeof savedAt === 'number' && savedAt < before; if (!expired) { kept[sessionId] = envelope; } else if (forgotten >= limit) { kept[sessionId] = envelope; more = true; } else { forgotten += 1; } } // One write, and only when something actually goes: a sweep that found // nothing must not rewrite the file every minute, because the rename // is the moment a crash can cost a turn. if (forgotten > 0) await writeFileAtomically({ version: 1, sessions: kept }); return { forgotten, more }; }, }), }; } // ─── 'memory': one AgentCore Memory event per persist ──────────────── const DEFAULT_ACTOR_ID = 'afp-standing-agent'; function memoryEventSessions(options) { if (!options.memoryId) { throw new Error(`agentCoreSessions({ store: 'memory' }) requires 'memoryId'.`); } const memoryId = options.memoryId; const actorId = options.actorId ?? DEFAULT_ACTOR_ID; const client = options._client ?? options.client ?? createSessionClient(options.region, options._sdk); return { async hydrate(sessionId) { // AgentCore Memory is an append-only log and lists newest-first, so the // newest readable event IS the conversation. Older ones are the earlier // turns and deliberately left where they are — they are the audit trail. const page = await client.listEvents({ memoryId, actorId, sessionId: safeSessionId(sessionId), maxResults: 1, }); const newest = page.events[0]; // No event, or an event carrying no blob: nothing here ever claimed to be // a session, so this really is a fresh start. Anything else — including a // blob that came back mangled — goes to `checkEnvelope`, which refuses a // conversation it cannot read BY NAME instead of returning `undefined` // and letting the agent answer as if the session were new. if (!newest || newest.envelope === null || newest.envelope === undefined) return undefined; // `decodeBlob` again rather than only inside the shim: a caller-supplied // `client` is free to hand back the stored text as it found it, and text // this adapter can plainly read is not something to refuse on a // technicality. Anything it cannot read still reaches `checkEnvelope`. return checkEnvelope(decodeBlob(newest.envelope), sessionId); }, async persist(sessionId, envelope) { await client.createEvent({ memoryId, actorId, sessionId: safeSessionId(sessionId), envelope, }); }, }; } const SESSION_ID_MAX = 99; /** Marks an id this adapter had to rewrite. Nothing else in arm A may start with it. */ const ENCODED_PREFIX = '_enc_'; /** Legal for AgentCore verbatim. */ const ID_ALREADY_LEGAL = /^[A-Za-z0-9_-]+$/; /** Legal AND not the escape character — the set `encodeSessionId` passes through. */ const CHAR_PASSES_THROUGH = /[A-Za-z0-9-]/; /** * A session id, made legal for AgentCore **without ever folding two ids into one**. * * AgentCore ids accept `[A-Za-z0-9_-]`. A session id does not have to: it arrives * in the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header, so it is caller * data, chosen by whoever is calling. The old version of this function replaced * every illegal character with `-`, which meant `a:b`, `a/b` and `a-b` all became * `a-b` and therefore all became ONE stored conversation. A caller who picks the * right id reads somebody else's session. This is the same defect the memory * identity namespace had in 9.40.0, and worse, because there the colliding parts * were server-side identity and here the whole key is caller-supplied. * * So this ENCODES instead of sanitising, in two arms whose outputs cannot meet: * * A. already legal, and not claiming to be an encoded id → returned byte for * byte. This is what keeps the fix from re-keying anybody's stored sessions: * every id that used to round-trip correctly still maps to exactly the key * it mapped to before. * B. anything else → `_enc_` + an escaped form, where `_` is the escape * character (`_` → `__`, any other illegal UTF-16 unit → `_uXXXX`). A * decoder is a left inverse of that, which is what makes it injective. * * Arm A never starts with `_enc_` (it is excluded by construction) and every arm * B output does, so no id from one arm can collide with an id from the other. * * **What this re-keys, stated exactly.** A stored session moves to a new key if * and only if its id contains a character outside `[A-Za-z0-9_-]`, is longer * than {@link SESSION_ID_MAX} characters, or begins with `_enc_`. Ids in the * first group were sharing a key with every other id that folded onto it, so * there was no single conversation there to preserve. Everything else — every * UUID, every `user_123` — keeps the exact key it had, so the ordinary * deployment migrates nothing. */ function safeSessionId(raw) { if (raw.length <= SESSION_ID_MAX && ID_ALREADY_LEGAL.test(raw) && !raw.startsWith(ENCODED_PREFIX)) { return raw; } const encoded = ENCODED_PREFIX + encodeSessionId(raw); // Strictly less, so the two shapes below are told apart by LENGTH alone: a // digested id is always exactly SESSION_ID_MAX and an escaped one never is. if (encoded.length < SESSION_ID_MAX) return encoded; // Past the provider's ceiling no mapping can stay injective — there are more // ids than there are keys — so the tail becomes a digest of the WHOLE raw id. // SHA-256 rather than FNV-1a because this input is caller-controlled: a // 32-bit hash is a collision somebody can go and FIND in a few seconds, which // for a session key is the same defect this function exists to close. const digest = sha256Hex(raw).slice(0, 32); return `${encoded.slice(0, SESSION_ID_MAX - digest.length - 2)}_z${digest}`; } /** `_` → `__`, anything else illegal → `_uXXXX`. Injective by construction. */ function encodeSessionId(raw) { let out = ''; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (ch === '_') out += '__'; else if (CHAR_PASSES_THROUGH.test(ch)) out += ch; else out += `_u${raw.charCodeAt(i).toString(16).padStart(4, '0')}`; } return out; } let cryptoModule; function sha256Hex(text) { if (!cryptoModule) { try { cryptoModule = lazyRequire('node:crypto'); } catch { throw new Error(`agentCoreSessions cannot store a session id longer than ${SESSION_ID_MAX} characters ` + 'without `node:crypto`, which it needs to keep two long ids from becoming one ' + 'conversation. AgentCore runs on Node, so this should not happen — if you are ' + 'exercising this adapter somewhere else, shorten the session id instead.'); } } return cryptoModule.createHash('sha256').update(text, 'utf8').digest('hex'); } /** * Turn one stored blob back into an envelope — the adapter's ONE decode step. * * ── Why a string is the normal case, not the exotic one ────────────────────── * This adapter writes the envelope as **JSON text** (see `createEvent` below), * so the blob that comes back is a string and is parsed here. Objects are still * accepted: a caller who supplies their own `client` can hand back a real * object, and refusing it would break a seam that never had this problem. * * A blob it cannot decode is handed back **AS-IS**, never as `null`. `null` * means "no blob", which is an absence, and an absence is answered with a fresh * start; a conversation that exists and cannot be read must not be. Passing the * raw value on puts it in front of `checkEnvelope`, which refuses by name. */ function decodeBlob(blob) { if (typeof blob !== 'string') return blob; try { const parsed = JSON.parse(blob); // JSON.parse('"x"') is a string, not an envelope. Hand back the ORIGINAL // bytes so the refusal quotes what the store actually holds. return parsed !== null && typeof parsed === 'object' ? parsed : blob; } catch { return blob; } } /** Pull the envelope out of an event's `payload` (a single `blob` document). */ function envelopeFromPayload(payload) { if (!Array.isArray(payload)) return null; for (const part of payload) { if (!part || typeof part !== 'object' || !('blob' in part)) continue; const blob = part.blob; // A part with no blob VALUE is not a session either — keep looking. if (blob === null || blob === undefined) continue; return decodeBlob(blob); } return null; } /** * Map {@link AgentCoreSessionClientLike} onto the real SDK commands. If AWS * renames a command, only this function changes — which is also why every test * injects past it. */ function createSessionClient(region, injected) { let mod; if (injected) { mod = injected; } else { try { mod = lazyRequire('@aws-sdk/client-bedrock-agentcore'); } catch { throw new Error(`agentCoreSessions({ store: 'memory' }) requires the ` + '`@aws-sdk/client-bedrock-agentcore` peer dependency.\n' + ' Install: npm install @aws-sdk/client-bedrock-agentcore\n' + " Or use { store: 'session-storage' }, which needs no SDK at all."); } } if (!mod.BedrockAgentCoreClient) { throw new Error('agentCoreSessions: `@aws-sdk/client-bedrock-agentcore` is installed but ' + '`BedrockAgentCoreClient` was not found. Update the SDK.'); } const sdk = new mod.BedrockAgentCoreClient({ ...(region && { region }) }); const send = async (Ctor, name, input) => { if (!Ctor) { throw new Error(`agentCoreSessions: \`@aws-sdk/client-bedrock-agentcore\` is missing ${name}. Upgrade the SDK.`); } return sdk.send(new Ctor(input)); }; return { async createEvent({ memoryId, actorId, sessionId, envelope }) { await send(mod.CreateEventCommand, 'CreateEventCommand', { memoryId, actorId, sessionId, eventTimestamp: new Date(), // JSON TEXT, not the object. Field-learned, and the whole reason for // this release: given an object, the service stores its own host // language's `toString()` rendering of it and returns THAT string — // `{format=conversation-v1, data={...}}`, which is not JSON and which // nothing can turn back into a conversation. The envelope is defined as // something a store can hold as text (`toEnvelope` round-trips through // `JSON.stringify` by construction), so the encoding is ours to pick and // the honest pick is the one whose bytes come back unchanged. payload: [{ blob: JSON.stringify(envelope) }], }); }, async listEvents({ memoryId, actorId, sessionId, maxResults }) { const result = (await send(mod.ListEventsCommand, 'ListEventsCommand', { memoryId, actorId, sessionId, includePayloads: true, ...(maxResults !== undefined && { maxResults }), })); const events = (result?.events ?? []).map((event) => ({ eventId: event.eventId ?? '', envelope: envelopeFromPayload(event.payload), })); return { events }; }, }; } //# sourceMappingURL=agentcore.js.map