agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,744 lines • 95.3 kB
TypeScript
/**
* hosting/types — the ports an agent needs to stand up and stay up.
*
* `AgentHost` is "something can call me". `ConversationHost` is "something can
* TALK to me" — a door that stays open, because `HostRequest → HostReply` is
* one exchange and some doors are not. `SessionLifecycle` is "the conversation
* outlives the request". All three are deliberately written in the vocabulary
* every transport and every store already has — an input, a reply, a frame, a
* session id, a stored blob — and in nothing else.
*
* **The rule these types are written under:** no runtime, product or protocol
* gets a field, a name or an assumption here. A port shaped around one
* provider's request envelope stops being a port and becomes that provider's
* SDK with extra steps, and every later adapter pays for it. If a decision only
* makes sense for one place you might deploy, it belongs in the adapter for
* that place, not in this file. `nodeHost` is the first adapter and it does not
* get special treatment either: its paths, its status codes and its JSON body
* shape all live in `nodeHost.ts`, and nothing in this file knows they exist.
*
* Pattern: Ports & adapters (hexagonal). Role: the port side, exclusively.
*/
/// <reference types="node" />
import type { FlowchartCheckpoint } from 'footprintjs';
import type { Agent } from '../core/Agent.js';
import type { AskComponent } from '../tools/askComponent.js';
import type { CheckInRequest } from '../core/checkin.js';
import type { MiddlewareAsk } from '../core/pause.js';
import type { AgentRunCheckpoint } from '../core/runCheckpoint.js';
import type { AwaitingInput } from '../tools/inputRequest.js';
import type { Unsubscribe } from '../events/dispatcher.js';
import type { ToolArtifacts } from '../artifacts/capability.js';
import type { ArtifactWireRequest, ArtifactWireResult } from './artifactWire.js';
import type { AnswerAccountsOptions } from './answerAccounts.js';
import type { DoorIdentity } from './identityVerification.js';
import type { AdmissionPolicy } from './admission.js';
import type { IngressSink } from './ingressRecord.js';
import type { RequestSealer } from './requestSealing.js';
import type { SessionSummary, SessionWireRequest, SessionWireResult } from './sessionWire.js';
export type { Unsubscribe };
/**
* Something a host can do BEYOND the baseline of "accept a request, deliver one
* reply". Read it from {@link AgentHost.capabilities} and branch on it — never
* assume it, and never infer it from the adapter's name.
*
* The union starts at exactly what a shipped adapter can honour today. A name
* is added when an adapter can actually keep the promise, never in anticipation
* of a transport that does not exist yet: a capability nobody implements is a
* promise the library cannot keep, and pre-minting one for an imagined future
* transport would bake that transport's assumptions in before it arrives.
*
* - `'streaming'` — the caller SEES `reply.emit(...)` pieces as they arrive.
* - `'conversation'` — this host can also carry a two-way channel that stays
* open ({@link ConversationHost.serveConversations}). It joined the union
* when two shipped adapters honoured it, not when it was imagined.
*
* Declared at CONSTRUCTION and static thereafter, which is a constraint worth
* knowing about: a capability whose truth depended on what happened to be
* installed at call time could not be declared here honestly, so an adapter
* that can only sometimes keep a promise does not make it.
*/
export type HostCapability = 'streaming' | 'conversation';
/**
* One inbound request, as the transport described it.
*/
export interface HostRequest {
/** What the caller is asking. */
readonly input: string;
/**
* A person's answer to an outstanding {@link PendingAsk} — and **the one
* thing that distinguishes a resume from a new message.**
*
* Present ⇒ this request answers the run that paused on this session. Absent
* ⇒ this request is a new message. That is the whole contract, and it is a
* FIELD rather than an inference on purpose: reading approval out of prose
* ("yes, go ahead") is a guess, and a guess is not something a consent gate
* may be built on.
*
* The port never interprets it. It is handed to `agent.resume(checkpoint,
* decision)` exactly as it arrived — a {@link CheckInRequest} or a middleware
* `ask` is answered with the shipped `checkInApproved()` / `checkInDeclined()`
* vocabulary, built from the pending ask (`checkInApproved({ request:
* pending.checkIn, by })`) so it names the request it answers, and the resume
* refuses it for any other request or a second time (`ResumeDecisionError`);
* a plain `askHuman` pause is answered with whatever that tool's author
* documented. Typed `unknown` because the library does not get to decide what
* a tool asked for.
*/
readonly decision?: unknown;
/**
* The conversation this request CLAIMS to belong to — caller data, exactly as
* the transport declared it (a JSON field, a header, a path segment).
*
* It is **not identity** and must never be trusted as identity on its own:
* anyone who can reach the host can put any string here, including someone
* else's. Authenticate the caller by your own means, then check that the
* authenticated principal is allowed this session, before you serve it.
*
* Bounded at every door the library ships: an id longer than
* `MAX_SESSION_ID_LENGTH` or holding a control character is refused before
* any handler sees the request, because it travels into every stored row,
* log line and trace span of the conversation.
*/
readonly sessionId?: string;
/**
* WHO the transport says is calling — the end user's id, exactly as the
* transport declared it (9.12.0).
*
* A different fact from {@link sessionId} beside it, and the difference is
* the whole reason it is a second field: a session is a THREAD and this is a
* PERSON. One conversation belongs to one user; one user has many
* conversations; and an audit trail that reports the thread where the actor
* belongs names the wrong party in a way nobody can see from the outside.
*
* **Absent when absent.** No wire derives it from the session id, from the
* body, or from anything else — a request that carried no user is a request
* with no `userId`, and inventing one would be worse than reporting none.
*
* **How much it is worth is the transport's answer, not this port's.** A
* managed runtime whose front door authenticates the caller and forwards the
* result is a transport whose wire can fill this in, and its adapter does. A
* container you expose directly is not: a header there is a string anybody
* can send, and the generic JSON wire therefore reads none — a wire that
* promoted one to "who did this" would hand every caller the ability to sign
* somebody else's name. Which is which is decided in the adapter, by whoever
* knows what stands in front of it.
*/
readonly userId?: string;
/**
* An artifact operation this request carries INSTEAD of a message (9.23.0)
* — `head` (the claim ticket's metadata) or `get` (metadata + payload), for
* one ref.
*
* Its PRESENCE is the discriminant, exactly as {@link decision}'s is: a
* request carrying `artifact` redeems a ticket and never starts or resumes
* a run — `input` and `decision` do not ride it. A handler that serves
* artifacts answers with {@link HostReply.artifact}; a handler that does
* not should refuse it by name rather than treat it as a message, because a
* redemption silently answered by a model turn is a caller told nothing and
* billed anyway. `standingAgent` answers it: the ref is resolved against
* the serving agent's store under the requesting session's
* identity-composed scope — exactly the scope the run's own tools used.
*/
readonly artifact?: ArtifactWireRequest;
/**
* A session-history operation this request carries INSTEAD of a message
* (9.26.0) — `list` (the verified caller's own sessions) or `transcript`
* (one owned session's messages).
*
* Its PRESENCE is the discriminant, exactly as {@link artifact}'s is: a
* request carrying `session` reads history and never starts or resumes a
* run. Both ops REQUIRE a door that verifies identity
* ({@link StandingAgentBaseOptions.identity}) — reading "your" conversations
* from an unverified header is enumeration — and both are refused by name
* where the session store keeps no owner index.
*/
readonly session?: SessionWireRequest;
/**
* Transport headers with lower-cased names, as delivered. Present so a
* handler can map its own conventions (a correlation id, a tenant) without
* the port having to guess which ones matter.
*
* Since 9.26.0 this is also where a door that verifies identity reads the
* caller's credential from — `authorization: Bearer <token>`, the one
* vocabulary every transport in this package normalizes onto. The port still
* does not interpret headers itself; it hands them to whoever was configured
* to.
*/
readonly headers?: Readonly<Record<string, string>>;
/**
* The sign-in this request named, as its KEY — set by a transport built
* with a sign-in cookie (`httpHost`/`nodeHost` `signIn`), which strips the
* cookie itself from {@link headers} before any handler sees them. The key is
* the cookie value's SHA-256: the store is keyed by it and it cannot be
* turned back into a cookie. Absent when the transport has no sign-in cookie
* configured, and when the request carried none.
*
* Not identity: `verifyRequestIdentity` turns it into one, through the
* deployment's sign-in source, exactly as it turns a bearer token into one.
*/
readonly signInKey?: string;
/** Aborted when the caller goes away. */
readonly signal?: AbortSignal;
}
/**
* The one reply a request gets. Exactly one of {@link HostReply.complete},
* {@link HostReply.awaiting}, {@link HostReply.artifact} or
* {@link HostReply.fail} ends it; a second call is ignored rather than allowed
* to corrupt the wire.
*
* Three terminals for a RUN, because a run has three ends and only three: it
* answered, it stopped to ask a person something, or it failed. Before
* `'flowchart-v1'` there was nowhere to keep a paused run, so the middle one
* was delivered through `fail` — an error standing in for unfinished work. It
* is a terminal of its own now, and a pause is never reported as a failure
* again. The fourth terminal ends a request that never was a run:
* {@link HostRequest.artifact} redeems a claim ticket, and `artifact(...)` is
* how the resolved ticket comes back.
*/
export interface HostReply {
/** Deliver the final answer and end the reply. */
complete(output: string): void;
/**
* End the reply with **unfinished work**: the run stopped to ask a person
* something, the paused run is stored, and a later request carrying
* {@link HostRequest.decision} continues it.
*
* This is not a failure and must not be reported as one. The agent did not
* break, no work was lost, and there is nothing to retry — there is a question
* outstanding. An adapter that maps this onto a 5xx, an error counter or a
* dead-letter queue is telling every dashboard it feeds something that is not
* true.
*
* Optional on the TYPE for the same reason {@link HostReply.emit} is: a
* minimal adapter need not implement it. Every shipped adapter does. When it
* is absent the composer still STORES the paused run — the store is not the
* transport's business — and ends the reply with a named refusal instead, so
* the pause is never lost merely because the wire could not describe it.
*/
awaiting?(pending: PendingAsk): void;
/**
* End the reply with a **resolved artifact** (9.23.0): the metadata for a
* `head`, metadata + payload for a `get`. The terminal a request carrying
* {@link HostRequest.artifact} ends through when the ref resolved; a ref
* that did not resolve ends through `fail` with the one indistinguishable
* not-found refusal.
*
* Optional on the TYPE for the same reason {@link HostReply.awaiting} is: a
* minimal adapter need not implement it, and every shipped adapter does.
* When it is absent the composer ends the reply with a named refusal
* (`ArtifactNotCarriedError`) instead — the resolution is not lost quietly
* merely because the wire could not describe it.
*/
artifact?(result: ArtifactWireResult): void;
/**
* End the reply with **resolved session history** (9.26.0): the caller's own
* sessions for a `list`, one owned session's messages for a `transcript`.
*
* The terminal a request carrying {@link HostRequest.session} ends through.
* A session the verified caller does not own does not end here — it ends
* through `fail` with the one indistinguishable not-found, because "exists
* but not yours" is an oracle for which ids are real.
*
* Optional on the TYPE for the same reason {@link artifact} is: a minimal
* adapter need not implement it, and every shipped adapter does. When it is
* absent the composer ends the reply with a named refusal
* (`SessionsNotCarriedError`) rather than improvising a body shape no client
* was written against.
*/
sessions?(result: SessionWireResult): void;
/**
* A piece of the answer, as it is produced.
*
* Optional on the TYPE so a minimal adapter need not implement it — every
* shipped adapter does. Whether the caller SEES the pieces as they arrive is
* the whole difference between hosts, and that is what `'streaming'` in
* {@link AgentHost.capabilities} reports. A host without it buffers what it is
* handed and the authoritative `complete(output)` is what the caller
* receives; the buffer is settled by the completion, never sent alongside it,
* because a chunk is a preview of the same text and delivering both would
* hand the caller the answer twice.
*
* Handler code is identical either way: emit freely, complete once.
*/
emit?(chunk: string): void;
/**
* The turn's artifact store, **already bound** to the scope this request's
* claim tickets are redeemed under — so host code that files its OWN
* artifacts for a turn (a story, the person's clicks, anything joined to it)
* files them where `artifact-get` will look for the same caller, without
* ever composing a scope.
*
* ── Awaited, inside the turn — and bounded ────────────────────────────────
* Not a terminal. The composer calls it exactly ONCE per turn, AWAITS it
* while the session's lane is still held, and only then ends the reply with
* `complete` or `awaiting` — so a ticket filed here exists before the body
* that carries it is composed. Never called for a request that is not a turn
* (an artifact redemption, a session op) or for a turn that ends through
* `fail`.
*
* The wait is BOUNDED: the hook and every operation it started must settle
* within `turnArtifactsTimeoutMs` (default `TURN_ARTIFACTS_TIMEOUT_MS`, 5 s)
* and before the request's own `signal` aborts. Whichever comes first ends
* the hand-over: the binding is revoked, what is in flight is NOT cancelled
* (it may still land, stamped with this turn's session and run), the cause is
* reported, and the terminal is delivered. In the shared `{ agent }` shape
* every session waits behind this turn's lane, so the hook's I/O is paid by
* everyone — keep it to filing.
*
* ── Live only for its turn ─────────────────────────────────────────────────
* The binding is live while the hand-over is: after the composer has waited
* for every operation started through it (a `put` that was not awaited
* included), or after the bound ended the wait, it REVOKES it. A verb called
* later rejects with `TurnArtifactsExpiredError` — a rejection already
* handled, so a floating late call cannot crash the process — and is reported
* as `'expired'`. File inside the hook; never hold the binding.
*
* ── Never decides the reply, never silent ──────────────────────────────────
* A hook that throws or rejects, an operation that fails (awaited or not), the
* bound, the abort and a late call are all put on the serving agent's stream
* as `agentfootprint.artifacts.hand_over_failed { cause, op?, errorClass?,
* errorCode? }` — class only, stamped with the session — and the FIRST of
* them on the ingress record (`IngressRecord.turnArtifactsFailure`). None of
* them turns a delivered answer, or a stored question, into a failure.
* Awaiting another turn of the same standing agent from inside the hook can
* never finish (that turn queues behind this one); the bound ends the wait.
* ── The scope, and what it is not ─────────────────────────────────────────
* The scope is the one {@link HostRequest.artifact} redemption re-composes
* for this request: the verified user (or the transport's own `userId` when
* no verifier is configured), the stored conversation's tenant and
* namespace, else the session rung `{ conversationId: sessionId }`. One
* owner of that tuple — the library — so an app cannot derive a slightly
* different one. It is where the DOOR redeems, which is not always where the
* run filed its own recording: at an unverified door a conversation that
* carries an identity of its own can put the recording elsewhere (the known
* edges are listed in `src/hosting/README.md`).
*
* Every put is stamped `origin: { runId }` with the run this turn executed,
* or carries no origin when it executed none (a partial answer to an input
* request, or its cancellation); a caller's own `origin` is always dropped.
* A PAUSED turn's run id names a run that files no recording — a pause is
* not a finished run, and the resumed run records under its own id — so a
* reader joining on it must say "paused, no recording", not "missing". Each
* filing lands on the serving agent's record as
* `agentfootprint.artifacts.minted` with no `tool` field, its meta naming the
* session and the run it was filed FOR — captured when the binding was
* built, never read from whichever run is live when a late fact lands.
*
* When there is nothing to bind, the value SAYS so rather than inventing a
* scope — see {@link TurnArtifacts}.
*
* Optional, and feature-detected: a host that does not implement it is
* served exactly as before.
*
* @example File a turn's story and carry its ticket on the reply
* turnArtifacts: async (turn) => {
* if (!turn.bound) return;
* const story = await turn.artifacts.put({ kind: 'story/turn', mediaType: 'application/json', data });
* ticketForThisReply = story.ref; // the body `complete` composes next can carry it
* },
*/
turnArtifacts?(turn: TurnArtifacts): void | Promise<void>;
/** End the reply with a failure. */
fail(error: Error): void;
}
/**
* What {@link HostReply.turnArtifacts} is handed: this turn's artifact store,
* bound to exactly one scope — or the reason there is none.
*
* `artifacts` has the shape a tool gets as `ctx.artifacts`: five verbs, none
* of which takes a scope, so a binding can reach this request's scope and no
* other. The scope itself is deliberately NOT on the value — a tuple a host
* can read is a tuple a host can copy, edit and put under. The verbs work only
* while the hand-over is live (then `TurnArtifactsExpiredError`, already
* handled, reported as `'expired'`).
*
* A filing's `origin.runId` is the run the turn executed — none for a turn that
* executed none. On a PAUSED turn that run files no recording (a pause is not a
* finished run; the resumed run records under its own id), so a reader joining
* a filing to "its recording" by run id must read the absence as "paused, no
* recording", never as "missing".
*
* `bound: false` says why nothing was bound, checked in this order:
* - `'no-session'` — the request named no session. An anonymous run scopes
* its artifacts to its own run id, which no later request can present, so
* there is no scope to bind — and none is invented. Wins over `'no-store'`
* when both apply.
* - `'no-store'` — the serving agent has no artifact store.
*/
export type TurnArtifacts = {
readonly bound: true;
readonly artifacts: ToolArtifacts;
} | {
readonly bound: false;
readonly reason: 'no-session' | 'no-store';
};
/**
* What {@link StandingAgentHandle.artifactsForRequest} reads off a request:
* WHICH conversation (`sessionId`) and WHO is asking — the transport's headers
* (the bearer token a configured verifier checks), the sign-in KEY (a sign-in
* the server keeps) and, at a door with no verifier, its `userId` claim. The
* same fields, read the same way, as a turn or a redemption on the wire.
*
* The fields are {@link HostRequest}'s own, so inside a handler the request the
* transport built goes in AS IT IS — `handle.artifactsForRequest(request)` —
* and no credential can be left behind. A {@link HostConversation} goes in the
* same way.
*
* `headers` takes a Node / Express `IncomingHttpHeaders` as it is: a value
* that arrived as an array (a repeated header) is not read, and a REPEATED
* `authorization` — two credentials — is refused as `'unverified'`.
*/
export interface ArtifactsForRequestInput {
readonly sessionId?: string;
readonly headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
readonly userId?: string;
/**
* The sign-in this request named, as its KEY — {@link HostRequest.signInKey},
* or on an app's own route `readSignIn(req.headers, cookieName).key`. Never
* the cookie: the seam does not read cookies, and a key cannot be turned
* back into one.
*
* Verified exactly as the turn door verifies it (`verifyRequestIdentity`
* with the key): an ended, expired or unknown sign-in is `'unverified'`
* (`'expired'`), a sign-in store that cannot answer is `'unavailable'`, and
* a key together with a bearer token is `'unverified'`
* (`'two-credentials'`). Absent, the request is judged as one that carried
* no sign-in — at a door that requires a credential, `'unverified'`
* (`'no-token'`), exactly as the turn door would judge it without its key.
*/
readonly signInKey?: string;
}
/**
* What {@link StandingAgentHandle.artifactsForRequest} answers: the serving
* agent's artifact store bound to the scope a REDEMPTION by this caller of this
* session would read — or why nothing was bound.
*
* `artifacts` has the {@link TurnArtifacts} shape: five verbs, no scope on the
* value, never the unscoped store. Its verbs count against
* `artifactOpsPerSession` with the wire's redemptions (a verb past the bound
* rejects with `ArtifactOpsBusyError`), and the binding is REVOKED when the
* instance it is bound to is retired from the pool or the host is closed —
* a verb STARTED after that rejects with `RequestArtifactsRevokedError`
* (already handled; ask the handle again); one already in flight completes.
*
* `bound: false` says why, checked in this order:
* - `'unverified'` — a verifier is configured and the request did not pass it
* (no credential where one is required, a token that does not verify, a
* sign-in that ended or expired, a token AND a sign-in, a claimed user the
* credential does not prove). `error` is the verifier's refusal; a host's
* 401.
* - `'unavailable'` — the verifier could not answer (`VerifierUnavailableError`,
* an identity-provider outage, a sign-in store that is down). `error` says
* which; a host's 503, never a 401 — the caller's credential was not
* judged.
* - `'no-session'` — the request named no session.
* - `'invalid-session'` — the session id is one the wire refuses at the door
* (empty, over the length bound, or a character outside visible ASCII —
* `InvalidSessionIdError` as `error`); a host's 400.
* - `'not-found'` — at a verifying door, no conversation THIS caller can
* open under that id: somebody else's, one nobody signed for, or one whose
* first turn has not persisted (unless that turn is this caller's, in
* flight). One reason for all of them, as redemption answers them with one
* not-found. Never builds or evicts a pooled instance to find out. Having
* no live instance is NOT by itself a reason: past that ownership check a
* lane-less session binds through the pool's reader — at a door with no
* verifier, a session nobody has chatted in yet included, which is where a
* guide filed before its first turn goes — and whether a ref exists is the
* store's answer (`head` → `null`).
* - `'no-store'` — the serving agent has no artifact store.
*/
export type ArtifactsForRequestResult = {
readonly bound: true;
readonly artifacts: ToolArtifacts;
} | {
readonly bound: false;
readonly reason: 'unverified' | 'unavailable' | 'no-session' | 'invalid-session' | 'not-found' | 'no-store';
/** Present with `'unverified'`, `'unavailable'` and `'invalid-session'`. */
readonly error?: Error;
};
/**
* What `standingAgent(...)` adds to the host adapter's own handle.
*/
export interface StandingAgentHandle {
/**
* The serving agent's artifact store, bound to the scope a redemption by this
* request's caller of this session reads — for the paths that are not a chat
* turn: a read before a run (a panel redeeming a payload by ref), or an
* app-owned route that files its own artifacts beside a conversation.
*
* "The caller" is what the door knows: at a door with a verifier, the person
* the token or the sign-in PROVES; at a door with no verifier, the session id and the
* transport's `userId` claim are the key, by law — exactly as on the wire.
*
* Inside a handler, hand it the request the transport built —
* `handle.artifactsForRequest(request)` — so the sign-in key
* ({@link HostRequest.signInKey}) goes in with the headers. On an app's own
* route, take the sign-in cookie off first and pass only its key:
* `const { key, headers } = readSignIn(req.headers, door.cookieName)`, then
* `{ sessionId, headers, signInKey: key }`.
*
* The host never composes the scope. This runs the door's own steps with the
* door's own instances: the configured verifier, the session-id check, the
* session store (woken and hydrated as a redemption does), the redemption
* door's ownership rule, the ONE scope composer redemption and
* `reply.turnArtifacts` use, and the store of the instance serving that
* session — never building or evicting a pooled instance to find it. So a ref
* filed through it is redeemed on the wire by the same caller, and a ref
* another caller filed answers exactly like a ref that never existed.
*
* A filing made through it carries NO `origin`: this call executes no run,
* so there is no run id the library can vouch for (a caller's own `origin`
* is dropped, as on every binding). The caller is already the scope, and
* its `minted` fact names the session.
*
* @example An Express route that reads a payload by ref before the turn runs
* const handle = await standingAgent({ agent, sessions, host, identity: door.identity });
* app.get('/panel/:sessionId/:ref', async (req, res) => {
* const { key, headers } = readSignIn(req.headers, door.cookieName); // the cookie comes off
* const found = await handle.artifactsForRequest({
* sessionId: req.params.sessionId,
* headers,
* ...(key !== undefined && { signInKey: key }),
* });
* if (!found.bound) {
* const status = { unverified: 401, unavailable: 503, 'invalid-session': 400 } as Record<string, number>;
* return res.status(status[found.reason] ?? 404).end();
* }
* const payload = await found.artifacts.get(req.params.ref); // null: missing, expired or not yours
* return payload ? res.json(payload.data) : res.status(404).end();
* });
*
* @throws HostClosedError after `close()` — including a call that was
* waiting (on the verifier or the session store) when `close()` ran: it
* binds nothing and builds nothing, exactly like the wire.
* @throws UnreadableEnvelopeError when the stored conversation is present but
* cannot be read (the turn door's law — never composed into a scope).
* @throws whatever the session store's own `onWake` / `hydrate` throws (an
* outage is the store's to name, not a refusal value).
*/
artifactsForRequest(request: ArtifactsForRequestInput): Promise<ArtifactsForRequestResult>;
}
/**
* What you hand {@link AgentHost.serve}. Throwing is treated exactly like
* calling `reply.fail(err)` — a handler that throws is a failed request, never
* a hung one.
*/
export type HostHandler = (request: HostRequest, reply: HostReply) => void | Promise<void>;
/** A live host. */
export interface HostHandle {
/**
* Stop taking new requests, let the in-flight ones finish, then release the
* transport. Idempotent, so a shutdown hook and an explicit close can
* coexist. Requests arriving after it are refused with a
* {@link HostClosedError} naming the adapter.
*
* "Release the transport" is as strong as the port can be, because an
* adapter may be serving on a transport the CALLER owns rather than one it
* created. Releasing one of those means handing the routes back — never
* closing it — and a request arriving afterwards is the caller's to answer,
* not this host's to refuse.
*/
close(): Promise<void>;
}
/**
* A request the HOST refused before any handler saw it — the door guard's
* decision about a caller: a cross-site shape, a Host this door does not answer
* for, a session id it will not carry.
*
* Only decisions travel here. A body that is merely malformed (unparseable
* JSON, an unknown wire op, a body over the ceiling) is a broken request, not a
* decision about who is knocking, and stays the transport's own 4xx.
*
* Carries the refusal's class and the fact of a credential — never a header,
* never the credential, never the session id. It is what
* {@link AgentHost.onRefusal} hands a listener, and a listener is free to log
* all of it.
*/
export interface HostRefusal {
/**
* Which door refused: `'request'` — the one-exchange door, refused before
* the body was read or before the handler was called (which of the
* composer's turn / session-op / artifact doors it was headed for is not
* recorded) — or `'conversation'`, an upgrade refused before the 101.
*/
readonly door: 'request' | 'conversation';
/** The named refusal the caller was answered with. Its `code` is stable. */
readonly error: Error & {
readonly code: string;
};
/** Whether an `authorization: Bearer …` credential was PRESENT. Never its value. */
readonly bearerPresent: boolean;
}
/**
* The port: something that can carry requests to one handler and carry its
* replies back.
*/
export interface AgentHost {
/**
* Which adapter this is. Every refusal names it, so an error tells you WHO
* refused rather than leaving you to guess which layer you are looking at.
*/
readonly name: string;
/** What this adapter can do beyond the baseline. Feature-detect; never assume. */
readonly capabilities: readonly HostCapability[];
/** Start serving. Resolves once the host is actually live. */
serve(handler: HostHandler): Promise<HostHandle>;
/**
* Hear about every request this host refuses BEFORE a handler sees it — on
* any of its doors, the conversation door included. Returns the way to stop
* listening.
*
* Optional, and feature-detected: a host that refuses nothing on its own
* has nothing to report. Every host built on `httpHost` has it, and
* `standingAgent` subscribes whenever `onIngressDecision` is set — which is
* how a forged request the host turned away still lands in the ingress
* record, though no run, no handler and no verifier ever saw it.
*
* A listener that throws is contained: the refusal is still answered.
*/
onRefusal?(listener: (refusal: HostRefusal) => void): Unsubscribe;
}
/**
* A door that stays open: one session-scoped, two-way channel.
*
* The distinction this type exists for, in the words of the field report that
* bought it: **`HostRequest → HostReply` is one exchange, and this door is a
* conversation.** A request has one reply and then it is over. A conversation
* has neither side taking turns by rule, no reply count, and an end that either
* side can call.
*
* ── Frames are STRINGS here, deliberately ───────────────────────────────────
* What the frames MEAN is the consumer's contract, not this port's: one
* consumer pushes tool calls down the channel, another speaks a standardized
* agent↔UI protocol, a third exchanges long-running task updates. JSON is what
* all three happen to use and none of them agree on beyond that, so the port
* carries text and stays out of it. Binary is a capability question, deferred
* until a consumer needs it rather than guessed at now.
*
* ── What is NOT here ────────────────────────────────────────────────────────
* No authentication, no chunking, no heartbeat, no protocol framing. See
* {@link ConversationLimits} for why the last two are absences with a reason
* rather than gaps.
*/
export interface HostConversation {
/**
* The conversation this channel CLAIMS to belong to — caller data, exactly as
* the transport declared it, and **not identity**. The whole of
* {@link HostRequest.sessionId}'s warning applies here word for word: anyone
* who can reach the host can put any string here, including someone else's.
*
* A conversation and a request carrying the same string are the same
* session's, as far as this port is concerned. What that entitles either of
* them to is yours to decide, above the port.
*/
readonly sessionId?: string;
/**
* Transport headers with lower-cased names, as delivered — so a handler can
* map its own conventions without the port guessing which ones matter.
*
* This is also where an adapter puts credentials that its transport spells
* some other way: a bearer token a browser could only send as a subprotocol
* arrives here as an ordinary `authorization` header, because a port field
* spelled the way one vendor spells it is how a port stops being one.
*
* Never the sign-in cookie: a host built with one strips it (see
* {@link signInKey}).
*/
readonly headers?: Readonly<Record<string, string>>;
/**
* The sign-in the handshake carried, as its KEY (see
* {@link HostRequest.signInKey}). When it is present the door already
* checked it before the 101, re-checks it before handing on every inbound
* frame, and closes the conversation when the sign-in ends.
*/
readonly signInKey?: string;
/**
* Host → far side. One frame, delivered whole.
*
* Refuses BY NAME rather than dropping quietly in two cases: a conversation
* that has ended (`ConversationClosedError`), and a frame past the ceiling the
* adapter declared (`FrameTooLargeError`). A dropped frame on a channel that
* looks open is the failure mode this port exists to make impossible.
*/
send(frame: string): void;
/**
* Far side → host. Returns an unsubscribe.
*
* Frames that arrive BEFORE the first subscriber are held and delivered to
* it, up to the bound the adapter declares
* ({@link ConversationLimits.maxPendingBytes}) — an `async` handler that
* awaits anything before subscribing would otherwise silently lose the far
* side's opening frame, which on a channel whose first frame is a greeting is
* every conversation.
*/
onFrame(cb: (frame: string) => void): Unsubscribe;
/**
* The end, delivered exactly once per subscriber — including to a subscriber
* that arrives after it already happened, which is answered immediately
* rather than never.
*/
onClose(cb: (reason: ConversationClose) => void): Unsubscribe;
/**
* End it politely: flush what is queued, tell the far side, then stop.
* Idempotent — the first call owns the ending and later ones do nothing.
*/
close(reason?: string): void;
}
/**
* How a conversation ended, in terms every transport can answer.
*
* `by` is the fact a consumer branches on and the reason there is no numeric
* code here: what all three of "a browser-parked channel", "a UI protocol" and
* "a long-running task exchange" need to know is whether the far side said
* goodbye, whether we did, or whether it broke — and a transport's own numbers
* answer that only if you already know that transport. Adapters render their
* own vocabulary (a close code, a timeout, a ceiling) into {@link reason}.
*/
export interface ConversationClose {
/**
* - `'far-side'` — they ended it.
* - `'host'` — we did, through {@link HostConversation.close}.
* - `'transport'` — nobody ended it; it broke or timed out.
*/
readonly by: 'far-side' | 'host' | 'transport';
/** What was said about it, when anything was. */
readonly reason?: string;
}
/**
* What you hand {@link ConversationHost.serveConversations} — called once per
* conversation, with that conversation.
*
* Throwing ends THAT conversation with a stated reason and never the host: one
* caller's bad frame is not an outage.
*/
export type ConversationHandler = (conversation: HostConversation) => void | Promise<void>;
/**
* The ceilings a door imposes, **declared rather than discovered**.
*
* ── Why the port does not just handle them ──────────────────────────────────
* A transport that caps frame size or idles out must SAY so, and then get out
* of the way. Hiding a 32KB cap inside auto-chunking would be the adapter
* deciding a protocol question for every consumer at once — how a message is
* split, how the pieces are numbered, how the far side knows the last one has
* landed — and those answers differ per consumer. Same for liveness: a
* heartbeat is frames on somebody's protocol, and inventing them puts bytes on
* the wire that the consumer's parser never agreed to.
*
* So the port's job is to make the ceiling VISIBLE and let the layer above act:
* chunk above the port, heartbeat above the port.
*
* ── Enforced vs reported ────────────────────────────────────────────────────
* A door enforces what it IS and reports what it SITS BEHIND, and the doc on
* each field says which. Absent means "no ceiling this adapter knows of", never
* "no ceiling" — the runtime in front of you may have one it never told us
* about.
*/
export interface ConversationLimits {
/**
* Largest single frame this door carries, in bytes of UTF-8.
*
* **Enforced, both directions**, by the door that declares it: an inbound
* frame past it ends the conversation with a stated reason, and
* {@link HostConversation.send} past it refuses by name instead of
* truncating or silently splitting.
*
* A frame the transport delivered in pieces counts in TOTAL — the port's
* frame is the whole message, not the transport's packet, so fragmentation
* cannot be used to walk around the ceiling.
*/
readonly maxFrameBytes?: number;
/**
* How long the transport tolerates silence before it closes the channel.
*
* **Reported, not imposed.** The door declaring it usually is not the thing
* enforcing it — a runtime's front door idles a socket out long before the
* process inside notices — and a consumer that needs the channel to stay up
* sends its own heartbeat frames on its own protocol. Making that possible is
* the whole reason this number is written down.
*/
readonly idleMs?: number;
/**
* How much the door holds for you before the first
* {@link HostConversation.onFrame} subscriber exists, in bytes.
*
* **Enforced**, and a ceiling on the DOOR rather than on the transport: the
* pre-subscribe buffer that stops an `async` handler from losing the opening
* frame is a queue somebody else fills and this process pays for, so it gets
* a number and a stated overflow instead of growing until the host dies. Past
* it, the conversation ends with a reason naming this bound.
*
* Bounded in BYTES rather than in frames on purpose: a frame count would
* still admit `count × maxFrameBytes` of memory, which is the same unbounded
* queue with an extra step.
*/
readonly maxPendingBytes?: number;
}
/**
* The port: something that can carry conversations to one handler.
*
* It sits BESIDE {@link AgentHost} rather than inside it, because a transport
* that can carry a request cannot necessarily carry a conversation, and one
* that carries conversations need not answer requests at all. A host that does
* both implements both and declares `'conversation'` in
* {@link AgentHost.capabilities}.
*/
export interface ConversationHost {
/** Which adapter this is. Every refusal names it. */
readonly name: string;
/** What this adapter can do beyond the baseline. Feature-detect; never assume. */
readonly capabilities: readonly HostCapability[];
/**
* What this door caps, as declared facts. Absent means this adapter knows of
* no ceiling — never that there is none.
*/
readonly conversationLimits?: ConversationLimits;
/**
* Start taking conversations. Resolves once the door is actually open.
*
* The handle's `close()` ends every live conversation politely and then
* releases the door.
*/
serveConversations(handler: ConversationHandler): Promise<HostHandle>;
}
/**
* A session packed for storage.
*
* `format` names WHAT is inside, so a reader that does not know the shape
* refuses BY NAME instead of restoring a session it cannot actually read.
* Formats are ADDED, never redefined: an old runtime meeting a new format says
* so and stops, which is the only safe thing it can do with a payload it cannot
* interpret. Two exist:
*
* • `'conversation-v1'` — a conversation and only a conversation. Every turn
* that ran to an answer stores this.
* • `'flowchart-v1'` — a run that stopped mid-flow to ask a person something:
* the engine's own checkpoint, the conversation as of the pause, and the
* outstanding ask. 7.14 shipped the version field for exactly this day, and
* said so; this is that day.
*
* The union is discriminated on `format`, so a reader that switches on it is
* exhaustive by construction and a third format tomorrow breaks the switch at
* compile time rather than at 3am.
*/
export type CheckpointEnvelope = ConversationEnvelope | PausedRunEnvelope;
/** A conversation packed for storage — what a turn that ANSWERED leaves behind. */
export interface ConversationEnvelope {
/** Names the shape of `data`. Unknown values are refused, never guessed at. */
readonly format: 'conversation-v1';
/** The conversation itself. */
readonly data: AgentRunCheckpoint;
/** Wall-clock when it was packed. Diagnostic. */
readonly savedAt: number;
}
/** A paused run packed for storage — what a turn that ASKED leaves behind. */
export interface PausedRunEnvelope {
/** Names the shape of `data`. Unknown values are refused, never guessed at. */
readonly format: 'flowchart-v1';
/** The paused run. */
readonly data: PausedRun;
/** Wall-clock when it was packed. Diagnostic. */
readonly savedAt: number;
}
/**
* A run that stopped to ask a person something, in the three pieces a session
* actually needs: what continues it, what it has said so far, and what it is
* waiting on.
*
* ── JSON, honestly ───────────────────────────────────────────────────────────
* A `FlowchartCheckpoint` is **JSON-safe to resume from, and not byte-identical
* through JSON.** `JSON.stringify` drops any property whose value is
* `undefined`, and a real paused agent run has a dozen of them. Every one
* measured sits in `executionTree` / `subflowResults` — the diagnostic halves
* the engine keeps for narrative and BTS. `sharedState`, which is the half
* `agent.resume()` actually reads, round-trips unchanged, because footprintjs's
* TypedScope already JSON-round-trips every object write on its way into
* committed state.
*
* So: store it anywhere that speaks JSON and resume works. Do not assert that
* what came back deep-equals what went in — `key: undefined` comes back as no
* key at all, and a test written to expect otherwise is testing `JSON`, not
* this library.
*/
export interface PausedRun {
/** The engine checkpoint — everything `agent.resume(checkpoint, decision)` needs. */
readonly checkpoint: FlowchartCheckpoint;
/**
* The conversation as of the pause, in the same shape every other turn stores.
*
* Kept alongside the checkpoint so a session that is waiting on a person is
* still a readable conversation: a support view can show what was said, and a
* runtime that cannot resume this run can still see the turn that led to the
* question.
*/
readonly conversation: AgentRunCheckpoint;
/** What the run is waiting on, as data. */
readonly pending: PendingAsk;
/**
* What the WIRE that served the pause recorded about it — the ids it handed
* the caller for the answer — so a later request on that wire is matched to
* this pause and continued as the same invocation (`streamHandler` keeps the
* AG-UI adapter's record here). Absent for a pause a door with nothing to
* remember stored, and opaque to the store and to every other door.
*/
readonly wire?: PausedWire;
/**
* Set when a resume of this pause failed — in the run, or in storing its
* result — after its decision may have been used: it reached a ledger, so
* the call may have run (a resume that never took the decision to a ledger
* leaves the pause unmarked). The pause is answered: a decision for it is
* refused (`ResumeDecisionError`, `'already-answered'`), whether its call ran
* is not known to the host, and the next message continues the conversation
* in `conversation` — what this session stored (the run's own, kept mid-run
* under durable writes, else as of the pause), every call left open settled
* as unknown, and a call the person declined settled as declined. `at` is
* when the host marked it. Absent on every pause still waiting — and code
* that reads stored pauses itself (`readPausedRun`) must honour it: an
* answered pause is a conversation to continue, never a question to show.
*/
readonly answered?: {
readonly at: number;
};
}
/** One wire protocol's own record of a pause it served — see {@link PausedRun.wire}. */
export interface PausedWire {
/** The protocol that recorded it: the protocol adapter's `name`. */
readonly protocol: string;
/** Plain JSON, the protocol's own; ids and names, never a value of the run. */
readonly record: unknown;
}
/**
* The question a paused run is waiting on — the part of a pause that is safe to
* hand to whoever asked.
*
* **It deliberately carries no checkpoint.** The engine checkpoint holds the
* entire shared state of the run: the system prompt, the whole conversation,
* every tool result. That belongs in the store, which the operator chose and
* controls, and not in a reply to whoever posted the request. The caller gets
* the question; the store gets the state.
*/
export interface PendingAsk {
/** Typed missing-input collection. Respond with {requestId, values}; never approval. */
readonly awaitingInput?: AwaitingInput;
/** The session holding the paused run — where the decision has to be sent back. */
readonly sessionId?: string;
/** The tool that asked, when the run recorded which one it was. */
readonly tool?: string;
/** The question in plain words, when the pause carried one. */
readonly question?: string;
/**
* Present ONLY when a tool declared `checkIn` — the typed ask plus its
* evidence pack (what the tool will do, what context the run read, which
* context drove the choice, the run so far). Answer with
* `checkInApproved({ request: pending.checkIn, by })` / `checkInDeclined(...)`
* — `checkIn.id` names the request, and the decision answers it once.
*/
readonly checkIn?: CheckInRequest;
/**
* Present ONLY when a `toolMiddleware` answered `ask` — the question and the
* middleware that put it. Answered with the same decision vocabulary a
* check-in uses, deliberately: a person approving is a person approving —
* `checkInApproved({ request: pending.ask, by })`, naming `ask.id`.
*/
readonly ask?: MiddlewareAsk;
/**
* Which REGISTERED screen component collects the answer (9.24.0) — the
* typed half of the question, lifted from whichever pause kind carried it
* so a screen has ONE place to look. Ids and props only, never markup: the
* registry lives in the frontend, and a screen that does not know the id
* falls back to the prose `question` exactly as before.
*
* `props` is the small inline half; `propsRef` is a claim ticket the screen
* redeems through the artifact wire (`head` then `get`) under the SAME
* session identity every other redemption presents — a 200-option picker
* rides the store, not this reply and not the checkpoint. The ref was
* validated to resolve when the ask was raised.
*
* The answer comes back through {@link HostRequest.decision} unchanged —
* the component changes how the question is asked, never what the answer
* is. A screen may render the decision as words; the words are display,
* the structured decision is the record.
*/
readonly component?: AskComponent;
/**
* Exactly what the tool passed to `askHuman()` / `pauseHere()`, uninterpreted.
* For a plain pause this is the whole of what the tool's author chose to say,
* and the library is not entitled to summarise it.
*/
readonly pauseData: unknown;
}
/**
* How often a run's progress is written to the session store — the trade
* between latency and how much a crash can cost you.
*
* - `'exit'` (default) — one write, when the run finishes. The behaviour every
* release before 7.19 had, spelled out rather than implied. A crash mid-run
* loses the whole turn.
* - `'async'` — a write is STARTED whenever the conversation changes and never
* waited on. The run never slows down; the store is behind by however much
* the newest un-landed write carried. At most one write is in flight and the
* newest snapshot supersedes any queued one, so what a crash leaves is always
* a PREFIX of the run, never a mixture.
* - `'sync'` — persist-then-proceed. The same trigger, but **iteration N's
* tools do not execute until iteration N-1's write has landed**, and the
* answer is not delivered until the last write has landed. You pay the
* store's latency once per iteration, knowingly, and in exchange the amount
* of work a crash can re-run has a number: **the current iteration, and
* nothing before it.**
*
* ── The bound, stated exactly ────────────────────────────────────────────────
* A commit boundary is a whole stage, and the agent dispatches ALL of one
* iteration's tool calls inside one stage body. So under `'sync'` a crash
* re-executes the tools of the iteration that was in flight — never an earlier
* one. That is the same idempotency requirement `resumeOnError` has always
* carried, now with a boundary instead of a warning: mutating tools must be
* idempotent, keyed on stable call content rather than `ctx.toolCallId`.
*/
export type DurabilityMode = 'exit' | 'async' | 'sync';
/**
* Why a session is being woken.
*
* - `'invoke'` — a request arrived for that session.
* - `'resume'` — that request carries a person's decision for a run which
* paused earlier.
* - `'artifact'` — that request redeems an artifact ref (or binds through
* `artifactsForRequest`), and the stored conversation decides something:
* its identity tuple composes the scope (a request carrying a `userId`) or
* its owner decides who may redeem (a verifying door). Fired only when the
* resolution actually reads the store; a session-only resolution at a door
* with no verifier composes its scope from the request alone and wakes
* nothing — whether its ref exists is the artifact store's answer.
* - `'transcript'` — a verified owner is READING that session's messages back
* (9.26.0). Nothing runs and nothing is written; the store is woken because
* it is about to be read from, which is the only promise this hook ever
* made.
*
* `'resume'` was absent until 7.19, `'artifact'` until 9.23 and `'transcript'`
* until 9.26, because nothing could produce them: naming reasons nothing fires
* would be an interface describing a system that does not exist. Something
* produces each of them now.
*/
export type WakeReason = 'invoke' | 'resume' | 'artifact' | 'transcript';
/**
* The port: where a conversation lives between requests.
*
* Deliberately two required methods. Anything a real store also wants — a TTL,
* a scan, a delete — is that store's own API, not a demand this port makes of
* every store that will ever implement it.
*
* ── Claiming the port ───────────────────────────────────────────────────────
* The two required methods are what TypeScript checks. What a store has to
* *behave* like is a battery every shipped store runs against and any store
* can: `sessionLifecycleConformance`, exported from this same subpath. It was
* written after a flaw in THIS interface's semantics turned up in four
* independent adapters at once — every one of them tested, and every one of
* them tested only against its own doubles, which is precisely why none of
* them could see it. Run it against your store; a case your store cannot
* satisfy is DECLARED by name with its reason, never skipped.
*/
export interface SessionLifecycle {
/** The stored conversation, or `undefined` for a session that has none yet. */
hydrate(sessionId: string): Promise<CheckpointEnvelope | undefined>;
/**
* Store the conversation for this session. Last write wins — with ONE
* exception, and it is the only thing this method ever refuses.
*
* **A store that keeps an owner index refuses a turn signed by somebody
* other than the established owner** (`SessionOwnershipConflictError`,
* 9.36.1), and refuses it whole: neither the index nor the ENVELOPE changes.
* Until 9.36.1 the write-once rule protected only the index, so such a write
* kept the first writer's `owner` and stored the second writer's entire
* conversation — leaving the index naming one person and the stored
* conversation naming another. The person the index named listed it, opened
* it, and read the other one's conversation.
*
* A turn carrying a LEANER identity is not refused. It claims nobody, so it
* contradicts nobody, and the established owner stands — see `listByUser`.
*
* The composer refuses a foreign turn at the door long before this, when a
* verifier is configured. This is the store keeping the same promise for
* every caller that holds it directly, and for a door with no verifier.
*/
persist(sessionId: string, envelope: CheckpointEnvelope): Promise<void>;
/**
* Called once per served request, before `hydrate`, for stores that need to
* spin something up before they can answer. Errors from it fail the request —
* a store that could not wake cannot be read from either.
*/
onWake?(sessionId: string, reason: WakeReason): void | Promise<void>;
/**
* OPTIONAL (9.26.0) — the sessions this user owns, newest first.
*
* Feature-detected, never assumed. The two required methods are a key/value
* map and most stores are exactly that; demanding a secondary index of every
* implementation that will ever exist would be this port breaking its own
* rule ("anything a real store also wants is that store's API, not a demand
* this port makes"). A store that leaves this absent makes
* `{ op: 'session-list' }` refuse BY NAME, naming the store's limitation —
* never answer "you have no sessions", which is an answer nobody could
* distinguish from the truth.
*
* **Ownership is derived, never declared.** `persist` takes no owner and
* gains none: a store fills its index from the stored envelope itself
* (`envelopeOwner`), which reads the `principal` on the conversation the
* composer put there. A store that let a caller state an owner would be a
* store where owning somebody's session is a matter of asking for it.
*
* **And established ONCE.** The first turn that signs for a conversation owns
* it; no later write moves that — not a leaner identity (which would erase
* it) and not a different one (which would transfer it). Every shipped store
* implements the index that way, and a custom one that let the last writer
* win would undo every ownership check made against it one turn later.
*
* The two halves of "no later write moves it" are NOT symmetrical, and
* 9.36.1 exists because they were treated as if they were:
*
* - a **leaner** turn is stored, and the owner is kept. It claims nobody, so
* it contradicts nobody. Refusing it would break a flow this contract
* blesses in the sentence above.
* - a **different** turn is REFUSED — index and envelope both. Keeping the
* index while storing the payload is not "not moving" the owner; it is
* moving the CONVERSATION out from under them, which is worse. Don't
* write this rule yourself: `resolveSessionOwner(sessionId, stored,
* incoming)` is the one implementation, and it is what every shipped store
* now calls.
*
* ── Which of the two says who owns a session ────────────────────────────────
* This index is the authority for LISTING and for the session-history ops:
* `ownerOf` is the question `{ op: 'session-transcript' }` asks, and nothing
* re-derives an answer behind it. An ordinary TURN does not ask it — it
* re-derives from the envelope it has just hydrated (`envelopeOwner`), which
* is the same derivation and one round-trip cheaper, and which is what keeps
* a store with NO index protected exactly as well as one with a fast one.
*
* Those two can never name different people: that is what the refusal above
* buys, and it is the only reason a turn is safe to answer from the envelope.
* They CAN differ in one direction — an index that names somebody over a
* conversation that names nobody, which is what a leaner turn leaves behind.
* That state only ever REFUSES: the owner can still list and read the
* session, and a turn on it is refused at any verifying door, for everybody.
* Stated because it is a real asymmetry and not a bug to be surprised by.
*
* A conversation that ran anonymously has no owner and appears in nobody's
* list. That is the honest consequence of deriving rather than inventing.
*/
listByUser?(userId: string, options?: SessionListOptions): Promise<SessionListPage>;
/**
* OPTIONAL (9.26.0) — who owns one session, or `undefined` for a session
* that does not exist OR names no owner.
*
* The deliberate ambiguity is the same one `ArtifactStore.get` makes:
* "missing" and "not yours" must be indistinguishable from the outside, and
* a store that answered them differently would hand a caller an oracle for
* which session ids are real.
*
* Implement it beside `listByUser` — a door that can list but not check
* ownership can hand somebody a list and then refuse to open any of it.
*
* This is the ownership INDEX, and it is not a second source of truth beside
* the identity embedded in the stored envelope: `persist` refuses any write
* that would make the two name different people. A store that answers this
* from something a caller supplied, rather than from what it derived, has
* built a permission system out of a request field.
*/
ownerOf?(sessionId: string): Promise<string | undefined>;
/**
* OPTIONAL (9.42.0) — how conversations in this store stop existing.
*
* Feature-detected exactly like the two members above, and for the same
* reason: the two required methods are a key/value map, and demanding a
* retention mechanism of every implementation that will ever exist would be
* this port breaking its own rule ("anything a real store also wants is that
* store's API, not a demand this port makes"). A store that leaves this
* absent makes {@link sessionRetention} refuse BY NAME, naming the store's
* limitation and which shipped stores do implement it — never a silent
* no-op, which is the one failure mode a retention feature must not have,
* because from the outside "nothing was deleted" and "everything is fine"
* look identical until somebody asks how long you keep conversations.
*
* ── Why it is one member with two arms, and not one verb ────────────────────
* Because there are exactly two honest implementations of retention, and a
* port member only half the shipped stores could implement would not be a
* port member at all:
*
* - some stores **delete when you ask them to** — a `Map`, a table, a file.
* A sweep is one loop or one statement there, and the caller's cron
* decides when.
* - some stores **cannot honestly sweep, because the backend already
* expires rows on a policy the operator configured** out of band. Asking
* such a store to sweep would mean a query plus one delete per row, at an
* unbounded cost, duplicating a job the service does for free — and a
* store that reported "0 deleted" from a backend that deletes plenty
* would be lying by omission.
*
* So the answer is a DISCRIMINATED UNION and `deletedBy` is the field a
* consumer branches on: one feature check, then one exhaustive branch the
* compiler enforces. See {@link SessionRetention}.
*
* ── The shape that was deliberately NOT chosen ──────────────────────────────
* A stated expiry at persist time — `persist(id, envelope, { expiresAt })` —
* reads well and is the wrong shape twice over. It puts a demand on the one
* method every store must implement, so every store that will ever exist has
* to honour it; and it cannot be feature-detected, because `typeof
* store.persist === 'function'` is true whether or not the third argument is
* read. A store that ignored it would keep every conversation forever while
* its caller believed retention was configured. An optional member that is
* absent refuses; an optional ARGUMENT that is ignored does not.
*
* ── Why not the shape the ARTIFACT port already uses ────────────────────────
* That sibling port states an expiry at MINT (`ttlMs` → a stamped
* `expiresAt`) and sweeps a scope when a put next touches it. It is the right
* shape THERE because every artifact store in that family holds its own
* bytes, so every one of them can sweep. This family does not: two of its
* stores are managed services that expire rows on a policy nobody here
* controls, and one has no delete at all. A port copies its sibling's
* vocabulary only where the sibling's assumption also holds.
*
* ── What this is not ────────────────────────────────────────────────────────
* Not a wire op. No door in this package deletes conversations on request,
* and the composer never expires anything on its own: retention is an
* operator's job with an operator's blast radius, and a request that could
* delete somebody's history because it named a cutoff is not a feature.
*
* @example A cleanup job that works against any store that can answer
* const policy = sessionRetention(sessions);
* if (policy.deletedBy === 'this-store') {
* const { forgotten, more } = await policy.forgetOlderThan(Date.now() - THIRTY_DAYS);
* log(`forgot ${forgotten} conversations${more ? ', more remain' : ''}`);
* } else {
* log(`the backend expires these on '${policy.expiresOn}': ${policy.enableWith}`);
* }
*/
retention?(): SessionRetention;
}
/**
* How one store's conversations stop existing — the answer
* {@link SessionLifecycle.retention} gives, in the two shapes a store can
* honestly give it.
*
* `deletedBy` is the discriminant, and it is spelled as WHO does the deleting
* rather than as a mechanism, because that is the fact a caller acts on: one
* arm hands you something to call, the other hands you something to configure.
*/
export type SessionRetention = SessionSweep | SessionExpiryPolicy;
/**
* This store deletes, when you ask it to.
*
* The shape for a store that holds its own bytes — a map, a table, a file.
* Nothing sweeps on a timer of its own: a library that started an interval
* would be deciding your process's lifetime for you, and a sweep that runs
* without anybody asking is a deletion nobody can point at afterwards.
*/
export interface SessionSweep {
readonly deletedBy: 'this-store';
/**
* Forget every session whose stored `savedAt` is STRICTLY BEFORE `before`
* (epoch milliseconds), and say how many went.
*
* Strictly before, so that passing the same cutoff twice is stable and a
* session written exactly at the boundary survives — an off-by-one here
* deletes a conversation somebody is still holding.
*
* The clock is the ENVELOPE's `savedAt` — the same value the listing sorts
* on — and not the store's own wall clock. That makes a sweep a pure
* function of what is stored: the same cutoff over the same rows forgets the
* same conversations, in a test and at 3am.
*
* Bounded by `options.limit` ({@link DEFAULT_SWEEP_LIMIT} when absent), so a
* first sweep of a store that has been running for a year cannot become one
* unbounded transaction. `more` says whether another call has work to do.
*
* Deleting a session that is not there is not an error, and a sweep that
* matches nothing answers `{ forgotten: 0, more: false }` rather than
* refusing — a cleanup job runs on a schedule, and most of its runs have
* nothing to do.
*/
forgetOlderThan(before: number, options?: SessionSweepOptions): Promise<SessionSweepResult>;
}
/**
* The BACKEND deletes, on a policy an operator configured — this store only
* writes what that policy reads, and can say exactly what to turn on.
*
* There is nothing to call here on purpose. A method that did nothing but
* report "the backend handles it" would be a method somebody puts in a cron
* job, and a cron job that deletes nothing is worse than no cron job: it looks
* like retention is running.
*/
export interface SessionExpiryPolicy {
readonly deletedBy: 'the-backend';
/**
* Whether this store is actually stamping an expiry on every write.
*
* `false` means the mechanism exists and nothing is using it — the store was
* built without a retention setting, so the backend's policy has nothing to
* act on and conversations live until somebody deletes them. Reported rather
* than implied, because "there is a TTL field" and "things are expiring" are
* different facts and only one of them answers an auditor.
*/
readonly active: boolean;
/**
* What the backend's policy acts on — a stored field name, or the setting
* that starts the clock. Named so an operator can configure the policy
* against the right thing rather than guessing from a schema dump.
*/
readonly expiresOn: string;
/**
* What an operator does to turn the policy on, in one step they can act on
* — with this store's own collection, database or resource already filled
* in where the step needs one.
*
* A sentence rather than a code, for the same reason the refusals in this
* package print a whole command instead of naming a concept: the operator
* reading this is the person who can fix it in sixty seconds, and only if
* nobody makes them go and look it up first.
*/
readonly enableWith: string;
}
/** Options for {@link SessionSweep.forgetOlderThan}. */
export interface SessionSweepOptions {
/**
* The most sessions to forget in this call. Default
* {@link DEFAULT_SWEEP_LIMIT}; stores may cap it lower.
*
* A bound rather than "delete everything matching", because the first sweep
* after this feature is turned on is the biggest one that store will ever
* do, and it is the one most likely to hold a write lock long enough to be
* noticed by every request behind it.
*/
readonly limit?: number;
}
/** What one sweep did. */
export interface SessionSweepResult {
/** How many conversations this call forgot. */
readonly forgotten: number;
/**
* True when sessions older than the cutoff REMAIN — i.e. the limit was
* reached. A caller that loops until this is `false` drains the backlog in
* bounded steps; one that ignores it simply sweeps again next time.
*/
readonly more: boolean;
}
/**
* How many sessions one {@link SessionSweep.forgetOlderThan} forgets when the
* caller names no limit.
*
* Big enough that an ordinary nightly sweep finishes in one call, small enough
* that the first sweep of a neglected store is a series of short transactions
* rather than one long one.
*/
export declare const DEFAULT_SWEEP_LIMIT = 1000;
/** Paging for {@link SessionLifecycle.listByUser} — the cursor convention every
* listing in this package follows. */
export interface SessionListOptions {
/** Continuation token from a previous page. Omit for the first page. */
readonly cursor?: string;
/** Maximum rows this page. Stores may cap it lower. */
readonly limit?: number;
}
/** One page of a user's sessions. Never carries message content — a listing
* says WHICH conversations exist, and `session-transcript` says what is in
* one. */
export interface SessionListPage {
readonly sessions: readonly SessionSummary[];
/** Present iff more pages exist. */
readonly cursor?: string;
}
/**
* What to do when a request arrives for a session that already has a run in
* flight.
*
* - `'reject'` (default) — refuse it, naming the run that is already going.
* A user who double-submits gets one answer and one refusal, not two runs
* racing to write the same conversation.
* - `'enqueue'` — queue it. It starts after the active run has persisted, so
* the second turn sees the first turn's stored state rather than the state
* it was about to overwrite.
*
* This governs the SAME session only. A request for a different session is
* never refused — there is nothing wrong with it; it simply waits its turn.
*/
export type ConcurrentInvokePolicy = 'reject' | 'enqueue';
/**
* Everything {@link standingAgent} takes EXCEPT which agent answers — that is
* the one choice with two shapes, and it lives on
* {@link StandingAgentOptions}.
*
* Generic in the host's own handle type so composing does not cost you what
* the adapter told you. `nodeHost` hands back the URL it actually bound —
* which is the only way to find out when you asked for port `0` — and passing
* it through `standingAgent` keeps that, without the port having to know that
* "a URL" is a thing some adapters have.
*/
export interface StandingAgentBaseOptions<TH extends HostHandle = HostHandle> {
/** Where conversations live between requests. */
readonly sessions: SessionLifecycle;
/** What carries requests in. */
readonly host: AgentHost & {
serve(handler: HostHandler): Promise<TH>;
};
/** Default `'reject'`. See {@link ConcurrentInvokePolicy}. */
readonly onConcurrentInvoke?: ConcurrentInvokePolicy;
/**
* Verify WHO is calling, instead of believing a header (9.26.0).
*
* With this set, every request's `Authorization: Bearer <token>` is checked
* BEFORE the run's identity/scope is composed, and the proven user id is the
* one that reaches `EventMeta.principal`, `ctx.identity`, the memory
* namespace and the artifact scope. A request that NAMES a user it cannot
* prove is refused by name — never downgraded to anonymous, never served
* under the name it claimed.
*
* **It also decides whose sessions are whose.** With a verifier configured,
* EVERY door that opens a stored conversation asks one question first — the
* two session-history ops and an ordinary turn alike: does this session
* belong to the caller who proved who they are? A turn naming somebody
* else's session is refused with the same indistinguishable
* `SessionNotFoundError` a transcript gets, before a line of that
* conversation is hydrated into a model's context and before anything is
* written back. Ownership is the `principal` the first turn signed with, and
* no later turn moves it.
*
* The consequence to plan for: conversations stored BEFORE a door started
* verifying name no owner, so they cannot be continued at one that does. That
* is a loud refusal by design — the alternative is a door that hands old
* conversations to whoever names them first.
*
* Unset — the default — nothing changes: `HostRequest.userId` is read exactly
* as it has been since 9.12.0, and every release's behaviour before this one
* is byte-identical. Which is right depends on what stands in front of you,
* and that is a fact only the deployment knows.
*
* @example
* identity: { verify: jwksIdentity({
* jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
* issuer: 'https://idp.example.com/',
* audience:'my-api',
* }).verify }
*/
readonly identity?: DoorIdentity;
/**
* Decide whether a request runs at all, BEFORE any model is called (9.26.0).
*
* Called once per turn with the verified caller, the session and what that
* caller has spent inside a rolling window; it answers `'allow'`,
* `{ queue: true }` (run it, but behind this session's other work) or
* `{ refuse: '<sentence>' }`. `turnsPerHour({ limit })` is the shipped
* reference policy.
*
* Unset — the default — no accounting is kept, no listener is installed, and
* not one line of this runs.
*
* **The honest boundary, stated once:** accounting is PER PROCESS. Two
* containers each keep their own window, so a limit of 20 across three
* replicas is a limit of 60. Centralize by writing a policy that consults
* your own store — the `decide` seam is exactly where that goes.
*
* And the second one: a turn is counted when it is ADMITTED, so a request
* the lane then refuses as a concurrent run still counts against the
* window. Counting later would let a burst of simultaneous requests each be
* decided against a window none of them had joined.
*/
readonly admission?: AdmissionPolicy;
/**
* Seal the credential a request carries for its run — a gateway's
* per-request token in a header, read by a `requestCredentials` provider
* (`agentfootprint/security`) for the tools that declare a `needs` for it.
*
* Called once per request that RUNS (a turn or an answer — never an
* artifact or session op, never a refused request), after the caller is
* verified and the session's ownership is checked, with the request's
* headers ({@link HostRequest.headers}: `nodeHost` and `httpHost` deliver
* them) and nothing else. Return
* `requestContext({ secrets })`, or `undefined` for a request that carries
* none; the handle goes to THAT request's `run()` / `resume()` only — never
* the reply, the stored session or the checkpoint, so a resumed run is
* served the RESUMING request's credential. A handle any host was handed
* before is refused; a sealer that throws refuses the request in fixed words
* (`ERR_REQUEST_NOT_SEALED`, 400) — so decrypt at vend time
* (`decodedWith`), not here. The HANDLE never reaches a reply; a tool that
* returns the credential's value itself does (`hostFilled` scrubs an echo).
*
* Unset — the default — no run carries a request, exactly as before.
*
* @example
* sealRequest: ({ headers }) =>
* headers['x-crm-token'] === undefined
* ? undefined
* : requestContext({ secrets: { crmToken: headers['x-crm-token'] } }),
*/
readonly sealRequest?: RequestSealer;
/**
* Where this door's ingress decisions go (9.32.0) — one record per request,
* including every request that never became a run.
*
* `auditExport()` is a record of RUNS, so a 401 from `identity.verify` and a
* 429 from `admission.decide` are not in it: they happen before an agent
* exists. Two independent review rounds named that gap. This is where the
* turned-away go — and where the SERVED are recorded too, because an absence
* is only readable against a census.
*
* **It does not join the audit hash chain.** Nothing here is hashed,
* sequenced or verifiable by `verifyAuditBundle`; it is a stream you chain
* into your own sink. Claiming otherwise would make this the very
* looks-like-evidence failure it exists to close.
*
* The record carries classes and identifiers only — never the token, never a
* header, never a claim set, never an error's message. See
* {@link IngressRecord} for the field-by-field contract and for the one thing
* it deliberately does not cover (a body the transport's own wire grammar
* refused as malformed before this composer ever saw it).
*
* The HOST's own refusals are on it too: set this, and the composer
* subscribes to the host's `onRefusal`, so a forged request the door guard
* turned away, or a session id over the bound, is recorded
* (`'cross-site-refused'` / `'refused'`, door `'request'` or
* `'conversation'`) though no handler ever saw it.
*
* Unset — the default — nothing is built, nothing is wrapped, nothing is
* subscribed, and the reply the handler uses is the host's own object, to the
* byte.
*
* @example
* onIngressDecision: (record) => {
* if (record.outcome !== 'served') securityLog.write(record);
* }
*/
readonly onIngressDecision?: IngressSink;
/**
* How often a run's progress becomes crash-survivable. Default `'exit'` —
* one write when the run finishes, which is what every release before this
* one did. See {@link DurabilityMode} for what the other two buy and cost.
*
* Under `'exit'` nothing is attached to the agent at all: no observer, no
* per-commit work, no barrier. An agent served this way behaves and performs
* exactly as it did in 7.18.
*/
readonly durability?: DurabilityMode;
/**
* What `close()` does to the telemetry enabled on the agent (8.12.0).
* Default `'flush'`.
*
* - `'flush'` — drain every strategy the agent has enabled, and leave them
* running. This is the default because it is what everyone already
* believed happened: an exporter that batches otherwise loses whatever it
* had buffered when the server stops. Draining is safe on an agent this
* composer only BORROWED — it ships data and disables nothing.
* - `'flush-and-stop'` — drain, then release: timers cleared, clients
* closed, further events dropped. Say this only when the agent's life
* ends with the host, because stopping is terminal.
* - `'none'` — touch nothing, exactly as releases before 8.12.0 did.
*/
readonly shutdown?: 'flush' | 'flush-and-stop' | 'none';
/**
* Signals whose arrival should close this host (8.12.0). Off by default,
* and that is a deliberate refusal rather than an omission.
*
* **A library must not grab signals.** `process.on('SIGTERM', …)` is not
* observation: Node's default action for SIGTERM is to terminate, and
* ADDING any listener suppresses that default. A library that installs one
* behind your back can turn a container's graceful stop into a thirty-second
* wait for SIGKILL in an application that never asked for it. Handlers are
* process-global and cannot be arbitrated between libraries, ten runners
* would mean ten handlers and a `MaxListenersExceededWarning`, only Node has
* signals at all, and no library can know your exit policy.
*
* A composition root may, because it already owns the shape of the process
* — and this one asks first. When you pass signals here, this composer:
*
* 1. installs one listener per signal;
* 2. on arrival, runs the same `close()` you would have called — the host
* stops taking requests, in-flight runs finish, telemetry drains per
* {@link StandingAgentBaseOptions.shutdown};
* 3. removes its own listeners and RE-RAISES the signal, so the process
* dies exactly the way the platform expects rather than by an exit code
* this library invented.
*
* `close()` removes the listeners too, so a handle you close yourself leaves
* nothing installed.
*
* @example
* await standingAgent({ agent, sessions, host, shutdownOn: ['SIGTERM', 'SIGINT'] });
*/
readonly shutdownOn?: readonly NodeJS.Signals[];
/**
* How long a turn's artifact hand-over (`HostReply.turnArtifacts`) may hold
* the reply — the hook AND every operation it started — before the composer
* stops waiting. Default {@link TURN_ARTIFACTS_TIMEOUT_MS} (5 s). The
* request's own `signal` ends the wait earlier when the caller hangs up.
*
* On expiry the binding is revoked, nothing in flight is cancelled (it may
* still land, attributed to its own session and run), the cause is reported
* (`agentfootprint.artifacts.hand_over_failed { cause: 'timeout' }`), the
* terminal is delivered and the lane released — so one host's hung store or
* hook can hold neither every other session (the shared shape), nor a pooled
* lane past `maxActiveSessions`, nor `close()` on the SIGTERM path.
*
* Must be a positive, finite number of milliseconds — a bound that can never
* fire is not a bound; anything else is refused at construction. Only read
* when the host implements the hook.
*/
readonly turnArtifactsTimeoutMs?: number;
/**
* Serve "Explain this answer": the `{ op: 'answer-account', ref }` wire op,
* which returns one answer's plain-words account (`AnswerAccount` plus its
* allow-listed show-me leaves), computed HERE from the recording the ref
* names. The browser never receives the recording through it.
*
* Off unless given — a host without it answers the op with the unknown-op
* refusal before reading anything (`false` says the same). `true` opts in
* with every default; the
* object form sets the app's `declarations` (validated now, never taken
* from a request), the recording ceiling and the cache size. See
* {@link AnswerAccountsOptions}.
*
* The op follows `artifact-get`'s own path — the same ownership check, the
* same one not-found, lane-free — and adds one refusal of its own: a
* recording over the ceiling (`ERR_RECORDING_TOO_LARGE_FOR_ACCOUNT`, 413).
*
* @example
* await standingAgent({ agent, sessions, host, answerAccounts: {
* declarations: { tools: { lookup_volumes: { rowsAt: 'volumes' } } },
* } });
*/
readonly answerAccounts?: AnswerAccountsOptions | boolean;
/**
* How many artifact operations — `artifact-head`, `artifact-get` and
* `answer-account`, counted together — ONE session may have in flight at
* once. Default {@link DEFAULT_ARTIFACT_OPS_PER_SESSION} (8).
*
* Artifact ops are lane-free (they never wait behind a run) and turn
* admission does not see them, so this is their bound: a session over it is
* refused with `ArtifactOpsBusyError` (`ERR_ARTIFACT_OPS_BUSY`, 429) while
* every other session is served. Counted after the ownership check, so a
* stranger cannot spend another session's slots. A screen that redraws many
* panes at once should redeem them a few at a time, or raise this.
* `Infinity` means no bound; anything else must be a positive whole number.
*/
readonly artifactOpsPerSession?: number;
}
/**
* How many artifact operations one session may have in flight by default —
* enough for a screen redrawing a handful of panes, too few for one session
* to crowd the event loop every session shares. See
* {@link StandingAgentBaseOptions.artifactOpsPerSession}.
*/
export declare const DEFAULT_ARTIFACT_OPS_PER_SESSION = 8;
/** How many sessions a `agentFactory` pool holds before it evicts the least
* recently used one. See {@link StandingAgentPoolOptions.maxActiveSessions}. */
export declare const DEFAULT_MAX_ACTIVE_SESSIONS = 100;
/**
* How long a turn's artifact hand-over may hold its reply by default — the
* hook plus the operations it started. Five seconds: the order of
* `TOOL_TEARDOWN_TIMEOUT_MS`, and for the same reason — both sit on the
* shutdown path (`close()` drains in-flight requests), where an unbounded wait
* turns a container stop into a wait for SIGKILL — while leaving a store room
* for a few ordinary writes. See
* {@link StandingAgentBaseOptions.turnArtifactsTimeoutMs}.
*/
export declare const TURN_ARTIFACTS_TIMEOUT_MS = 5000;
/**
* One shared agent, serving every session — the original shape, and still the
* right one for most deployments.
*
* An `Agent` instance holds per-run state on itself, so one instance can only
* be in one run at a time. That is why this shape SERIALIZES globally: not a
* tuning choice, a correctness requirement. See
* {@link StandingAgentPoolOptions.agentFactory} for the shape that runs
* sessions in parallel, and `standingAgent`'s own doc for the full comparison.
*/
export interface StandingAgentSharedOptions<TH extends HostHandle = HostHandle> extends StandingAgentBaseOptions<TH> {
/**
* The agent that answers. ONE instance, shared by every session — which is
* why this shape runs one request at a time, globally (see
* {@link ConcurrentInvokePolicy} for the separate question of two turns of
* the SAME conversation).
*
* The composer only BORROWS it: `close()` drains its telemetry and leaves it
* usable, per {@link StandingAgentBaseOptions.shutdown}.
*/
readonly agent: Agent;
/** Refused beside `agent` — see {@link StandingAgentPoolOptions.agentFactory}. */
readonly agentFactory?: undefined;
/** Meaningless without a pool; refused rather than ignored. */
readonly maxActiveSessions?: undefined;
}
/**
* One agent PER ACTIVE SESSION, built on demand — sessions run in parallel
* (9.10.0).
*/
export interface StandingAgentPoolOptions<TH extends HostHandle = HostHandle> extends StandingAgentBaseOptions<TH> {
/** Refused beside `agentFactory` — two spellings of one choice. */
readonly agent?: undefined;
/**
* Build an agent. Called once per session that arrives and is not already in
* the pool; the instance it returns serves that session and nobody else.
*
* ── What it buys ──────────────────────────────────────────────────────────
* **Sessions run at the same time.** Two people asking two questions are two
* instances and two runs; neither waits for the other. Within one session the
* turns still serialize — on that session's own instance — which is the same
* correctness rule the shared shape enforces globally, applied where it
* actually binds.
*
* ── What it costs ─────────────────────────────────────────────────────────
* An instance per active session: memory, and whatever your agent builds at
* construction (a provider client, tool wiring). The pool is bounded by
* {@link StandingAgentPoolOptions.maxActiveSessions} and evicts least
* recently used, which is invisible to the user because the CONVERSATION
* lives in the session store — an evicted session re-hydrates onto a fresh
* instance on its next request.
*
* ── The one law ───────────────────────────────────────────────────────────
* **Return a NEW agent every call.** A factory that hands back an instance it
* has already handed back is refused BY NAME on the spot: two sessions on one
* instance is the exact corruption the pool exists to prevent, and it would
* otherwise show up as one user's answer appearing in another user's
* conversation with nothing in the recording to say so. Build the agent
* INSIDE the factory; do not close over one.
*
* Instances the factory makes are the composer's, so it stops them — on
* eviction, and on `close()` unless
* {@link StandingAgentBaseOptions.shutdown} is `'none'`.
*
* @example
* await standingAgent({
* agentFactory: () => Agent.create({ provider, model }).system('…').build(),
* sessions: sqliteSessions({ file: './sessions.db' }),
* host: nodeHost({ port: 8080 }),
* maxActiveSessions: 200,
* });
*/
readonly agentFactory: () => Agent;
/**
* How many sessions hold an instance at once. Default
* {@link DEFAULT_MAX_ACTIVE_SESSIONS} (100). Must be a positive integer.
*
* When a new session arrives at a full pool, the least recently used session
* that is NOT running is retired: its tool sessions are closed with reason
* `'evicted'`, its agent is shut down, and its conversation stays in the
* session store. Nothing about that is visible to the person on the other
* end — their next message hydrates the same conversation onto a fresh
* instance.
*
* **A running session is never evicted.** If every session in the pool is
* busy, the pool grows past this number rather than tearing down a live run:
* the bound is on how many idle instances are RETAINED, and losing somebody's
* answer to a cache policy is not a trade this composer will make on your
* behalf. It comes back under the bound as soon as a run finishes.
*/
readonly maxActiveSessions?: number;
}
/**
* Options for {@link standingAgent} — pick ONE of the two agent shapes.
*
* - `{ agent }` — one instance shared by every session, serialized globally.
* See {@link StandingAgentSharedOptions}.
* - `{ agentFactory }` — one instance per active session, sessions in
* parallel. See {@link StandingAgentPoolOptions}.
*
* Passing both is refused by name at construction: they are two spellings of
* the same decision, and which one won would be invisible.
*/
export type StandingAgentOptions<TH extends HostHandle = HostHandle> = StandingAgentSharedOptions<TH> | StandingAgentPoolOptions<TH>;