agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
441 lines (440 loc) • 24.6 kB
TypeScript
/**
* memoryBankStore — the `MemoryStore` port over Vertex AI **Memory Bank**.
*
* import { memoryBankStore } from 'agentfootprint/memory';
*
* const store = memoryBankStore({
* project: 'my-project',
* location: 'us-central1',
* reasoningEngine: '1234567890',
* });
*
* ── Read this before you write anything into it ─────────────────────────────
* Memory Bank is a **natural-language** memory service, not a key-value store
* and not a vector database. A `Memory` is a `fact` string plus an immutable
* `scope`; retrieval takes a QUESTION IN WORDS, embeds it on Google's side and
* ranks there. Three consequences, each of which has a silent-failure mode
* that this adapter turns into something you can see:
*
* 1. **It never ranks the vectors you wrote.** `supportsVectorSearch` is
* `false` and `ranksBy` is `'server-text'`, so the corpus builders
* (`indexCorpus`, `indexFolder`, `indexDocuments`) refuse this store by
* name instead of embedding a whole corpus, reporting success, and leaving
* it unreachable forever. An `embedding` on an entry handed to `put()` is
* **not stored** — there is nowhere to put it and nothing that would read
* it — and the two declarations above are how this adapter says so before
* you spend anything.
*
* 2. **The retrieval score is a DISTANCE, and smaller is closer.** The port's
* `ScoredEntry.score` is a cosine similarity, where HIGHER is closer.
* Passed through unconverted, `search()` would return the LEAST relevant
* memories first with a confident-looking number in the right range —
* which no threshold and no eyeball can separate from a working search.
* See {@link MemoryBankStore.search} for exactly what this adapter does
* instead, and why it refuses `minScore` rather than reinterpreting it.
*
* 3. **`scope` is an exact match and immutable once written.** A retrieval
* whose scope is a subset of a memory's scope returns NOTHING — not a
* superset, not a partial match, nothing. So the scope convention has to
* be right before the first write, because it cannot be changed after it
* and a bank written under the wrong one is poisoned permanently. See
* {@link MemoryBankStoreOptions.scopeFor}.
*
* ── How a memory is ADDRESSED, and why it is not just the entry id ──────────
* A memory's resource name is `<engine>/memories/<resource id>`, and that
* resource id is composed from **the resolved scope and the entry id
* together** — never the entry id alone.
*
* The reason is that entry ids in this library are deliberately deterministic
* and identity-free: `msg-<turn>-<index>`, `fact:<key>`, `snap-<turn>`. Two
* people talking to the same agent produce the SAME entry ids, so an address
* built from the id alone is one row for both of them — and since a resource
* name addresses a row directly, the second writer's `fact` lands on the first
* writer's row while the immutable `scope` stays the first writer's. What comes
* back is one tenant reading another tenant's private fact, one tenant's own
* write invisible to them, and `forget()` finding nothing to erase. Every
* sibling store in this library namespaces by identity (`s3Vectors` keys on
* `<namespace>#<id>`, `pgVector` on `(namespace, id)`, `InMemoryStore` on a map
* per namespace); this one does the same thing, keyed on the SCOPE rather than
* the raw identity so that a widened `scopeFor` widens sharing exactly as much
* as it widens retrieval, and not one row more.
*
* The composed address is a partition, not the boundary itself. The boundary is
* the stored `scope`, which is re-checked on the way back from every read AND
* before every overwrite — so even an address collision is refused rather than
* written through.
*
* ── The retrieved NAME is not the name you wrote ────────────────────────────
* A live field trial on the raw service found this and it is worth stating,
* because it is exactly the assumption an adapter is tempted to make: `create`
* and `list` came back with the caller-chosen memory ids, while SIMILARITY
* RETRIEVAL answered with generated numeric resource names for the very same
* facts (FINDINGS "Agent Runtime Memory Bank"). Anything reading an entry id
* out of `memory.name` would therefore work perfectly on `list()` and hand back
* unusable ids from `search()` — ids that no `get()` or `delete()` could find.
*
* This adapter never does that: `toEntry` reads the id from the metadata this
* library wrote, and the resource name is carried only as
* `entry.metadata.resourceName`, for looking at. The same trial also confirmed
* exact-match scope semantics (a partial `{tenant}` scope retrieved nothing)
* and the distance-not-similarity ranking that {@link MemoryBankStore.search}
* converts.
*
* ── Writes are long-running operations ──────────────────────────────────────
* `create`, `patch` and `delete` all answer with an Operation rather than the
* resource — verified against the installed SDK's own return types. Every
* write here waits for `done` before returning, because a `put` that came back
* early followed by a `get` is a race whose failure mode is "no data", and
* nobody can tell that from a memory that was never written.
*
* ── What has no primitive here, and is therefore refused ────────────────────
* `putIfVersion`, `seen`, `recordSignature`, `feedback` and `getFeedback` have
* no counterpart in this service: a `Memory` carries no etag and there is no
* dedup or feedback surface. The sibling AgentCore adapter emulates them
* in-process; this one refuses them by name, and the difference is deliberate.
* A store you reach for BECAUSE it is shared across a fleet is the worst place
* for a per-process shadow: `seen()` would answer `false` in the second
* container for a signature the first one recorded, and an emulated
* `putIfVersion` across two writers is a lost-update generator that reports
* `{ applied: true }` to both. A refusal you read once beats a correctness bug
* you never find.
*
* Pattern: Adapter (GoF) — `MemoryStore` onto `reasoningEngines.memories`,
* through the shared REST client in `adapters/google/aiPlatform.ts`.
*/
import type { MemoryEntry } from '../../memory/entry/index.js';
import type { MemoryIdentity } from '../../memory/identity/index.js';
import { type MemoryWriteOptions } from '../../memory/store/writeOptions.js';
import type { ListOptions, ListResult, MemoryStore, PutIfVersionResult, ScoredEntry, SearchOptions } from '../../memory/store/types.js';
import { type AiPlatformConnection } from '../google/aiPlatform.js';
/**
* The most JSON one carried field may be, in characters.
*
* Memory Bank metadata values are typed scalars and this column has NOT
* measured the service's own ceiling on a string one, so the bound is this
* adapter's own and says so where it refuses. It is a refusal rather than a
* truncation on purpose: provenance that came back shortened would be
* provenance nobody could tell was shortened.
*/
export declare const MAX_CARRIED_JSON = 8192;
/** The scope map an identity resolves to. Keys and values are both strings. */
export type MemoryScope = Readonly<Record<string, string>>;
/** Options for {@link memoryBankStore}. */
export interface MemoryBankStoreOptions extends AiPlatformConnection {
/**
* Map this library's identity tuple onto Memory Bank's `scope` — **the one
* decision that cannot be taken back.**
*
* The default is the full tuple:
*
* ```
* { tenant: '<tenant|_>', principal: '<principal|_>', conversation: '<conversationId>' }
* ```
*
* which is the same isolation every other store in this library enforces
* (`identityNamespace` composes exactly these three), so agent code behaves
* identically whichever column it runs on. That consistency is why it is the
* default even though it is the NARROWEST useful choice.
*
* **What it costs, stated plainly.** Because scope matching is exact, a
* memory written under a conversation is retrievable only within that
* conversation. If what you want from a memory bank is "remember this person
* across their conversations" — which is usually the point — widen it here:
*
* ```ts
* import { encodeIdentityField } from 'agentfootprint/memory';
*
* scopeFor: (identity) => ({
* tenant: encodeIdentityField(identity.tenant),
* principal: encodeIdentityField(identity.principal),
* })
* ```
*
* Encode the fields rather than writing `identity.tenant ?? '_'`: a raw `_`
* is what "no tenant" spells, so an un-encoded widening hands the anonymous
* scope to anyone whose tenant is literally `_`. The encoder returns
* ordinary ids byte for byte and only escapes the ones that would collide.
*
* **And why it is worth getting right the first time.** `Memory.scope` is
* immutable. Memories already written keep the scope they were written with,
* and a later retrieval under a different convention will not find them —
* not with a warning, not with a partial match, but with an empty result
* that looks exactly like "this person has told us nothing". Changing the
* convention on a live bank means re-writing every memory in it.
*
* Values may not contain `*`; this adapter replaces any it is handed and
* refuses an empty scope outright, because a scope of `{}` is the one value
* that matches every other empty-scoped memory in the bank regardless of who
* wrote it.
*/
readonly scopeFor?: (identity: MemoryIdentity) => MemoryScope;
/**
* How long a memory lives, as a duration string the API accepts (`'86400s'`).
* Omit and memories do not expire.
*
* A `MemoryEntry.ttl` is a unix TIMESTAMP and this is a DURATION; the two
* are different quantities and the entry's own is honoured per write, so
* this is only the default for entries that name none.
*/
readonly ttl?: string;
/**
* How long a write waits for its long-running operation before refusing.
* Default {@link DEFAULT_OPERATION_TIMEOUT_MS} (30s).
*/
readonly operationTimeoutMs?: number;
/**
* How many rows a `list()` page carries when the caller names no limit.
* Default 20. The service's own ceiling is 100 and it silently coerces
* anything larger, so this adapter clamps rather than letting a request for
* 500 come back as 100 with no explanation.
*/
readonly pageSize?: number;
}
/** The service's own ceiling on a page or a top-k. Larger values are coerced. */
export declare const MAX_PAGE_SIZE = 100;
/**
* A `MemoryStore` over Vertex AI Memory Bank.
*
* **Status: field-validated on the data plane (2026-08-14).** An independent
* trial ran THIS class against a real Memory Bank and every one of these
* answered a live request: the honest `supportsVectorSearch: false` /
* `ranksBy: 'server-text'` declarations; cross-conversation recall under a
* widened `scopeFor`; two identities using the SAME entry id without collision
* or disclosure; string and structured values; opaque pagination cursors; tier
* filtering; text similarity that found the right marker, excluded the other
* identity, returned finite distances and converted them to correctly ORDERED
* scores; overwrite advancing value and version; a scoped delete leaving the
* other identity intact; `forget()` erasing across the widened scope; and the
* five unsupported operations refusing by name rather than pretending.
*
* The same trial found the one thing that was NOT faithful — `source` and
* caller `metadata` were accepted and silently dropped — which 9.30.0 fixes by
* carrying them (see `toMemory`). That fix is tested here and has not itself
* been re-run in a live project.
*/
export declare class MemoryBankStore implements MemoryStore {
/**
* **No.** `search()` exists here, but it is Memory Bank's own retrieval:
* Google embeds and ranks on its side, over the `fact` strings this store
* wrote, and never over an `embedding` handed to `put()`. Embeddings are not
* stored at all.
*
* Declared because a method's presence could not say that — and because the
* sibling column already paid for the lesson once, with a corpus that
* indexed, billed, reported success, and was unreachable forever.
*/
readonly supportsVectorSearch = false;
/**
* The query form this store takes: **words, not a vector.** `search()` reads
* {@link SearchOptions.text} and refuses by name without it. A retriever
* built over this store therefore needs no `Embedder`, and wiring one would
* be spend on a vector discarded on arrival.
*/
readonly ranksBy: "server-text";
private readonly memories;
private readonly scope;
private readonly scopeFor;
private readonly operationTimeoutMs;
private readonly pageSize;
private readonly defaultTtl;
private closed;
constructor(options: MemoryBankStoreOptions);
/**
* One memory by id.
*
* Two independent things keep this from reading somebody else's memory, and
* both are deliberate. The **address** carries the scope, so another
* identity's row for the same entry id is a different resource name that
* simply is not there. And the **stored scope is re-checked on the way
* back**, because a resource name addresses a memory directly and a `get`
* alone would happily read another tenant's row for anyone who could guess a
* name. A memory whose scope is not this identity's answers `null` — the same
* `null` a missing one answers, because "exists but not yours" is an oracle
* for which ids are real.
*/
get<T = unknown>(identity: MemoryIdentity, id: string): Promise<MemoryEntry<T> | null>;
/**
* A page of this identity's memories, in no particular order.
*
* It rides `retrieve` with `simpleRetrievalParams` rather than `memories.list`
* — deliberately. `list` filters with AIP-160 over the resource's own fields,
* and whether that filter language can express an exact scope match is not
* something this adapter is willing to guess at for the call that decides
* which memories a caller can see. `retrieve` takes the scope as a structured
* field with semantics the SDK states outright, so the isolation is the
* service's rather than a filter string's.
*
* `tiers` filtering is applied to what comes back, since a tier is this
* library's own metadata and not something the service ranks on.
*/
list<T = unknown>(identity: MemoryIdentity, options?: ListOptions): Promise<ListResult<T>>;
/**
* Memory Bank's own semantic retrieval — **text in, and the ranking trap
* handled rather than passed on.**
*
* ── It takes WORDS, not the vector ───────────────────────────────────────
* Google embeds and ranks server-side, so the `query` vector this method is
* handed cannot be sent anywhere. The query it needs travels in
* {@link SearchOptions.text}, and omitting it is refused by name — returning
* `[]` would read as "no matches" when it means "wrong query form".
*
* ── The score, and what this adapter refuses to pretend ──────────────────
* The service reports a **distance**, in its own words "smaller values
* indicate more similar memories". The port's score is a cosine similarity,
* where higher is closer. Two things follow, and both are decisions:
*
* • The distance is **converted**, never forwarded: `score = 1 / (1 + d)`,
* which is strictly decreasing in `d`, so **the ordering is right** —
* the closest memory has the highest score, which is the whole point.
* The raw distance is carried on `entry.metadata.distance` so nothing is
* hidden and a caller who knows the metric can do better.
*
* • **`minScore` is REFUSED by name**, because that number is calibrated
* for a cosine similarity and this scale is not one. Silently applying a
* cosine threshold to a converted distance is precisely the failure this
* library refuses elsewhere: a number that READS like a similarity, in
* the right range, that no threshold and no eyeball can separate from a
* real one. The sibling S3 Vectors adapter refuses a non-cosine index
* for the same reason; here the metric is Google's and cannot be
* changed, so the threshold is what goes rather than the store.
*
* ── One more thing the service requires ──────────────────────────────────
* Similarity search only works if the reasoning engine was configured with a
* similarity-search config. Without it the service refuses the call, and
* that refusal is passed through with its status — it is a setup fact, not a
* bug in the query.
*
* @throws when `options.text` is absent, or `options.minScore` is present.
*/
search<T = unknown>(identity: MemoryIdentity, query: readonly number[], options?: SearchOptions): Promise<readonly ScoredEntry<T>[]>;
/**
* Write one memory, waiting for the service to say it landed.
*
* ── Read-then-write, and what the read is FOR ────────────────────────────
* This costs one `get` before the write, and that read is not an
* optimisation — it is the tenant check. A `patch` names a resource
* directly and the service does not ask whose it is, so a patch sent
* without looking is a write this adapter cannot promise landed on its own
* row. The address already carries the scope (see the module header), so
* the row under this name is ours in every ordinary run; the read is what
* turns "ordinary" into "checked", and what makes the one case where it is
* NOT ours a refusal you can read instead of a fact one tenant wrote into
* another tenant's memory.
*
* What the read finds decides the rest: an existing row of ours is patched,
* an absent one is created, and a row that is somebody else's is refused by
* name. The `create` race — two writers, neither of whom saw a row — is
* caught as `ALREADY EXISTS` and folded back into the same checked patch.
*/
put<T = unknown>(identity: MemoryIdentity, entry: MemoryEntry<T>, options?: MemoryWriteOptions): Promise<void>;
/**
* Sequential, not batched: the service has no batch-write operation, and
* each write is a long-running operation that has to be waited on
* individually. An empty batch is a no-op and costs no round trip, which
* callers rely on.
*/
putMany<T = unknown>(identity: MemoryIdentity, entries: readonly MemoryEntry<T>[], options?: MemoryWriteOptions): Promise<void>;
/**
* Remove one memory.
*
* Scope-checked first, for the reason {@link get} spells out: a resource
* name addresses a row directly, and a delete that skipped the check would
* let anyone who could guess an id remove another tenant's memory. A memory
* that is not this identity's is left alone and reported as nothing to do —
* the same answer a missing one gets.
*/
delete(identity: MemoryIdentity, id: string): Promise<void>;
/**
* GDPR — every memory for this identity, gone.
*
* Paginated retrieve-then-delete, and **deliberately not `memories.purge`**,
* for two reasons that are both about not being silently wrong on the one
* operation where that matters most:
*
* 1. `PurgeMemoriesRequest.force` defaults to **false**, which the service
* documents as "the purge request will be validated but not executed".
* A forget built on it and written without that flag would report
* success and delete nothing — a compliance failure that looks exactly
* like a working erasure.
* 2. Purge selects rows with an AIP-160 filter STRING, and whether that
* language can express an exact scope match is not verified. A filter
* that under-matches leaves data behind; one that over-matches deletes
* somebody else's. Neither is a guess worth making here.
*
* The scoped retrieve has semantics the SDK states outright, so that is what
* this uses. It costs one delete per memory, which is the right price.
*/
forget(identity: MemoryIdentity): Promise<void>;
/**
* @throws always — this service has no compare-and-set.
* @see the module header for why this refuses where the sibling adapter
* emulates.
*/
putIfVersion<T = unknown>(_identity: MemoryIdentity, entry: MemoryEntry<T>, expectedVersion: number): Promise<PutIfVersionResult>;
/** @throws always — this service has no recognition set. */
seen(_identity: MemoryIdentity, signature: string): Promise<boolean>;
/** @throws always — the write side of a recognition set this service does not have. */
recordSignature(_identity: MemoryIdentity, signature: string): Promise<void>;
/** @throws always — this service has no feedback primitive. */
feedback(_identity: MemoryIdentity, id: string, _usefulness: number): Promise<void>;
/** @throws always — the read side of feedback this service does not record. */
getFeedback(_identity: MemoryIdentity, id: string): Promise<{
average: number;
count: number;
} | null>;
/**
* Stop using this store. Idempotent and final. Nothing is torn down on
* Google's side — the memories outlive this process, which is the point.
*/
close(): Promise<void>;
/** Where this identity's copy of `id` lives. See the module header. */
private nameOf;
/** One memory by resource name, or `undefined` when there is none. */
private fetch;
/**
* Patch the row at `name` if it exists AND is this scope's; answer `false`
* when there is nothing there to patch.
*
* A row that exists under somebody else's scope is REFUSED rather than
* written. It should be unreachable — the address carries the scope — so
* reaching it means the fingerprint collided or the bank was written by
* another tool under a name of ours, and both of those are facts an operator
* has to be told rather than have resolved in favour of the last writer.
*/
private overwrite;
private ensureOpen;
private wait;
/** Keep an already-sanitized refusal; sanitize anything else. */
private asFailure;
/** The identity's scope, checked for the two values that would break isolation. */
private resolveScope;
}
/**
* A `MemoryStore` over Vertex AI Memory Bank.
*
* @example Per-person memory that survives a conversation ending
* const store = memoryBankStore({
* project: 'my-project',
* location: 'us-central1',
* reasoningEngine: '1234567890',
* scopeFor: (id) => ({
* tenant: encodeIdentityField(id.tenant),
* principal: encodeIdentityField(id.principal),
* }),
* });
*
* const hits = await store.search(identity, [], { text: 'what does she prefer?', k: 5 });
*/
export declare function memoryBankStore(options: MemoryBankStoreOptions): MemoryBankStore;
/**
* Distance → a score whose ORDER is right.
*
* `1 / (1 + d)` is strictly decreasing on `d >= 0`, lands in `(0, 1]`, and is
* exactly 1 at distance 0. It is **not** a cosine similarity and this adapter
* never says it is — see {@link MemoryBankStore.search} for why `minScore` is
* refused rather than measured against it.
*
* A row with no distance is a simple retrieval, which does no ranking at all;
* `0` is the honest score for "this was not ranked", and it sorts last.
*/
export declare function scoreFromDistance(distance: number | null | undefined): number;