agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
476 lines • 26.4 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.fingerprint = exports.safeResourceId = exports.LEGAL_RESOURCE_ID = exports.MAX_RESOURCE_ID_LENGTH = exports.isAlreadyExists = exports.isNotFound = exports.httpStatusOf = exports.googleSdkFailure = exports.isSanitizedGoogleError = exports.OPERATION_ERROR_NAME = exports.awaitOperation = exports.DEFAULT_OPERATION_TIMEOUT_MS = exports.buildAiPlatformClient = exports.regionalRootUrl = exports.resolveEngine = exports.CLOUD_PLATFORM_SCOPE = exports.AI_PLATFORM_API_VERSION = void 0;
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
// ─── Connection ──────────────────────────────────────────────────────
/**
* 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.
*/
exports.AI_PLATFORM_API_VERSION = 'v1';
/** The OAuth scope every Vertex data-plane call needs. */
exports.CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
const ENGINE_NAME = /^projects\/([^/]+)\/locations\/([^/]+)\/reasoningEngines\/([^/]+)$/;
/**
* 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.
*/
function resolveEngine(adapter, connection) {
const { reasoningEngine, location } = connection;
if (typeof reasoningEngine !== 'string' || reasoningEngine.trim() === '') {
throw new TypeError(`${adapter} requires 'reasoningEngine' — the id of the Agent Runtime resource these ` +
`live under, or its full resource name.\n` +
` Sessions and memories are CHILDREN of a reasoning engine; there is nowhere else ` +
`to put them, so one has to exist even if you deploy no code to it.\n` +
` Creating it is a control-plane job (gcloud / Terraform / the console) that this ` +
`library deliberately does not do.`);
}
const match = ENGINE_NAME.exec(reasoningEngine.trim());
if (match) {
const [, project, engineLocation, engineId] = match;
if (connection.project !== undefined && connection.project !== project) {
throw new TypeError(`${adapter}: 'reasoningEngine' names project '${project}' but 'project' says ` +
`'${connection.project}'. Two spellings of one fact that disagree are refused rather ` +
`than arbitrated — drop one of them.\n` +
(looksLikeProjectNumber(project) && !looksLikeProjectNumber(connection.project)
? ` These may well be the SAME project: Google hands back canonical resource ` +
`names with the numeric project NUMBER, while you configured the textual project ` +
`ID. This adapter cannot tell one case from the other without a Resource Manager ` +
`lookup it deliberately does not make — resolving them as equal on a guess is how ` +
`one project's conversations end up in another's.\n` +
` Fix: pass the ENGINE ID alone ('${engineId}') beside your ` +
`project/location, or pass the full name Google gave you and drop 'project'.\n`
: '') +
` A field trial hit exactly this (2026-08-14) after copying an engine's canonical ` +
`name out of the console.`);
}
if (location !== undefined && location !== engineLocation) {
throw new TypeError(`${adapter}: 'reasoningEngine' names location '${engineLocation}' but 'location' says ` +
`'${location}'. The location picks the regional host as well as the resource name, ` +
`so a mismatch would call one region for a resource that lives in another. Drop one.`);
}
return { project, location: engineLocation, engineId, parent: reasoningEngine.trim() };
}
const project = connection.project ?? readEnv('GOOGLE_CLOUD_PROJECT');
if (project === undefined || project === '') {
throw new TypeError(`${adapter} requires 'project' — no project was configured and GOOGLE_CLOUD_PROJECT is ` +
`not set.\n` +
` Pass it, set the environment variable, or give 'reasoningEngine' as a full resource ` +
`name ('projects/…/locations/…/reasoningEngines/…'), which carries the project with it.`);
}
if (typeof location !== 'string' || location.trim() === '') {
throw new TypeError(`${adapter} requires 'location' (e.g. 'us-central1'). There is no default, on purpose: ` +
`it selects the REGIONAL API host as well as composing the resource name, so a guess ` +
`does not fail — it calls a different region and answers "not found".`);
}
const engineId = reasoningEngine.trim();
return {
project,
location: location.trim(),
engineId,
parent: `projects/${project}/locations/${location.trim()}/reasoningEngines/${engineId}`,
};
}
exports.resolveEngine = resolveEngine;
/**
* Is this string the numeric project NUMBER rather than the textual project ID?
*
* Google accepts either in a resource name and answers with the number, so the
* two spellings of one project routinely disagree byte for byte. A project ID
* must start with a letter, so "all digits" separates them without ambiguity.
* Used only to make the refusal above teach; nothing resolves on it.
*/
function looksLikeProjectNumber(value) {
return /^\d+$/.test(value);
}
/** `process.env` without assuming `process` exists (browser bundles do not have one). */
function readEnv(name) {
return typeof process !== 'undefined' && process.env ? process.env[name] : undefined;
}
/**
* 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.
*/
function regionalRootUrl(location) {
return `https://${location}-aiplatform.googleapis.com/`;
}
exports.regionalRootUrl = regionalRootUrl;
/**
* 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.
*/
function buildAiPlatformClient(adapter, connection, scope) {
if (connection._client)
return connection._client;
let mod;
if (connection._sdk) {
mod = connection._sdk;
}
else {
try {
mod = (0, lazyRequire_js_1.lazyRequire)('@googleapis/aiplatform');
}
catch {
throw new Error(`${adapter} requires the \`@googleapis/aiplatform\` peer dependency.\n` +
` Install: npm install @googleapis/aiplatform\n` +
` It is the SPLIT per-API package (~27 MB), not the \`googleapis\` mega-package ` +
`(~209 MB) that carries every Google API for the same generated code.\n` +
` It is optional and loaded only when you construct this adapter, so nothing else ` +
`in this library pays for it.`);
}
}
if (typeof mod.aiplatform !== 'function') {
throw new Error(`${adapter}: \`@googleapis/aiplatform\` is installed but exports no \`aiplatform\` ` +
`factory. This adapter is built against the 31.x package — update it, or pass a ` +
`pre-built client.`);
}
const auth = connection.auth ?? defaultAuth(adapter, mod);
return mod.aiplatform({
version: exports.AI_PLATFORM_API_VERSION,
// Never the client's global default — see the module header.
rootUrl: regionalRootUrl(scope.location),
auth,
});
}
exports.buildAiPlatformClient = buildAiPlatformClient;
/**
* Application Default Credentials, from the SDK's own re-exported auth surface.
*
* Taken from `@googleapis/aiplatform` rather than by loading
* `google-auth-library` separately: the package already carries it, and one
* load is one version to reason about instead of two that can drift apart.
*/
function defaultAuth(adapter, mod) {
const GoogleAuth = mod.auth?.GoogleAuth;
if (typeof GoogleAuth !== 'function') {
throw new Error(`${adapter}: \`@googleapis/aiplatform\` exposes no \`auth.GoogleAuth\`, so Application ` +
`Default Credentials cannot be resolved.\n` +
` Pass 'auth' with your own GoogleAuth / OAuth2Client, or update the package.`);
}
return new GoogleAuth({ scopes: [exports.CLOUD_PLATFORM_SCOPE] });
}
// ─── Long-running operations ─────────────────────────────────────────
/** How long {@link awaitOperation} keeps waiting before it refuses, by default. */
exports.DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
/** One server-side wait, in the duration spelling the API takes. */
const WAIT_SLICE = '10s';
/**
* 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.
*/
async function awaitOperation(adapter, operations, operation, what, timeoutMs = exports.DEFAULT_OPERATION_TIMEOUT_MS) {
if (operation === undefined)
return;
// Some services answer an already-finished operation on the first call. That
// is the common case for a small write, and it costs no round trip.
if (operation.done === true) {
throwIfOperationFailed(adapter, operation, what);
return;
}
const name = operation.name;
if (typeof name !== 'string' || name === '') {
// Nothing to wait ON. Answering as though the write had landed would be
// exactly the silent success this whole function exists to prevent.
throw operationError(`${adapter}: ${what} was accepted but the service returned an operation with no name, ` +
`so there is nothing to wait on and no way to confirm the write landed.\n` +
` Refusing rather than reporting a success this adapter cannot verify.`);
}
const deadline = Date.now() + Math.max(0, timeoutMs);
let current = operation;
while (current.done !== true) {
if (Date.now() >= deadline) {
throw operationError(`${adapter}: ${what} did not finish within ${timeoutMs}ms.\n` +
` The operation is still running on Google's side — it may yet succeed — but this ` +
`call will not report a write it has not seen land.\n` +
` Raise the adapter's 'operationTimeoutMs' if your engine is routinely slower.`);
}
const answer = await operations.wait({ name, timeout: WAIT_SLICE });
current = answer?.data ?? {};
}
throwIfOperationFailed(adapter, current, what);
}
exports.awaitOperation = awaitOperation;
function throwIfOperationFailed(adapter, operation, what) {
const error = operation.error;
if (error === null || error === undefined)
return;
const code = typeof error.code === 'number' ? ` (code ${error.code})` : '';
throw operationError(`${adapter}: ${what} failed${code}.\n` +
` The operation's own message is withheld: it is Google's description of OUR request, ` +
`and this request carried conversation state. Check Cloud Logging for the full text.`);
}
/**
* 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.
*/
exports.OPERATION_ERROR_NAME = 'GoogleOperationError';
function operationError(message) {
const err = new Error(message);
err.name = exports.OPERATION_ERROR_NAME;
return err;
}
/**
* 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.
*/
function isSanitizedGoogleError(err) {
return (err instanceof Error && (err.name === exports.OPERATION_ERROR_NAME || err.name === 'GoogleApiError'));
}
exports.isSanitizedGoogleError = isSanitizedGoogleError;
// ─── Failures ────────────────────────────────────────────────────────
/**
* 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`.
*/
function googleSdkFailure(adapter, operation, err) {
const status = httpStatusOf(err);
const failure = new Error(`${adapter}: ${operation} failed` +
(status === undefined ? '' : ` (HTTP ${status})`) +
`.\n The SDK's own message is withheld: this request carried session state and an ` +
`access token, and REST clients echo request detail into failure text. Check Cloud ` +
`Logging for the full error.`);
failure.name = 'GoogleApiError';
return failure;
}
exports.googleSdkFailure = googleSdkFailure;
/**
* 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.
*/
function httpStatusOf(err) {
const e = err;
if (e === null || typeof e !== 'object')
return undefined;
for (const candidate of [e.status, e.code, e.response?.status]) {
if (typeof candidate === 'number' && Number.isFinite(candidate))
return candidate;
// Gaxios sometimes reports the status as a numeric STRING.
if (typeof candidate === 'string' && /^\d{3}$/.test(candidate))
return Number(candidate);
}
return undefined;
}
exports.httpStatusOf = httpStatusOf;
/** Is this the service's "no such resource"? */
function isNotFound(err) {
return httpStatusOf(err) === 404;
}
exports.isNotFound = isNotFound;
/** Is this "a resource by that name already exists"? */
function isAlreadyExists(err) {
return httpStatusOf(err) === 409;
}
exports.isAlreadyExists = isAlreadyExists;
// ─── Ids ─────────────────────────────────────────────────────────────
/** The longest a resource id may be in both collections this column writes to. */
exports.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.
*/
exports.LEGAL_RESOURCE_ID = /^[a-z]$|^[a-z][a-z0-9-]*[a-z0-9]$/;
/**
* Turn caller data into a resource id the service will accept — and never into
* a DIFFERENT caller's id.
*
* ── The grammar, quoted rather than guessed ─────────────────────────────────
* This function used to state that "Vertex resource ids accept `[A-Za-z0-9_-]`".
* That is a Google-wide habit and it is not what either collection this column
* writes to documents. From the installed package (`@googleapis/aiplatform`
* 31.0.0, `build/v1.d.ts`), verbatim:
*
* `memoryId` — "This value may be up to 63 characters, and valid characters
* are `[a-z0-9-]`. The first character must be a letter, and
* the last character must be a letter or number."
* `sessionId` — "This value may be up to 63 characters, and valid characters
* are `[a-z0-9-]`. The first and last characters must be a
* letter or number."
*
* So UPPERCASE and `_` are illegal in both, and a leading digit is illegal for
* a memory. Under the old grammar a UUID entry id (leading digit) and a ULID
* session id (uppercase Crockford base32) — two of the most ordinary id shapes
* there are — went to the wire unchanged, to be refused by Google with a 400
* whose text {@link googleSdkFailure} then withholds. A grammar error the
* adapter could have caught locally, delivered as a censored 400, is the one
* place the secrecy law and the teaching-refusal law collide; the fix is to not
* send it. The surface pin reads those two sentences out of the installed
* `.d.ts` and checks them against {@link LEGAL_RESOURCE_ID}, so this cannot
* drift back to a guess.
*
* ── Why a hash and not just a fold ──────────────────────────────────────────
* Lowercasing and substituting are LOSSY: `Alice` folds onto `alice`, `a_b`
* onto `a-b`. Two ids that folded together would be ONE row, which is the same
* silent overwrite this column's addressing law exists to prevent. So the
* readable fast path is taken only when the caller's id already is a legal id
* byte for byte; anything the fold touched — or that overruns 63 characters —
* is carried by a {@link fingerprint} of the ORIGINAL, which keeps distinct
* inputs distinct.
*/
/** Marks an id this function had to rewrite; excluded from the pass-through arm. */
const ENCODED_PREFIX = 'id-';
function safeResourceId(raw, max = exports.MAX_RESOURCE_ID_LENGTH) {
const slug = raw.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// `!startsWith(ENCODED_PREFIX)` is the whole of the collision fix. Without it
// the two arms OVERLAP: the fold's output is itself a legal id, so the fast
// arm returns it unchanged and `x` and `f(x)` become one resource. Two
// different session ids then address one conversation, which is the law
// `awkward-session-ids-round-trip` exists to hold.
if (slug === raw &&
slug.length <= max &&
exports.LEGAL_RESOURCE_ID.test(slug) &&
!slug.startsWith(ENCODED_PREFIX)) {
return slug;
}
const suffix = fingerprint(raw);
const room = max - suffix.length - 1;
if (room < 2) {
// Returning something longer than `max` would be a silently invalid id —
// refused rather than sent, since only a caller of this function can fix it.
throw new RangeError(`safeResourceId: max of ${max} leaves no room for the fingerprint that keeps two ` +
`folded ids apart. The smallest workable bound is ${suffix.length + 3}.`);
}
// `id-` rather than the bare slug: the first character must be a LETTER, and
// the fold cannot promise one.
//
// This function used to be IDEMPOTENT on purpose — its own output came back
// unchanged — and `agentEngineSessions.listByUser` leaned on that when it
// handed composed resource ids back to callers who fed them to `hydrate`.
// That property IS the collision: if `f(x)` is a fixed point then `x` and
// `f(x)` are two different session ids addressing one conversation, and a
// caller who chose the second as their own id reads the first one's
// conversation. The prefix is now excluded from the fast arm instead, and the
// listing carries the caller's own id rather than the composed one.
const head = `${ENCODED_PREFIX}${slug}`.slice(0, room).replace(/-+$/, '');
return `${head}-${suffix}`;
}
exports.safeResourceId = safeResourceId;
/**
* 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.
*/
function fingerprint(value) {
let a = 0x811c9dc5;
let b = 0x01000193;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
a = Math.imul(a ^ code, 0x01000193);
b = Math.imul(b ^ code, 0x85ebca6b);
}
return `${(a >>> 0).toString(36)}-${(b >>> 0).toString(36)}`;
}
exports.fingerprint = fingerprint;
//# sourceMappingURL=aiPlatform.js.map