@theoremjs/web 0.3.4-next.586.ga59d695 → 0.3.4-next.594.g145ceaf

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -237,6 +237,7 @@ const transport = withPageToolDispatch(
237
237
  | `session` | Returns the session id of the caller, for example `${userId}:${conversationId}`. Return `undefined` to refuse the request with a 401. Default: a random id in an HttpOnly, SameSite=Lax cookie that lasts 30 days. |
238
238
  | `host` | App data for the tool handlers (`ctx.host`), such as the signed-in user. |
239
239
  | `prepare` | Your app's own turn, from the one the handler builds (`TurnWiring.prepare` from `@theoremjs/agents`). Set what your app sets per turn, such as `system`, `credentials`, `onStage` and `onAgentCall`; read your app data as `turn.host`. The handler keeps its profile, its tool execution and its abort. Your `onStage` runs before the handler's steering, and your `credentials` are read before the session's. |
240
+ | `onStage` | The server's own stage handler, on every call the handler runs, a host profile's included: after your app's (`prepare`), before steering. For a rule the server adds to whatever the app says, such as a question before each tool that writes. |
240
241
  | `pageToolBroker` | Delivers results to active page dispatches. Default: bounded process memory. |
241
242
  | `conversationStore` | Where the conversation is kept. Required, except for a stateless profile, which must not have one. See [Keep the conversation](#keep-the-conversation). |
242
243
  | `sessionStore`, `steerInbox` | Where open gates are kept, and where steers and stops reach a running turn. Default: process memory, which reaches only turns this process runs. On more than one instance, give a shared `SteerInbox`. |
@@ -378,26 +379,23 @@ Each saved outcome records its absolute retention deadline. Changing a handler's
378
379
  Each issued gate records its absolute expiry. Changing `gateTtlMs` does not extend an issued gate.
379
380
  Ready, running, and uncertain operations do not expire.
380
381
 
381
- Use `sessionLimits` when your storage budget requires different finite limits. The defaults are:
382
+ A session store declares its `capacity`: what it holds for one session, in bytes of UTF-8 JSON.
382
383
 
383
- | Limit | Default |
384
- | --- | --- |
385
- | `maxGates` | 32 |
386
- | `maxOperations` | 128 |
387
- | `maxCheckpoints` | 256 |
388
- | `maxPermissions` | 2,048 |
389
- | `maxStateBytes` | 32 MiB |
390
- | `maxInvocationBytes` | 256 KiB |
391
- | `maxOutcomeBytes` | 256 KiB |
384
+ | Field | What it bounds | Memory store default |
385
+ | --- | --- | --- |
386
+ | `maxStateBytes` | One session's saved state | 16 MiB |
387
+ | `maxOutcomeBytes` | One tool result | 1 MiB (`DEFAULT_MAX_OUTCOME_BYTES`) |
388
+
389
+ Pass `capacity` to `createMemorySessionStore` to change them. A store you write declares the capacity of its own storage.
392
390
 
393
391
  Set `maxRequestBytes` to bound browser JSON before parsing. Its default is 16 MiB.
394
392
  The handler rejects oversized requests before provider access or tool preparation.
395
393
  Provider host options also accept `limits` for deadlines and upstream output.
396
- Byte limits measure UTF-8 JSON. The handler reserves one maximum outcome for each gate and unresolved operation.
397
- A new claim fails before dispatch when it cannot reserve that space.
398
- An oversized result after dispatch leaves an uncertain claim. It does not restore an approval.
399
- Limits never remove unresolved authority or shorten retained settlements. The host must reconcile and archive unresolved actions.
400
- The memory store accepts the same `sessionLimits` options. Shared stores receive checked states through the handler.
394
+ The handler keeps room for one `maxOutcomeBytes` result for each gate and each call that has not settled.
395
+ When a session is full, the handler drops its oldest settled results, then its oldest provider checkpoints.
396
+ It never drops gates, permissions, or calls that have not settled. When those alone do not fit, the new call is refused before it runs, and the user reads `session.full`.
397
+ A result larger than `maxOutcomeBytes` settles as the call's `result_too_large` failure. The call is never left unresolved because of its size.
398
+ The handler fits each state to the store's capacity before `compareAndSwap`.
401
399
 
402
400
  The default memory store coordinates handlers that share the same store object.
403
401
  It retains unexpired gates, unresolved operations, and unexpired outcomes during idle and capacity eviction.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/web",
3
- "version": "0.3.4-next.586.ga59d695",
3
+ "version": "0.3.4-next.594.g145ceaf",
4
4
  "description": "Theorem on the web platform, with no UI framework: Web-standard server handlers that serve a profile, and the browser client that talks to them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,6 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "zod": "^4.6.5",
28
- "@theoremjs/agents": "^0.3.4-next.586.ga59d695"
28
+ "@theoremjs/agents": "^0.3.4-next.594.g145ceaf"
29
29
  }
30
30
  }
@@ -3,4 +3,4 @@ import type { TheoremSessionState } from './session-store.ts';
3
3
  export declare function checkpointIdentity(checkpoint: ProviderCheckpoint): Promise<string>;
