agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
628 lines • 35.8 kB
JavaScript
"use strict";
/**
* agentEngineSessions — conversations in Vertex AI's own session service, so a
* fleet shares them.
*
* `memorySessions()` loses everything on restart and says so. `sqliteSessions()`
* survives a restart on ONE machine and says so. The row above both — *many
* containers, one conversation* — is where a managed session service belongs,
* and on this column that service is the `sessions` collection under a
* reasoning engine.
*
* ── The name, said once ─────────────────────────────────────────────────────
* The product was **Agent Engine**, is now **Agent Runtime**, and the API
* resource is still spelled `reasoningEngines`. This factory keeps the name it
* was designed under; where the product name and the API disagree, the API is
* the one that has not moved.
*
* ── The fit, and the one thing it cost ──────────────────────────────────────
* `Session.sessionState` is an arbitrary JSON `Struct`. A `CheckpointEnvelope`
* is arbitrary JSON. So the envelope goes in whole, under one key, and comes
* back whole — no event log to fold, no blob encoding to get wrong. What it
* cost is the WRITE VERB, and that took a field trial to learn: state goes in
* through an appended EVENT, never through a patch. See fact 2b below.
*
* ── The five facts that shaped the code ─────────────────────────────────────
* 1. **`sessions.create` takes a caller-supplied `sessionId`.** So our session
* id IS the resource id and `hydrate` is one `get` by name. No mapping
* table, no listing to find a conversation.
* 2. **`create` and `delete` answer a long-running Operation; `get` and
* `appendEvent` answer directly.** Every operation-shaped write here waits
* for `done` before it returns — a `persist` that returned early would make
* the very next `hydrate` a race whose failure mode is "no conversation",
* which nobody can tell from a new user.
*
* 2b. **`sessionState` CANNOT be patched. This one is field truth, and it cost
* a release.** 9.29.0 wrote the steady state as
* `sessions.patch({ updateMask: 'sessionState,ttl' })`, which every
* injected-client test accepted because a double will patch anything. An
* independent field trial ran it against the live service (2026-08-14) and
* found that turn one stored and **every later turn failed**:
*
* HTTP 400 — "Can't update the session state for session …, you can only
* update it by appending an event."
*
* A store that keeps the first turn of a conversation and refuses the
* second is not a session store, so the verb changed rather than the
* documentation: `persist` now appends a `SessionEvent` whose
* `actions.stateDelta` carries the envelope, which the same trial verified
* end to end (append accepted, `GET` returned the new state).
*
* Two consequences worth saying out loud, because they are the difference
* between this fix and a plausible-looking one:
*
* • **A state delta MERGES by top-level key.** This store writes exactly
* two, both namespaced to this library — {@link SESSION_STATE_KEY} and
* {@link SESSION_ID_KEY} — so "merge the delta" and "replace our keys"
* are the same outcome for us, and another guest's keys under the same
* session are left alone either way. The second arrived in 9.45.0: the
* resource id is a lossy fold, so the listing has to carry the id its
* CALLER would recognise rather than the composed one. Answering with
* the composed id, and making the fold idempotent so that answer could
* be fed back, is what made two session ids address one conversation.
* • **A conversation now has an event log behind it**, one event per
* persisted turn, because that is the only writing surface the service
* offers. Nothing here READS that log — `hydrate` still reads the one
* envelope out of `sessionState` — so the log is the service's audit
* trail of our writes and never a second copy of the truth. Sessions
* are per-conversation and events are small; if that growth matters to
* you, `forget()` deletes the session and its events together.
* 3. **`Session.userId` is required and immutable.** Our port's
* `persist(sessionId, envelope)` carries no user, so one has to be
* resolved — see {@link AgentEngineSessionsOptions.userId}. Immutable
* means the first write decides forever, which is the same ownership rule
* this library enforces in its own stores.
*
* **It is metadata, not authorization, and a field trial proved it.** Two
* sessions were created under `alice` and `bob`; the project's ordinary ADC
* principal then read BOTH by name, presenting neither end-user identity
* (FINDINGS "Native Agent Runtime Sessions"). `userId` is what a listing
* filters on and what a first write pins — it is not a check the service
* performs on a read. So the sentence "here the service enforces it for us"
* would be false: anyone who can call the API with your project's
* credentials and can guess a session id can read that conversation.
*
* Where the check belongs is above this port, and this library already has
* it: `envelopeOwner` records who a conversation belongs to, and the
* host/gateway is what must compare that to the authenticated caller
* before handing a session id down here. This adapter is a STORE. Nothing
* that only stores can tell an impostor from an owner.
* 4. **`ttl` is input-only with a 24-hour floor**, and `expireTime` always
* comes back. An hour-long TTL is not available at any price.
*
* **Sliding expiry is NOT claimed here, and cannot be.** A `ttl` is sent on
* `create`, where the service takes it; later turns append an event, and
* appending one was MEASURED against the real service — by a field trial,
* reported in issue #2 — to leave `expireTime` exactly where it was. So a
* conversation expires on the clock its FIRST turn started, however active
* it has been since, and the adapter sends the ttl once and says so because
* there is no second call that would refresh it.
* See {@link AgentEngineSessionsOptions.ttl}.
*
* ── The laws it inherits rather than re-implements ──────────────────────────
* `checkEnvelope` runs on the way OUT and on the way IN, so an envelope whose
* `format` this runtime does not know is refused by name, and a session that
* is PRESENT but unreadable is refused by name too. Only a session that was
* never written hydrates as `undefined`. A conversation that exists and cannot
* be read must never be answered with a fresh start — that failure is
* indistinguishable, from the outside, from a brand-new user.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.agentEngineSessions = exports.SESSION_EVENT_AUTHOR = exports.DEFAULT_USER_ID = exports.SESSION_ID_KEY = exports.SESSION_STATE_KEY = void 0;
const answeredPause_js_1 = require("../../hosting/answeredPause.js");
const envelope_js_1 = require("../../hosting/envelope.js");
const errors_js_1 = require("../../hosting/errors.js");
const sessionOwnership_js_1 = require("../../hosting/sessionOwnership.js");
const aiPlatform_js_1 = require("../google/aiPlatform.js");
const ADAPTER = 'agentEngineSessions';
/**
* The `sessionState` key the envelope lives under.
*
* One key, namespaced, rather than spreading the envelope's own fields across
* `sessionState`: the struct belongs to whoever owns the reasoning engine, an
* agent framework is a guest in it, and a guest that scatters `format` and
* `data` at the top level collides with the next guest. Namespacing also makes
* the console readable — one entry that says whose it is.
*/
exports.SESSION_STATE_KEY = 'agentfootprint.envelope';
/**
* The caller's OWN session id, stored beside the envelope.
*
* The resource id is a fold — lower-cased, punctuation replaced, long ids
* fingerprinted — so it cannot be turned back into what the caller passed.
* `listByUser` used to answer with the resource id and rely on
* `safeResourceId` being idempotent, and that idempotence was a collision: it
* made the fold's output a legal input addressing the same conversation. So the
* raw id travels with the conversation instead, and the listing answers with
* the id its caller would recognise.
*
* Sessions written before 9.45.0 do not carry it; `listByUser` falls back to
* the resource id for those, which is what it always returned.
*/
exports.SESSION_ID_KEY = 'agentfootprint.sessionId';
/** What a conversation that named nobody is stored under. */
exports.DEFAULT_USER_ID = 'agentfootprint-anonymous';
/**
* The `author` on every event this store appends, unless
* {@link AgentEngineSessionsOptions.eventAuthor} names another.
*
* The service requires the field and treats it as free text. This name says
* which library wrote the turn, which is the useful thing to see in the console
* beside somebody else's agent writing to the same engine.
*/
exports.SESSION_EVENT_AUTHOR = 'agentfootprint';
/**
* Conversations in Vertex AI's session service — the store that survives a
* fleet, not just a restart.
*
* **Status: field-validated except the write verb, which is field-CORRECTED.**
* An independent trial ran this adapter against a live Agent Runtime engine
* (2026-08-14) and everything below answered a real request from Google:
* creating first-turn sessions from real envelopes, hydrating them through a
* fresh instance, owners preserved, paged `listByUser` filtering, `ownerOf`,
* an unknown envelope format refused before storage, and idempotent `forget`.
*
* The one thing that FAILED was the second write to an existing session — the
* core job — because 9.29.0 patched `sessionState` and the service refuses
* that. The trial recovered the service's own words and verified the repair
* path (`appendEvent` with `actions.stateDelta`, then a `GET` showing the new
* state) with a raw diagnostic. This adapter now uses that verb; the repair is
* built on measured service behaviour and tested here, but the corrected write
* has not itself been re-run through this adapter in a live project.
*
* @example A standing agent whose conversations are shared across instances
* import { standingAgent, nodeHost } from 'agentfootprint/hosting';
* import { agentEngineSessions } from 'agentfootprint/hosting';
*
* const handle = await standingAgent({
* agentFactory: () => buildAgent(),
* host: nodeHost({ port: 8080 }),
* sessions: agentEngineSessions({
* project: 'my-project',
* location: 'us-central1',
* reasoningEngine: '1234567890',
* }),
* });
*/
function agentEngineSessions(options) {
const scope = (0, aiPlatform_js_1.resolveEngine)(ADAPTER, options);
const client = (0, aiPlatform_js_1.buildAiPlatformClient)(ADAPTER, options, scope);
const sessions = client.projects.locations.reasoningEngines.sessions;
const operationTimeoutMs = options.operationTimeoutMs ?? aiPlatform_js_1.DEFAULT_OPERATION_TIMEOUT_MS;
const resolveUserId = userIdResolver(options.userId);
let closed = false;
const open = (verb) => {
if (!closed)
return;
throw new Error(`[hosting] the ${ADAPTER} store for '${scope.parent}' is closed, so it cannot ${verb}. ` +
`close() is final by design — reconnecting behind you would hide a shutdown-ordering ` +
`bug rather than surface it. Build a new store if you need one after closing this.`);
};
const nameOf = (sessionId) => `${scope.parent}/sessions/${(0, aiPlatform_js_1.safeResourceId)(sessionId)}`;
/**
* Does a session by this name exist? Asked ONLY to tell "there is nothing to
* append to" apart from "the append itself was refused".
*
* The live trial proved the two ends of the write path — `create` stores a
* new conversation, `appendEvent` updates an existing one — and left the
* middle unmeasured: what the service answers when you append to a session
* that is not there. Branching on an assumed 404 would put the FIRST turn of
* every conversation on a guess. So a failed append asks the resource itself,
* which is a fact rather than a convention, and costs one call on the one
* turn where the append was never going to work anyway.
*/
const missing = async (name) => {
try {
await sessions.get({ name });
return false;
}
catch (err) {
// Anything that is not a clean "no such session" leaves the append's own
// failure standing — it is the one that says what went wrong.
return (0, aiPlatform_js_1.isNotFound)(err);
}
};
/**
* Write the envelope onto an EXISTING session, as one appended event.
*
* Answers `false` — rather than raising — for the one case the caller has a
* repair for: there is no session here yet, so it has to be created.
*/
/**
* Who already signed for this conversation, as far as this service can say —
* or `undefined` for a session that is new, or that nobody has signed for.
*
* TWO places have to be consulted, and only this adapter has that problem.
* `userId` is the index `ownerOf` answers from, and the service pins it at
* CREATE and will not let it change; so a conversation that ran anonymously
* on turn one and gained an identity on turn two is stored under the
* anonymous placeholder forever, with its real owner recorded only inside
* the envelope. Reading `userId` alone would therefore see "nobody owns
* this" on a conversation somebody plainly signed for, and let the next
* signer overwrite it.
*/
const signedBy = async (name, sessionId) => {
let session;
try {
session = (await sessions.get({ name }))?.data;
}
catch (err) {
// No session is not an owner — it is the first turn, and there is
// nothing to contradict. Anything else is a failure to READ, and a write
// that proceeded on an unread ownership check would be the defect this
// whole read exists to close.
if ((0, aiPlatform_js_1.isNotFound)(err))
return undefined;
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.get', err);
}
// A session that EXISTS but whose owner cannot be read is not an unowned
// session, and answering `undefined` for both is what let a foreign turn
// land. The service REQUIRES userId at create, so a materialised session
// always has one and an anonymous conversation carries the explicit
// DEFAULT_USER_ID placeholder — an absent userId therefore means the row's
// fields have not become readable yet, not that nobody owns it.
//
// This is the `unreadable-is-not-absent` law of the port, applied to
// OWNERSHIP rather than to the conversation: the two answers must stay
// different, because only one of them is safe to write on.
const userId = session?.userId;
if (typeof userId !== 'string' || userId === '') {
throw new Error(`[hosting] ${ADAPTER} could not determine who owns session "${sessionId}". The session ` +
`exists but came back without a userId, so this store cannot tell an unowned ` +
`conversation from somebody else's — and it refuses rather than appending your turn ` +
`into a session that may not be yours. Nothing was written. This is transient ` +
`(another writer's create is still settling): retry the turn.`);
}
if (userId !== exports.DEFAULT_USER_ID)
return userId;
const stored = session?.sessionState?.[exports.SESSION_STATE_KEY];
return stored !== null && typeof stored === 'object' ? (0, envelope_js_1.envelopeOwner)(stored) : undefined;
};
const append = async (name, stateDelta) => {
try {
await sessions.appendEvent({
name,
requestBody: {
author: options.eventAuthor ?? exports.SESSION_EVENT_AUTHOR,
// Required, and one per persisted turn. It groups the events of one
// invocation on the service's side; this store writes exactly one
// event per turn, so a fresh id per call is the honest grouping.
invocationId: newInvocationId(),
timestamp: new Date().toISOString(),
// ONE top-level key — see the module header. A state delta merges by
// top-level key, so this replaces our envelope and touches nothing
// else in the struct.
actions: { stateDelta },
},
});
return true;
}
catch (err) {
if ((0, aiPlatform_js_1.isNotFound)(err) || (await missing(name)))
return false;
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.appendEvent', err);
}
};
return {
parent: scope.parent,
async hydrate(sessionId) {
open('hydrate a session');
let session;
try {
session = (await sessions.get({ name: nameOf(sessionId) }))?.data;
}
catch (err) {
// The ONE failure that means "no conversation". Everything else is a
// failure to READ, which is a different fact and never answered with a
// fresh start.
if ((0, aiPlatform_js_1.isNotFound)(err))
return undefined;
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.get', err);
}
if (session === undefined)
return undefined;
const state = session.sessionState;
// A session with no state at all was created by something that is not
// this library — or by us, and never written to. Nothing here ever
// claimed to be a conversation, so it is an absence.
if (state === null || state === undefined)
return undefined;
const stored = state[exports.SESSION_STATE_KEY];
if (stored === undefined)
return undefined;
// Present and not an object: a conversation EXISTS here and this runtime
// cannot read it. Refused by name, never as `undefined`.
if (stored === null || typeof stored !== 'object') {
throw new errors_js_1.UnreadableEnvelopeError(stored, sessionId);
}
// Validated HERE as well as in the composer, so a refusal points at the
// store that produced the bytes rather than at whoever read them next.
return (0, envelope_js_1.checkEnvelope)(stored, sessionId);
},
async persist(sessionId, envelope) {
open('persist a session');
// Checked on the way IN as well as out: a session this store could not
// read back is one it has no business writing.
const checked = (0, envelope_js_1.checkEnvelope)(envelope, sessionId);
const name = nameOf(sessionId);
const state = {
[exports.SESSION_STATE_KEY]: checked,
[exports.SESSION_ID_KEY]: sessionId,
};
// ── Whose conversation is this, before a byte of it is written ────
//
// The 9.36.1 correction, and this adapter had the defect in its own
// dialect: `userId` is immutable at the service, `hydrate` answers from
// `sessionState`, and nothing compared them — so appending an envelope
// signed by somebody else left `ownerOf` naming the person who created
// the session and the stored conversation belonging to the person who
// wrote last. The one the index named would open it and read the other's
// conversation.
//
// ONE read, and only when this turn actually names somebody. An
// anonymous envelope claims nobody, so it can contradict nobody, and it
// is written on exactly the call path it always was — which is also why
// the steady state of an anonymous conversation still costs one call.
const incoming = (0, envelope_js_1.envelopeOwner)(checked);
if (incoming !== undefined) {
// Throws SessionOwnershipConflictError on a different signer, before
// either the append or the create below.
(0, sessionOwnership_js_1.resolveSessionOwner)(sessionId, await signedBy(name, sessionId), incoming);
}
// APPEND first, CREATE when there is no session to append to.
//
// The steady state of a conversation is "it already exists": created
// once, written on every turn after that. So the ordinary turn costs one
// call, and the extra call is paid on turn one only.
//
// Appending — not patching. `sessions.patch` with a `sessionState` mask is
// refused by the live service (module header, fact 2b); it is the verb
// 9.29.0 used and the reason a second turn could not be stored.
if (await append(name, state))
return;
const userId = resolveUserId(sessionId, checked);
let created;
try {
created = await sessions.create({
parent: scope.parent,
sessionId: (0, aiPlatform_js_1.safeResourceId)(sessionId),
requestBody: {
sessionState: state,
...(options.ttl !== undefined && { ttl: options.ttl }),
userId,
},
});
}
catch (err) {
// Two writers opened the same conversation at once and the other one
// won. That is not a failure: the session exists now, which is all
// this call wanted, so our state goes on with the same appended event.
if (!(0, aiPlatform_js_1.isAlreadyExists)(err))
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.create', err);
// ASK AGAIN before appending into it. The ownership check above ran
// when there was no session to own; an ALREADY_EXISTS is proof that
// somebody created one in between, and they may have signed it. This
// is the exact interleaving the field trial hit — two writers, one
// fresh session, both calls fulfilling — and re-reading here is what
// closes it: the 409 itself guarantees the other writer's create has
// already landed, so this read cannot miss it.
if (incoming !== undefined) {
(0, sessionOwnership_js_1.resolveSessionOwner)(sessionId, await signedBy(name, sessionId), incoming);
}
if (await append(name, state))
return;
// Created by somebody and gone again before we could write to it.
// Nothing here can say this turn landed, so it does not say it.
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.create', err);
}
// OUTSIDE the try on purpose: this refusal is already sanitized and
// already says what to do, and re-wrapping it would replace a precise
// diagnosis with a generic one.
await (0, aiPlatform_js_1.awaitOperation)(ADAPTER, sessions.operations, created?.data, `creating session '${sessionId}'`, operationTimeoutMs);
},
async listByUser(userId, listOptions) {
open('list a user’s sessions');
const limit = Math.max(1, Math.floor(listOptions?.limit ?? DEFAULT_PAGE));
let page;
try {
page = (await sessions.list({
parent: scope.parent,
// The service's own filter over its own immutable field, with the
// caller's id as a quoted literal — see quoteFilterValue.
filter: `user_id=${quoteFilterValue(userId)}`,
orderBy: 'update_time desc',
pageSize: limit,
...(listOptions?.cursor !== undefined && { pageToken: listOptions.cursor }),
}))?.data;
}
catch (err) {
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.list', err);
}
const summaries = (page?.sessions ?? []).map((session) => {
// The caller's own id when the conversation carries it, the resource id
// when it predates that — never the composed id for a session that knows
// its real one, because a caller cannot feed the composed one back.
const carried = session.sessionState?.[exports.SESSION_ID_KEY];
const sessionId = typeof carried === 'string' && carried !== '' ? carried : lastSegment(session.name);
const stored = session.sessionState?.[exports.SESSION_STATE_KEY];
// A listing must not fail because ONE row is unreadable — a sidebar
// that 500s over a corrupt conversation is worse than one that shows
// it with an honest zero. The transcript op reads the envelope itself
// and refuses there, which is where a reader can act on it.
const readable = stored !== null && typeof stored === 'object' ? stored : undefined;
return {
sessionId,
savedAt: toMillis(session.updateTime ?? session.createTime),
// What the row SAYS the session is — a pause whose decision was
// already used lists as a conversation (`listedFormatOf`).
format: (0, answeredPause_js_1.listedFormatOf)(readable),
messageCount: readable === undefined ? 0 : (0, envelope_js_1.envelopeTranscript)(readable).length,
};
});
const cursor = page?.nextPageToken;
return {
sessions: summaries,
...(typeof cursor === 'string' && cursor !== '' && { cursor }),
};
},
async ownerOf(sessionId) {
open('read a session’s owner');
try {
const session = (await sessions.get({ name: nameOf(sessionId) }))?.data;
const userId = session?.userId;
// `undefined` for "no such session" AND for a session stored under the
// anonymous placeholder — the deliberate ambiguity the composer's one
// not-found rests on. A store that answered those differently would
// hand a caller an oracle for which session ids are real.
return typeof userId === 'string' && userId !== '' && userId !== exports.DEFAULT_USER_ID
? userId
: undefined;
}
catch (err) {
if ((0, aiPlatform_js_1.isNotFound)(err))
return undefined;
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.get', err);
}
},
// ── Retention (9.42.0) ──────────────────────────────────────────────
//
// `'the-backend'` is the only honest answer, and here it is not even a
// choice: the service OWNS expiry. `ttl` is input-only with a 24-hour
// floor, `expireTime` comes back and cannot be set, and there is no
// service-side call that means "delete everything older than X". This
// store could have swept — list, then delete one long-running operation at
// a time — and it would have been a second, slower, billed
// reimplementation of a policy the caller already handed the service at
// create.
//
// Answers on a CLOSED store on purpose: it reads nothing and calls
// nothing, and an operator asking "what expires these?" during a shutdown
// should get the answer rather than the refusal that guards the data
// plane.
retention: () => ({
deletedBy: 'the-backend',
active: options.ttl !== undefined,
// The service's own field, not one this adapter writes — which is why
// the name is `expireTime` and not something of ours. It is returned on
// every session and is read-only; `ttl` is what SETS it.
expiresOn: 'expireTime',
enableWith: `pass ttl: '<seconds>s' to agentEngineSessions() — e.g. ttl: '604800s' for a week. ` +
`The service's floor is 24 HOURS and it refuses anything shorter, so this can only ` +
`keep conversations longer, never expire them sooner. It is sent on CREATE only, ` +
`and the clock does NOT restart: appending a later turn was measured against the ` +
`real service to leave expireTime where it was, so a conversation expires on the ` +
`clock its FIRST turn started however active it has been since. Set it long enough ` +
`at creation — a conversation cannot push its own deadline out.`,
}),
async forget(sessionId) {
open('forget a session');
let deleted;
try {
deleted = await sessions.delete({ name: nameOf(sessionId) });
}
catch (err) {
// Already gone is the outcome this asked for.
if ((0, aiPlatform_js_1.isNotFound)(err))
return;
throw (0, aiPlatform_js_1.googleSdkFailure)(ADAPTER, 'sessions.delete', err);
}
await (0, aiPlatform_js_1.awaitOperation)(ADAPTER, sessions.operations, deleted?.data, `deleting session '${sessionId}'`, operationTimeoutMs);
},
close() {
closed = true;
},
};
}
exports.agentEngineSessions = agentEngineSessions;
// ─── Internals ───────────────────────────────────────────────────────
/** How many rows one `listByUser` page carries when the caller names no limit. */
const DEFAULT_PAGE = 50;
/**
* A user id as an AIP-160 string literal — **backslash first, then the quote.**
*
* The order is the whole point. This grammar honours backslash escapes, so
* escaping only the quote leaves the escape character itself free to escape our
* escape: a user id of `\" OR user_id!=` renders as `user_id="\\" OR user_id!=""`,
* where `\\` is a literal backslash, the quote after it CLOSES the literal, and
* the rest of the id is filter syntax the service evaluates. The listing then
* matches every session with a non-empty user id and hands back other people's
* conversation ids, timestamps and message counts.
*
* The benign case matters too: any id merely ENDING in a backslash swallows the
* closing quote and the call fails with a malformed-filter 400 whose text
* {@link googleSdkFailure} withholds — a local error delivered as a censored
* remote one. Escaping the backslash first fixes both.
*
* A user id is caller data on every column of this library. It is never
* concatenated into a query language without passing through here.
*/
function quoteFilterValue(value) {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
/**
* Resolve the immutable `userId` a session is created under.
*
* See {@link AgentEngineSessionsOptions.userId} for why the default reads the
* envelope rather than inventing anything.
*/
function userIdResolver(option) {
if (typeof option === 'function') {
return (sessionId, envelope) => {
const resolved = option(sessionId, envelope);
if (typeof resolved !== 'string' || resolved.trim() === '') {
throw new TypeError(`${ADAPTER}: the 'userId' resolver returned ${JSON.stringify(resolved)} for session ` +
`'${sessionId}'. The service requires a non-empty user id on create and will not ` +
`let it be changed afterwards, so this refuses rather than storing the ` +
`conversation under a name nobody chose.\n` +
` Return '${exports.DEFAULT_USER_ID}' explicitly if an anonymous conversation is what ` +
`you mean.`);
}
return resolved;
};
}
if (typeof option === 'string') {
if (option.trim() === '') {
throw new TypeError(`${ADAPTER}: 'userId' was an empty string. The service requires one on create and ` +
`treats it as immutable. Pass a real id, a resolver function, or leave it unset to ` +
`derive the owner from the conversation itself.`);
}
return () => option;
}
// The default: the principal the conversation was signed with, so the
// service's owner and this library's own ownership index agree.
return (_sessionId, envelope) => (0, envelope_js_1.envelopeOwner)(envelope) ?? exports.DEFAULT_USER_ID;
}
/**
* A fresh `invocationId` for one appended event.
*
* The field is required and groups the events of one invocation on the
* service's side. This store writes exactly one event per persisted turn, so a
* new id per call is what that grouping honestly is — reusing one across turns
* would tell the console that a whole conversation was a single invocation.
*
* `randomUUID` where the runtime has it, and a time-plus-random id where it
* does not: this is a correlation label, not a security value, and an adapter
* that threw on an old runtime because a label could not be minted would be
* failing a conversation over nothing.
*/
function newInvocationId() {
const uuid = globalThis.crypto?.randomUUID;
if (typeof uuid === 'function')
return `af-${uuid.call(globalThis.crypto)}`;
return `af-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** The id out of a resource name, which is everything after the last `/`. */
function lastSegment(name) {
if (typeof name !== 'string')
return '';
const at = name.lastIndexOf('/');
return at === -1 ? name : name.slice(at + 1);
}
/** An RFC 3339 timestamp as unix milliseconds, or 0 when it is not one. */
function toMillis(value) {
if (typeof value !== 'string')
return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
//# sourceMappingURL=googleAgentEngine.js.map