agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
461 lines (460 loc) • 21.2 kB
TypeScript
/**
* adapters/google/aiPlatform — the one place this package builds a Vertex AI
* REST client, names a Vertex resource, waits on a Vertex operation, or
* describes a Vertex failure.
*
* Three adapters sit on top of it — `agentEngineSessions`, `memoryBankStore`
* and (for its auth half only) `googleIdentity` — and none of them repeats any
* of the four things above. That is the whole reason this file exists: the AWS
* column learned, expensively, that a shim duplicated across three adapters is
* three places for the same wire fact to go stale, and only one of them gets
* fixed.
*
* ── Which client, and why THIS one ──────────────────────────────────────────
* `@googleapis/aiplatform` — the **split, per-API** discovery package (31.0.0,
* 27 MB) rather than the `googleapis` mega-package (174.0.1, 209 MB) that
* carries every Google API at once for the same generated code. Both were
* installed and measured before this was written.
*
* The gax/proto client (`@google-cloud/aiplatform`, 78 MB) was rejected on a
* fact rather than on size: its Memory Bank surface is **v1beta1 only**, while
* the REST **v1** surface is complete — `memories.purge` and `memories.rollback`
* exist at v1 and do not exist at v1beta1. One client, at the version that has
* everything, beats two clients at two versions.
*
* `@google-cloud/vertexai` is past its own announced removal date and is not
* loaded anywhere in this package, ever.
*
* ── The endpoint fact that is not in anybody's design doc ───────────────────
* The generated client defaults to `https://aiplatform.googleapis.com/` — the
* GLOBAL host. Reasoning engines, their sessions and their memories are
* **regional** resources. An adapter that accepts the default calls the wrong
* host and gets a 404 that reads exactly like "your resource does not exist".
* So {@link buildAiPlatformClient} always sets a regional `rootUrl`, derived
* from the same `location` that composes the resource name, and the surface pin
* asserts the default really is the global one so this comment cannot go stale
* quietly.
*
* ── Secrets ─────────────────────────────────────────────────────────────────
* {@link googleSdkFailure} is the `sdkFailure` law from the AWS identity
* adapter, re-aimed at a Gaxios error: the SDK's own message never comes
* through. It is not that a Vertex error always contains a secret — it is that
* a REST client echoes the request into failure text, our requests carry
* conversation state and access tokens, and an error message here reaches the
* model as a tool result AND rides the event stream to every sink attached.
* One echo publishes it to both at once.
*/
/**
* One REST call's answer. The discovery clients wrap every response as
* `{ data }`, and that wrapper is part of the contract an injected `_client`
* has to honour.
*/
export interface RestResponse<T> {
readonly data: T;
}
/** A long-running operation, as these adapters read it. */
export interface LongRunningOperation {
readonly name?: string | null;
readonly done?: boolean | null;
readonly error?: {
readonly code?: number | null;
readonly message?: string | null;
} | null;
readonly response?: Record<string, unknown> | null;
}
/** The `operations` sub-resource, which both collections carry identically. */
export interface OperationsApi {
/**
* Block server-side until the operation finishes or `timeout` elapses, then
* answer whatever state it is in.
*
* `wait` rather than a `get` poll loop: it is the operation the service
* publishes for exactly this, and it turns "poll every 200 ms for ten
* seconds" into one or two round trips. It may still answer un-done, so
* {@link awaitOperation} loops it against a deadline — a server-side wait is
* not a promise that the work is finished, and treating it as one is how an
* adapter reads a resource that is not there yet.
*/
wait(params: {
name: string;
timeout?: string;
}): Promise<RestResponse<LongRunningOperation> | undefined>;
}
/** A Vertex `Session`, in the fields these adapters read or write. */
export interface VertexSession {
readonly name?: string | null;
readonly userId?: string | null;
readonly sessionState?: Record<string, unknown> | null;
readonly createTime?: string | null;
readonly updateTime?: string | null;
readonly expireTime?: string | null;
readonly ttl?: string | null;
}
/**
* One appended session event — **the only way session STATE may be changed.**
*
* A live field trial established this against the real service (2026-08-14):
* `sessions.patch` with `updateMask: 'sessionState'` is refused with HTTP 400
* and the service's own words, recovered through a raw diagnostic that carried
* no conversation state:
*
* "Can't update the session state for session …, you can only update it by
* appending an event."
*
* The same trial then sent `appendEvent` with `actions.stateDelta` and the
* following `GET` returned the new state. So the fields below are not the whole
* `SessionEvent` message — they are the four this column writes: the three the
* message marks Required (`author`, `invocationId`, `timestamp`) plus the
* `actions.stateDelta` that carries the change.
*/
export interface VertexSessionEvent {
/** Required by the message: who sent it. */
readonly author?: string | null;
/** Required by the message: which invocation it belongs to. */
readonly invocationId?: string | null;
/** Required by the message: RFC 3339, client-side. */
readonly timestamp?: string | null;
/** What the event DOES. `stateDelta` is the half this column uses. */
readonly actions?: {
readonly stateDelta?: Record<string, unknown> | null;
} | null;
}
/** The `sessions` collection. Every method here is pinned. */
export interface SessionsApi {
create(params: {
parent: string;
sessionId?: string;
requestBody: VertexSession;
}): Promise<RestResponse<LongRunningOperation> | undefined>;
get(params: {
name: string;
}): Promise<RestResponse<VertexSession> | undefined>;
/**
* Append one event — and, through `actions.stateDelta`, the only supported
* way to change `sessionState` after creation. Synchronous: it answers a
* response, not a long-running operation.
*/
appendEvent(params: {
name: string;
requestBody: VertexSessionEvent;
}): Promise<RestResponse<Record<string, unknown>> | undefined>;
/**
* **Not for `sessionState`.** See {@link VertexSessionEvent}: the live
* service refuses a state patch by name. Pinned because the method exists and
* the surface pin asserts what this column can reach; no adapter here sends a
* `sessionState` mask through it.
*/
patch(params: {
name: string;
updateMask?: string;
requestBody: VertexSession;
}): Promise<RestResponse<VertexSession> | undefined>;
delete(params: {
name: string;
}): Promise<RestResponse<LongRunningOperation> | undefined>;
list(params: {
parent: string;
filter?: string;
orderBy?: string;
pageSize?: number;
pageToken?: string;
}): Promise<RestResponse<{
sessions?: VertexSession[];
nextPageToken?: string | null;
}> | undefined>;
readonly operations: OperationsApi;
}
/** One typed metadata scalar. Memory Bank's metadata map is NOT free JSON. */
export interface VertexMemoryMetadataValue {
readonly stringValue?: string | null;
readonly doubleValue?: number | null;
readonly boolValue?: boolean | null;
readonly timestampValue?: string | null;
}
/** A Vertex `Memory`, in the fields these adapters read or write. */
export interface VertexMemory {
readonly name?: string | null;
readonly fact?: string | null;
readonly scope?: Record<string, string> | null;
readonly metadata?: Record<string, VertexMemoryMetadataValue> | null;
readonly createTime?: string | null;
readonly updateTime?: string | null;
readonly expireTime?: string | null;
readonly ttl?: string | null;
}
/** One row of a retrieval answer — and the whole ranking trap in two fields. */
export interface VertexRetrievedMemory {
/**
* **A DISTANCE. Smaller is closer.** The SDK's own words for this field are
* "the distance between the query and the retrieved Memory. Smaller values
* indicate more similar memories."
*
* The port's `ScoredEntry.score` is a cosine similarity, where HIGHER is
* closer. These two facts are the silent-inversion trap this column was
* warned about, and the type says so here so that nothing downstream can
* copy it into a `score` field by looking only at its shape.
*
* Only set when similarity search was used; a simple retrieval has no
* ranking at all and reports none.
*/
readonly distance?: number | null;
readonly memory?: VertexMemory;
}
/** The `memories` collection. Every method here is pinned. */
export interface MemoriesApi {
create(params: {
parent: string;
memoryId?: string;
requestBody: VertexMemory;
}): Promise<RestResponse<LongRunningOperation> | undefined>;
get(params: {
name: string;
}): Promise<RestResponse<VertexMemory> | undefined>;
patch(params: {
name: string;
updateMask?: string;
requestBody: VertexMemory;
}): Promise<RestResponse<LongRunningOperation> | undefined>;
delete(params: {
name: string;
}): Promise<RestResponse<LongRunningOperation> | undefined>;
list(params: {
parent: string;
filter?: string;
pageSize?: number;
pageToken?: string;
}): Promise<RestResponse<{
memories?: VertexMemory[];
nextPageToken?: string | null;
}> | undefined>;
retrieve(params: {
parent: string;
requestBody: {
scope: Record<string, string>;
similaritySearchParams?: {
searchQuery: string;
topK?: number;
};
simpleRetrievalParams?: {
pageSize?: number;
pageToken?: string;
};
};
}): Promise<RestResponse<{
retrievedMemories?: VertexRetrievedMemory[];
nextPageToken?: string | null;
}> | undefined>;
readonly operations: OperationsApi;
}
/**
* The shape both adapters call, and the shape an injected `_client` must have.
*
* It is the SDK's own nesting rather than a flattened convenience surface, on
* purpose: the surface pin walks these exact property paths against the really
* installed package, and a flattened shim would move the names being checked
* out of the file that does the calling.
*/
export interface AiPlatformClientLike {
readonly projects: {
readonly locations: {
readonly reasoningEngines: {
readonly sessions: SessionsApi;
readonly memories: MemoriesApi;
};
};
};
}
/** The slice of `@googleapis/aiplatform` this module loads. */
export interface AiPlatformSdkModule {
readonly aiplatform?: (options: {
version: string;
rootUrl?: string;
auth?: unknown;
}) => AiPlatformClientLike;
readonly auth?: {
readonly GoogleAuth?: new (options: unknown) => unknown;
};
}
/**
* The API version every adapter in this column dials, **stated rather than
* defaulted**.
*
* v1, not v1beta1, and that is a decision with evidence behind it: at v1 the
* memories collection carries `purge` and `rollback`; at v1beta1 it does not.
* The Google column's pin asserts this against the installed package on every
* test run, because "which version does this really call" is the question that
* made the pin exist.
*/
export declare const AI_PLATFORM_API_VERSION: "v1";
/** The OAuth scope every Vertex data-plane call needs. */
export declare const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
/** What every adapter in this column needs to reach one reasoning engine. */
export interface AiPlatformConnection {
/**
* The Google Cloud project. Read from `GOOGLE_CLOUD_PROJECT` when omitted;
* refused by name when neither is available, because a project guessed wrong
* is a 404 that reads like a missing resource.
*/
readonly project?: string;
/**
* The region, e.g. `'us-central1'`. **Load-bearing twice over:** it composes
* the resource name AND selects the regional host. There is no default — a
* guess here calls the wrong continent.
*/
readonly location: string;
/**
* The reasoning engine these sessions or memories live under: either the
* bare id (`'1234567890'`) or a full resource name
* (`'projects/p/locations/l/reasoningEngines/1234567890'`), in which case
* the project and location are read from it and need not be repeated.
*
* A reasoning engine resource is REQUIRED even when you deploy no code to
* it: sessions and memories are children of it, and there is nowhere else to
* put them. Creating one is a control-plane job this library does not do.
*/
readonly reasoningEngine: string;
/**
* Credentials. Anything the discovery client accepts — a `GoogleAuth`, an
* `OAuth2Client`, an `Impersonated`. Omit and Application Default
* Credentials are used with the cloud-platform scope, which is the ordinary
* path on Cloud Run, GKE, or a developer machine that has run
* `gcloud auth application-default login`.
*/
readonly auth?: unknown;
/**
* @internal Test seam — a pre-built client. Skips the SDK load entirely, so
* a suite never needs the package installed or a credential configured.
*/
readonly _client?: AiPlatformClientLike;
/** @internal Test seam — the SDK module, to exercise the real construction. */
readonly _sdk?: AiPlatformSdkModule;
}
/** A resolved engine: the parent resource name, plus the pieces it was built from. */
export interface EngineScope {
readonly project: string;
readonly location: string;
readonly engineId: string;
/** `projects/{project}/locations/{location}/reasoningEngines/{engine}` */
readonly parent: string;
}
/**
* Work out which reasoning engine this adapter talks to, from either spelling
* of `reasoningEngine`.
*
* A full resource name wins over `project` / `location` and is CHECKED against
* them rather than silently overriding: two spellings of one fact that could
* disagree is the same law the builder refuses `agent` beside `agentFactory`
* under, and a name that says `us-central1` under a config that says
* `europe-west4` is a bug somebody should be told about, not arbitrated.
*/
export declare function resolveEngine(adapter: string, connection: AiPlatformConnection): EngineScope;
/**
* The regional host for a location.
*
* See the module header: the client's own default is the global host, and
* these resources are regional. This is the fix, in one line, applied at every
* construction.
*/
export declare function regionalRootUrl(location: string): string;
/**
* Build the REST client, or refuse by name.
*
* An injected `_client` short-circuits everything below it, which is how every
* test in this column runs with no package installed and no credential.
*/
export declare function buildAiPlatformClient(adapter: string, connection: AiPlatformConnection, scope: EngineScope): AiPlatformClientLike;
/** How long {@link awaitOperation} keeps waiting before it refuses, by default. */
export declare const DEFAULT_OPERATION_TIMEOUT_MS = 30000;
/**
* Wait for a long-running operation to finish, or refuse by name.
*
* ── Why any of this exists ──────────────────────────────────────────────────
* `sessions.create`, `sessions.delete`, `memories.create`, `memories.patch`
* and `memories.delete` **all answer with an Operation, not with the resource**
* — verified against the installed package's own return types before a line of
* either adapter was written. `get`, `list`, `patch`-on-a-session and
* `retrieve` are the synchronous ones.
*
* That asymmetry is the trap: a `put` that returns as soon as the service
* accepts the request, followed by a `get`, is a race that passes on a warm
* day and fails under load — and fails by answering "no data", which is
* indistinguishable from the truth. So every write in this column goes through
* here and does not return until the service says `done`.
*
* An operation that finished with an error is raised as one. The operation's
* own message is a Google-side description of OUR request, so it is subject to
* the same secrecy law as everything else here: the code comes through, the
* text does not.
*/
export declare function awaitOperation(adapter: string, operations: OperationsApi, operation: LongRunningOperation | undefined, what: string, timeoutMs?: number): Promise<void>;
/**
* The name every refusal from this function carries.
*
* It exists so a caller's `catch` can tell "the operation went wrong" — which
* is already sanitized and already says what to do — apart from "the transport
* went wrong", which still needs {@link googleSdkFailure} run over it. Without
* the distinction an adapter re-wraps its own refusal and replaces a precise
* diagnosis ("did not finish within 30000ms") with a generic one.
*/
export declare const OPERATION_ERROR_NAME = "GoogleOperationError";
/**
* Has this error already been sanitized by this module? Used by every adapter's
* `catch` so a refusal is reported once rather than wrapped twice.
*/
export declare function isSanitizedGoogleError(err: unknown): boolean;
/**
* Re-raise a failed REST call **without its text** — the `sdkFailure` law from
* the AWS identity adapter, re-aimed at a Gaxios error.
*
* What comes through is the part that is both safe and actionable: which
* operation failed and the HTTP status. What does not is the message, because
* a REST client echoes the request into its failure text and these requests
* carry a whole conversation's state, a user id, and an access token in the
* headers. A thrown message here reaches the LLM as a tool result AND rides the
* event stream to every sink attached to the agent.
*
* **The original is deliberately not attached as `cause`** — a cause travels
* with the error into every serializer that walks own properties, which would
* undo all of this in one `JSON.stringify`.
*/
export declare function googleSdkFailure(adapter: string, operation: string, err: unknown): Error;
/**
* The HTTP status of a failed call, wherever this client happened to put it.
*
* Three places rather than one because the client reports a transport failure,
* an API error and a thrown `GaxiosError` slightly differently, and a
* classification that only reads one of them silently stops classifying the
* day the client is upgraded.
*/
export declare function httpStatusOf(err: unknown): number | undefined;
/** Is this the service's "no such resource"? */
export declare function isNotFound(err: unknown): boolean;
/** Is this "a resource by that name already exists"? */
export declare function isAlreadyExists(err: unknown): boolean;
/** The longest a resource id may be in both collections this column writes to. */
export declare const MAX_RESOURCE_ID_LENGTH = 63;
/**
* The id grammar this column really has to satisfy — the INTERSECTION of the
* two documented rules, so one function is right for both callers and neither
* has to remember which is stricter: `[a-z0-9-]`, first character a letter,
* last character a letter or a digit.
*
* Exported so the surface pin can hold it against the installed package's own
* words rather than against this file's memory of them.
*/
export declare const LEGAL_RESOURCE_ID: RegExp;
export declare function safeResourceId(raw: string, max?: number): string;
/**
* A stable fingerprint of a string, in `[0-9a-z-]`.
*
* TWO FNV-1a lanes rather than one, because of what this hash is now asked to
* carry: it is the only thing keeping two ids apart once the fold has run them
* together, and it is half of how `memoryBankStore` addresses one identity's
* memories apart from another's. A single 32-bit lane collides at around one
* chance in a hundred across ten thousand ids, which is too often for either
* job; two lanes cost one more multiply per character and make it negligible.
*
* It is a fingerprint, not a security boundary — a colliding address is still
* refused by the scope check that runs before every read and every overwrite.
*/
export declare function fingerprint(value: string): string;