/** * 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. */ import type { CheckpointEnvelope, SessionExpiryPolicy, SessionLifecycle } from '../../hosting/types.js'; import { type AiPlatformConnection } from '../google/aiPlatform.js'; /** * 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. */ export declare const 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. */ export declare const SESSION_ID_KEY = "agentfootprint.sessionId"; /** Options for {@link agentEngineSessions}. */ export interface AgentEngineSessionsOptions extends AiPlatformConnection { /** * WHO a session belongs to — required by the service on create, and * **immutable** once written. * * The port hands `persist` a session id and an envelope, never a user, so * this is where the missing half comes from. Two spellings: * * - a **function**, called with the session id and the envelope about to be * stored. The recommended shape: return `envelopeOwner(envelope)` — the * principal the conversation itself was signed with — so the service's * idea of the owner and this library's own ownership index agree by * construction rather than by coincidence. That is what the default does. * - a **string**, when every conversation in this engine belongs to one * service identity. Honest for a single-tenant deployment and wrong the * moment it is not, which is why it is not the default. * * The default resolver reads the envelope's own principal and falls back to * {@link DEFAULT_USER_ID} for a conversation that ran anonymously. It never * invents a per-session user id: the service treats `userId` as the thing * you filter a listing by, and minting a unique one per session would make * every listing return exactly one row and look like it worked. */ readonly userId?: string | ((sessionId: string, envelope: CheckpointEnvelope) => string); /** * How long a session lives after its last write, as a duration string the * API accepts (`'86400s'`). **The service's own floor is 24 hours** and it * rejects anything shorter, so this is a knob for keeping conversations * LONGER, never for expiring them sooner. * * Omit and no `ttl` is sent, which leaves the service's own default * expiry in charge. * * **Sent on CREATE only, and the clock does NOT restart.** Later turns append * an event (see the module header, fact 2b), and appending one was measured * against the real service — by a field trial, reported in issue #2 — to * leave `expireTime` where it was. So a conversation expires on the clock its * FIRST turn started, however active it has been since. * * That is a real operational edge: a busy conversation can disappear * mid-use, and nothing in this adapter can extend it, because the service * treats `ttl` as input-only at create. If you need a conversation to outlive * that window, the only lever is a `ttl` long enough at creation. * * Recorded here rather than in a changelog because it is the kind of fact * somebody needs at the moment they are choosing this value, and measured * rather than assumed — the previous version of this comment said the * question had not been measured, which was true until it was. */ readonly ttl?: string; /** * The `author` recorded on every event this store appends. Default * {@link SESSION_EVENT_AUTHOR}. * * The service requires one and treats it as free text; it is who the console * shows as having written the turn. This is not the conversation's owner — * that is `userId`, which is pinned at create and immutable. */ readonly eventAuthor?: string; /** * How long a write waits for its long-running operation before refusing. * Default {@link DEFAULT_OPERATION_TIMEOUT_MS} (30s). * * It refuses rather than returning: a `persist` that reported success on an * operation it never saw finish is a conversation that may or may not be * there next turn. */ readonly operationTimeoutMs?: number; } /** What a conversation that named nobody is stored under. */ export declare const 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. */ export declare const SESSION_EVENT_AUTHOR = "agentfootprint"; /** * A session store in Vertex AI's session service. * * It is a {@link SessionLifecycle} plus the two things a real store owns beyond * the port — forgetting, and closing — because the port deliberately asks for * two methods and leaves the rest to whoever implements it. */ export interface AgentEngineSessions extends SessionLifecycle { /** The resource these sessions live under. Useful in an incident. */ readonly parent: string; /** Forget one session. A session that was never there is not an error. */ forget(sessionId: string): Promise; /** * How conversations here stop existing: **the service does it**, on the * `ttl` this store was built with (9.42.0). * * Narrowed from the port's optional member to a required one, and to the one * arm this store can be — a caller holding an `AgentEngineSessions` needs no * feature check, and reaching for a sweep that does not exist here is a * compile error rather than a surprise at 3am. * * There is nothing to call and nothing for a cron job to do. `active` says * whether a `ttl` was given at all; `enableWith` says what to pass and * repeats the two facts a caller has to plan around — the service's 24-hour * floor, and that the value is sent on CREATE only. */ retention(): SessionExpiryPolicy; /** * Stop using this store. Idempotent, and **final** — reading or writing * afterwards refuses by name rather than quietly reconnecting, because a * store that reopened behind you would hide a shutdown-ordering bug instead * of surfacing it. * * Nothing is torn down on Google's side: the sessions outlive this process, * which is the entire reason to use a managed store. */ close(): void; } /** * 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', * }), * }); */ export declare function agentEngineSessions(options: AgentEngineSessionsOptions): AgentEngineSessions; //# sourceMappingURL=googleAgentEngine.d.ts.map