@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 +13 -15
- package/package.json +2 -2
- package/server/checkpoint-ownership.d.ts +1 -1
- package/server/checkpoint-ownership.js +2 -2
- package/server/handler.d.ts +7 -4
- package/server/handler.js +12 -8
- package/server/mod.d.ts +2 -2
- package/server/mod.js +1 -1
- package/server/session-authority.d.ts +6 -2
- package/server/session-authority.js +7 -6
- package/server/session-boundary.d.ts +0 -2
- package/server/session-boundary.js +4 -4
- package/server/session-limits.d.ts +15 -8
- package/server/session-limits.js +46 -48
- package/server/session-store.d.ts +5 -3
- package/server/session-store.js +5 -6
- package/server/tool-operations.d.ts +3 -0
- package/server/tool-operations.js +20 -9
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
|
-
|
|
382
|
+
A session store declares its `capacity`: what it holds for one session, in bytes of UTF-8 JSON.
|
|
382
383
|
|
|
383
|
-
|
|
|
384
|
-
| --- | --- |
|
|
385
|
-
| `
|
|
386
|
-
| `
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
The
|
|
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.
|
|
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.
|
|
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[]
|
|
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
|
|
41
|
-
return [...new Set([...existing, ...added].reverse())].
|
|
40
|
+
export function retainedCheckpointIdentities(existing, added) {
|
|
41
|
+
return [...new Set([...existing, ...added].reverse())].reverse();
|
|
42
42
|
}
|
package/server/handler.d.ts
CHANGED
|
@@ -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',
|
|
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 }, {
|
|
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
|
-
...(
|
|
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(
|
|
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 {
|
|
23
|
-
export {
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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',
|
|
74
|
-
|
|
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
|
|
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, [
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
export declare
|
|
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;
|
package/server/session-limits.js
CHANGED
|
@@ -1,59 +1,57 @@
|
|
|
1
|
-
import { labelText, TheoremError } from '@theoremjs/agents';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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: '
|
|
15
|
+
cause: { code: 'session_store_capacity_declared' },
|
|
17
16
|
});
|
|
18
17
|
}
|
|
19
|
-
return
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
for (const operation of
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
/**
|
|
103
|
-
|
|
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. */
|
package/server/session-store.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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',
|
|
250
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|