agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
289 lines (288 loc) • 16.5 kB
TypeScript
/**
* 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<void>;
/**
* 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;