UNPKG

agentfootprint

Version:

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

479 lines (478 loc) • 25 kB
/** * AgentCoreStore — AWS Bedrock **AgentCore Memory** adapter * (peer-dep `@aws-sdk/client-bedrock-agentcore`). * * import { AgentCoreStore } from 'agentfootprint/memory'; * * const store = new AgentCoreStore({ * memoryId: 'arn:aws:bedrock-agentcore:us-west-2:...:memory/my-mem', * region: 'us-west-2', * }); * * Pattern: Adapter (GoF) — maps the `MemoryStore` interface onto AgentCore Memory's * data-plane **event** model (`CreateEvent` / `GetEvent` / `ListEvents` / * `DeleteEvent`, `@aws-sdk/client-bedrock-agentcore`): * MemoryIdentity.{tenant,principal} ↔ AgentCore `actorId` * MemoryIdentity.conversationId ↔ AgentCore `sessionId` * MemoryEntry ↔ one event whose `payload` is a single * `blob` document holding the entry * * **AgentCore Memory is an append-only event log, not a key-value store.** The server * assigns each event's `eventId` on `CreateEvent` (you cannot choose it), and there is no * "delete the whole session" call. This shapes the adapter: * * • `put` → `CreateEvent` (append; `actorId` + `eventTimestamp` are required). O(1) * Its write options become CreateEvent's own fields: `extraction: 'skip'` → * `extractionMode: 'SKIP'`, `namespaceScope` → `extractionConfig.namespaceVariables`. * • `list` → `ListEvents` (paginated, `includePayloads`). ← window / episodic memory. O(page) * • `get(id)` / `delete(id)` → list-then-find by the entry id stored in the blob, since * AgentCore's ids are server-assigned. **O(events in session)** — fine for typical * window sizes; if you need O(1) keyed access at scale, use RedisStore. * • `forget` → `ListEvents` + `DeleteEvent` per event (no `DeleteSession` on AgentCore). * • `search` → `RetrieveMemoryRecords`, **text-in**. AgentCore embeds and ranks on its * own side, so it takes a natural-language query; the port's `search()` takes a * vector. Pass `options.text` and this store serves the query; omit it and it refuses * by name rather than ranking an empty set. See `search()` below for the whole story. * • `putIfVersion` / `seen` / `feedback` → in-process emulation (AgentCore has no native * CAS / dedup / feedback primitive; these don't survive process restart). * • no `stream()` — AgentCore Memory has no streaming data-plane operation, and the * `MemoryStore` port has no streaming method to implement. Inventing one for a single * backend is how a port stops being a port. * * Role: Outer ring. Lazy-requires the AWS SDK; zero runtime cost when another adapter is * in use. Emits: N/A (storage adapters don't emit). */ import type { ListOptions, ListResult, MemoryStore, PutIfVersionResult, ScoredEntry, SearchOptions } from '../../memory/store/types.js'; import type { MemoryEntry } from '../../memory/entry/index.js'; import type { MemoryIdentity } from '../../memory/identity/index.js'; import { type MemoryWriteOptionName, type MemoryWriteOptions } from '../../memory/store/writeOptions.js'; /** * A caller's value — a tenant id — in the alphabet AgentCore admits for a * namespace variable, ONE-TO-ONE. * * The alphabet is narrow (`[a-z0-9][a-z0-9-_]*`, at most 64 characters), so the * easy mappings fold: lowercasing merges `Acme` with `acme`, stripping merges * `a.b` with `ab`, truncating merges any two long ids that share a prefix — and * a fold here files two tenants' derived records under one namespace. This is * the same law as the actor and session ids above, in this alphabet: * * - `[a-z0-9-]` passes through; every other UTF-16 code unit becomes `_` * plus its four lowercase hex digits (fixed width, so it decodes); * - every value starts with `v`, so one whose first character had to be * escaped still starts with a letter — and no two inputs share an output; * - every value ENDS with `_z`, which cannot occur inside an encoding (an * `_` there is always followed by four hex digits) — so no tenant's value * is the beginning of another's (`1` and `12` would otherwise be `v1` and * `v12`), and a prefix match over namespaces cannot reach a neighbour; * - too long is REFUSED, never truncated. * * `agentCoreNamespaceValue('acme')` is `'vacme_z'`; `'Acme'` is `'v_0041cme_z'`. * Use the same mapping wherever the namespace is read back (`searchNamespace`). * * @throws {TypeError} for an empty value — an absent tenant is not a tenant * @throws {RangeError} when the encoding exceeds 64 characters (the value is * not quoted: it names a tenant) */ export declare function agentCoreNamespaceValue(raw: string): string; /** One event as the adapter cares about it: AgentCore's id + the decoded entry. */ export interface AgentCoreEvent { /** AgentCore server-assigned event id (needed to delete it). */ readonly eventId: string; /** * The MemoryEntry decoded from the event's blob payload. * * `null` means the event carried **no blob at all** — nothing in it ever * claimed to be one of this store's entries (AgentCore writes events of its * own into the same log), so it is skipped as an absence. * * A blob that IS present and cannot be decoded never arrives here: it raises * {@link UnreadableMemoryEntryError} at the decode step instead. An unreadable * stored memory and an absent one are different facts, and only one of them is * safe to answer with silence. */ readonly entry: MemoryEntry | null; } /** * Thrown when an event carries a blob that is **present but unreadable** where a * `MemoryEntry` should be. * * The same law the session store inherits from `hosting/envelope`, applied to * this port's own shape: an unreadable stored memory and an absent one are * different facts, and only one of them is safe to answer with silence. A * memory that exists and cannot be decoded, quietly skipped, is an agent that * answers as if it were never told — indistinguishable from working, until * somebody notices the assistant has forgotten a customer's address. * * ── Why the law lives HERE and not in a shared reader ──────────────────────── * `MemoryStore` has no envelope and no shared reading path — nothing on this * port corresponds to `hosting`'s `readFormat`, the single choke point every * session adapter reads through — so the refusal belongs at the one place raw * bytes become an entry: this adapter's decode step. If a `MemoryStore` reading * path ever grows such a choke point, the law moves there and this becomes a * caller of it. * * ── Why this one QUOTES NOTHING, where the session refusal quotes a prefix ─── * Same discipline — "never the stored content" — and the same shared helper, but * a different answer, because the two shapes differ in where content begins. A * `CheckpointEnvelope` opens `{ format, data, savedAt }`, so a capped prefix is * metadata. A `MemoryEntry` opens `{ id, value, … }`, so its SECOND field is the * thing somebody asked the agent to remember, and even a short prefix would * print it. `storedShape` therefore reports type, length, JSON-ness and the * opening character and nothing else — still enough to recognise "an object * stringified by something that was not JSON", which is the only diagnosis this * message needs to support. */ export declare class UnreadableMemoryEntryError extends TypeError { readonly code: "ERR_UNREADABLE_MEMORY_ENTRY"; /** AgentCore's id for the event those bytes came from. */ readonly eventId: string; /** The AgentCore session (this store's conversation) it was stored under. */ readonly sessionId: string; /** * What came back, described by SHAPE — type, length, JSON-ness, opening * character. Never a quote: on this port the stored bytes are the memory. */ readonly storedShape: string; constructor(input: { eventId: string; sessionId: string; stored: unknown; }); } /** * Minimal, entry-semantic surface the store uses. The real implementation * (`createAgentCoreClient`) maps these onto `CreateEvent` / `ListEvents` / * `DeleteEvent`; tests inject a mock via `_client`. */ export interface AgentCoreLikeClient { /** * `true` when {@link createEvent} forwards `extractionMode` and * `extractionConfig` to `CreateEvent` unchanged. The built-in client does. * * Declared, not assumed: a client written before these fields existed * compiles against the wider input and drops them, and a dropped * `extractionMode: 'SKIP'` sends a turn the caller kept out of long-term * memory INTO extraction. Without this, `AgentCoreStore` honours no write * options and refuses them by name. */ readonly forwardsWriteOptions?: true; /** Append one entry as an event (server assigns the eventId). */ createEvent(input: { memoryId: string; actorId: string; sessionId: string; entry: MemoryEntry; /** * `CreateEvent`'s own field (`ExtractionMode.SKIP`): store the event in * short-term memory, exclude it from long-term extraction. */ extractionMode?: 'SKIP'; /** * `CreateEvent`'s own field: `namespaceVariables` are substituted into the * memory's `namespaceTemplates` during long-term extraction. */ extractionConfig?: { readonly namespaceVariables: Readonly<Record<string, string>>; }; }): Promise<void>; /** One page of the session's events (newest-first is AgentCore's default). */ listEvents(input: { memoryId: string; actorId: string; sessionId: string; maxResults?: number; nextToken?: string; }): Promise<{ events: readonly AgentCoreEvent[]; nextToken?: string; }>; /** Delete one event by its AgentCore eventId. */ deleteEvent(input: { memoryId: string; actorId: string; sessionId: string; eventId: string; }): Promise<void>; /** * Server-side semantic retrieval (`RetrieveMemoryRecords`). Optional: a client * built before this existed still satisfies the interface, and `search()` * feature-detects it rather than assuming. */ retrieveRecords?(input: { memoryId: string; namespace: string; searchQuery: string; maxResults?: number; memoryStrategyId?: string; }): Promise<{ records: readonly AgentCoreMemoryRecord[]; }>; } /** One record as `RetrieveMemoryRecords` returns it. */ export interface AgentCoreMemoryRecord { /** AgentCore's own record id. */ readonly memoryRecordId: string; /** The record's text content. */ readonly content: string; /** Relevance as AgentCore scored it, when it reports one. */ readonly score?: number; /** Which strategy produced the record (semantic, summary, user-preference, …). */ readonly memoryStrategyId?: string; /** The namespace it was found in — the first of {@link namespaces}. */ readonly namespace?: string; /** * Every namespace the record is filed under, as `RetrieveMemoryRecords` * reports them (`MemoryRecordSummary.namespaces`). `search()` keeps a record * only when one of these is the namespace it asked for, or lies under it. */ readonly namespaces?: readonly string[]; /** When AgentCore created it (unix ms), when reported. */ readonly createdAt?: number; } export interface AgentCoreStoreOptions { /** AgentCore Memory ARN or id. Required. */ readonly memoryId: string; /** AWS region. Required when constructing the SDK client internally. */ readonly region?: string; /** Pre-built AgentCore client (shares one SDK config across the host app). */ readonly client?: AgentCoreLikeClient; /** Page size for session scans and default `list()` calls: integer 1..100. Default 100. */ readonly pageSize?: number; /** * Where `search()` looks. AgentCore organises extracted memory records into * namespaces configured on the Memory resource's strategies (commonly * something like `/strategies/{strategyId}/actors/{actorId}`). * * A function, because the namespace usually contains the actor: it is handed * the resolved AgentCore ids for the identity being searched, the identity * itself, and — when the memory binds one (`writeOptions.namespaceScope`) — * the SAME namespace scope its writes are filed under, resolved for this * identity. Build the namespace from that scope * (`({ namespaceScope }) => '/tenants/' + namespaceScope?.tenantid + '/facts'`): * a search given a scope is refused unless every one of its values is a * path segment of the namespace, so a read cannot leave the tenant its * writes were filed under. Default: `/actors/{actorId}/sessions/{sessionId}` * — the session's own records. */ readonly searchNamespace?: (scope: { readonly actorId: string; readonly sessionId: string; readonly identity: MemoryIdentity; readonly namespaceScope?: Readonly<Record<string, string>>; }) => string; /** * Restrict `search()` to one extraction strategy (semantic, summary, * user-preference…). Omit to search across all of them. * * This is the metadata filter that reaches AgentCore's own side; `tiers` / * `minScore` / `k` from {@link SearchOptions} are applied to what comes back. */ readonly searchStrategyId?: string; /** * Refuse, by name, every write and search that carries no `namespaceScope`. * * Set it on a store whose every memory is scoped — a multi-tenant * deployment. A memory, a `defineRAG` or a direct call that forgot its scope * is then refused, instead of filing or reading under a namespace no tenant * owns (a template with an empty variable). Default `false`. */ readonly requireNamespaceScope?: boolean; /** * Let a search read the records derived from EVERY principal of a tenant. * * Off by default: a search for an identity that names a principal is refused * unless its namespace holds that identity's `actorId` as path segments — * a namespace that stops at the tenant serves one person's extracted facts, * preferences and summaries to another. Set it only when the memory's * strategies file per tenant ON PURPOSE (knowledge the whole tenant shares). * It applies to SCOPED searches only: a search with no scope has no tenant to * stay inside, so it must always hold the actor. */ readonly searchAcrossPrincipals?: boolean; /** @internal Test injection — skips the SDK require entirely. */ readonly _client?: AgentCoreLikeClient; /** * Statically imported `@aws-sdk/client-bedrock-agentcore` module. Bypasses the * lazy loader for bundled hosts; the adapter still owns command construction. * A scripted module also exercises the real mapping without AWS calls. */ readonly sdk?: BedrockAgentCoreSdkModule; } /** * AgentCore Memory-backed `MemoryStore`. Implements every method except `search()`. * * @throws when `@aws-sdk/client-bedrock-agentcore` is not installed and no `client`/`sdk` * is supplied. */ export declare class AgentCoreStore implements MemoryStore { /** * **No.** `search()` below exists, but it is `RetrieveMemoryRecords`: * AgentCore embeds and ranks on its own side, over the records its * extraction strategies derived, and never over the vectors written * through `put`/`putMany`. Embeddings handed to this store are stored * inside the event blob and are never ranked by anything. * * Declared (8.19.0) because a method's presence could not say that. * `indexCorpus` used to accept this store, embed a whole corpus, pay for * it and report success — and the chunks were unreachable forever. The * corpus builders read this bit and refuse, naming this store and what to * use instead; nothing else changes, and `defineMemory` over AgentCore's * own retrieval is untouched. */ readonly supportsVectorSearch = false; /** * **Words, not a vector.** `search()` is `RetrieveMemoryRecords`, whose * query is natural-language text: it reads {@link SearchOptions.text} and * refuses by name without it. * * {@link supportsVectorSearch} already said this store cannot serve back the * embeddings written into it — the question a corpus BUILDER asks. This * answers the next one down, which is what a corpus READER needs: what query * form does `search()` take? Declaring it is what lets `defineRAG` know, * before the first turn rather than after the bill, that a retriever over * this store needs no `Embedder` at all — embedding the question anyway is * spend on a vector discarded on arrival. * * Added in the same batch as the Google column's Memory Bank store, which is * the second adapter of this shape. Two stores that behave identically and * declare it differently is how a capability check quietly stops meaning * anything. */ readonly ranksBy: "server-text"; /** * **Both write options, translated to `CreateEvent`'s own fields:** * * `extraction: 'skip'` → `extractionMode: 'SKIP'` — the event stays in * short-term memory and is excluded from * long-term extraction. * `namespaceScope` → `extractionConfig: { namespaceVariables }` — * substituted into the memory's * `namespaceTemplates` during extraction, which * is how one tenant's derived records stay in * that tenant's namespace. * * Absent — and both refused by name — when they could not reach AgentCore: * an injected client that does not declare `forwardsWriteOptions`, or an * installed SDK too old to MODEL the two fields (its serializer would drop * them without a word). The variables are checked here against AgentCore's * documented domain before anything is sent (see `checkNamespaceVariables`). * * A getter, not a field: a wrapper built by spreading this store * (`{ ...store, put }`) does not copy it — and a wrapper that does not carry * the third argument must not carry the declaration either. */ get writeOptions(): readonly MemoryWriteOptionName[] | undefined; private readonly client; private readonly memoryId; private readonly pageSize; private readonly searchNamespace; private readonly searchStrategyId; private readonly requireNamespaceScope; /** * Set by the first write or search that carries a scope: from then on this * store is a SCOPED store, and a call without one is refused — a second * memory, a `defineRAG` or a direct call that forgot its scope never reads * or files across the tenants the first one kept apart. */ private servedScoped; private readonly searchAcrossPrincipals; /** Why the write options cannot be honoured — `undefined` when they can. */ private readonly writeOptionsRefusal; private closed; private readonly signatures; private readonly feedbackBag; constructor(options: AgentCoreStoreOptions); private actorId; private sessionId; private scope; private shadowKey; private feedbackKey; /** Walk every event in the session (paginated). */ private eachEvent; get<T = unknown>(identity: MemoryIdentity, id: string): Promise<MemoryEntry<T> | null>; put<T = unknown>(identity: MemoryIdentity, entry: MemoryEntry<T>, options?: MemoryWriteOptions): Promise<void>; /** One event, with the write options already turned into fields (and checked) once. */ private createEvent; putMany<T = unknown>(identity: MemoryIdentity, entries: readonly MemoryEntry<T>[], options?: MemoryWriteOptions): Promise<void>; /** * Emulated optimistic concurrency. AgentCore appends unconditionally; we read-then-write * inside a JS critical section — adequate for single-writer-per-session deployments. */ putIfVersion<T = unknown>(identity: MemoryIdentity, entry: MemoryEntry<T>, expectedVersion: number, options?: MemoryWriteOptions): Promise<PutIfVersionResult>; /** * The namespace one search reads — rendered by `searchNamespace` and refused * by name when it could read beyond this identity: a wildcard, a namespace * that does not hold the scope segment for segment, or one that stops above * this identity's actor while the identity names a principal. */ private namespaceFor; /** * The write options as `CreateEvent`'s own fields — or a refusal by name for * any this store's client cannot carry. The ONE place the vendor-neutral * names become AWS's. */ private extractionFields; list<T = unknown>(identity: MemoryIdentity, options?: ListOptions): Promise<ListResult<T>>; delete(identity: MemoryIdentity, id: string): Promise<void>; seen(identity: MemoryIdentity, signature: string): Promise<boolean>; recordSignature(identity: MemoryIdentity, signature: string): Promise<void>; feedback(identity: MemoryIdentity, id: string, usefulness: number): Promise<void>; getFeedback(identity: MemoryIdentity, id: string): Promise<{ average: number; count: number; } | null>; /** GDPR "everything for this identity, gone." No DeleteSession on AgentCore → delete every event. */ forget(identity: MemoryIdentity): Promise<void>; /** * Server-side semantic retrieval over AgentCore's extracted memory records * (`RetrieveMemoryRecords`). * * ── Read this before you call it ───────────────────────────────────────── * **It takes TEXT, not the vector.** AgentCore embeds and ranks on its own * side, so `query` — the port's vector — is unusable here, and the query it * actually needs travels in `options.text`. Omit that and this method throws * a corrective error naming what is missing, because the alternative is * ranking nothing and handing back `[]`, which reads as "no matches" when it * really means "wrong query form". Pass both and every store can serve you: * local-ranking backends use the vector and ignore the text. * * **It searches a DIFFERENT population than `list()`.** `list` returns the * events this store wrote. This returns the records AgentCore's extraction * strategies derived FROM those events — summaries, semantic facts, user * preferences. The ids therefore belong to AgentCore, not to entries you * `put()`, and `store.get(entry.id)` will not find them. They arrive as * entries so ranking code needs no special case, with `metadata.source` * saying plainly where they came from, and each value is `{ content }` — * the record's text where every memory formatter reads it. * * Filters: `searchStrategyId` and the namespace reach AgentCore's own side; * `k`, `minScore` and `tiers` are applied to what comes back. * * @throws when `options.text` is absent, or when the client cannot retrieve. */ search<T = unknown>(identity: MemoryIdentity, query: readonly number[], options?: SearchOptions): Promise<readonly ScoredEntry<T>[]>; close(): Promise<void>; private ensureOpen; } /** * The slice of `@aws-sdk/client-bedrock-agentcore` the shim touches. * Command inputs are SDK-owned: `never` keeps their specific constructor * signatures assignable without importing the optional peer's types. The * adapter alone supplies each command's wire input at its internal boundary. */ export interface BedrockAgentCoreSdkModule { readonly BedrockAgentCoreClient?: new (config: { region?: string; }) => { send(cmd: unknown): Promise<unknown>; }; readonly CreateEventCommand?: new (input: never) => unknown; readonly ListEventsCommand?: new (input: never) => unknown; readonly DeleteEventCommand?: new (input: never) => unknown; readonly RetrieveMemoryRecordsCommand?: new (input: never) => unknown; /** * The SDK's own schema of `CreateEventInput` — read only to learn whether this * SDK MODELS the extraction fields (see `sdkModelsExtraction`). */ readonly CreateEventInput$?: unknown; }