4
4
  export declare function checkpointOwnershipKey(identity: string, operations: TheoremSessionState['operations']): Promise<string>;
5
5
  export declare function requireOwnedCheckpoint(identities: readonly string[], checkpoint: ProviderCheckpoint | undefined, operations?: TheoremSessionState['operations'], portableHistory?: readonly TurnHistoryMessage[]): Promise<ProviderCheckpoint | undefined>;
6
- export declare function retainedCheckpointIdentities(existing: readonly string[], added: readonly string[], limit: number): string[];
6
+ export declare function retainedCheckpointIdentities(existing: readonly string[], added: readonly string[]): string[];
@@ -37,6 +37,6 @@ export async function requireOwnedCheckpoint(identities, checkpoint, operations
37
37
  }
38
38
  return parsed;
39
39
  }
40
- export function retainedCheckpointIdentities(existing, added, limit) {
41
- return [...new Set([...existing, ...added].reverse())].slice(0, limit).reverse();
40
+ export function retainedCheckpointIdentities(existing, added) {
41
+ return [...new Set([...existing, ...added].reverse())].reverse();
42
42
  }
@@ -32,12 +32,11 @@ import { type PageToolBroker } from './page-dispatch.ts';
32
32
  *
33
33
  * @module
34
34
  */
35
- import { type Profile, type ProfileDefinition, type ProviderHostOptions, type ToolGate, type TurnWiring, type z } from '@theoremjs/agents';
35
+ import { type Profile, type ProfileDefinition, type ProviderHostOptions, type StageHandler, type ToolGate, type TurnWiring, type z } from '@theoremjs/agents';
36
36
  import { type ClientTurnOptions } from '@theoremjs/agents/host';
37
37
  import { type TheoremConversationStore } from './conversation-store.ts';
38
38
  import { type TheoremCredentialStore } from './credential-store.ts';
39
39
  export { theoremSession, theoremSessionId } from './session-identity.ts';
40
- import { type TheoremSessionLimits } from './session-limits.ts';
41
40
  import { type SessionToolOperation, type TheoremSessionStore } from './session-store.ts';
42
41
  import { type SteerInbox } from './steer-inbox.ts';
43
42
  export type TheoremRequestContext = {
@@ -72,6 +71,12 @@ export type TheoremHandlerOptions = {
72
71
  * answered, with the `onStage` and `credentials` it gives for that call's turn.
73
72
  */
74
73
  prepare?: TurnWiring['prepare'];
74
+ /**
75
+ * The server's own stage handler, on every call this handler runs, a host's included: after
76
+ * the application's, before steering. For a rule the server adds to whatever the application
77
+ * says, such as a question before each tool that writes.
78
+ */
79
+ onStage?: StageHandler;
75
80
  /**
76
81
  * Resolve the caller's session id — e.g. `${userId}:${conversationId}` from your
77
82
  * auth. Return `undefined` to refuse the request (401). Default: an opaque id
@@ -97,8 +102,6 @@ export type TheoremHandlerOptions = {
97
102
  gateTtlMs?: number;
98
103
  /** Retain settled tool results in milliseconds. Default: 24 hours. */
99
104
  operationRetentionMs?: number;
100
- /** Finite per-session bounds. Unresolved authority is never evicted to satisfy them. */
101
- sessionLimits?: Partial<TheoremSessionLimits>;
102
105
  /** Maximum UTF-8 request body bytes before JSON parsing. Default: 16 MiB. */
103
106
  maxRequestBytes?: number;
104
107
  pageToolBroker?: PageToolBroker;
package/server/handler.js CHANGED
@@ -42,17 +42,16 @@ import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRe
42
42
  import { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, requiredOperationId, rewindConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
43
43
  import { unknownConversation } from "./conversation-store.js";
44
44
  import { createMemoryCredentialStore, } from "./credential-store.js";
45
- import { sessionStateStore } from "./session-authority.js";
45
+ import { sessionStateStore, } from "./session-authority.js";
46
46
  import { theoremSession } from "./session-identity.js";
47
47
  export { theoremSession, theoremSessionId } from "./session-identity.js";
48
48
  import { jsonResponse } from "./json-response.js";
49
49
  import { boundedRequestJson, requestByteLimit } from "./request-body.js";
50
50
  import { checkRequest } from "./request-check.js";
51
- import { resolveSessionLimits } from "./session-limits.js";
52
51
  import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
53
52
  import { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
54
53
  import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
55
- import { preparedTurn } from "./turn-wiring.js";
54
+ import { preparedTurn, stagesInOrder } from "./turn-wiring.js";
56
55
  const NDJSON_HEADERS = {
57
56
  'content-type': 'application/x-ndjson; charset=utf-8',
58
57
  'cache-control': 'no-store',
@@ -216,7 +215,8 @@ async function recordOutcome(sessions, sessionId, events, context) {
216
215
  for (const { gate } of pending) {
217
216
  const operation = state.operations[gate.operationId];
218
217
  if (operation && operation.status !== 'ready')
219
- throw new TheoremError('unavailable', labelText('error.unavailable'));
218
+ throw new TheoremError('unavailable', `Tool operation ${gate.operationId} is ${operation.status}, not ready, so its gate was not saved`, // labels-exempt: internal diagnostic; the user reads session.busy
219
+ { copy: { key: 'session.busy' } });
220
220
  delete state.operations[gate.operationId];
221
221
  Object.defineProperty(state.gates, gate.operationId, {
222
222
  value: gate,
@@ -361,7 +361,10 @@ async function* settleAnswered(ctx, request, session, answered) {
361
361
  async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
362
362
  const host = await ctx.options.host?.(request);
363
363
  // why: The held call goes on with what the application set for its turn: its stage handler and credentials.
364
- const { credentials, onStage } = await preparedTurn(ctx.options.prepare, { profile: ctx.profile.id, input: pending.turnInput ?? {}, host, signal: request.signal }, { credentials: sessionCredentials(ctx, sessionId) });
364
+ const { credentials, onStage } = await preparedTurn(ctx.options.prepare, { profile: ctx.profile.id, input: pending.turnInput ?? {}, host, signal: request.signal }, {
365
+ credentials: sessionCredentials(ctx, sessionId),
366
+ ...(ctx.options.onStage ? { onStage: ctx.options.onStage } : {}),
367
+ });
365
368
  yield* invokeTool({
366
369
  profile: ctx.profile.id,
367
370
  execution: sessionToolCoordinator(ctx.sessions, sessionId),
@@ -400,6 +403,7 @@ async function* chatTurn(ctx, request, session, turn, key, stop) {
400
403
  stopOnRequest(ctx.inbox, key, stop);
401
404
  }
402
405
  try {
406
+ const onStage = stagesInOrder(ctx.options.onStage, key ? steerStage(ctx.inbox, key) : undefined);
403
407
  const prepared = await preparedTurn(ctx.options.prepare, {
404
408
  profile: ctx.profile.id,
405
409
  execution: sessionToolCoordinator(ctx.sessions, session.id),
@@ -413,7 +417,7 @@ async function* chatTurn(ctx, request, session, turn, key, stop) {
413
417
  ...(turn.effort ? { effort: turn.effort } : {}),
414
418
  }, {
415
419
  credentials: sessionCredentials(ctx, session.id),
416
- ...(key ? { onStage: steerStage(ctx.inbox, key) } : {}),
420
+ ...(onStage ? { onStage } : {}),
417
421
  });
418
422
  const events = runTurn(prepared, provider);
419
423
  yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
@@ -575,6 +579,7 @@ async function* callEvents(ctx, request, session, body) {
575
579
  input: body.input,
576
580
  sessionPermissions: state.permissions,
577
581
  credentials: sessionCredentials(ctx, session.id),
582
+ ...(ctx.options.onStage ? { onStage: ctx.options.onStage } : {}),
578
583
  signal: request.signal,
579
584
  host,
580
585
  });
@@ -773,7 +778,6 @@ function serve(name, profile, options, chatOptions = {}) {
773
778
  cause: { code: 'operation_retention' },
774
779
  });
775
780
  }
776
- const limits = resolveSessionLimits(options.sessionLimits);
777
781
  requestByteLimit(options.maxRequestBytes);
778
782
  registerProfile(profile);
779
783
  const ctx = {
@@ -782,7 +786,7 @@ function serve(name, profile, options, chatOptions = {}) {
782
786
  profile,
783
787
  ...(chatOptions.chat ? { chat: chatOptions.chat } : {}),
784
788
  inbox: chatOptions.steerInbox ?? createMemorySteerInbox(),
785
- sessions: sessionStateStore(options.sessionStore ?? createMemorySessionStore({ sessionLimits: limits }), gateTtlMs, operationRetentionMs, limits),
789
+ sessions: sessionStateStore(options.sessionStore ?? createMemorySessionStore(), gateTtlMs, operationRetentionMs),
786
790
  credentials: lockedStore(options.credentialStore ?? createMemoryCredentialStore(), (saved = {}) => saved),
787
791
  };
788
792
  return async (request) => {
package/server/mod.d.ts CHANGED
@@ -19,8 +19,8 @@ export { createMemoryPageToolBroker, type PageToolBroker, type PageToolResult, p
19
19
  export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from './request-check.ts';
20
20
  export { sessionStateStore } from './session-authority.ts';
21
21
  export { createTheoremSessionBoundary, type PreparedLiveOpen, type SessionBoundaryOptions, type TheoremSessionBoundary, } from './session-boundary.ts';
22
- export type { TheoremSessionLimits } from './session-limits.ts';
23
- export { resolveSessionLimits } from './session-limits.ts';
22
+ export type { TheoremSessionCapacity } from './session-limits.ts';
23
+ export { DEFAULT_SESSION_CAPACITY } from './session-limits.ts';
24
24
  export { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, type MemorySessionStoreOptions, type PendingToolGate, type SessionSnapshot, type SessionToolOperation, type SessionWriteResult, type TheoremSessionState, type TheoremSessionStore, theoremSessionStateSchema, } from './session-store.ts';
25
25
  export { createMemorySteerInbox, type SteerInbox, type SteerUnit, steerStage, steerUnitOf, stopOnRequest, } from './steer-inbox.ts';
26
26
  export { sessionToolCoordinator } from './tool-operations.ts';
package/server/mod.js CHANGED
@@ -18,7 +18,7 @@ export { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch,
18
18
  export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from "./request-check.js";
19
19
  export { sessionStateStore } from "./session-authority.js";
20
20
  export { createTheoremSessionBoundary, } from "./session-boundary.js";
21
- export { resolveSessionLimits } from "./session-limits.js";
21
+ export { DEFAULT_SESSION_CAPACITY } from "./session-limits.js";
22
22
  export { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, theoremSessionStateSchema, } from "./session-store.js";
23
23
  export { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
24
24
  export { sessionToolCoordinator } from "./tool-operations.js";
@@ -1,8 +1,12 @@
1
- import { type TheoremSessionLimits } from './session-limits.ts';
1
+ import { type TheoremSessionCapacity } from './session-limits.ts';
2
2
  import { type TheoremSessionState, type TheoremSessionStore } from './session-store.ts';
3
3
  export type MutableStore<T> = {
4
4
  read(sessionId: string): Promise<T>;
5
5
  mutate<R>(sessionId: string, change: (value: T) => R | Promise<R>): Promise<R>;
6
6
  };
7
+ /** Session state with the capacity its store declared. */
8
+ export type SessionStateStore = MutableStore<TheoremSessionState> & {
9
+ readonly capacity: TheoremSessionCapacity;
10
+ };
7
11
  /** Session state, with expired gates dropped as it loads so they can't be answered. */
8
- export declare function sessionStateStore(store: TheoremSessionStore, gateTtlMs: number, operationRetentionMs: number, limits: TheoremSessionLimits): MutableStore<TheoremSessionState>;
12
+ export declare function sessionStateStore(store: TheoremSessionStore, gateTtlMs: number, operationRetentionMs: number): SessionStateStore;
@@ -1,13 +1,14 @@
1
1
  import { labelText, TheoremError, z } from '@theoremjs/agents';
2
- import { checkSessionLimits } from "./session-limits.js";
2
+ import { checkedCapacity, fitSession } from "./session-limits.js";
3
3
  import { emptySessionState, pruneGates, theoremSessionStateSchema, } from "./session-store.js";
4
4
  /** Session state, with expired gates dropped as it loads so they can't be answered. */
5
- export function sessionStateStore(store, gateTtlMs, operationRetentionMs, limits) {
5
+ export function sessionStateStore(store, gateTtlMs, operationRetentionMs) {
6
6
  if (typeof store.compareAndSwap !== 'function') {
7
7
  throw new TheoremError('config', labelText('error.config'), {
8
8
  cause: { code: 'session_store_contract', required: 'compareAndSwap' },
9
9
  });
10
10
  }
11
+ const capacity = checkedCapacity(store.capacity);
11
12
  const writeResult = z.discriminatedUnion('written', [
12
13
  z.strictObject({ written: z.literal(false) }),
13
14
  z.strictObject({ written: z.literal(true), revision: z.string().min(1) }),
@@ -34,6 +35,7 @@ export function sessionStateStore(store, gateTtlMs, operationRetentionMs, limits
34
35
  return { revision: snapshot?.revision ?? null, state };
35
36
  };
36
37
  return {
38
+ capacity,
37
39
  async read(sessionId) {
38
40
  return (await load(sessionId, Date.now())).state;
39
41
  },
@@ -48,7 +50,7 @@ export function sessionStateStore(store, gateTtlMs, operationRetentionMs, limits
48
50
  if (operation.status === 'settled')
49
51
  operation.retainUntil ??= operation.settledAt + operationRetentionMs;
50
52
  }
51
- checkSessionLimits(state, limits);
53
+ fitSession(state, capacity);
52
54
  const next = theoremSessionStateSchema.safeParse(state);
53
55
  if (!next.success) {
54
56
  throw new TheoremError('config', labelText('error.config'), {
@@ -70,9 +72,8 @@ export function sessionStateStore(store, gateTtlMs, operationRetentionMs, limits
70
72
  return result;
71
73
  }
72
74
  }
73
- throw new TheoremError('unavailable', labelText('error.unavailable'), {
74
- cause: { code: 'session_store_contention' },
75
- });
75
+ throw new TheoremError('unavailable', 'Other writes changed the session on each of 8 tries, so this change was not saved', // labels-exempt: internal diagnostic; the user reads session.busy
76
+ { copy: { key: 'session.busy' }, cause: { code: 'session_store_contention' } });
76
77
  },
77
78
  };
78
79
  }
@@ -1,7 +1,6 @@
1
1
  import { type PageToolExecution, type ProviderCheckpoint, type SessionRequest, type ToolExecutionCoordinator, type TurnHistoryMessage } from '@theoremjs/agents';
2
2
  import { type LiveOpenMessage } from '../client/live-messages.ts';
3
3
  import { type PageToolBroker, type PageToolResult } from './page-dispatch.ts';
4
- import { type TheoremSessionLimits } from './session-limits.ts';
5
4
  import { type TheoremSessionStore } from './session-store.ts';
6
5
  export interface SessionBoundaryOptions {
7
6
  request: Request;
@@ -9,7 +8,6 @@ export interface SessionBoundaryOptions {
9
8
  sessionStore: TheoremSessionStore;
10
9
  gateTtlMs?: number;
11
10
  operationRetentionMs?: number;
12
- sessionLimits?: Partial<TheoremSessionLimits>;
13
11
  pageToolBroker?: PageToolBroker;
14
12
  }
15
13
  export type PreparedLiveOpen = Pick<SessionRequest, 'history' | 'slots' | 'context' | 'providerState' | 'awayMs'>;
@@ -6,7 +6,6 @@ import { createMemoryPageToolBroker, sessionPageDispatch, } from "./page-dispatc
6
6
  import { checkRequest } from "./request-check.js";
7
7
  import { sessionStateStore } from "./session-authority.js";
8
8
  import { theoremSession } from "./session-identity.js";
9
- import { resolveSessionLimits } from "./session-limits.js";
10
9
  import { DEFAULT_OPERATION_RETENTION_MS } from "./session-store.js";
11
10
  import { sessionToolCoordinator } from "./tool-operations.js";
12
11
  import { liveSessionOpen } from "./turn-input.js";
@@ -20,8 +19,7 @@ export async function createTheoremSessionBoundary(options) {
20
19
  const retention = options.operationRetentionMs ?? DEFAULT_OPERATION_RETENTION_MS;
21
20
  if (!Number.isSafeInteger(retention) || retention <= 0)
22
21
  throw new TheoremError('config', labelText('error.config'));
23
- const limits = resolveSessionLimits(options.sessionLimits);
24
- const store = sessionStateStore(options.sessionStore, resolveGateTtlMs('session boundary', options.gateTtlMs), retention, limits);
22
+ const store = sessionStateStore(options.sessionStore, resolveGateTtlMs('session boundary', options.gateTtlMs), retention);
25
23
  // invariant: Refuse unavailable or malformed authority before an upstream connection opens.
26
24
  await store.read(id);
27
25
  const page = sessionPageDispatch(store, id, options.pageToolBroker ?? createMemoryPageToolBroker());
@@ -56,7 +54,9 @@ export async function createTheoremSessionBoundary(options) {
56
54
  async recordCheckpoint(checkpoint) {
57
55
  const identity = await checkpointIdentity(checkpoint);
58
56
  await store.mutate(id, async (state) => {
59
- state.providerCheckpoints = retainedCheckpointIdentities(state.providerCheckpoints, [await checkpointOwnershipKey(identity, state.operations)], limits.maxCheckpoints);
57
+ state.providerCheckpoints = retainedCheckpointIdentities(state.providerCheckpoints, [
58
+ await checkpointOwnershipKey(identity, state.operations),
59
+ ]);
60
60
  });
61
61
  },
62
62
  };
@@ -1,12 +1,19 @@
1
1
  import type { TheoremSessionState } from './session-store.ts';
2
- export interface TheoremSessionLimits {
3
- maxGates: number;
4
- maxOperations: number;
5
- maxCheckpoints: number;
6
- maxPermissions: number;
2
+ /** What a session store holds for one session, in bytes of UTF-8 JSON. The store declares it. */
3
+ export interface TheoremSessionCapacity {
4
+ /** The most one session's saved state may take. */
7
5
  maxStateBytes: number;
8
- maxInvocationBytes: number;
6
+ /** The most one tool result may take. Room for one is kept for each call that has not settled. */
9
7
  maxOutcomeBytes: number;
10
8
  }
11
- export declare function resolveSessionLimits(options?: Partial<TheoremSessionLimits>): TheoremSessionLimits;
12
- export declare function checkSessionLimits(state: TheoremSessionState, limits: TheoremSessionLimits): void;
9
+ /** The memory store's default: room for sixteen full-size tool results in each session. */
10
+ export declare const DEFAULT_SESSION_CAPACITY: TheoremSessionCapacity;
11
+ /** The store's declared capacity, refused as `config` unless both are positive and one result fits. */
12
+ export declare function checkedCapacity(capacity: unknown): TheoremSessionCapacity;
13
+ /**
14
+ * Fits the state in the store's capacity: drops the oldest settled results, then the oldest
15
+ * provider checkpoints, and refuses with `session.full` when open work alone does not fit.
16
+ *
17
+ * invariant: Gates, permissions and calls that have not settled are never dropped to make room.
18
+ */
19
+ export declare function fitSession(state: TheoremSessionState, capacity: TheoremSessionCapacity): void;
@@ -1,59 +1,57 @@
1
- import { labelText, TheoremError } from '@theoremjs/agents';
2
- const DEFAULTS = {
3
- maxGates: 32,
4
- maxOperations: 128,
5
- maxCheckpoints: 256,
6
- maxPermissions: 2048,
7
- maxStateBytes: 32 * 1024 * 1024,
8
- maxInvocationBytes: 256 * 1024,
9
- maxOutcomeBytes: 256 * 1024,
1
+ import { DEFAULT_MAX_OUTCOME_BYTES, labelText, TheoremError } from '@theoremjs/agents';
2
+ /** The memory store's default: room for sixteen full-size tool results in each session. */
3
+ export const DEFAULT_SESSION_CAPACITY = {
4
+ maxStateBytes: 16 * DEFAULT_MAX_OUTCOME_BYTES,
5
+ maxOutcomeBytes: DEFAULT_MAX_OUTCOME_BYTES,
10
6
  };
11
- export function resolveSessionLimits(options = {}) {
12
- const limits = { ...DEFAULTS, ...options };
13
- if (Object.keys(options).some((key) => !Object.hasOwn(DEFAULTS, key)) ||
14
- Object.values(limits).some((value) => !Number.isSafeInteger(value) || value <= 0)) {
7
+ /** The store's declared capacity, refused as `config` unless both are positive and one result fits. */
8
+ export function checkedCapacity(capacity) {
9
+ const { maxStateBytes, maxOutcomeBytes } = (capacity ?? {});
10
+ if (!Number.isSafeInteger(maxStateBytes) ||
11
+ !Number.isSafeInteger(maxOutcomeBytes) ||
12
+ maxOutcomeBytes <= 0 ||
13
+ maxStateBytes < maxOutcomeBytes) {
15
14
  throw new TheoremError('config', labelText('error.config'), {
16
- cause: { code: 'session_limits' },
15
+ cause: { code: 'session_store_capacity_declared' },
17
16
  });
18
17
  }
19
- return limits;
18
+ return { maxStateBytes: maxStateBytes, maxOutcomeBytes: maxOutcomeBytes };
20
19
  }
21
20
  function bytes(value) {
22
21
  return new TextEncoder().encode(JSON.stringify(value)).byteLength;
23
22
  }
24
- function capacity() {
25
- throw new TheoremError('unavailable', labelText('error.unavailable'), {
26
- cause: { code: 'session_state_capacity' },
27
- });
23
+ /** The bytes the state takes with room kept for the result of each gate and each call that has not settled. */
24
+ function needed(state, capacity) {
25
+ const open = Object.keys(state.gates).length +
26
+ Object.values(state.operations).filter((operation) => operation.status !== 'settled').length;
27
+ return bytes(state) + open * capacity.maxOutcomeBytes;
28
28
  }
29
- export function checkSessionLimits(state, limits) {
30
- const gates = Object.values(state.gates);
31
- const operations = Object.values(state.operations);
32
- if (gates.length > limits.maxGates ||
33
- operations.length > limits.maxOperations ||
34
- state.providerCheckpoints.length > limits.maxCheckpoints ||
35
- state.permissions.length > limits.maxPermissions)
36
- capacity();
37
- for (const gate of gates)
38
- if (bytes(gate) > limits.maxInvocationBytes)
39
- capacity();
40
- let reserved = gates.length * limits.maxOutcomeBytes;
41
- for (const operation of operations) {
42
- if (operation.status !== 'ready' &&
43
- operation.invocation &&
44
- bytes(operation.invocation) > limits.maxInvocationBytes)
45
- capacity();
46
- if (operation.status === 'settled') {
47
- if (bytes(operation.outcome) > limits.maxOutcomeBytes)
48
- capacity();
49
- }
50
- else {
51
- reserved += limits.maxOutcomeBytes;
52
- if (operation.status === 'ready' && bytes(operation) > limits.maxInvocationBytes)
53
- capacity();
54
- }
29
+ /**
30
+ * Fits the state in the store's capacity: drops the oldest settled results, then the oldest
31
+ * provider checkpoints, and refuses with `session.full` when open work alone does not fit.
32
+ *
33
+ * invariant: Gates, permissions and calls that have not settled are never dropped to make room.
34
+ */
35
+ export function fitSession(state, capacity) {
36
+ if (needed(state, capacity) <= capacity.maxStateBytes)
37
+ return;
38
+ const settled = Object.values(state.operations)
39
+ .flatMap((operation) => (operation.status === 'settled' ? [operation] : []))
40
+ .sort((a, b) => a.settledAt - b.settledAt);
41
+ for (const operation of settled) {
42
+ delete state.operations[operation.operationId];
43
+ if (needed(state, capacity) <= capacity.maxStateBytes)
44
+ return;
45
+ }
46
+ while (state.providerCheckpoints.length) {
47
+ state.providerCheckpoints.shift();
48
+ if (needed(state, capacity) <= capacity.maxStateBytes)
49
+ return;
55
50
  }
56
- // invariant: Reserve terminal evidence before dispatch; unresolved records are never evicted to make room.
57
- if (bytes(state) + reserved > limits.maxStateBytes)
58
- capacity();
51
+ const need = needed(state, capacity);
52
+ throw new TheoremError('unavailable', `The session's open work needs ${need} bytes with room for its results, more than the store's maxStateBytes of ${capacity.maxStateBytes}`, // labels-exempt: internal diagnostic; the user reads session.full
53
+ {
54
+ copy: { key: 'session.full' },
55
+ cause: { code: 'session_state_capacity', need, maxStateBytes: capacity.maxStateBytes },
56
+ });
59
57
  }
@@ -11,7 +11,7 @@
11
11
  import { z } from '@theoremjs/agents';
12
12
  import type { ToolGateAuth } from '@theoremjs/agents/interface';
13
13
  import type { AnsweredGate, ToolGate, ToolId, ToolOperationIdentity, ToolOperationOutcome, ToolOperationUncertainty, TurnInput, TurnToolSnapshot } from '@theoremjs/agents/kernel';
14
- import { type TheoremSessionLimits } from './session-limits.ts';
14
+ import { type TheoremSessionCapacity } from './session-limits.ts';
15
15
  /** A tool call the kernel paused on a gate, as the server saw it. */
16
16
  export type PendingToolGate = {
17
17
  profileId: string;
@@ -88,6 +88,8 @@ export type SessionWriteResult = {
88
88
  * store (KV, Redis, a database row) when requests can reach different instances.
89
89
  */
90
90
  export interface TheoremSessionStore {
91
+ /** What it holds for one session. The handler fits each write to it before `compareAndSwap`. */
92
+ readonly capacity: TheoremSessionCapacity;
91
93
  load(sessionId: string): Promise<SessionSnapshot | undefined>;
92
94
  /** Compare and replace atomically; null creates only an absent session. */
93
95
  compareAndSwap(sessionId: string, expectedRevision: string | null, next: TheoremSessionState): Promise<SessionWriteResult>;
@@ -99,8 +101,8 @@ export declare function pruneGates<Gate extends {
99
101
  expiresAt?: number;
100
102
  }>(gates: Record<string, Gate>, now: number, ttlMs: number): Record<string, Gate>;
101
103
  export type MemorySessionStoreOptions = {
102
- /** Finite authority storage bounds. */
103
- sessionLimits?: Partial<TheoremSessionLimits>;
104
+ /** What it holds for one session. Default {@linkcode DEFAULT_SESSION_CAPACITY}. */
105
+ capacity?: Partial<TheoremSessionCapacity>;
104
106
  /** invariant: Idle retention is in milliseconds. Protected authority remains. Default 24h. */
105
107
  ttlMs?: number;
106
108
  /** Oldest sessions are evicted past this count. Default 10 000. */
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { jsonObjectSchema, jsonValueSchema, labelText, savedToolInvocationSchema, TheoremError, TOOL_AUTH_TYPES, TOOL_GATE_KINDS, TOOL_PERMISSION, toolOperationOutcomeSchema, turnHistoryMessageSchema, turnToolSnapshotSchema, z, } from '@theoremjs/agents';
12
12
  import { gateExpired } from '@theoremjs/agents/kernel';
13
- import { checkSessionLimits, resolveSessionLimits, } from "./session-limits.js";
13
+ import { checkedCapacity, DEFAULT_SESSION_CAPACITY, } from "./session-limits.js";
14
14
  export const DEFAULT_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000;
15
15
  const blobSchema = z.strictObject({
16
16
  data: z.string(),
@@ -219,7 +219,7 @@ export function createMemorySessionMap(options = {}) {
219
219
  }
220
220
  export function createMemorySessionStore(options = {}) {
221
221
  const { ttlMs, maxSessions } = memoryStoreLimits(options);
222
- const limits = resolveSessionLimits(options.sessionLimits);
222
+ const capacity = checkedCapacity({ ...DEFAULT_SESSION_CAPACITY, ...options.capacity });
223
223
  const sessions = new Map();
224
224
  const held = (entry) => Object.values(entry.snapshot.state.gates).some((gate) => gate.expiresAt === undefined || Date.now() < gate.expiresAt) ||
225
225
  Object.values(entry.snapshot.state.operations).some((operation) => operation.status !== 'settled' ||
@@ -234,6 +234,7 @@ export function createMemorySessionStore(options = {}) {
234
234
  return entry?.snapshot;
235
235
  };
236
236
  return {
237
+ capacity,
237
238
  load(sessionId) {
238
239
  return Promise.resolve(structuredClone(current(sessionId)));
239
240
  },
@@ -242,13 +243,11 @@ export function createMemorySessionStore(options = {}) {
242
243
  if ((snapshot?.revision ?? null) !== expectedRevision)
243
244
  return Promise.resolve({ written: false });
244
245
  const state = theoremSessionStateSchema.parse(next);
245
- checkSessionLimits(state, limits);
246
246
  if (!snapshot && sessions.size >= maxSessions) {
247
247
  const removable = [...sessions].find(([, entry]) => !held(entry));
248
248
  if (!removable) {
249
- throw new TheoremError('unavailable', labelText('error.unavailable'), {
250
- cause: { code: 'session_store_capacity' },
251
- });
249
+ throw new TheoremError('unavailable', `The memory session store holds ${maxSessions} sessions and each has work in progress, so a new one was not saved`, // labels-exempt: internal diagnostic; the user reads session.busy
250
+ { copy: { key: 'session.busy' }, cause: { code: 'session_store_capacity' } });
252
251
  }
253
252
  sessions.delete(removable[0]);
254
253
  }
@@ -1,6 +1,9 @@
1
1
  import { type ToolExecutionCoordinator } from '@theoremjs/agents';
2
2
  import type { TheoremSessionState } from './session-store.ts';
3
3
  export declare function sessionToolCoordinator(store: {
4
+ readonly capacity: {
5
+ maxOutcomeBytes: number;
6
+ };
4
7
  mutate<T>(id: string, change: (state: TheoremSessionState) => T): Promise<T>;
5
8
  }, sessionId: string): ToolExecutionCoordinator;
6
9
  export declare function sameAcceptedAnswer(left: unknown, right: unknown): boolean;
@@ -1,6 +1,7 @@
1
- import { labelText, TheoremError, } from '@theoremjs/agents';
2
- function unavailable() {
3
- throw new TheoremError('unavailable', labelText('error.unavailable'));
1
+ import { TheoremError, } from '@theoremjs/agents';
2
+ /** The session's record of a tool operation does not match the call: the user reads `tool.execution_uncertain`. */
3
+ function unavailable(reason) {
4
+ throw new TheoremError('unavailable', reason, { copy: { key: 'tool.execution_uncertain' } });
4
5
  }
5
6
  function sameOperation(record, identity) {
6
7
  return (record.operationId === identity.operationId &&
@@ -10,8 +11,9 @@ function sameOperation(record, identity) {
10
11
  record.invocationHash === identity.invocationHash);
11
12
  }
12
13
  function checkedRecord(state, identity) {
14
+ // labels-exempt: internal diagnostic; the user reads tool.execution_uncertain
13
15
  if (identity.operationId === '__proto__')
14
- unavailable();
16
+ unavailable('The tool operation id is __proto__');
15
17
  const record = Object.hasOwn(state.operations, identity.operationId)
16
18
  ? state.operations[identity.operationId]
17
19
  : undefined;
@@ -20,10 +22,12 @@ function checkedRecord(state, identity) {
20
22
  record.pending.profileId !== identity.profileId ||
21
23
  record.pending.name !== identity.toolId ||
22
24
  record.profileId !== identity.profileId)
23
- unavailable();
25
+ // labels-exempt: internal diagnostic; the user reads tool.execution_uncertain
26
+ unavailable(`The ready tool operation ${identity.operationId} is for another call`);
24
27
  }
25
28
  else if (record && !sameOperation(record, identity))
26
- unavailable();
29
+ // labels-exempt: internal diagnostic; the user reads tool.execution_uncertain
30
+ unavailable(`The session holds tool operation ${identity.operationId} for another call`);
27
31
  return record;
28
32
  }
29
33
  function checkedClaim(state, claim) {
@@ -31,7 +35,9 @@ function checkedClaim(state, claim) {
31
35
  if (!record ||
32
36
  (record.status !== 'running' && record.status !== 'uncertain') ||
33
37
  record.claimToken !== claim.claimToken)
34
- unavailable();
38
+ unavailable(
39
+ // labels-exempt: internal diagnostic; the user reads tool.execution_uncertain
40
+ `The claim on tool operation ${claim.operationId} is not the session's running claim`);
35
41
  return record;
36
42
  }
37
43
  function existingResult(record) {
@@ -63,6 +69,7 @@ function saveOutcome(state, identity, outcome) {
63
69
  }
64
70
  export function sessionToolCoordinator(store, sessionId) {
65
71
  return {
72
+ maxOutcomeBytes: store.capacity.maxOutcomeBytes,
66
73
  lookup(identity) {
67
74
  return store.mutate(sessionId, (state) => {
68
75
  const record = checkedRecord(state, identity);
@@ -96,9 +103,13 @@ export function sessionToolCoordinator(store, sessionId) {
96
103
  return store.mutate(sessionId, (state) => {
97
104
  const record = checkedRecord(state, identity);
98
105
  if (record && record.status !== 'ready')
99
- unavailable();
106
+ unavailable(
107
+ // labels-exempt: internal diagnostic; the user reads tool.execution_uncertain
108
+ `Tool operation ${identity.operationId} is ${record.status}, so it cannot settle as not started`);
100
109
  if (outcome.effects !== 'not_started')
101
- unavailable();
110
+ unavailable(
111
+ // labels-exempt: internal diagnostic; the user reads tool.execution_uncertain
112
+ `A not-started settlement of ${identity.operationId} has effects ${outcome.effects}`);
102
113
  saveOutcome(state, identity, outcome);
103
114
  });
104
115
  },