@theoremjs/web 0.3.4-next.602.gb5a7fab → 0.3.4-next.606.g8936f75
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 +27 -11
- package/package.json +2 -2
- package/server/handler.d.ts +7 -2
- package/server/handler.js +6 -5
- package/server/mcp-handler.d.ts +1 -1
- package/server/mod.d.ts +0 -1
- package/server/mod.js +0 -1
- package/server/page-dispatch.d.ts +2 -0
- package/server/page-dispatch.js +13 -8
- package/server/request-body.d.ts +3 -2
- package/server/request-body.js +7 -4
- package/server/request-check.d.ts +5 -4
- package/server/request-check.js +11 -9
- package/server/session-authority.d.ts +2 -2
- package/server/session-authority.js +9 -6
- package/server/session-boundary.d.ts +2 -0
- package/server/session-boundary.js +1 -1
- package/server/session-limits.d.ts +6 -5
- package/server/session-limits.js +7 -7
- package/server/session-store.d.ts +10 -7
- package/server/session-store.js +5 -5
- package/server/tool-operations.d.ts +1 -1
- package/server/tool-operations.js +1 -1
package/README.md
CHANGED
|
@@ -243,10 +243,26 @@ const transport = withPageToolDispatch(
|
|
|
243
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`. |
|
|
244
244
|
| `credentialStore` | Where tool credentials are kept, by session. Default: process memory. |
|
|
245
245
|
| `authorizationUrl` | Returns the sign-in URL for an OAuth gate. Without it, an OAuth gate has no URL. |
|
|
246
|
-
| `gateTtlMs` | How long a gate waits for an answer.
|
|
246
|
+
| `gateTtlMs` | How long a gate waits for an answer. A later answer fails with `session.gate_expired`. Unset, a gate waits until it is answered. |
|
|
247
|
+
| `maxRequestBytes` | The largest browser request body, in bytes. Unset, a body of any size is read. |
|
|
248
|
+
| `pageToolTimeoutMs` | How long the page has to answer a tool call it runs. A call it has not answered by then settles as timed out. Unset, the call waits until the page answers or the turn stops. |
|
|
249
|
+
| `operationRetentionMs` | How long a settled tool result is kept. Default: 24 hours. |
|
|
247
250
|
| `clientEvents` | Options for `forClient` and `clientError`, which prepare each event and each caught failure for the browser. `reader: 'builder'` sends the raw reason in place of the wording of the profile. Use it only in a development tool on your own machine. |
|
|
248
251
|
| `onError` | Receives each error that the handler catches. A visitor sees the wording of the profile for that kind of error, never the error. |
|
|
249
252
|
|
|
253
|
+
### Defaults
|
|
254
|
+
|
|
255
|
+
A limit that stops a turn or refuses a request is unset unless you set it: `gateTtlMs`, `maxRequestBytes`, `pageToolTimeoutMs`, and a session store's `capacity`. Only what keeps stored data from growing without end has a default:
|
|
256
|
+
|
|
257
|
+
| What | Default | Set it with |
|
|
258
|
+
| --- | --- | --- |
|
|
259
|
+
| A settled tool result | Kept 24 hours | `operationRetentionMs` |
|
|
260
|
+
| A session in `createMemorySessionStore` | Dropped after 24 idle hours; at most 10,000 kept | `ttlMs`, `maxSessions` |
|
|
261
|
+
| A conversation in `createMemoryConversationStore` | Dropped after 24 hours unsaved; at most 10,000 kept | `ttlMs`, `maxSessions` |
|
|
262
|
+
| The session cookie | Lasts 30 days | `session` |
|
|
263
|
+
|
|
264
|
+
A model profile that leaves out `maxSteps` makes at most 20 model calls a turn.
|
|
265
|
+
|
|
250
266
|
### Start a conversation with content
|
|
251
267
|
|
|
252
268
|
`startConversation` stores a conversation that opens on blocks you wrote, and returns its ref and its view. The model reads the blocks as history, so the user's next message is a follow-up.
|
|
@@ -379,23 +395,23 @@ Each saved outcome records its absolute retention deadline. Changing a handler's
|
|
|
379
395
|
Each issued gate records its absolute expiry. Changing `gateTtlMs` does not extend an issued gate.
|
|
380
396
|
Ready, running, and uncertain operations do not expire.
|
|
381
397
|
|
|
382
|
-
A session store declares its `capacity`: what it holds for one session, in bytes of UTF-8 JSON.
|
|
398
|
+
A session store with a real limit declares its `capacity`: what it holds for one session, in bytes of UTF-8 JSON.
|
|
383
399
|
|
|
384
|
-
| Field | What it bounds |
|
|
385
|
-
| --- | --- |
|
|
386
|
-
| `maxStateBytes` | One session's saved state |
|
|
387
|
-
| `maxOutcomeBytes` | One tool result |
|
|
400
|
+
| Field | What it bounds |
|
|
401
|
+
| --- | --- |
|
|
402
|
+
| `maxStateBytes` | One session's saved state |
|
|
403
|
+
| `maxOutcomeBytes` | One tool result |
|
|
388
404
|
|
|
389
|
-
|
|
405
|
+
A store that declares no `capacity` holds any size. `createMemorySessionStore` declares none unless you pass `capacity`, with both fields. A store you write declares the capacity of its own storage, if it has one.
|
|
390
406
|
|
|
391
|
-
Set `maxRequestBytes` to bound browser JSON before parsing.
|
|
407
|
+
Set `maxRequestBytes` to bound browser JSON before parsing. Unset, a body of any size is read. A relay passes the same number to `parseLiveOpenMessage` and `parseLiveClientMessage`.
|
|
392
408
|
The handler rejects oversized requests before provider access or tool preparation.
|
|
393
409
|
Provider host options also accept `limits` for deadlines and upstream output.
|
|
394
|
-
|
|
410
|
+
When the store declares a capacity, the handler keeps room for one `maxOutcomeBytes` result for each gate and each call that has not settled.
|
|
395
411
|
When a session is full, the handler drops its oldest settled results, then its oldest provider checkpoints.
|
|
396
412
|
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
413
|
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`.
|
|
414
|
+
The handler fits each state to the store's capacity, when it has one, before `compareAndSwap`.
|
|
399
415
|
|
|
400
416
|
The default memory store coordinates handlers that share the same store object.
|
|
401
417
|
It retains unexpired gates, unresolved operations, and unexpired outcomes during idle and capacity eviction.
|
|
@@ -413,7 +429,7 @@ Forward live page results to `answerPageResult`. Preserve checkpoint ownership t
|
|
|
413
429
|
The default `PageToolBroker` keeps active result waiters in process memory. Several workers need a shared broker or request affinity.
|
|
414
430
|
Use `pageToolBroker` to supply that broker. It carries results; the atomic session store retains execution authority.
|
|
415
431
|
Lost result delivery does not permit another dispatch. The operation retains its claim or an uncertain outcome.
|
|
416
|
-
|
|
432
|
+
Set `pageToolTimeoutMs` to give the page a deadline for a result. Unset, a dispatch waits until the page answers or the turn stops. An expired or interrupted result cannot reopen approval.
|
|
417
433
|
|
|
418
434
|
## Build your own server
|
|
419
435
|
|
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.606.g8936f75",
|
|
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.606.g8936f75"
|
|
29
29
|
}
|
|
30
30
|
}
|
package/server/handler.d.ts
CHANGED
|
@@ -97,14 +97,19 @@ export type TheoremHandlerOptions = {
|
|
|
97
97
|
/**
|
|
98
98
|
* How long a gate (permission, confirmation, sign-in) waits for its answer, in
|
|
99
99
|
* milliseconds. An answer after that is refused with `session.gate_expired`.
|
|
100
|
-
*
|
|
100
|
+
* Unset, a gate waits until it is answered.
|
|
101
101
|
*/
|
|
102
102
|
gateTtlMs?: number;
|
|
103
103
|
/** Retain settled tool results in milliseconds. Default: 24 hours. */
|
|
104
104
|
operationRetentionMs?: number;
|
|
105
|
-
/** Maximum UTF-8 request body bytes before JSON parsing.
|
|
105
|
+
/** Maximum UTF-8 request body bytes before JSON parsing. Unset, a body of any size is read. */
|
|
106
106
|
maxRequestBytes?: number;
|
|
107
107
|
pageToolBroker?: PageToolBroker;
|
|
108
|
+
/**
|
|
109
|
+
* How long the page has to answer a tool call it runs, in milliseconds. A call it has not
|
|
110
|
+
* answered by then settles as timed out. Unset, it waits until the page answers or the turn stops.
|
|
111
|
+
*/
|
|
112
|
+
pageToolTimeoutMs?: number;
|
|
108
113
|
/** Check current resource access before disclosing a saved tool result. This callback must be read-only. */
|
|
109
114
|
authorizeToolReplay?: (context: {
|
|
110
115
|
request: Request;
|
package/server/handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { withPublicLabels } from '@theoremjs/agents/guardrails';
|
|
2
|
-
import { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
|
|
2
|
+
import { checkPageToolTimeout, createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
|
|
3
3
|
/**
|
|
4
4
|
* One Web-standard handler that serves a Theorem chat UI for a single profile.
|
|
5
5
|
*
|
|
@@ -368,7 +368,7 @@ async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answe
|
|
|
368
368
|
yield* invokeTool({
|
|
369
369
|
profile: ctx.profile.id,
|
|
370
370
|
execution: sessionToolCoordinator(ctx.sessions, sessionId),
|
|
371
|
-
pageExecution: sessionPageDispatch(ctx.sessions, sessionId, ctx.pageToolBroker).execution,
|
|
371
|
+
pageExecution: sessionPageDispatch(ctx.sessions, sessionId, ctx.pageToolBroker, ctx.options.pageToolTimeoutMs).execution,
|
|
372
372
|
name: pending.name,
|
|
373
373
|
callId,
|
|
374
374
|
operationId: pending.operationId,
|
|
@@ -407,7 +407,7 @@ async function* chatTurn(ctx, request, session, turn, key, stop) {
|
|
|
407
407
|
const prepared = await preparedTurn(ctx.options.prepare, {
|
|
408
408
|
profile: ctx.profile.id,
|
|
409
409
|
execution: sessionToolCoordinator(ctx.sessions, session.id),
|
|
410
|
-
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
|
|
410
|
+
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker, ctx.options.pageToolTimeoutMs).execution,
|
|
411
411
|
input: turn.input,
|
|
412
412
|
...(turn.providerState ? { providerState: turn.providerState } : {}),
|
|
413
413
|
sessionPermissions: state.permissions,
|
|
@@ -574,7 +574,7 @@ async function* callEvents(ctx, request, session, body) {
|
|
|
574
574
|
const events = invokeTool({
|
|
575
575
|
profile: ctx.profile.id,
|
|
576
576
|
execution: sessionToolCoordinator(ctx.sessions, session.id),
|
|
577
|
-
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
|
|
577
|
+
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker, ctx.options.pageToolTimeoutMs).execution,
|
|
578
578
|
name: body.name,
|
|
579
579
|
input: body.input,
|
|
580
580
|
sessionPermissions: state.permissions,
|
|
@@ -691,7 +691,7 @@ async function hostRoute(ctx, request, session) {
|
|
|
691
691
|
async function route(ctx, request, session) {
|
|
692
692
|
if (request.method === 'POST' && routeOf(request) === 'page-result') {
|
|
693
693
|
const body = await readBody(request, pageToolResultSchema, ctx.options.maxRequestBytes);
|
|
694
|
-
await sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).answer(body);
|
|
694
|
+
await sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker, ctx.options.pageToolTimeoutMs).answer(body);
|
|
695
695
|
return jsonResponse(200, { accepted: true });
|
|
696
696
|
}
|
|
697
697
|
if (ctx.profile.type === 'host')
|
|
@@ -779,6 +779,7 @@ function serve(name, profile, options, chatOptions = {}) {
|
|
|
779
779
|
});
|
|
780
780
|
}
|
|
781
781
|
requestByteLimit(options.maxRequestBytes);
|
|
782
|
+
checkPageToolTimeout(options.pageToolTimeoutMs);
|
|
782
783
|
registerProfile(profile);
|
|
783
784
|
const ctx = {
|
|
784
785
|
options,
|
package/server/mcp-handler.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type TheoremMcpHandlerOptions = {
|
|
|
30
30
|
origins?: readonly string[];
|
|
31
31
|
/** Handed to each tool as `ctx.host`. */
|
|
32
32
|
host?: (request: Request) => unknown;
|
|
33
|
-
/** Largest request body read, in bytes. */
|
|
33
|
+
/** Largest request body read, in bytes. Unset, a body of any size is read. */
|
|
34
34
|
maxRequestBytes?: number;
|
|
35
35
|
/** Who reads this host's replies: forwarded to `clientError` for a failure the handler catches. */
|
|
36
36
|
clientEvents?: ClientTurnOptions;
|
package/server/mod.d.ts
CHANGED
|
@@ -20,7 +20,6 @@ export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from './re
|
|
|
20
20
|
export { sessionStateStore } from './session-authority.ts';
|
|
21
21
|
export { createTheoremSessionBoundary, type PreparedLiveOpen, type SessionBoundaryOptions, type TheoremSessionBoundary, } from './session-boundary.ts';
|
|
22
22
|
export type { TheoremSessionCapacity } from './session-limits.ts';
|
|
23
|
-
export { DEFAULT_SESSION_CAPACITY } from './session-limits.ts';
|
|
24
23
|
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
24
|
export { createMemorySteerInbox, type SteerInbox, type SteerUnit, steerStage, steerUnitOf, stopOnRequest, } from './steer-inbox.ts';
|
|
26
25
|
export { sessionToolCoordinator } from './tool-operations.ts';
|
package/server/mod.js
CHANGED
|
@@ -18,7 +18,6 @@ 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 { DEFAULT_SESSION_CAPACITY } from "./session-limits.js";
|
|
22
21
|
export { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, theoremSessionStateSchema, } from "./session-store.js";
|
|
23
22
|
export { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
|
|
24
23
|
export { sessionToolCoordinator } from "./tool-operations.js";
|
|
@@ -16,6 +16,8 @@ export interface PageToolBroker {
|
|
|
16
16
|
deliver(sessionId: string, grantId: string, page: PageAnswer): Promise<void>;
|
|
17
17
|
}
|
|
18
18
|
export declare function createMemoryPageToolBroker(maxPending?: number): PageToolBroker;
|
|
19
|
+
/** Refuses a page tool timeout that is not a whole number of milliseconds a timer can hold. */
|
|
20
|
+
export declare function checkPageToolTimeout(timeoutMs: number | undefined): void;
|
|
19
21
|
export declare function sessionPageDispatch(store: MutableStore<TheoremSessionState>, sessionId: string, broker: PageToolBroker, timeoutMs?: number): {
|
|
20
22
|
execution: PageToolExecution;
|
|
21
23
|
answer(result: PageToolResult): Promise<boolean>;
|
package/server/page-dispatch.js
CHANGED
|
@@ -54,16 +54,21 @@ export function createMemoryPageToolBroker(maxPending = 128) {
|
|
|
54
54
|
},
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
/** Refuses a page tool timeout that is not a whole number of milliseconds a timer can hold. */
|
|
58
|
+
export function checkPageToolTimeout(timeoutMs) {
|
|
59
|
+
if (timeoutMs !== undefined &&
|
|
60
|
+
(!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647))
|
|
59
61
|
throw new TheoremError('config', labelText('error.config'));
|
|
62
|
+
}
|
|
63
|
+
export function sessionPageDispatch(store, sessionId, broker, timeoutMs) {
|
|
64
|
+
checkPageToolTimeout(timeoutMs);
|
|
60
65
|
return {
|
|
61
66
|
execution: {
|
|
62
67
|
async dispatch(claim, signal) {
|
|
63
68
|
if (!claim.invocation)
|
|
64
69
|
refused('internal', 'The page dispatch got a claim with no invocation'); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
65
70
|
const grantId = crypto.randomUUID();
|
|
66
|
-
const expiresAt = Date.now() + timeoutMs;
|
|
71
|
+
const expiresAt = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
|
|
67
72
|
const pending = broker.reserve(sessionId, grantId, signal);
|
|
68
73
|
try {
|
|
69
74
|
await store.mutate(sessionId, (state) => {
|
|
@@ -73,15 +78,15 @@ export function sessionPageDispatch(store, sessionId, broker, timeoutMs = 20_000
|
|
|
73
78
|
record.invocationHash !== claim.invocationHash ||
|
|
74
79
|
record.pageGrant)
|
|
75
80
|
refused('internal', 'The session store holds no running claim for this page dispatch');
|
|
76
|
-
record.pageGrant = { grantId, expiresAt };
|
|
81
|
+
record.pageGrant = { grantId, ...(expiresAt === undefined ? {} : { expiresAt }) };
|
|
77
82
|
});
|
|
78
83
|
}
|
|
79
84
|
catch (error) {
|
|
80
85
|
pending.cancel();
|
|
81
86
|
throw error;
|
|
82
87
|
}
|
|
83
|
-
const remaining = expiresAt - Date.now();
|
|
84
|
-
if (remaining <= 0 || signal?.aborted) {
|
|
88
|
+
const remaining = expiresAt === undefined ? undefined : expiresAt - Date.now();
|
|
89
|
+
if ((remaining !== undefined && remaining <= 0) || signal?.aborted) {
|
|
85
90
|
pending.cancel();
|
|
86
91
|
if (signal?.aborted)
|
|
87
92
|
refused('cancelled', 'The page dispatch was stopped before it was sent'); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
@@ -91,7 +96,7 @@ export function sessionPageDispatch(store, sessionId, broker, timeoutMs = 20_000
|
|
|
91
96
|
const expired = new Promise((resolve) => {
|
|
92
97
|
expire = () => resolve({ timedOut: true });
|
|
93
98
|
});
|
|
94
|
-
const timer = setTimeout(expire, remaining);
|
|
99
|
+
const timer = remaining === undefined ? undefined : setTimeout(expire, remaining);
|
|
95
100
|
signal?.addEventListener('abort', expire, { once: true });
|
|
96
101
|
return {
|
|
97
102
|
grantId,
|
|
@@ -122,7 +127,7 @@ export function sessionPageDispatch(store, sessionId, broker, timeoutMs = 20_000
|
|
|
122
127
|
}
|
|
123
128
|
if (record.status !== 'running')
|
|
124
129
|
refused('request', `The page answered a call that is ${record.status}, not running`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
125
|
-
if (Date.now() >= record.pageGrant.expiresAt)
|
|
130
|
+
if (record.pageGrant.expiresAt !== undefined && Date.now() >= record.pageGrant.expiresAt)
|
|
126
131
|
refused('timeout', 'The page answered after its grant expired'); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
127
132
|
record.pageGrant.resultHash = hash;
|
|
128
133
|
return { duplicate: false, settled: false };
|
package/server/request-body.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function
|
|
1
|
+
/** The host's request byte limit, checked. Unset, a request of any size is read. */
|
|
2
|
+
export declare function requestByteLimit(bytes: number | undefined): number | undefined;
|
|
3
|
+
export declare function boundedRequestJson(request: Request, maxBytes: number | undefined): Promise<unknown>;
|
package/server/request-body.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { labelText, TheoremError, throwIfAborted } from '@theoremjs/agents';
|
|
2
|
-
|
|
3
|
-
export function requestByteLimit(bytes
|
|
2
|
+
/** The host's request byte limit, checked. Unset, a request of any size is read. */
|
|
3
|
+
export function requestByteLimit(bytes) {
|
|
4
|
+
if (bytes === undefined)
|
|
5
|
+
return undefined;
|
|
4
6
|
if (!Number.isSafeInteger(bytes) || bytes <= 0) {
|
|
5
7
|
throw new TheoremError('config', labelText('error.config'));
|
|
6
8
|
}
|
|
7
9
|
return bytes;
|
|
8
10
|
}
|
|
9
11
|
export async function boundedRequestJson(request, maxBytes) {
|
|
10
|
-
if (Number(request.headers.get('content-length')) > maxBytes ||
|
|
12
|
+
if ((maxBytes !== undefined && Number(request.headers.get('content-length')) > maxBytes) ||
|
|
13
|
+
!request.body) {
|
|
11
14
|
throw new TheoremError('request', labelText('error.request'));
|
|
12
15
|
}
|
|
13
16
|
const reader = request.body.getReader();
|
|
@@ -26,7 +29,7 @@ export async function boundedRequestJson(request, maxBytes) {
|
|
|
26
29
|
if (next.done)
|
|
27
30
|
break;
|
|
28
31
|
bytes += next.value.byteLength;
|
|
29
|
-
if (bytes > maxBytes)
|
|
32
|
+
if (maxBytes !== undefined && bytes > maxBytes)
|
|
30
33
|
throw new TheoremError('request', labelText('error.request'));
|
|
31
34
|
if (next.value.byteLength)
|
|
32
35
|
text.push(decoder.decode(next.value, { stream: true }));
|
|
@@ -11,11 +11,12 @@ import { type LiveClientMessage, type LiveOpenMessage } from '../client/live-mes
|
|
|
11
11
|
export declare function checkRequest<T>(schema: z.ZodType<T>, raw: unknown, what: string): T;
|
|
12
12
|
/**
|
|
13
13
|
* One text frame from the live client, as a relay reads it: JSON that passes
|
|
14
|
-
* `liveClientMessageSchema`, else a `request` error.
|
|
14
|
+
* `liveClientMessageSchema`, else a `request` error. A frame over `maxBytes`, when set, is refused.
|
|
15
15
|
*/
|
|
16
|
-
export declare function parseLiveClientMessage(text: string): LiveClientMessage;
|
|
16
|
+
export declare function parseLiveClientMessage(text: string, maxBytes?: number): LiveClientMessage;
|
|
17
17
|
/**
|
|
18
18
|
* The live client's first frame, as a relay reads it before it opens the
|
|
19
|
-
* session: JSON that passes `liveOpenMessageSchema`, else a `request` error.
|
|
19
|
+
* session: JSON that passes `liveOpenMessageSchema`, else a `request` error. A frame over
|
|
20
|
+
* `maxBytes`, when set, is refused.
|
|
20
21
|
*/
|
|
21
|
-
export declare function parseLiveOpenMessage(text: string): LiveOpenMessage;
|
|
22
|
+
export declare function parseLiveOpenMessage(text: string, maxBytes?: number): LiveOpenMessage;
|
package/server/request-check.js
CHANGED
|
@@ -18,9 +18,10 @@ export function checkRequest(schema, raw, what) {
|
|
|
18
18
|
}
|
|
19
19
|
return parsed.data;
|
|
20
20
|
}
|
|
21
|
-
function liveFrame(text) {
|
|
22
|
-
const limit = requestByteLimit();
|
|
23
|
-
if (
|
|
21
|
+
function liveFrame(text, maxBytes) {
|
|
22
|
+
const limit = requestByteLimit(maxBytes);
|
|
23
|
+
if (limit !== undefined &&
|
|
24
|
+
(text.length > limit || new TextEncoder().encode(text).byteLength > limit))
|
|
24
25
|
throw new TheoremError('request', labelText('error.request'));
|
|
25
26
|
try {
|
|
26
27
|
return JSON.parse(text);
|
|
@@ -34,16 +35,17 @@ function liveFrame(text) {
|
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
37
|
* One text frame from the live client, as a relay reads it: JSON that passes
|
|
37
|
-
* `liveClientMessageSchema`, else a `request` error.
|
|
38
|
+
* `liveClientMessageSchema`, else a `request` error. A frame over `maxBytes`, when set, is refused.
|
|
38
39
|
*/
|
|
39
|
-
export function parseLiveClientMessage(text) {
|
|
40
|
-
return checkRequest(liveClientMessageSchema, liveFrame(text), 'live message');
|
|
40
|
+
export function parseLiveClientMessage(text, maxBytes) {
|
|
41
|
+
return checkRequest(liveClientMessageSchema, liveFrame(text, maxBytes), 'live message');
|
|
41
42
|
}
|
|
42
43
|
/**
|
|
43
44
|
* The live client's first frame, as a relay reads it before it opens the
|
|
44
|
-
* session: JSON that passes `liveOpenMessageSchema`, else a `request` error.
|
|
45
|
+
* session: JSON that passes `liveOpenMessageSchema`, else a `request` error. A frame over
|
|
46
|
+
* `maxBytes`, when set, is refused.
|
|
45
47
|
*/
|
|
46
|
-
export function parseLiveOpenMessage(text) {
|
|
48
|
+
export function parseLiveOpenMessage(text, maxBytes) {
|
|
47
49
|
// labels-exempt: internal diagnostic; the user reads the error kind's wording
|
|
48
|
-
return checkRequest(liveOpenMessageSchema, liveFrame(text), 'live open message');
|
|
50
|
+
return checkRequest(liveOpenMessageSchema, liveFrame(text, maxBytes), 'live open message');
|
|
49
51
|
}
|
|
@@ -6,7 +6,7 @@ export type MutableStore<T> = {
|
|
|
6
6
|
};
|
|
7
7
|
/** Session state with the capacity its store declared. */
|
|
8
8
|
export type SessionStateStore = MutableStore<TheoremSessionState> & {
|
|
9
|
-
readonly capacity
|
|
9
|
+
readonly capacity?: TheoremSessionCapacity;
|
|
10
10
|
};
|
|
11
11
|
/** Session state, with expired gates dropped as it loads so they can't be answered. */
|
|
12
|
-
export declare function sessionStateStore(store: TheoremSessionStore, gateTtlMs: number, operationRetentionMs: number): SessionStateStore;
|
|
12
|
+
export declare function sessionStateStore(store: TheoremSessionStore, gateTtlMs: number | undefined, operationRetentionMs: number): SessionStateStore;
|
|
@@ -23,8 +23,9 @@ export function sessionStateStore(store, gateTtlMs, operationRetentionMs) {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
const state = structuredClone(parsed.data);
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
if (gateTtlMs !== undefined)
|
|
27
|
+
for (const gate of Object.values(state.gates))
|
|
28
|
+
gate.expiresAt ??= gate.createdAt + gateTtlMs;
|
|
28
29
|
state.gates = pruneGates(state.gates, now, gateTtlMs);
|
|
29
30
|
for (const operation of Object.values(state.operations)) {
|
|
30
31
|
if (operation.status === 'ready' && operation.answer.resume.granted === true)
|
|
@@ -35,7 +36,7 @@ export function sessionStateStore(store, gateTtlMs, operationRetentionMs) {
|
|
|
35
36
|
return { revision: snapshot?.revision ?? null, state };
|
|
36
37
|
};
|
|
37
38
|
return {
|
|
38
|
-
capacity,
|
|
39
|
+
...(capacity ? { capacity } : {}),
|
|
39
40
|
async read(sessionId) {
|
|
40
41
|
return (await load(sessionId, Date.now())).state;
|
|
41
42
|
},
|
|
@@ -44,13 +45,15 @@ export function sessionStateStore(store, gateTtlMs, operationRetentionMs) {
|
|
|
44
45
|
for (let conflict = 0; conflict < 8; conflict++) {
|
|
45
46
|
const { revision, state } = await load(sessionId, now);
|
|
46
47
|
const result = await change(state);
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
if (gateTtlMs !== undefined)
|
|
49
|
+
for (const gate of Object.values(state.gates))
|
|
50
|
+
gate.expiresAt ??= gate.createdAt + gateTtlMs;
|
|
49
51
|
for (const operation of Object.values(state.operations)) {
|
|
50
52
|
if (operation.status === 'settled')
|
|
51
53
|
operation.retainUntil ??= operation.settledAt + operationRetentionMs;
|
|
52
54
|
}
|
|
53
|
-
|
|
55
|
+
if (capacity)
|
|
56
|
+
fitSession(state, capacity);
|
|
54
57
|
const next = theoremSessionStateSchema.safeParse(state);
|
|
55
58
|
if (!next.success) {
|
|
56
59
|
throw new TheoremError('config', labelText('error.config'), {
|
|
@@ -9,6 +9,8 @@ export interface SessionBoundaryOptions {
|
|
|
9
9
|
gateTtlMs?: number;
|
|
10
10
|
operationRetentionMs?: number;
|
|
11
11
|
pageToolBroker?: PageToolBroker;
|
|
12
|
+
/** How long the page has to answer a tool call it runs, in milliseconds. Unset, it waits. */
|
|
13
|
+
pageToolTimeoutMs?: number;
|
|
12
14
|
}
|
|
13
15
|
export type PreparedLiveOpen = Pick<SessionRequest, 'history' | 'slots' | 'context' | 'providerState' | 'awayMs'>;
|
|
14
16
|
export interface TheoremSessionBoundary {
|
|
@@ -22,7 +22,7 @@ export async function createTheoremSessionBoundary(options) {
|
|
|
22
22
|
const store = sessionStateStore(options.sessionStore, resolveGateTtlMs('session boundary', options.gateTtlMs), retention);
|
|
23
23
|
// invariant: Refuse unavailable or malformed authority before an upstream connection opens.
|
|
24
24
|
await store.read(id);
|
|
25
|
-
const page = sessionPageDispatch(store, id, options.pageToolBroker ?? createMemoryPageToolBroker());
|
|
25
|
+
const page = sessionPageDispatch(store, id, options.pageToolBroker ?? createMemoryPageToolBroker(), options.pageToolTimeoutMs);
|
|
26
26
|
return {
|
|
27
27
|
pageExecution: page.execution,
|
|
28
28
|
answerPageResult: page.answer,
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { TheoremSessionState } from './session-store.ts';
|
|
2
|
-
/** What a session store holds for one session, in bytes of UTF-8 JSON.
|
|
2
|
+
/** What a session store holds for one session, in bytes of UTF-8 JSON. A store with a real limit declares it. */
|
|
3
3
|
export interface TheoremSessionCapacity {
|
|
4
4
|
/** The most one session's saved state may take. */
|
|
5
5
|
maxStateBytes: number;
|
|
6
6
|
/** The most one tool result may take. Room for one is kept for each call that has not settled. */
|
|
7
7
|
maxOutcomeBytes: number;
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The store's declared capacity, refused as `config` unless both are positive and one result fits.
|
|
11
|
+
* Undefined when the store declares none: then it holds any size.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkedCapacity(capacity: unknown): TheoremSessionCapacity | undefined;
|
|
13
14
|
/**
|
|
14
15
|
* Fits the state in the store's capacity: drops the oldest settled results, then the oldest
|
|
15
16
|
* provider checkpoints, and refuses with `session.full` when open work alone does not fit.
|
package/server/session-limits.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
};
|
|
7
|
-
/** The store's declared capacity, refused as `config` unless both are positive and one result fits. */
|
|
1
|
+
import { labelText, TheoremError } from '@theoremjs/agents';
|
|
2
|
+
/**
|
|
3
|
+
* The store's declared capacity, refused as `config` unless both are positive and one result fits.
|
|
4
|
+
* Undefined when the store declares none: then it holds any size.
|
|
5
|
+
*/
|
|
8
6
|
export function checkedCapacity(capacity) {
|
|
7
|
+
if (capacity === undefined)
|
|
8
|
+
return undefined;
|
|
9
9
|
const { maxStateBytes, maxOutcomeBytes } = (capacity ?? {});
|
|
10
10
|
if (!Number.isSafeInteger(maxStateBytes) ||
|
|
11
11
|
!Number.isSafeInteger(maxOutcomeBytes) ||
|
|
@@ -33,7 +33,7 @@ export type PendingToolGate = {
|
|
|
33
33
|
};
|
|
34
34
|
export type SessionPageGrant = {
|
|
35
35
|
grantId: string;
|
|
36
|
-
expiresAt
|
|
36
|
+
expiresAt?: number;
|
|
37
37
|
resultHash?: string;
|
|
38
38
|
};
|
|
39
39
|
export type SessionToolOperation = {
|
|
@@ -88,21 +88,24 @@ 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
|
-
/**
|
|
92
|
-
|
|
91
|
+
/**
|
|
92
|
+
* What it holds for one session, when it has a limit. The handler fits each write to it before
|
|
93
|
+
* `compareAndSwap`. Unset, the handler writes the state whole.
|
|
94
|
+
*/
|
|
95
|
+
readonly capacity?: TheoremSessionCapacity;
|
|
93
96
|
load(sessionId: string): Promise<SessionSnapshot | undefined>;
|
|
94
97
|
/** Compare and replace atomically; null creates only an absent session. */
|
|
95
98
|
compareAndSwap(sessionId: string, expectedRevision: string | null, next: TheoremSessionState): Promise<SessionWriteResult>;
|
|
96
99
|
}
|
|
97
100
|
export declare function emptySessionState(): TheoremSessionState;
|
|
98
|
-
/** Drop gates (waiting or settled) older than `ttlMs`. */
|
|
101
|
+
/** Drop gates (waiting or settled) older than `ttlMs`, or past their own `expiresAt`. */
|
|
99
102
|
export declare function pruneGates<Gate extends {
|
|
100
103
|
createdAt: number;
|
|
101
104
|
expiresAt?: number;
|
|
102
|
-
}>(gates: Record<string, Gate>, now: number, ttlMs: number): Record<string, Gate>;
|
|
105
|
+
}>(gates: Record<string, Gate>, now: number, ttlMs: number | undefined): Record<string, Gate>;
|
|
103
106
|
export type MemorySessionStoreOptions = {
|
|
104
|
-
/** What it holds for one session.
|
|
105
|
-
capacity?:
|
|
107
|
+
/** What it holds for one session. Unset, it holds any size. */
|
|
108
|
+
capacity?: TheoremSessionCapacity;
|
|
106
109
|
/** invariant: Idle retention is in milliseconds. Protected authority remains. Default 24h. */
|
|
107
110
|
ttlMs?: number;
|
|
108
111
|
/** 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 { checkedCapacity
|
|
13
|
+
import { checkedCapacity } 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(),
|
|
@@ -100,7 +100,7 @@ const identityFields = {
|
|
|
100
100
|
};
|
|
101
101
|
const pageGrant = z.strictObject({
|
|
102
102
|
grantId: z.string().min(1),
|
|
103
|
-
expiresAt: z.number().finite().positive(),
|
|
103
|
+
expiresAt: z.number().finite().positive().optional(),
|
|
104
104
|
resultHash: z.string().optional(),
|
|
105
105
|
});
|
|
106
106
|
const operationSchema = z.discriminatedUnion('status', [
|
|
@@ -171,7 +171,7 @@ export function emptySessionState() {
|
|
|
171
171
|
providerCheckpoints: [],
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
|
-
/** Drop gates (waiting or settled) older than `ttlMs`. */
|
|
174
|
+
/** Drop gates (waiting or settled) older than `ttlMs`, or past their own `expiresAt`. */
|
|
175
175
|
export function pruneGates(gates, now, ttlMs) {
|
|
176
176
|
return Object.fromEntries(Object.entries(gates).filter(([, gate]) => gate.expiresAt !== undefined
|
|
177
177
|
? now < gate.expiresAt
|
|
@@ -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 capacity = checkedCapacity(
|
|
222
|
+
const capacity = checkedCapacity(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,7 +234,7 @@ export function createMemorySessionStore(options = {}) {
|
|
|
234
234
|
return entry?.snapshot;
|
|
235
235
|
};
|
|
236
236
|
return {
|
|
237
|
-
capacity,
|
|
237
|
+
...(capacity ? { capacity } : {}),
|
|
238
238
|
load(sessionId) {
|
|
239
239
|
return Promise.resolve(structuredClone(current(sessionId)));
|
|
240
240
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
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
|
|
4
|
+
readonly capacity?: {
|
|
5
5
|
maxOutcomeBytes: number;
|
|
6
6
|
};
|
|
7
7
|
mutate<T>(id: string, change: (state: TheoremSessionState) => T): Promise<T>;
|
|
@@ -69,7 +69,7 @@ function saveOutcome(state, identity, outcome) {
|
|
|
69
69
|
}
|
|
70
70
|
export function sessionToolCoordinator(store, sessionId) {
|
|
71
71
|
return {
|
|
72
|
-
maxOutcomeBytes: store.capacity.maxOutcomeBytes,
|
|
72
|
+
...(store.capacity ? { maxOutcomeBytes: store.capacity.maxOutcomeBytes } : {}),
|
|
73
73
|
lookup(identity) {
|
|
74
74
|
return store.mutate(sessionId, (state) => {
|
|
75
75
|
const record = checkedRecord(state, identity);
|