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
TypeScript
/**
* 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;
}