agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
2,095 lines • 115 kB
JavaScript
/**
* hosting/standingAgent — an agent that stays up and remembers.
*
* await standingAgent({
* agent,
* sessions: memorySessions(),
* host: nodeHost({ port: 8080 }),
* durability: 'sync', // optional; 'exit' is the default
* });
*
* One request at a time it does four things: wake and hydrate the session,
* continue that session or start a fresh one, persist what the run leaves
* behind, then reply. Everything else is somebody else's job — the host carries
* bytes, the store keeps them, the agent thinks.
*
* ── A run has three ends, and this composer honours all three ────────────────
* It answered, it asked a person something, or it failed. An answer completes
* the reply and stores a conversation. A QUESTION stores the paused run as
* `'flowchart-v1'` and leaves through `reply.awaiting(...)` — its own terminal,
* never `fail`, because a pause is unfinished work and reporting it as a failure
* tells every dashboard downstream something untrue. A later request for that
* session carrying `decision` continues the run from exactly where it stopped.
*
* ── "Start a fresh one" is an answer it must EARN ───────────────────────────
* A session with nothing stored is answered fresh, and that is right. A session
* whose stored conversation cannot be READ is not: an unreadable stored
* conversation and an absent one are different facts, and only one of them is
* safe to answer with a fresh start. So an `UnreadableEnvelopeError` out of the
* store fails THIS REQUEST, naming the session, and the fresh-start path is
* never reached. The alternative is a reply that looks perfect to everyone
* involved while a user's conversation quietly stops existing.
*
* ── Resuming a CONVERSATION is a REPLAY, and that has a cost ─────────────────
* A stored conversation is restored through `agent.run({ message, continueFrom })`
* — the public conversation door since 9.2.0. This composer used to assemble the
* continuation by hand (append the turn, rewrite `originalInput`, hand the result
* to `resumeOnError`); that hand-assembly IS the door now, so a server and a
* script continue a conversation the same way and the identity travels with it.
* The caveat below is stated here in the words the Agent states it in, because a
* composition that hides the caveat of the thing it composes is worse than no
* composition at all:
*
* > **Tool re-execution / idempotency**: tool side effects from the FAILED
* > iteration are not in the checkpoint. The model re-decides from the
* > restored history and may re-issue those tool calls — they WILL execute
* > again (there is no built-in toolCallId dedup). Mutating tools (payments,
* > emails, DB writes) must be idempotent — key on stable call content, not
* > `ctx.toolCallId` (a re-issued call gets a new id).
*
* `durability` is the dial that bounds how much of that a crash can cost you.
* Resuming a PAUSED run is different in kind: it is not a replay at all — the
* engine continues from its own checkpoint, and no earlier tool call re-runs.
*
* ── Why one run at a time, and where that bound actually is ─────────────────
* An Agent instance holds per-run state on itself. Two runs overlapping on ONE
* instance do not crash — which is precisely the danger. They both finish, and
* the state the composer reads afterwards belongs to whichever started last, so
* one session's envelope can end up holding another session's conversation.
* Nothing in the recording would say so. **Runs on one instance are therefore
* serialized, and that is a correctness requirement rather than a tuning
* choice.**
*
* What CHANGED in 9.10.0 is not that rule but its scope. `{ agent }` hands this
* composer one instance to share, so the serialization is global — the shape
* every earlier release had, unchanged to the byte. `{ agentFactory }` hands it
* a way to MAKE instances, so each session gets its own and the same rule binds
* per session: sessions run in parallel, each session's turns queue behind that
* session's own instance.
*
* standingAgent({ agent }) → one instance, global queue
* standingAgent({ agentFactory }) → one per session, a queue each
*
* Everything else is identical between the two: the same stores, the same
* durability modes, the same pause/resume contract, the same refusals. The
* session store is keyed per session already, which is why the pool can evict a
* session's instance without the person on the other end noticing — the
* CONVERSATION was never in the instance.
*
* `onConcurrentInvoke` is the separate question of what to do when a second
* turn of the SAME conversation arrives while the first is running: refuse it
* (default) or queue it. It means the same thing in both shapes. A request for
* a DIFFERENT session is never refused — there is nothing wrong with it; in the
* shared shape it waits its turn, and in the pooled shape it does not have to.
*
* ── The pool, in one paragraph ──────────────────────────────────────────────
* One instance per ACTIVE session, built on first sight by the factory, bounded
* by `maxActiveSessions` (default 100) and evicted least-recently-used. An
* evicted session's agent has its tool sessions closed with reason `'evicted'`
* — the 9.7.0 vocabulary, not a new one — and is then shut down; its
* conversation stays in the store, so its next request re-hydrates onto a fresh
* instance and nothing was lost. A session that is RUNNING is never evicted:
* the bound is on retained idle instances, and a cache policy does not get to
* end somebody's turn. Requests with NO session share one fallback instance,
* because there is no conversation to isolate and an instance per anonymous
* request would be an instance per request.
*
* Pattern: Composition root. It owns wiring and ordering; it invents no
* mechanism of its own.
*/
import { isDevMode } from 'footprintjs';
import { bindArtifacts, } from '../artifacts/capability.js';
import { applyInputResponse, readAwaitingInput } from '../tools/inputRequest.js';
import { isPaused, pauseDemandsDecision } from '../core/pause.js';
import { ANSWER_ACCOUNT_OP, artifactOpWireName, NOT_A_REF, } from './artifactWire.js';
import { answerAccounts } from './answerAccounts.js';
import { isArtifactRef } from '../artifacts/naming.js';
import { RECORDING_ARTIFACT_KIND } from '../artifacts/recordingArtifact.js';
import { refuseUnknownWireOp } from './wireOps.js';
import { durableWriter } from './durability.js';
import { describePause } from './pendingAsk.js';
import { afterFailedAnswer, answeredRefusal, closedConversation, openToolCalls, readStoredSession, unanswerablePause, watchAnswer, } from './answeredPause.js';
import { envelopeOwner, envelopeTranscript, toEnvelope, toPausedEnvelope } from './envelope.js';
import { AdmissionRefusedError, ArtifactNotCarriedError, ArtifactOpsBusyError, ArtifactNotFoundError, ArtifactSessionRequiredError, AwaitingDecisionError, ConcurrentRunError, HostClosedError, IdentityNotVerifiedError, RequestArtifactsRevokedError, InvalidWireOpError, NoArtifactStoreError, NoPendingAskError, PauseNotCarriedError, RecordingTooLargeForAccountError, SessionIndexUnavailableError, SessionNotFoundError, SessionOpNeedsIdentityError, SessionsNotCarriedError, UnreadableEnvelopeError, VerifierUnavailableError, } from './errors.js';
import { checkSessionId } from './doorGuard.js';
import { withHostedConversation } from '../core/agent/servingConversation.js';
import { verifyRequestIdentity } from './identityVerification.js';
import { spendKeyFor, spendLedger } from './admission.js';
import { beginIngress, recordHostRefusal } from './ingressRecord.js';
import { requestSealing } from './requestSealing.js';
import { openTurnArtifacts } from './turnArtifacts.js';
import { SESSION_LIST_OP, SESSION_TRANSCRIPT_OP } from './sessionWire.js';
import { DEFAULT_ARTIFACT_OPS_PER_SESSION, DEFAULT_MAX_ACTIVE_SESSIONS, TURN_ARTIFACTS_TIMEOUT_MS, } from './types.js';
import { runnerLive } from '../core/runnerLive.js';
/**
* The keys this composer invents live in a namespace no client string can
* reach (recheck RB1 / N9). A session id is whatever a caller sent — `#` and
* all — so every key built FROM one is prefixed `session:`, and every key the
* host mints for a request with no session is spelled without that prefix. The
* two spaces are disjoint by construction, not by what a wire happens to send.
*
* - {@link laneKeyOf} — the pool key: `session:<id>`, or {@link ANONYMOUS_LANE}.
* - {@link latchKeyOf} — the concurrency latch: `session:<id>`, or
* `anonymous:<n>` (unique per request).
*/
const ANONYMOUS_LANE = 'anonymous';
function laneKeyOf(sessionId) {
return sessionId === undefined ? ANONYMOUS_LANE : `session:${sessionId}`;
}
/**
* Serve an agent, with per-session conversation memory, on any
* {@link AgentHost}.
*
* Resolves once the host is live. Closing the returned handle closes the host,
* detaches the listeners this composer added, and removes its durability
* wiring.
*
* Two shapes, one composer — see the file header for the full comparison:
*
* - `{ agent }` — one instance for every session, runs serialized globally.
* Correct, and unchanged from every earlier release.
* - `{ agentFactory }` — one instance per active session, sessions in
* PARALLEL, each session's turns serialized on its own instance.
*
* @example One agent, every session
* const handle = await standingAgent({
* agent,
* sessions: memorySessions(),
* host: nodeHost({ port: 0 }),
* onConcurrentInvoke: 'enqueue',
* durability: 'sync',
* });
* process.on('SIGTERM', () => void handle.close());
*
* @example One agent per session — they answer at the same time
* const handle = await standingAgent({
* agentFactory: () => Agent.create({ provider, model }).system('…').build(),
* sessions: sqliteSessions({ file: './sessions.db' }),
* host: nodeHost({ port: 8080 }),
* maxActiveSessions: 200,
* });
*/
export async function standingAgent(options) {
const { sessions, host } = options;
const policy = options.onConcurrentInvoke ?? 'reject';
const durability = options.durability ?? 'exit';
const sharedAgent = options.agent;
const factory = options.agentFactory;
/** The badge check (9.26.0), or `undefined` — the zero-delta path. */
const identityOptions = options.identity;
/** The admission policy (9.26.0), or `undefined`. */
const admission = options.admission;
/**
* The app's sealer for a request's own credential, checked once per run, or
* `undefined` — no run carries a request, as before. Refused at
* construction when it is not a function.
*/
const sealRequest = requestSealing(options.sealRequest, 'standingAgent');
/**
* The rolling-window accountant — built ONLY when a policy exists, because
* with no policy nobody would ever read it and keeping a per-caller map of a
* server's whole traffic would be a cost nobody asked for.
*/
const ledger = admission !== undefined ? spendLedger() : undefined;
/**
* Where ingress decisions go (9.32.0), or `undefined` — the zero-delta path.
* With no sink nothing is wrapped and the handler uses the host's own reply
* object, which is what makes this option free for everyone who never asks
* for it.
*/
const ingressSink = options.onIngressDecision;
// ── Refused at construction, before a socket exists ──────────────────
//
// Two spellings of one decision, and nothing could make the winner visible
// afterwards: an operator reading `{ agent, agentFactory }` in a config would
// have no way to know whether their sessions were isolated or not.
if (sharedAgent !== undefined && factory !== undefined) {
throw new Error(`[hosting] standingAgent was given both 'agent' and 'agentFactory'. They are two ` +
`answers to one question — whether sessions SHARE an instance or each get their ` +
`own — and honouring either would leave the other silently ignored. Pass 'agent' ` +
`for one shared instance (runs serialized globally, the original shape), or ` +
`'agentFactory' for one instance per active session (sessions run in parallel).`);
}
if (sharedAgent === undefined && factory === undefined) {
throw new Error(`[hosting] standingAgent needs something to answer with: pass 'agent' (one instance ` +
`shared by every session) or 'agentFactory' (a new instance per active session). ` +
`Neither was given, so there is nothing to serve.`);
}
// Two options whose shape TypeScript enforces and plain JavaScript does not.
// Both fail CLOSED at runtime if left half-spelled — an `identity` with no
// `verify` would refuse every caller as unverifiable, an `admission` with no
// `decide` would throw on the first turn — and a door that refuses everybody
// for a configuration reason is exactly the outage that takes an hour to
// diagnose from the outside. Named here instead, before a socket exists.
if (identityOptions !== undefined &&
typeof identityOptions.verify !== 'function' &&
typeof identityOptions.signIn?.identify !== 'function') {
throw new Error(`[hosting] standingAgent was given 'identity' without a \`verify\` function (or a ` +
`\`signIn\` source). It is the ` +
`strategy that turns a caller's bearer token into a proven user, so there is nothing ` +
`to verify with — and with it half-spelled every request would be refused as ` +
`unverifiable. Pass identity: { verify: jwksIdentity({ jwksUrl, issuer, audience }` +
`).verify } — or any IdentityVerifier's verify — or drop the option and read ` +
`HostRequest.userId as the transport's own claim, exactly as before.`);
}
if (admission !== undefined && typeof admission.decide !== 'function') {
throw new Error(`[hosting] standingAgent was given 'admission' without a \`decide\` function. It is the ` +
`whole policy — one method answering 'allow' | { queue: true } | { refuse: '<why>' } ` +
`— so a bag without it decides nothing and would throw on the first turn. Pass ` +
`admission: turnsPerHour({ limit }), or your own { decide }, or drop the option.`);
}
// A pool bound with no pool is a caller who believes something untrue about
// how their process will behave under load. Named rather than ignored.
if (factory === undefined && options.maxActiveSessions !== undefined) {
throw new Error(`[hosting] standingAgent was given 'maxActiveSessions' without 'agentFactory'. ` +
`With a single shared 'agent' there is no pool to bound — every session already ` +
`uses the one instance you passed. Drop it, or switch to 'agentFactory' to get ` +
`the per-session pool it describes.`);
}
// A bound on the artifact hand-over that could never fire is not a bound —
// and a hand-over that may hold a lane forever is the outage it exists to
// prevent. Refused here, before a socket exists.
const turnArtifactsTimeoutMs = options.turnArtifactsTimeoutMs ?? TURN_ARTIFACTS_TIMEOUT_MS;
if (!Number.isFinite(turnArtifactsTimeoutMs) || turnArtifactsTimeoutMs <= 0) {
throw new Error(`[hosting] standingAgent was given turnArtifactsTimeoutMs: ${String(options.turnArtifactsTimeoutMs)}. It is how long a turn's artifact hand-over (reply.turnArtifacts) may hold the ` +
`reply, so it has to be a positive, finite number of milliseconds — a bound that ` +
`can never fire would let one host's hung store hold every other session. Drop it ` +
`for the default (${TURN_ARTIFACTS_TIMEOUT_MS} ms).`);
}
// The `answer-account` op (explain-answer): built — and its declarations,
// ceiling and cache size validated — only when the host opted in, so a bad
// declaration throws HERE, at boot, and never meets a request. Absent, the
// op answers the unknown-op refusal and nothing below is ever built.
const accounts = options.answerAccounts === undefined || options.answerAccounts === false
? undefined
: answerAccounts(options.answerAccounts);
// How many artifact ops (head / get / answer-account) one session may have
// in flight at once. A bound that can never be met, or never fire, is
// refused here; `Infinity` is the explicit "no bound".
const artifactOpsPerSession = options.artifactOpsPerSession ?? DEFAULT_ARTIFACT_OPS_PER_SESSION;
if (artifactOpsPerSession !== Number.POSITIVE_INFINITY &&
(!Number.isInteger(artifactOpsPerSession) || artifactOpsPerSession < 1)) {
throw new Error(`[hosting] standingAgent was given artifactOpsPerSession: ${String(options.artifactOpsPerSession)}. It is how many artifact operations (artifact-head, artifact-get, ` +
`answer-account) one session may have in flight at once, so it has to be a ` +
`positive whole number — or Infinity for no bound. Drop it for the default ` +
`(${DEFAULT_ARTIFACT_OPS_PER_SESSION}).`);
}
/** Session id → artifact ops in flight right now. An entry at zero is removed. */
const artifactOpsInFlight = new Map();
const maxActiveSessions = options.maxActiveSessions ?? DEFAULT_MAX_ACTIVE_SESSIONS;
if (factory !== undefined && (!Number.isInteger(maxActiveSessions) || maxActiveSessions < 1)) {
throw new Error(`[hosting] standingAgent was given maxActiveSessions: ${String(options.maxActiveSessions)}. ` +
`It is how many sessions may hold an agent at once, so it has to be a positive ` +
`integer — a pool that can hold no sessions could never serve one.`);
}
/** Session id → its lane. Empty and unused in the shared shape. */
const pool = new Map();
/**
* Every instance the factory has handed over. A WeakSet so a retired agent is
* collectable — the check is "have I seen this object", and nothing here
* needs to keep it alive to answer that.
*/
const handedOver = new WeakSet();
/** The single lane of the shared shape, or `undefined` in the pooled one. */
const shared = sharedAgent !== undefined ? makeLane(sharedAgent, '#shared') : undefined;
/** The pooled shape's reader — see `redeemerFor`. Built on first need only. */
let reader;
/** Instances retired from the pool — a seam binding to one is revoked. */
const retired = new WeakSet();
let anonymous = 0;
/**
* Attach this composer's wiring to one agent and hand back its lane.
*
* Everything installed here is undone by `lane.detach()` — on `close()`, and
* on eviction. An agent whose lane was detached has nothing of this
* composer's left on it.
*/
function makeLane(agent, poolKey, sessionId) {
const lane = {
poolKey,
...(sessionId !== undefined && { sessionId }),
agent,
chain: Promise.resolve(),
queued: [],
admitted: 0,
lastUsedMs: Date.now(),
detach: () => undefined,
};
// ── The lane's own events, as the run made them ──────────────────
// Every subscription below reads the agent's REAL-value path
// (`core/runnerLive.ts` · `runnerLive`), never the served stream: the reply is the
// caller's own answer and the spend ledger is an admission decision, so an
// agent's `redact` — which governs what the RECORD keeps — must reach
// neither (`src/redaction/runRedaction.ts`). One subscription, one switch.
//
// Tokens reach the caller only if the host streams; `emit` on a host that
// does not is a no-op by design, so this needs no capability check. The
// subscription is per LANE because a token event names no session — the
// agent that produced it is the only thing that identifies whose it is,
// and with a pool that is the whole reason two sessions' tokens cannot mix.
//
// ── Spend accounting (9.26.0) ────────────────────────────────────
// Only with an admission policy. Without one the two spend arms are never
// taken — the agent is untouched and the events cost their usual nothing.
//
// Two sources, because they answer two different questions honestly:
// `llm_end` always carries TOKENS, and `cost.tick` carries MONEY only
// where a pricing table turned tokens into it. Summing an invented rate
// here would be a number that looks like a bill.
// Read live (`core/runnerLive.ts`): the reply goes to the person who asked
// and the ledger counts what was spent — neither is a record, so neither
// is served under the agent's `redact`.
// Every `Agent` is a runner of this library, so its live taps exist; one
// that does not is refused here rather than served placeholders or nothing.
const live = runnerLive(agent);
if (live === undefined) {
throw new TypeError('standingAgent: the agent was not built by agentfootprint (Agent.create(...).build()), ' +
'so its run cannot be read live — the reply, the spend ledger and the session store ' +
'need it.');
}
const offLane = live.onRealEvent((event) => {
switch (event.type) {
case 'agentfootprint.agent.turn_start':
lane.activeRunId = event.meta?.runId;
return;
case 'agentfootprint.stream.token': {
const content = event.payload?.content;
if (typeof content === 'string' && content.length > 0)
lane.activeReply?.emit?.(content);
return;
}
case 'agentfootprint.stream.llm_end': {
if (ledger === undefined)
return;
const key = lane.activeSpendKey;
if (key === undefined)
return;
const usage = event
.payload?.usage;
ledger.add(key, {
inputTokens: usage?.input ?? 0,
outputTokens: usage?.output ?? 0,
});
return;
}
case 'agentfootprint.cost.tick': {
if (ledger === undefined)
return;
const key = lane.activeSpendKey;
if (key === undefined)
return;
const usd = event.payload?.estimatedUsd;
if (typeof usd === 'number' && Number.isFinite(usd))
ledger.add(key, { usd });
return;
}
default:
return;
}
});
// Under 'exit' NOTHING is built: no observer on the agent, no barrier, no
// per-commit work. That is what "the default is byte-identical" means here —
// not a mode that happens to write once, but wiring that is never installed.
const writer = durability === 'exit'
? undefined
: durableWriter({
mode: durability,
session: () => lane.activeSessionId,
runId: () => lane.activeRunId,
// The conversation is read from the run's LIVE committed state,
// never from the commit event's (served) values: the store holds
// what the next turn resumes from (`hosting/durability.ts`).
state: () => live.liveState(),
write: (sessionId, conversation) => sessions.persist(sessionId, toEnvelope(conversation)),
});
const detachWriter = writer ? agent.attach(writer.recorder) : undefined;
const uninstallBarrier = writer?.install(agent);
lane.writer = writer;
lane.detach = () => {
offLane();
uninstallBarrier?.();
detachWriter?.();
};
return lane;
}
/**
* The lane this request runs on.
*
* Shared shape: always the one lane, so every request queues behind every
* other — the global serialization, unchanged. Pooled shape: this session's
* lane, built on first sight, with anonymous traffic sharing one.
*
* SYNCHRONOUS on purpose, and called with no `await` between it and the
* `serialize` that admits the request. A gap there would let the pool evict
* the very lane the caller is about to run on.
*/
function laneFor(sessionId) {
if (shared !== undefined)
return shared;
const key = laneKeyOf(sessionId);
const held = pool.get(key);
if (held !== undefined) {
held.lastUsedMs = Date.now();
return held;
}
evictToFit();
const built = makeLane(fromFactory(), key, sessionId);
pool.set(key, built);
return built;
}
/**
* One new agent out of the factory — or a refusal naming what went wrong.
*
* The repeat-instance check is the load-bearing one. A factory that closes
* over a single agent and returns it every time type-checks perfectly and
* destroys the only property the pool exists for: two sessions would share
* per-run state, both runs would finish, and one person's conversation would
* end up holding another person's turn with nothing in the recording to say
* so. That is the exact failure the shared shape serializes to avoid, and it
* is worth a loud refusal at the moment it becomes possible.
*/
function fromFactory() {
const made = factory?.();
if (made === undefined || made === null) {
throw new Error(`[hosting] standingAgent's agentFactory returned nothing. It is called once per ` +
`active session and must return a NEW Agent each time — build it inside the ` +
`factory: agentFactory: () => Agent.create({ provider, model }).build().`);
}
if (handedOver.has(made)) {
throw new Error(`[hosting] standingAgent's agentFactory returned an Agent it has already returned. ` +
`One instance per session is the entire point: an Agent holds per-run state on ` +
`itself, so two sessions sharing one would each finish while the composer read ` +
`the other's state — one person's conversation quietly holding another's turn. ` +
`Build a NEW Agent inside the factory rather than closing over one; if you meant ` +
`every session to share a single instance, pass it as 'agent' instead and get ` +
`the global serialization that makes it safe.`);
}
handedOver.add(made);
return made;
}
/**
* Make room for one more session, if room is needed.
*
* Least recently used, and never a lane with work: `admitted` counts requests
* that are queued or running, and retiring one of those would abandon a turn
* a person is waiting on. When EVERY lane is busy the pool grows past its
* bound instead — stated on the option, and the honest trade. It comes back
* under the bound as soon as a run finishes and another session arrives.
*/
function evictToFit() {
while (pool.size >= maxActiveSessions) {
let victim;
for (const lane of pool.values()) {
if (lane.admitted > 0)
continue;
if (victim === undefined || lane.lastUsedMs < victim.lastUsedMs)
victim = lane;
}
if (victim === undefined)
return;
// Out of the pool FIRST, so a request arriving for that session during
// the teardown below builds itself a fresh lane rather than racing one
// that is being dismantled.
pool.delete(victim.poolKey);
void retire(victim);
}
}
/**
* Retire an evicted lane: detach, close what its tools were holding, stop it.
*
* The conversation is NOT touched — it is in the session store, which is why
* eviction is invisible to the person on the other end. What is torn down is
* the instance: tool sessions first, under the `'evicted'` reason the tool
* teardown vocabulary already has (9.7.0), so a sandbox or a browser context
* this session was holding is released and SAYS which firing site released
* it. Then the agent itself, stopped rather than merely drained — nothing can
* reach it again, so leaving its timers running would be a leak with no
* owner.
*
* Failures here are swallowed, and this is the only place that happens: the
* agent's own event channel already reports a teardown that threw
* (`tools.session_close_failed`, with its error class), and failing the
* UNRELATED request that happened to trigger the eviction would blame one
* person's turn for another session's broken cleanup.
*/
async function retire(lane) {
retired.add(lane.agent);
lane.detach();
const sessionId = lane.sessionId;
try {
await lane.agent.closeToolSessions({
...(sessionId !== undefined && { sessionId }),
reason: 'evicted',
});
}
catch {
// Reported on the agent's own channel; see above.
}
try {
await lane.agent.shutdown({ stop: true });
}
catch {
// Same.
}
}
/** Queue `work` behind everything already waiting on this lane, FIFO. */
function serialize(lane, sessionKey, work, queueAnyway = false) {
if (policy === 'reject' &&
!queueAnyway &&
(lane.activeSession === sessionKey || lane.queued.includes(sessionKey))) {
return Promise.reject(new ConcurrentRunError(sessionKey.startsWith('session:') ? sessionKey.slice('session:'.length) : '(anonymous)', lane.activeRunId));
}
lane.queued.push(sessionKey);
// Admitted from here, so the pool cannot retire this lane out from under a
// request that is merely waiting its turn.
lane.admitted += 1;
lane.lastUsedMs = Date.now();
const mine = lane.chain.then(async () => {
lane.queued.splice(lane.queued.indexOf(sessionKey), 1);
lane.activeSession = sessionKey;
try {
await work();
}
finally {
lane.activeSession = undefined;
lane.activeRunId = undefined;
lane.admitted -= 1;
lane.lastUsedMs = Date.now();
}
});
// The chain must never reject, or one failed request would wedge every
// request behind it.
lane.chain = mine.then(() => undefined, () => undefined);
return mine;
}
/**
* The composer's whole request path — with the reply it is handed already
* wrapped, when an ingress record is being kept.
*
* Split out from {@link handler} for exactly one reason: the record's funnel
* has to be ONE place. Every exit below ends at a reply terminal, so wrapping
* the reply once above this function covers all of them — a refusal added
* later cannot forget to report, because reporting is not something any exit
* does.
*/
const answerRequest = async (request, reply, note) => {
// ── The badge, before anything else ───────────────────────────────
// FIRST, and once — so a turn, a claim-ticket redemption and a session
// read all inherit one answer to "who is this", and a door added later
// cannot accidentally be the lenient one. With no verifier configured this
// returns `undefined` without touching the request: every earlier
// release's behaviour, to the byte.
let verified;
try {
verified = await verifyRequestIdentity(identityOptions, request.headers, request.userId, request.signInKey);
}
catch (err) {
reply.fail(err instanceof Error ? err : new Error(String(err)));
return;
}
// The PROVEN id, on the record — never the claimed one, which is the whole
// distinction the verifier exists to draw. Nothing is recorded when nobody
// was proven; absent and "anonymous" are the same fact here.
note?.identified(verified);
// The user id that reaches every downstream composition. With a verifier
// it is the one the TOKEN proved (the request's own claim was already
// reconciled or refused); without one it is exactly what the transport
// said, unchanged since 9.12.0.
const userId = verified?.userId ?? request.userId;
const identified = userId === request.userId ? request : { ...request, userId };
// ── Reading history is not a turn either ──────────────────────────
if (identified.session !== undefined) {
try {
await answerSessionOp(identified.session, verified, reply);
}
catch (err) {
reply.fail(err instanceof Error ? err : new Error(String(err)));
}
return;
}
// ── A claim-ticket redemption is not a turn ────────────────────────
// Its presence is the discriminant, exactly as `decision`'s is one line
// down: a request carrying `artifact` redeems a ref and never starts,
// queues behind, or is refused as, a run. Read-only against the store, so
// it deliberately bypasses the lane's serialization — a screen describing
// a chart while the model is mid-turn must not be refused as a
// "concurrent run", and must not make the turn wait either.
if (identified.artifact !== undefined) {
try {
await answerArtifact(identified.artifact, identified, verified, reply);
}
catch (err) {
reply.fail(err instanceof Error ? err : new Error(String(err)));
}
return;
}
const sessionId = identified.sessionId;
// A request with no session has nothing to hydrate and nowhere to persist
// that the caller could ask for again, so it gets its own key and can never
// collide with anyone. In the pooled shape those requests still SHARE one
// fallback instance — there is no conversation to isolate — and this unique
// key is what keeps them from ever refusing each other as a "same session"
// collision, which they are not.
const sessionKey = sessionId !== undefined ? `session:${sessionId}` : `anonymous:${++anonymous}`;
try {
// ── Admission, before a lane is even chosen ────────────────────
// The cheapest refusal is the one made before any work: no hydrate, no
// model call, no store write. With no policy this is a single
// `undefined` check and nothing else runs.
const spendKey = spendKeyFor(verified);
let queueAnyway = false;
if (admission !== undefined && ledger !== undefined) {
const verdict = await admission.decide({
...(verified !== undefined && { identity: verified }),
...(sessionId !== undefined && { sessionId }),
recentSpend: ledger.read(spendKey),
});
if (typeof verdict === 'object' && verdict !== null && 'refuse' in verdict) {
note?.admitted('refuse');
reply.fail(new AdmissionRefusedError(verdict.refuse, userId));
return;
}
queueAnyway = typeof verdict === 'object' && verdict !== null && 'queue' in verdict;
// An ALLOW is recorded too. A stream of refusals alone cannot answer
// "was anybody turned away?" — only a census can, which is the same
// reason an empty audit bundle is not evidence of an empty door.
note?.admitted(queueAnyway ? 'queue' : 'allow');
// Counted at ADMISSION, not at completion: a turn that is running has
// been spent whether or not it ever answers, and counting on the way
// out would let a caller hold N runs open under a limit of one.
//
// The stated consequence: a request the LANE then refuses as a
// concurrent run still counts against the window. Deferring the count
// to the moment a run actually starts would fix that and open a
// bigger hole — a burst of simultaneous requests would each be decided
// against a window none of them had joined, and walk straight through
// the ceiling. A bound that a burst defeats is not a bound.
ledger.admit(spendKey);
}
const lane = laneFor(sessionId);
await serialize(lane, sessionKey, () => answerOne(lane, sessions, identified, reply, sessionId, verified, spendKey, note), queueAnyway);
}
catch (err) {
reply.fail(err instanceof Error ? err : new Error(String(err)));
}
};
/**
* What the host calls — {@link answerRequest} with the ingress record's one
* funnel around it (9.32.0).
*
* With no sink configured this is the identity function over the host's own
* reply: no wrapper object, no bookkeeping, no branch inside any exit path.
*
* `settle()` covers the one case a terminal cannot: a request that leaves
* without ending its reply at all. The composer never does that, and the
* `finally` is there because "never" is a claim about today's code rather
* than a guarantee — a census with a hole in it is what this replaces.
*/
const handler = async (request, hostReply) => {
const note = ingressSink === undefined ? undefined : beginIngress(request, ingressSink);
const reply = note === undefined ? hostReply : note.watch(hostReply);
try {
await answerRequest(request, reply, note);
}
finally {
note?.settle();
}
};
/**
* Answer "which conversations are mine?" and "what did we say?" — the two
* session-history ops (9.26.0).
*
* ── Three refusals before an answer is even attempted ────────────────────
* 1. no verifier on this door → {@link SessionOpNeedsIdentityError}. Reading
* "your" sessions off an unverified header is enumeration.
* 2. a verifier, and this caller proved nothing (an anonymous request at a
* door built with `allowAnonymous`) → the same refusal shape: anonymous
* owns nothing, and answering an empty list would imply the question was
* honoured.
* 3. the session store keeps no owner index →
* {@link SessionIndexUnavailableError}, naming the missing member. An
* unanswerable question is not an empty result.
*
* ── And ONE indistinguishable not-found ──────────────────────────────────
* A transcript for a session that does not exist, one owned by somebody
* else, and one whose stored conversation names no owner all end the same
* way. Told apart, they would be an oracle for which session ids are real.
*
* Read-only and lane-free: history is answered out of the session store,
* which no run holds, so nothing here queues behind a turn or is refused as
* a concurrent one.
*/
async function answerSessionOp(op, verified, reply) {
if (op.op === 'pending') {
// Reloading the current question uses the same session admission as a
// reply. It neither enumerates sessions nor exposes a checkpoint.
await sessions.onWake?.(op.sessionId, 'transcript');
const stored = await sessions.hydrate(op.sessionId);
if (stored !== undefined && !mayOpenSession(stored, verified)) {
reply.fail(new SessionNotFoundError(op.sessionId));
return;
}
// The one reading every door uses (`answeredPause.ts`): a pause whose
// decision a resume already used is not pending, and neither is one no
// answer can reach any more — the next message closes it.
const held = stored === undefined ? undefined : readStoredSession(stored);
const waiting = held?.waiting !== undefined && unanswerablePause(held.waiting, sharedAgent) === undefined
? held.waiting
: undefined;
deliverSessions(reply, 'session-pending', {
op: 'pending',
pending: waiting?.pending ?? null,
});
return;
}
const opName = op.op === 'list' ? SESSION_LIST_OP : SESSION_TRANSCRIPT_OP;
if (identityOptions === undefined || verified === undefined) {
reply.fail(new SessionOpNeedsIdentityError(opName));
return;
}
if (op.op === 'list') {
if (sessions.listByUser === undefined) {
reply.fail(new SessionIndexUnavailableError(opName, 'listByUser'));
return;
}
const page = await sessions.listByUser(verified.userId);
deliverSessions(reply, opName, { op: 'list', sessions: page.sessions });
return;
}
if (sessions.ownerOf === undefined) {
reply.fail(new SessionIndexUnavailableError(opName, 'ownerOf'));
return;
}
const owner = await sessions.ownerOf(op.sessionId);
if (owner === undefined || owner !== verified.userId) {
// The ONE not-found. Missing, foreign and owner-less are one answer.
reply.fail(new SessionNotFoundError(op.sessionId));
return;
}
await sessions.onWake?.(op.sessionId, 'transcript');
let stored;
try {
stored = await sessions.hydrate(op.sessionId);
}
catch (err) {
throw err instanceof UnreadableEnvelopeError ? err.withSession(op.sessionId) : err;
}
if (stored === undefined) {
// The index said one thing and the store another — a session forgotten
// between the two calls. Same not-found: the caller learns nothing it
// did not already know.
reply.fail(new SessionNotFoundError(op.sessionId));
return;
}
deliverSessions(reply, opName, {
op: 'transcript',
sessionId: op.sessionId,
messages: envelopeTranscript(stored),
});
}
/**
* Redeem one claim ticket for the screen: `artifact-head` (metadata — the
* render-by-ref decision) or `artifact-get` (metadata + payload), against
* the store of the agent that serves this session.
*
* ── The scope is the run's own, re-composed — one isolation rule ────────
* A ref is redeemed under EXACTLY the scope the run's tools minted it in:
* `sessionArtifactScope(userId, sessionId, storedIdentity)` — the same
* function a turn's hand-over (`reply.turnArtifacts`) is bound with, so a
* host's filing and this redemption compose one tuple from the same inputs
* for the same caller. It is `identityForRequest` when the request names a
* user — the same call, on the same inputs, that composed the run's
* identity — else the session rung
* `{ conversationId: sessionId }` (what seed derives for an identity-less
* served run). No new spelling exists to drift. The stored identity is read
* only when a `userId` arrived (it is the only rung that needs the stored
* tenant/conversation tuple), through the same wake → hydrate →
* refuse-unreadable steps a turn takes.
*
* A ref from another session — or a right session presented with the wrong
* identity — composes a different tuple, and the store answers a wrong
* tuple with "no data", never with a distinguishable error. So the wire's
* not-found is ONE shape for missing, expired and foreign alike:
* {@link ArtifactNotFoundError}, byte-identical but for the ref itself.
*
* ── On the record, once ─────────────────────────────────────────────────
* Resolution rides the existing `artifacts.resolved` / `artifacts.refused`
* events through the capability's own fact sink (the raw store emits
* nothing, so binding is the ONE emitter — no double-emit), delivered on
* the serving agent's dispatcher with no `tool` field: the redeemer was the
* hosting door, and a phantom tool name would be an actor that does not
* exist.
*
* ── Nothing a stranger chose reaches the record ──────────────────────────
* Every fact this door emits is stamped with the session the request NAMED
* (`emitArtifactFact`), and that stamp decides which run's recording keeps
* it (`eventBelongsToRun`). So the door checks, before it emits anything or
* builds a lane:
*
* 1. **The ref's shape** (`isArtifactRef`) — free text posing as a ref would
* be text written into a live recording (the wire readers refuse it too;
* this covers a host that builds `HostRequest.artifact` itself).
* 2. **At a verifying door, ownership** — the turn door's own rule
* (`mayOpenSession`) for a stored conversation; for one whose FIRST turn
* is still in flight (nothing stored yet), only the caller that turn is
* serving. Anyone else gets the ONE not-found, and nothing is emitted —
* a stranger can neither write into another person's recording nor flood
* it until its own events are evicted.
* 3. **With no live lane** (the reader answers — `redeemerFor`), whether the
* store HOLDS the ref under the session's scope, asked silently
* (`holds`; `account` asks it in `explainAnswer`). A made-up session id
* is the one not-found with nothing emitted; a real one is redeemed,
* whether or not its conversation was ever stored.
*
* At a door with no verifier the session id IS the key, by law: whoever
* presents it may redeem under it, and its facts are that session's.
*
* Zero-cost when unused: an agent without a store answers with the teaching
* refusal naming the attach, and nothing else in the composer changes.
*/
async function answerArtifact(op, request, verified, reply) {
// The account op is spoken only by a host that opted in — and refused
// before anything is read, so a host that never asked for it pays nothing
// and reveals nothing.
if (op.op === 'account' && accounts === undefined)
refuseUnknownWireOp(ANSWER_ACCOUNT_OP);
const sessionId = request.sessionId;
if (sessionId === undefined) {
// An anonymous run scopes its artifacts to its own runId — a name no
// later request can present — so there is genuinely nothing a
// session-less redemption could ever resolve. Taught, not 404'd: this
// is a caller gap, not a missing ref.
reply.fail(new ArtifactSessionRequiredError(op.op));
return;
}
if (!isArtifactRef(op.ref)) {
reply.fail(new InvalidWireOpError(`'${artifactOpWireName(op.op)}' ${NOT_A_REF}`));
return;
}
// Read the stored conversation once, and only when it decides something:
// its OWNER (a verifying door) and its identity tuple (any request naming
// a user). Whether a ticket EXISTS is never its question — that is the
// artifact store's, asked under the composed scope below.
const needsStored = identityOptions !== undefined || request.userId !== undefined;
const envelope = needsStored ? await storedFor(sessionId) : undefined;
if (identityOptions !== undefined && !mayRedeemFrom(sessionId, envelope, verified)) {
reply.fail(new ArtifactNotFoundError(op.ref));
return;
}
// Outlived the composer across an await: answer like the door after close.
if (closing !== undefined) {
reply.fail(new HostClosedError(host.name));
return;
}
// The per-session bound (after ownership, so a stranger can never spend
// someone else's slots): artifact ops are lane-free and turn admission
// does not see them, so without it one session could keep the event loop
// every session shares busy with payload reads and parses. Refused by
// name, never queued — a screen retries; a queue would only move the wait.
const busy = artifactOpsInFlight.get(sessionId) ?? 0;
if (busy >= artifactOpsPerSession) {
reply.fail(new ArtifactOpsBusyError(op.op, artifactOpsPerSession));
return;
}
// NEVER a new lane, never an eviction (R2-12): the session's live
// instance, else the reader — see `redeemerFor`.
const redeemer = redeemerFor(sessionId);
if (redeemer === undefined) {
reply.fail(new ArtifactNotFoundError(op.ref));
return;
}
const lane = redeemer.lane;
// Admitted for the duration: a lane with work is never evicted, and a
// resolution mid-flight is work — retiring the instance under it would
// tear down the very store being read.
if (lane !== undefined)
lane.admitted += 1;
// Counted with no await since the check above, so a burst cannot all pass it.
artifactOpsInFlight.set(sessionId, busy + 1);
try {
const store = redeemer.agent.getArtifactStore();
if (store === undefined) {
// The fail-closed law at the wire: the refusal lands on the record
// (`no-store`, the same reason `ctx.artifacts` teaches with) and the
// reply names the attach.
// (An account would have been READ with `get`, so that is the verb its
// refusal names — no new `ArtifactOp` member.)
// On a live lane only: a lane-less session has proved nothing exists
// under its name, so the reader emits nothing for it (R2-12).
if (lane !== undefined) {
emitArtifactFact(redeemer.agent, {
type: 'refused',
op: op.op === 'account' ? 'get' : op.op,
reason: 'no-store',
ref: op.ref,
}, { sessionId });
}
reply.fail(new NoArtifactStoreError(op.op));
return;
}
const stored = request.userId !== undefined ? storedIdentityOf(envelope, sessionId) : undefined;
const scope = sessionArtifactScope(request.userId, sessionId, stored);
// A lane-less session (the reader answers) emits only for a ref its scope
// HOLDS: asked silently first — the `explainAnswer` precedent, which
// already covers `account` — so a made-up session id is the one
// not-found with nothing emitted (R2-12), and a real one is redeemed.
if (lane === undefined && op.op !== 'account' && !(await holds(store, scope, op.ref))) {
reply.fail(new ArtifactNotFoundError(op.ref));
return;
}
const bound = bindArtifacts(store, scope, {
onEvent: (fact) => emitArtifactFact(redeemer.agent, fact, { sessionId }),
});
if (op.op === 'account') {
// `accounts` is defined: the first line of this function refused otherwise.
await explainAnswer(accounts, store, scope, op.ref, bound, reply);
}
else if (op.op === 'head') {
const meta = await bound.head(op.ref);
if (meta === null) {
reply.fail(new ArtifactNotFoundError(op.ref));
return;
}
deliverArtifact(reply, { op: 'head', ref: op.ref, meta });
}
else {
// A digest mismatch throws here (ArtifactIntegrityError) rather than
// returning corrupt bytes as whole — it propagates to the handler's
// catch as this request's failure, already on the record via the sink.
const record = await bound.get(op.ref);
if (record === null) {
reply.fail(new ArtifactNotFoundError(op.ref));
return;
}
deliverArtifact(reply, { op: 'get', ref: op.ref, meta: record.meta, data: record.data });
}
}
finally {
if (lane !== undefined) {
lane.admitted -= 1;
lane.lastUsedMs = Date.now();
}
const left = (artifactOpsInFlight.get(sessionId) ?? 1) - 1;
if (left <= 0)
artifactOpsInFlight.delete(sessionId);
else
artifactOpsInFlight.set(sessionId, left);
}
}
/**
* The `answer-account` branch, after ownership (explain-answer af-3).
*
* Everything before this line is `artifact-get`'s own path — the same
* ownership check, the same lane admission (never the run queue), the same
* composed scope. What the branch adds, in order:
*
* 1. **A SILENT head** — `bindArtifacts(store, scope)` with no sink, so it
* emits nothing. It runs BEFORE the cache (review R3-S1): whether the
* record still exists is the store's answer, so a swept or expired
* recording is "not available" even while its account is cached, and a
* kind change cannot be masked.
* 2. **Kind, then size** — not a `recording/run` → the ONE not-found (and
* nothing was emitted); over the ceiling → the named 413, before a byte
* of the payload is read (and again, by the service, on the payload's
* REAL size before any parse — a store may under-report `bytes`). Both before the cache: neither can ever have
* an entry there (failures are never cached; the ceiling is per host).
* 3. **The cache, then single-flight** — a hit reads nothing more and emits
* nothing; a miss starts or joins the ONE computation for its key.
* 4. **The one door fact** — the leader's `get` goes through the sinked
* binding, which emits the single `artifacts.resolved` (`via: 'get'`),
* stamped with this session: the fact truthfully says the record was
* read, and no new `ArtifactOp` exists to say it differently.
* 5. **Parse + fold + show-me** — `explainRecording`. Anything that cannot
* be explained (the record vanished between head and get, it does not
* parse, it is not a recording) is the one not-found, and is not cached.
*/
async function explainAnswer(service, store, scope, ref, bound, reply) {
const meta = await bindArtifacts(store, scope).head(ref);
if (meta === null || meta.kind !== RECORDING_ARTIFACT_KIND) {
reply.fail(new ArtifactNotFoundError(ref));
return;
}
if (meta.bytes > service.maxRecordingBytes) {
reply.fail(new RecordingTooLargeForAccountError(service.maxRecordingBytes));
return;
}
const key = service.keyFor(scope, ref);
const answer = service.cached(key) ??
(await service.compute(key, async () => {
const record = await bound.get(ref);
if (record === null || record.meta.kind !== RECORDING_ARTIFACT_KIND)
return null;
// The payload's REAL size is checked by the service before any parse.
const runId = record.meta.origin?.runId;
return { data: record.data, ...(runId !== undefined && { runId }) };
}));
if (answer === null) {
reply.fail(new ArtifactNotFoundError(ref));
return;
}
deliverArtifact(reply, { op: 'account', ref, meta, answer });
}
/** The stored conversation for a redemption — woken and hydrated the way a
* turn does it, the unreadable-envelope law included. */
async function storedFor(sessionId) {
await sessions.onWake?.(sessionId, 'artifact');
try {
return await sessions.hydrate(sessionId);
}
catch (err) {
throw err instanceof UnreadableEnvelopeError ? err.withSession(sessionId) : err;
}
}
/**
* The identity a stored conversation carries — the `stored` input of
* `sessionArtifactScope`. The same law as a turn: an unreadable stored
* conversation is a different fact from an absent one, and only one of them
* is safe to compose a scope from, so it throws by name.
*/
function storedIdentityOf(envelope, sessionId) {
if (envelope === undefined)
return undefined;
try {
const held = readStoredSession(envelope);
return (held.waiting?.conversation ?? held.prior)?.identity;
}
catch (err) {
throw err instanceof UnreadableEnvelopeError ? err.withSession(sessionId) : err;
}
}
/**
* `handle.artifactsForRequest` — the redemption door's composition, handed
* to the host for a path that is not a turn (see
* {@link StandingAgentHandle.artifactsForRequest}).
*
* Every step is the one `answerArtifact` takes, in the same order and with
* the same instances — the badge (`verifyRequestIdentity`), the stored
* conversation (`storedFor`), ownership (`mayRedeemFrom`), the serving
* agent's store, the ONE composer (`sessionArtifactScope`) and the fact sink
* stamped with the session — so the scope it binds is the scope a wire
* redemption by the same caller reads. What differs is only what is handed
* back: the bound verbs instead of one resolved ref. Refusals are VALUES,
* because the host decides its own status codes; nothing is emitted before
* ownership is settled, and a refused caller builds no lane.
*/
async function artifactsForRequest(request) {
if (closing !== undefined)
throw new HostClosedError(host.name);
// Two credentials are ambiguous: refused by name, never read as none.
if (identityOptions !== undefined && repeatsAuthorization(request.headers)) {
return {
bound: false,
reason: 'unverified',
error: new IdentityNotVerifiedError('unverifiable', false),
};
}
let verified;
try {
// The turn door's four arguments — the sign-in key included, so a
// person signed in by the cookie is who they are here too.
verified = await verifyRequestIdentity(identityOptions, singleValuedHeaders(request.headers), request.userId, request.signInKey);
}
catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
// An identity-provider outage judged nobody's credential: its own
// reason (a 503), never 'unverified' (a 401 that sends every client to
// re-authenticate against a provider that is down).
return {
bound: false,
reason: err instanceof VerifierUnavailableError ? 'unavailable' : 'unverified',
error,
};
}
// The same answer after an await as before one: a composer that closed
// while this call waited is closed (the wire's HostClosedError, too).
if (closing !== undefined)
throw new HostClosedError(host.name);
const userId = verified?.userId ?? request.userId;
const sessionId = request.sessionId;
if (sessionId === undefined)
return { bound: false, reason: 'no-session' };
// The wire refuses these ids at the adapter (`doorGuard.ts`); the seam
// has no adapter in front of it, so it asks the same check itself.
const invalid = checkSessionId(sessionId, host.name);
if (invalid !== undefined)
return { bound: false, reason: 'invalid-session', error: invalid };
const needsStored = identityOptions !== undefined || userId !== undefined;
const envelope = needsStored ? await storedFor(sessionId) : undefined;
if (identityOptions !== undefined && !mayRedeemFrom(sessionId, envelope, verified)) {
return { bound: false, reason: 'not-found' };
}
// Every await above could have outlived the composer (RS5): a call that
// lost the race to `close()` binds nothing, builds nothing, and says so the
// way a call made after close does.
if (closing !== undefined)
throw new HostClosedError(host.name);
const stored = userId !== undefined ? storedIdentityOf(envelope, sessionId) : undefined;
// Never a new lane, never an eviction — the redemption door's own rule. A
// session with no live lane binds through the reader: what its scope holds
// is the store's answer, and a session nobody has chatted in yet is where
// an app-owned route files a guide before the first turn.
const redeemer = redeemerFor(sessionId);
if (redeemer === undefined)
return { bound: false, reason: 'not-found' };
const agent = redeemer.agent;
const store = agent.getArtifactStore();
if (store === undefined)
return { bound: false, reason: 'no-store' };
const bound = bindArtifacts(store, sessionArtifactScope(userId, sessionId, stored), {
onEvent: (fact) => emitArtifactFact(agent, fact, { sessionId }),
});
return { bound: true, artifacts: heldBinding(bound, sessionId, agent, redeemer.lane) };
}
/**
* A seam binding's verbs, held to the same terms as the wire's redemptions
* and a turn's hand-over (S5):
*
* - **Revoked** when the instance it is bound to is retired from the pool or
* the host closes — a write after that would land on a stopped agent and
* its fact on a record nobody holds. `RequestArtifactsRevokedError`,
* created already handled. Checked per call against `retired`, so a
* binding that is never used again costs nothing to keep.
* - **Bounded** by `artifactOpsPerSession`, counted with the wire's
* redemptions of the same session (`ArtifactOpsBusyError`).
* - **Admitted** to its lane for the duration of each call, so the pool
* cannot retire the instance under an operation in flight.
*/
function heldBinding(bound, sessionId, agent, lane) {
const refused = (error) => {
const refusal = Promise.reject(error);
refusal.then(undefined, () => undefined);
return refusal;
};
function held(verb, start) {
if (closing !== undefined || retired.has(agent)) {
return refused(new RequestArtifactsRevokedError(verb));
}
const busy = artifactOpsInFlight.get(sessionId) ?? 0;
if (busy >= artifactOpsPerSession) {
return refused(new ArtifactOpsBusyError(verb, artifactOpsPerSession));
}
artifactOpsInFlight.set(sessionId, busy + 1);
if (lane !== undefined)
lane.admitted += 1;
let running;
try {
running = start();
}
catch (err) {
running = Promise.reject(err);
}
return running.finally(() => {
if (lane !== undefined) {
lane.admitted -= 1;
lane.lastUsedMs = Date.now();
}
const left = (artifactOpsInFlight.get(sessionId) ?? 1) - 1;
if (left <= 0)
artifactOpsInFlight.delete(sessionId);
else
artifactOpsInFlight.set(sessionId, left);
});
}
return Object.freeze({
put: (input) => held('put', () => bound.put(input)),
head: (ref) => held('head', () => bound.head(ref)),
get: (ref) => held('get', () => bound.get(ref)),
delete: (ref) => held('delete', () => bound.delete(ref)),
list: (options) => held('list', () => bound.list(options)),
});
}
/**
* May this caller redeem under `sessionId` at a VERIFYING door?
*
* A stored conversation answers with the turn door's own rule
* (`mayOpenSession`) — asked leniently, before any strict reader, for the
* same reason the turn door asks it first. With nothing stored yet the only
* conversation that can hold artifacts is one whose FIRST turn is in flight
* right now (a chart presented mid-turn), and then only the caller that turn
* is serving may redeem. Read from the lane WITHOUT building one: a refused
* caller never makes the pool grow or evict.
*/
function mayRedeemFrom(sessionId, envelope, verified) {
if (envelope !== undefined)
return mayOpenSession(envelope, verified);
const serving = shared ?? pool.get(laneKeyOf(sessionId));
return serving?.activeSessionId === sessionId && serving.activeOwner === verified?.userId;
}
/** The lane serving `sessionId` right now — never built, never refreshed. */
function liveLane(sessionId) {
return shared ?? pool.get(laneKeyOf(sessionId));
}
/**
* Whose store answers a redemption (or `artifactsForRequest`) for
* `sessionId` — WITHOUT building a lane or evicting one (R2-12).
*
* Before, both doors called `laneFor`, so a caller naming session ids —
* anybody, at a door with no verifier, where the id is the key by law —
* built a pooled instance per id and evicted the least recently used idle
* session to make room, closing its tool sessions as `'evicted'`. A read is
* not a reason to take a person's instance away.
*
* - **A live lane** (the shared agent, or this session's pooled instance) →
* its agent. The lane is refreshed and admitted by the caller.
* - **No live lane** (no turn yet, its instance was evicted, or the process
* restarted) → the READER: one instance from the factory, held OUTSIDE
* the pool, built on first need and shared by every lane-less session. It
* never counts toward `maxActiveSessions` and never evicts, so the pool is
* exactly what the turns made it. It answers from the factory's store,
* which is the only store a lane-less session's artifacts can be in — the
* one-shared-store shape. (With a store built per instance, an evicted
* instance's artifacts left with it, and the reader's not-found is the
* true answer.) The wire asks the store silently before it emits anything
* for a lane-less session (`holds`), so a flood of made-up ids costs one
* artifact-store read each, one reader per process, and nothing else.
* - **Closing** → `undefined`: the one not-found.
*
* Whether a ticket EXISTS is the artifact store's answer, asked under the
* session's own scope — never the pool's, never the session store's.
* Rejected (shipped from 9.117.0, then found false): the one not-found for
* a lane-less session with no STORED conversation, on the premise that such
* a session has no turn that could have minted anything. The library itself
* falsifies it — an app-owned route files beside a conversation before its
* first turn (`artifactsForRequest`, built for exactly that), and a tool
* mints during a first turn that then throws, which persists nothing — and
* the shared shape redeemed the same filing, so the answer depended on the
* deployment's shape.
*/
function redeemerFor(sessionId) {
const live = liveLane(sessionId);
if (live !== undefined) {
live.lastUsedMs = Date.now();
return { agent: live.agent, lane: live };
}
// Never built once the composer is closing: `close()` stops the reader it
// can see, and one built after that would run with no owner (RS5).
if (closing !== undefined)
return undefined;
reader ??= fromFactory();
return { agent: reader };
}
/**
* Whose conversation is this — and may THIS caller open it? (9.26.0)
*
* The ownership question, asked ONCE and answered the same way at every door
* that opens a session. `session-transcript` asks it of the store's index;
* a TURN asks it of the conversation it just hydrated — the same derivation
* (`envelopeOwner`, the `principal` the composer itself wrote), so a store
* with no index is protected exactly as well as one with a fast one, and no
* second round-trip is spent to learn something already in hand.
*
* That a turn had to ask at all is the lesson: the ops that READ history were
* gated from the day they were written, while the ordinary door beside them
* hydrated any named session, answered from it, and re-stamped its owner —
* one handler, two answers, and the strict one was the decoration. A gate
* that only some doors honour is not a gate.
*
* The rule, whole:
*
* - **No verifier configured → no question asked.** A door that reads
* `userId` off a header has no proof to compare against, and inventing
* one would refuse turns that worked in every earlier release. Byte-
* identical, deliberately: how much a header is worth is the deployment's
* answer, and this is the option that says so.
* - **Verified caller, owned session → the owner, and only the owner.**
* - **Verified caller, session that names no owner → refused.** The same
* answer `session-transcript` gives, for the same reason: a conversation
* nobody signed for is not evidence that it is yours. A store written
* before this door verified anything therefore holds sessions that cannot
* be continued — a stated migration boundary, and a loud one, rather than
* a door that hands over conversations to whoever names them first.
* - **Anonymous caller (`allowAnonymous`) → only a session nobody owns.**
* Which is the same rule spelled with `undefined` on both sides.
*
* A session that does not exist YET is not this function's business: the
* caller's own first turn creates it, and the ownership fact is written when
* it persists.
*
* ── A conversation this runtime cannot read has no owner it can name ───────
* The question is asked BEFORE the strict readers, so a stored session in a
* format this build does not know refuses as "not one you can open" rather
* than describing the state of somebody's storage to a stranger. The
* consequence, stated: at a verifying door that is also the answer its real
* owner gets, because a runtime that cannot read a conversation genuinely
* cannot tell whose it is, and "this is yours, but broken" is a claim it
* would be making up. A door with no verifier is untouched — it still fails
* loudly by name (`UnreadableEnvelopeError`), which is where an operator
* mid-migration meets that fact.
*
* ── The one thing this cannot hide ─────────────────────────────────────────
* A refused turn tells the caller the id is taken, because the alternative
* (starting a fresh conversation over somebody else's) destroys it. That is
* the only fact a door which lets callers choose their own session ids can
* never conceal — and it is a long way from the conversation itself, which is
* what the refusal keeps.
*/
function mayOpenSession(stored, verified) {
if (identityOptions === undefined)
return true;
return envelopeOwner(stored) === verified?.userId;
}
async function answerOne(lane, store, request, reply, sessionId, verified, spendKey, note) {
const runner = lane.agent;
lane.activeReply = reply;
// The writer only ever writes for the run it is inside; an anonymous
// request has nowhere to write to and gets nothing.
lane.activeSessionId = sessionId;
lane.activeOwner = verified?.userId;
// Whose usage this lane's next token and cost events belong to. Set only
// with an admission policy; the subscriptions that read it do not exist
// otherwise.
if (ledger !== undefined)
lane.activeSpendKey = spendKey;
try {
let prior;
let paused;
// A pause whose decision a resume already used (`answeredPause.ts`): no
// decision answers it again, and the next message continues the
// conversation that resume left.
let answered;
if (sessionId !== undefined) {
// A request carrying a decision is waking this session to CONTINUE a
// run, which is a different thing to ask a store to be ready for.
await store.onWake?.(sessionId, request.decision !== undefined ? 'resume' : 'invoke');
try {
const stored = await store.hydrate(sessionId);
if (stored !== undefined) {
// WHOSE conversation this is, before a single line of it is read
// into this run. A turn is the door that hydrates history into a
// model's context AND the door that re-persists who owns it, so it
// is the door where getting this wrong costs the most: see
// `mayOpenSession` for the rule and its one honest leak.
//
// Placed before the readers on purpose — an unreadable envelope
// belonging to somebody else must answer "not yours", not describe
// the state of a stranger's storage.
if (!mayOpenSession(stored, verified)) {
reply.fail(new SessionNotFoundError(sessionId));
return;
}
// Both readers throw by name on a format this runtime cannot read —
// better a loud refusal than an agent answering from half a session.
// The one reading every door uses: an answered pause is never
// offered for an answer (`answeredPause.ts`).
const held = readStoredSession(stored);
paused = held.waiting;
prior = held.prior;
answered = held.answered;
}
}
catch (err) {
// A stored conversation this runtime cannot read fails THIS REQUEST,
// naming the session. It must never fall through to the fresh-start
// path below: the conversation exists, and answering it as if the
// caller were new is the one failure nobody can see from the outside.
// A store that knows the session already named it; one that does not
// gets the name added here, so the guarantee does not depend on which
// store you chose.
throw err instanceof UnreadableEnvelopeError ? err.withSession(sessionId) : err;
}
}
// The signal reaches tool execution, tool discovery and skill-entry
// scoring. It does NOT currently reach the LLM call, so a caller who
// hangs up mid-generation stops the tools, not the token stream.
//
// `sessionId` (9.4.0) rides the same bag onto every event this run
// emits, so a shipped telemetry stream can be asked "what happened in
// this conversation?" and not only "what happened in this run?". Since
// 9.10.0 it also decides the memory namespace when the caller named no
// identity — a session IS a conversation, so a served session recalls its
// own earlier turns with no configuration. This is the only place that
// knows the answer — the agent is handed a message, not a session. An
// ANONYMOUS request has no session and gets no key: `sessionKey` above is
// a concurrency latch this host invented, and stamping it on telemetry
// would be publishing a fact nobody can join to.
//
// `identity` (9.12.0) is the WHO beside that WHICH-conversation: a
// transport that named the end user gets a principal on the run, which is
// what puts a real person in `EventMeta.principal`, in `ctx.identity`
// inside a tool, and in the identity a credential provider scopes its
// token vault on — the whole chain, from the front door to the downstream
// API, without anybody configuring it. See `identityForRequest` for what
// each field is composed from and why. A transport that named nobody
// produces `undefined` here and every earlier release's behaviour is
// unchanged.
const identity = identityForRequest(request.userId, sessionId, prior?.identity ?? paused?.conversation.identity);
// What this turn offers a host that files its own artifacts
// (`reply.turnArtifacts`): WHERE its claim tickets are redeemed — the one
// composition the artifact door re-composes for the same caller, built
// only if a host asks — and where a hook failure is recorded. No session,
// no scope: nothing a later request presents could ever redeem an
// anonymous run's artifacts, so there is nothing to bind.
const stored = prior?.identity ?? paused?.conversation.identity;
const offer = {
...(sessionId !== undefined && {
session: {
id: sessionId,
scope: () => sessionArtifactScope(request.userId, sessionId, stored),
},
}),
timeoutMs: turnArtifactsTimeoutMs,
...(request.signal !== undefined && { signal: request.signal }),
note,
};
const optionsForRun = request.signal !== undefined || sessionId !== undefined || identity !== undefined
? {
...(request.signal !== undefined && { env: { signal: request.signal } }),
...(sessionId !== undefined && { sessionId }),
...(identity !== undefined && { identity }),
}
: undefined;
// A request with no session is its OWN conversation for self-explain
// (B1, recheck RB1): keyed by a random UUID minted for this request, in
// the `hosted:` key space no session id can enter — so no other caller,
// signed in or not, can name it. It is not a session: memory, telemetry
// and teardown are untouched (`core/agent/servingConversation.ts`).
const runOptions = sessionId === undefined
? withHostedConversation(optionsForRun, globalThis.crypto.randomUUID())
: optionsForRun;
/**
* The options THIS run is handed, with the credential the request
* carries sealed by the app (`sealRequest`) — asked only on the two
* paths that run, after every refusal, so a refused request seals
* nothing. The handle rides the run's options alone: never the reply,
* the stored session or the checkpoint.
*/
const withSealedRequest = async () => {
const sealed = await sealRequest?.({ headers: request.headers ?? {} });
return sealed === undefined ? runOptions : { ...runOptions, request: sealed };
};
// ── The one discriminant ─────────────────────────────────────────
// A request carrying `decision` answers a pending question; a request
// without one is a new message. Never inferred from the text: reading
// approval out of prose is a guess, and a consent gate may not be built
// on a guess.
if (request.decision !== undefined) {
if (answered !== undefined) {
// One decision runs one call, and this request's decision was used.
reply.fail(answeredRefusal(answered));
return;
}
if (!paused) {
reply.fail(new NoPendingAskError(sessionId ?? '(anonymous)'));
return;
}
const inputRequest = pauseDemandsDecision(paused.checkpoint.pauseData) === undefined
? readAwaitingInput(paused.checkpoint.pauseData)
: undefined;
if (inputRequest !== undefined) {
const response = applyInputResponse(inputRequest, request.decision);
if ('cancel' in response) {
// Data collection, never permission: settle unanswered calls
// without executing their handlers or consulting the model.
const history = [...paused.conversation.history];
for (const call of openToolCalls(paused.conversation.history))
history.push({
role: 'tool',
toolName: call.name,
toolCallId: call.id,
...(paused.checkpoint.sharedState.findingsServe !== undefined && {
toolResultOrigin: 'input-cancelled',
}),
content: JSON.stringify({
status: 'input_cancelled',
requestId: response.requestId,
}),
});
const output = 'Input request cancelled.';
history.push({ role: 'assistant', content: output });
const { skillCursor: _cursor, ...conversation } = paused.conversation;
void _cursor;
await lane.writer?.settle();
if (sessionId !== undefined)
await store.persist(sessionId, toEnvelope({ ...conversation, history, checkpointedAt: Date.now() }));
runner.abandonPause();
if (sessionId !== undefined)
await runner.closeToolSessions({ scope: 'run', sessionId });
// A turn that ends, so the host is offered its binding like any
// other — but no run happened, so there is no run id to stamp.
await offerTurnArtifacts(runner, reply, offer, () => undefined);
reply.complete(output);
return;
}
}
const resumeOptions = await withSealedRequest();
// Opened before the answer is handed over: what it sees says whether a
// failure below may have used the decision (`answeredPause.ts`).
const watch = watchAnswer(paused);
try {
await deliver(lane, await runner.resume(paused.checkpoint, request.decision, resumeOptions), store, reply, sessionId, offer, paused);
}
catch (err) {
// A failure in the resume or in storing its result: when the
// decision may have been used, the stored pause is answered before
// anyone hears of it (`answeredPause.ts`).
if (sessionId !== undefined) {
await afterFailedAnswer({
store,
sessionId,
paused,
watch,
answer: request.decision,
error: err,
settle: () => lane.writer?.settle(),
runner,
});
}
throw err;
}
finally {
watch?.release();
}
return;
}
if (paused && sessionId !== undefined) {
if (unanswerablePause(paused, runner) === undefined) {
// The message is not run and the pause is not discarded. Both of the
// other options lose something a person cared about.
reply.fail(new AwaitingDecisionError(sessionId, paused.pending));
return;
}
// …unless no answer can ever reach it: paused before requests were
// named, its call changed after the ask, or an ask made under rules
// this agent no longer runs. Held, it would hold the session for
// good — so it is closed, the message continues the conversation, and
// the calls it left open are recorded as not run only when the ledger
// the request was minted under proves it, else as possibly run
// (`answeredPause.ts`).
prior = await closedConversation(paused, { runner, store });
paused = undefined;
}
const turnOptions = await withSealedRequest();
const output = prior
? await runner.run({ message: request.input, continueFrom: prior }, turnOptions)
: await runner.run({ message: request.input }, turnOptions);
await deliver(lane, output, store, reply, sessionId, offer);
}
catch (err) {
// A run that threw still has to drain. A write left in flight would race
// the NEXT turn's terminal write and could land after it. The drain's own
// failure is swallowed here and only here: the caller is already being
// told about the run's error, and replacing it with a storage error would
// hide the thing that actually went wrong.
await lane.writer?.settle().catch(() => undefined);
throw err;
}
finally {
lane.activeReply = undefined;
lane.activeSessionId = undefined;
lane.activeOwner = undefined;
lane.activeSpendKey = undefined;
}
}
/** Store what the run left behind, then end the reply with the right terminal. */
async function deliver(lane, output, store, reply, sessionId, offer, priorPause) {
const runner = lane.agent;
// Mid-run writes settle FIRST. Ordering, not tidiness: an 'async' write
// still in flight would otherwise land after the terminal envelope and
// overwrite it — a stored pause quietly demoted back to a conversation,
// and the question a person was asked gone with it. It also rethrows a
// store that refused this run's progress, so a broken store fails the
// request rather than being reported as a clean answer.
await lane.writer?.settle();
if (isPaused(output)) {
const pending = describePause(output, sessionId);
const conversation = sessionId === undefined
? undefined
: output.awaitingInput !== undefined &&
output.awaitingInput.requestId === priorPause?.pending.awaitingInput?.requestId
? priorPause.conversation
: runner.checkpoint();
try {
if (sessionId !== undefined && conversation) {
await store.persist(sessionId, toPausedEnvelope({ checkpoint: output.checkpoint, conversation, pending }));
if (reply.awaiting) {
// The run id for `origin` comes from the conversation just
// snapshotted. When that is the PRIOR pause's — re-used because
// the same question is still open, which is what a partial answer
// does WITHOUT running anything — no origin is stamped: this
// request ran nothing, and the instance's last run may be another
// session's. Absent is never wrong; a borrowed run id would be.
await offerTurnArtifacts(runner, reply, offer, () => conversation === priorPause?.conversation ? undefined : conversation?.runId);
reply.awaiting(pending);
return;
}
}
// Either there was nowhere to store it, or the host cannot describe a
// question. Both are refusals about THIS reply, not about the run.
reply.fail(new PauseNotCarriedError(pending.tool, sessionId, conversation !== undefined));
return;
}
finally {
// ── OWNERSHIP MOVES HERE ────────────────────────────────────────
// A pause belongs to a SESSION. In the shared shape one Agent carries
// every session, and the Agent's own pending-question guard (9.2.0) is
// right for a script driving one instance and wrong here: the instance
// would go on holding session A's question, and session B's next
// message — a different conversation, a different person — would be
// refused on behalf of an answer B was never asked for.
//
// Once the pause is in the STORE, the store owns it: a later request
// for that session carrying `decision` continues it from exactly
// where it stopped, and nothing about that consults the instance. So
// the instance is released, here, at the moment ownership transfers.
// On the failure path above it is released for the opposite reason —
// the question could not be carried at all, and blocking every other
// session on one nobody can ever answer is strictly worse.
//
// The pooled shape releases it too, and for a reason of its own: this
// session's instance can be EVICTED while the question is outstanding,
// and a pause held only in an instance nobody can reach again is a
// question nobody could ever answer. The store is the one place it can
// survive that, so the store is the only place it is kept.
runner.abandonPause();
}
}
let conversation;
if (sessionId !== undefined) {
conversation = runner.checkpoint();
// Persist BEFORE answering: the caller learns the answer only once the
// conversation that produced it is durable, so a queued next turn can
// never read state older than the answer already given.
if (conversation)
await store.persist(sessionId, toEnvelope(conversation));
}
// The snapshot just persisted names the run that answered — the same id
// the run's own recording carries on its `origin` — so stamping a host's
// filing costs no second snapshot.
await offerTurnArtifacts(runner, reply, offer, () => conversation?.runId);
reply.complete(typeof output === 'string' ? output : String(output));
}
// The HOST's own refusals — a forged request its door guard turned away, a
// session id over the bound — never reach `handler`, so they would never
// reach the record either. Subscribed only when a sink is set (the zero-delta
// path stays zero), and only on a host that reports them (feature-detected).
// Before `serve`, so a refusal in the first instant of serving is not lost.
const stopHostRefusals = ingressSink !== undefined && typeof host.onRefusal === 'function'
? host.onRefusal((refusal) => recordHostRefusal(refusal, ingressSink))
: undefined;
let handle;
try {
handle = await host.serve(handler);
}
catch (err) {
stopHostRefusals?.();
throw err;
}
// ── Shutdown ────────────────────────────────────────────────────────
//
// Telemetry drains on close, because a batching exporter that loses its
// last batch when the server stops is the most common way a shutdown lies
// about what happened. Draining is safe on an agent this composer only
// borrowed; RELEASING it is not, so that is opt-in.
//
// A POOLED instance is not borrowed — this composer made it, and nothing can
// reach it once the pool is dropped. So `'flush'` and `'flush-and-stop'` both
// stop a pooled agent (drain, then release), because the alternative is
// leaving timers running that nobody owns. `'none'` still means what it says:
// touch nothing.
const shutdownMode = options.shutdown ?? 'flush';
const installedSignals = [];
function removeSignalListeners() {
for (const { signal, listener } of installedSignals)
process.removeListener(signal, listener);
installedSignals.length = 0;
}
// Idempotent: the first call owns the shutdown, later ones await it — the
// same rule `httpHost.close()` follows, and what lets a signal handler and
// an explicit close coexist.
let closing;
function closeOnce() {
closing ??= (async () => {
await handle.close();
stopHostRefusals?.();
if (shared !== undefined) {
shared.detach();
if (shutdownMode !== 'none') {
// `stop: false` under 'flush' — drain without releasing what the
// caller still owns.
await shared.agent.shutdown({ stop: shutdownMode === 'flush-and-stop' });
}
}
else {
const lanes = [...pool.values()];
pool.clear();
for (const lane of lanes)
lane.detach();
if (shutdownMode !== 'none') {
// allSettled, not all: one instance whose exporter refuses to drain
// must not strand the drain of every other session's. The reader is
// this composer's too, so it stops with them.
const owned = [...lanes.map((lane) => lane.agent), ...(reader ? [reader] : [])];
await Promise.allSettled(owned.map((agent) => agent.shutdown({ stop: true })));
}
}
removeSignalListeners();
})();
return closing;
}
for (const signal of options.shutdownOn ?? []) {
const listener = () => {
void closeOnce().finally(() => {
// Our listeners are gone by now (closeOnce removes them), so
// re-raising lands on the DEFAULT disposition: the process ends the
// way the platform meant it to, not by an exit code we invented.
removeSignalListeners();
process.kill(process.pid, signal);
});
};
process.on(signal, listener);
installedSignals.push({ signal, listener });
}
// Keep whatever the adapter put on its own handle (nodeHost's bound `url`,
// say) and replace only `close`, which now also detaches this composer's
// listeners. The cast is the one place TypeScript cannot see that a spread
// plus one override is still the adapter's handle type.
return {
...handle,
close: closeOnce,
artifactsForRequest,
};
}
/**
* WHO this request is for, when the transport said (9.12.0) — and `undefined`
* when it did not, which leaves every earlier release's behaviour untouched to
* the byte.
*
* The composition, field by field, because each one has a different rightful
* source:
*
* - `principal` — {@link HostRequest.userId}, the fact THIS request carried.
* It is the only field the transport supplies and the only one this function
* ever introduces.
* - `conversationId` — the namespace the conversation already runs in, else
* the session id. Composed with the 9.10.0 derivation rather than replacing
* it: a session IS a conversation, and moving a live conversation's
* namespace because a header appeared would lose its memory mid-thread.
* - `tenant` — whatever the stored identity carried. No transport field
* supplies one, so there is nothing here to override it with.
*
* **Why the header wins on `principal` specifically.** Every other field
* belongs to the conversation and survives untouched; the actor belongs to the
* request. Before this release `standingAgent` named no identity at all, so the
* only principal a stored conversation can realistically carry is one an
* EARLIER turn of this same mechanism put there — and preferring it would pin a
* whole session to whoever spoke first, which is precisely the audit trail that
* "looks complete and names the wrong party".
*
* **Nothing is invented.** With no session and no stored conversation there is
* no conversation to name, and this returns `undefined` rather than fabricating
* one; the id still reaches a custom handler on `HostRequest`. The managed
* runtimes that forward a user require a session id on the same call, so that
* gap is a guard rather than the shape this exists for.
*
* One consequence worth stating: naming an identity here takes the run off the
* 9.10.0 derived rung, so `runIdentitySource: 'session'` is not written on this
* path. The namespace is identical either way — the marker distinguishes a
* namespace the caller chose from one the library derived, and on this path the
* caller did choose.
*/
function identityForRequest(userId, sessionId, stored) {
if (userId === undefined)
return undefined;
const conversationId = stored?.conversationId ?? sessionId;
if (conversationId === undefined)
return undefined;
return {
conversationId,
...(stored?.tenant !== undefined && { tenant: stored.tenant }),
principal: userId,
};
}
/**
* The scope a SESSION's claim tickets live in, for the request being served —
* the ONE composition of that tuple, shared by the two doors that need it.
*
* `answerArtifact` redeems under it, and a turn hands its host a store bound
* to it (`reply.turnArtifacts`). One function rather than two call sites that
* agree today: the field bug this exists for was a host that composed its own
* tuple, got the session rung right and the signed-in rung wrong, and filed
* every ticket where redemption would never look. Deliberately NOT exported —
* a function a host can call with a user id is a function a host can call
* with SOMEBODY ELSE's user id.
*
* `identityForRequest` when the request names a user, else the session rung
* `{ conversationId: sessionId }` — what seed derives for an identity-less
* served run.
*/
function sessionArtifactScope(userId, sessionId, stored) {
return identityForRequest(userId, sessionId, stored) ?? { conversationId: sessionId };
}
/**
* Hand this turn's binding to a host that asked for it — AWAITED while the
* session's lane is still held, BOUNDED, immediately before the turn's
* terminal.
*
* Four guarantees, each one a finding an earlier cut of this seam failed:
*
* - **Inside the turn.** The composer awaits the hook here, so a host files
* while no other run can be in flight on this instance.
* - **Bounded.** The hook and every operation it started race ONE deadline
* (`turnArtifactsTimeoutMs`) and the request's signal. Whichever ends the
* wait, the binding is revoked, nothing in flight is cancelled (it may
* still land — its facts name their own session and run, so they cannot
* enter anyone else's record), the cause is reported and the terminal is
* delivered — so one host's hung store or hook can hold neither every other
* session (the shared shape), nor a pooled lane, nor `close()`.
* - **Never the terminal's decision, never silent.** A hook that throws, an
* operation that fails, the bound, the abort and a call after the turn are
* reported (`turnArtifactsFailed`) and the terminal is delivered anyway.
* - **Revoked when the turn ends.** A verb called later is refused by name
* (`TurnArtifactsExpiredError`), already handled, and reported.
*
* A host without the hook gets nothing built: no binding, no scope, no store
* lookup, no run-id read, no timer.
*/
async function offerTurnArtifacts(agent, reply, offer, runIdOf) {
if (reply.turnArtifacts === undefined)
return;
const session = offer.session;
// WHOSE facts these are, captured NOW — the binding owns "whose run", never
// the agent's mutable current-run state when a late fact lands.
const runId = session !== undefined ? runIdOf() : undefined;
const attribution = session === undefined
? undefined
: { sessionId: session.id, ...(runId !== undefined && { runId }) };
const failed = (failure) => turnArtifactsFailed(agent, attribution, offer.note, failure);
const opened = openTurnArtifacts({
store: agent.getArtifactStore(),
scope: session?.scope(),
runId,
onEvent: (fact) => {
// Only a bound hand-over reports facts, and only a session binds.
if (attribution !== undefined)
emitArtifactFact(agent, fact, attribution);
},
onFailure: (failure) => failed({
cause: failure.cause,
op: failure.op,
...(failure.cause === 'operation' && errorClassOf(failure.error)),
}),
});
const handOver = (async () => {
try {
await reply.turnArtifacts?.(opened.turn);
}
catch (err) {
// A hook that merely re-threw an operation's failure was already counted.
if (!opened.reported(err))
failed({ cause: 'hook', ...errorClassOf(err) });
}
await opened.drain();
})();
try {
const ended = await withinBound(handOver, offer.timeoutMs, offer.signal);
if (ended !== 'settled')
failed({ cause: ended });
}
finally {
opened.revoke();
}
}
/**
* Wait for `work`, but no longer than `timeoutMs` and no longer than `signal`
* allows. `work` is NOT cancelled when the wait ends — there is nothing to
* cancel a store's in-flight write with, and pretending otherwise would be
* worse than saying we stopped waiting (the `toolSessions.ts · withTimeout`
* law). The timer is unref'd, so a pending hand-over can never be the reason a
* process stays up.
*/
async function withinBound(work, timeoutMs, signal) {
if (signal?.aborted === true)
return 'abort';
let timer;
let onAbort;
try {
return await Promise.race([
work.then(() => 'settled'),
new Promise((resolve) => {
timer = setTimeout(() => resolve('timeout'), timeoutMs);
timer.unref?.();
}),
new Promise((resolve) => {
if (signal === undefined)
return;
onAbort = () => resolve('abort');
signal.addEventListener('abort', onAbort, { once: true });
}),
]);
}
finally {
if (timer !== undefined)
clearTimeout(timer);
if (onAbort !== undefined)
signal?.removeEventListener('abort', onAbort);
}
}
/** The routable half of a failure — class and code, never the message. */
function errorClassOf(err) {
const code = err?.code;
return {
...(err instanceof Error && { errorClass: err.constructor?.name ?? err.name }),
...(typeof code === 'string' && { errorCode: code }),
};
}
/**
* Record one failure of a turn's artifact hand-over — on the serving agent's
* stream as `agentfootprint.artifacts.hand_over_failed`, stamped with the
* session and run it was FOR; the first one on the ingress record's census
* (`IngressRecord.turnArtifactsFailure`); and on the console in dev mode. The
* swallow is the library's decision, so the visibility is its duty: the
* default deployment keeps no ingress sink, and a missing story must not be
* silent there (the `toolSessions.ts` law 5, "never throws into the run — but
* never silent either"). It never reaches the reply.
*/
function turnArtifactsFailed(agent, attribution, note, failure) {
const payload = {
cause: failure.cause,
...(failure.op !== undefined && { op: failure.op }),
...(failure.errorClass !== undefined && { errorClass: failure.errorClass }),
...(failure.errorCode !== undefined && { errorCode: failure.errorCode }),
};
note?.turnArtifactsFailed(payload);
if (attribution !== undefined) {
agent.emitAttributed('agentfootprint.artifacts.hand_over_failed', { ...payload }, attribution);
}
if (isDevMode()) {
// eslint-disable-next-line no-console
console.error('[hosting] reply.turnArtifacts hand-over failed; the reply was delivered anyway:', payload);
}
}
/**
* End the reply with a resolved artifact — through the terminal built for it
* when the host has one, and through the named refusal when it does not.
*
* The `awaiting` fallback law, applied to the other optional terminal: a host
* that cannot describe the result must say so by name rather than deliver an
* improvised shape or nothing. The resolution itself already happened and is
* already on the record either way.
*/
function deliverArtifact(reply, result) {
if (reply.artifact) {
reply.artifact(result);
return;
}
reply.fail(new ArtifactNotCarriedError(result.ref));
}
/**
* End the reply with resolved session history — through the terminal built for
* it when the host has one, and through the named refusal when it does not.
*
* The {@link deliverArtifact} law, applied to the third optional terminal: a
* host that cannot describe the result says so by name rather than delivering
* an improvised shape or nothing.
*/
function deliverSessions(reply, op, result) {
if (reply.sessions) {
reply.sessions(result);
return;
}
reply.fail(new SessionsNotCarriedError(op));
}
/**
* Does `scope` hold `ref`? Asked through a binding with NO fact sink, so the
* question itself puts nothing on any record — what lets the redemption door
* answer a lane-less session (one the reader serves) without emitting a fact
* under a session id nobody has proved exists (R2-12).
*/
async function holds(store, scope, ref) {
return (await bindArtifacts(store, scope).head(ref)) !== null;
}
/**
* Put one capability fact on the serving agent's typed record — the hosting
* door's adapter onto the SAME `artifacts.*` events every tool's use of
* `ctx.artifacts` rides, minus the `tool` field (the actor was the hosting
* door or the host's own code, and payloads never carry an actor that does not
* exist).
*
* Two bindings feed it. The wire's redemption (`artifact-head` /
* `artifact-get`) is read-only and only ever produces `resolved` / `refused`
* — the wire deliberately has no put. The turn's hand-over
* (`reply.turnArtifacts`) is the host's own code filing for its turn, so its
* `put` produces `minted` and, when retention made room, `expired` —
* tool-less, like the run's own recording mint, and carrying every meta field
* a tool's mint carries.
*
* Every fact is stamped with the SESSION it was produced for
* (`RunnerBase.emitAttributed`). The door answers outside any run and — for a
* redemption — outside the lane, so on an instance shared by several sessions
* a fact can be delivered while ANOTHER session's run is in flight; the stamp
* is what keeps it out of that run's recording and self-explain evidence
* (`bridge/eventMeta.ts · eventBelongsToRun`). Delivery to listeners is
* unchanged. Dropped when nobody listens (the dispatcher's own fast path), so
* an unobserved agent pays one listener check per fact and nothing more.
*/
function emitArtifactFact(agent, fact, attribution) {
switch (fact.type) {
case 'minted': {
const meta = fact.meta;
agent.emitAttributed('agentfootprint.artifacts.minted', {
ref: meta.ref,
kind: meta.kind,
mediaType: meta.mediaType,
bytes: meta.bytes,
...(meta.label !== undefined && { label: meta.label }),
...(meta.digest !== undefined && { digest: meta.digest }),
...(meta.expiresAt !== undefined && { expiresAt: meta.expiresAt }),
...(meta.origin !== undefined && { origin: meta.origin }),
...(meta.parentRefs !== undefined && { parentRefs: meta.parentRefs }),
...(meta.timeAxis !== undefined && { timeAxis: meta.timeAxis }),
}, attribution);
return;
}
case 'expired':
agent.emitAttributed('agentfootprint.artifacts.expired', {
ref: fact.swept.ref,
reason: fact.swept.reason,
kind: fact.swept.kind,
bytes: fact.swept.bytes,
}, attribution);
return;
case 'resolved':
agent.emitAttributed('agentfootprint.artifacts.resolved', {
ref: fact.ref,
via: fact.via,
kind: fact.kind,
bytes: fact.bytes,
}, attribution);
return;
case 'refused':
agent.emitAttributed('agentfootprint.artifacts.refused', {
op: fact.op,
reason: fact.reason,
...(fact.ref !== undefined && { ref: fact.ref }),
...(fact.detail !== undefined && { detail: fact.detail }),
}, attribution);
return;
default:
return;
}
}
/**
* Transport headers as the verifier reads them: string values only. A header
* that arrived as an array (repeated) is dropped — except `authorization`,
* which the caller refuses first (`repeatsAuthorization`): two credentials are
* ambiguous, and dropping them would let the call proceed as anonymous at a
* door that allows it.
*/
function singleValuedHeaders(headers) {
if (headers === undefined)
return undefined;
const out = {};
for (const [name, value] of Object.entries(headers)) {
if (typeof value === 'string')
out[name] = value;
}
return out;
}
/** Did the transport deliver `authorization` (any case) more than once? */
function repeatsAuthorization(headers) {
if (headers === undefined)
return false;
const values = Object.entries(headers).filter(([name]) => name.toLowerCase() === 'authorization');
return values.length > 1 || values.some(([, value]) => Array.isArray(value));
}
//# sourceMappingURL=standingAgent.js.map