@theoremjs/web 0.3.4-next.747.g0909061 → 0.3.4-next.755.g88665bd
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 +8 -1
- package/client/chat.d.ts +6 -0
- package/client/chat.js +94 -1
- package/client/index.d.ts +1 -1
- package/client/transport.d.ts +14 -0
- package/client/transport.js +9 -0
- package/package.json +2 -2
- package/server/conversation.js +1 -2
- package/server/handler.d.ts +15 -1
- package/server/handler.js +104 -12
- package/server/mod.d.ts +2 -1
- package/server/mod.js +2 -1
- package/server/notifier.d.ts +16 -0
- package/server/notifier.js +25 -0
package/README.md
CHANGED
|
@@ -270,6 +270,7 @@ const transport = withPageToolDispatch(
|
|
|
270
270
|
| `pageStatesTurn` | Let the page say which prompt lines a turn gets, through `stated` on a turn or a live call. Set it only for a page you trust as yourself, such as a builder's own tool. Without it, a request with `stated` is refused. |
|
|
271
271
|
| `conversationStore` | Where the conversation is kept. Required, except for a stateless profile, which must not have one. See [Keep the conversation](#keep-the-conversation). |
|
|
272
272
|
| `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`. |
|
|
273
|
+
| `notifier`, `notifyRetryMs` | Tell an open page that a background call ended, and how long the page waits to reopen a cut stream. Unset, the page reads the end on its next turn or reload. See [the background section](#share-session-authority). |
|
|
273
274
|
| `credentialStore` | Where tool credentials are kept, by session. Default: process memory. |
|
|
274
275
|
| `authorizationUrl` | Returns the sign-in URL for an OAuth gate. Without it, an OAuth gate has no URL. |
|
|
275
276
|
| `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. |
|
|
@@ -456,7 +457,13 @@ To retain execution claims across connections, supply a durable coordinator as `
|
|
|
456
457
|
Browser dispatch uses a host-owned `PageToolExecution` service beside the execution coordinator.
|
|
457
458
|
`createTheoremHandler` supplies both services. Live relays put the session boundary's `pageExecution` and `toolExecution` on `SessionRequest`.
|
|
458
459
|
|
|
459
|
-
A tool with a `background` setting runs inline until you pass the `background` option to `createTheoremHandler`. The option takes `{ request, sessionId }` and returns a `ToolBackgroundService`, which receives each detached call and its late events. The session keeps the call as `background` until it settles, and a late settlement does not invalidate a checkpoint. A conversation read shows the call as it stands: running, then its result, or an error for a marked call. An open page reads it on its next turn or reload
|
|
460
|
+
A tool with a `background` setting runs inline until you pass the `background` option to `createTheoremHandler`. The option takes `{ request, sessionId }` and returns a `ToolBackgroundService`, which receives each detached call and its late events. The session keeps the call as `background` until it settles, and a late settlement does not invalidate a checkpoint. A conversation read shows the call as it stands: running, then its result, or an error for a marked call. An open page reads it on its next turn or reload, unless you set `notifier`. When the session has dropped a settled operation, the block reads an error with the code `outcome_not_retained`. On a durable store, a worker that dies leaves the operation as `background`, so your sweep marks it `uncertain` with `worker_lost` through `compareAndSwap`. Keep the service's values valid after the request ends. A live relay passes its service to `runSession`.
|
|
461
|
+
To show an open page that a background call ended, with no turn or reload, pass `notifier: createMemoryNotifier()` to `createTheoremHandler`. While a call runs, the conversation read tells the page to open `POST <base>/notify`. The handler sends one `changed` line when the stream opens, and one when each background call in the session ends. The line carries no content, so the page calls the same conversation read as before. The page closes the stream when no call runs. The model reads nothing new: a late result still reaches it through `input.lateResults`.
|
|
462
|
+
|
|
463
|
+
`createMemoryNotifier` reaches only pages connected to the process that ran the call. On more than one instance, give a shared `TheoremNotifier`. Your sweep calls `publish` with the session id after it marks a call `worker_lost`, because that write does not pass through the handler. Hosting platforms cut long responses. Set `notifyRetryMs` so that the page reopens the stream after that wait. Unset, the page does not reopen it, and reads the end on its next turn or reload.
|
|
464
|
+
|
|
465
|
+
To reach a closed tab, send a Web Push from your `background` service, when the handle's `done` resolves. Theorem does not hold push subscriptions or VAPID keys. If your service worker then reaches an open page, call `refresh()` on the `ChatController`. It runs the same read.
|
|
466
|
+
|
|
460
467
|
Forward live page results to `answerPageResult`. Preserve checkpoint ownership through `beforeEvent` when using the Studio bridge.
|
|
461
468
|
The default `PageToolBroker` keeps active result waiters in process memory. Several workers need a shared broker or request affinity.
|
|
462
469
|
Use `pageToolBroker` to supply that broker. It carries results; the atomic session store retains execution authority.
|
package/client/chat.d.ts
CHANGED
|
@@ -120,6 +120,12 @@ export type ChatController = {
|
|
|
120
120
|
* `conversationView` is read then, and a restored page gate answered then.
|
|
121
121
|
*/
|
|
122
122
|
subscribe(listener: () => void): () => void;
|
|
123
|
+
/**
|
|
124
|
+
* Reads the conversation from the host and draws it. For a page told by its own channel that
|
|
125
|
+
* something ended, such as a service worker that receives a Web Push. A run in flight reads it
|
|
126
|
+
* anyway when it ends, and a failed read leaves the chat as it stands.
|
|
127
|
+
*/
|
|
128
|
+
refresh(): Promise<void>;
|
|
123
129
|
/** The latest options. `conversation`, `conversationView` and `initialText` are read only at creation. */
|
|
124
130
|
update(options: ChatOptions): void;
|
|
125
131
|
setDraftText(value: ChatUpdate<string>): void;
|
package/client/chat.js
CHANGED
|
@@ -112,6 +112,11 @@ class Chat {
|
|
|
112
112
|
#pageToolsWarned;
|
|
113
113
|
#frame = null;
|
|
114
114
|
#frameBlocks = null;
|
|
115
|
+
/** What the last view said about the host's push; unset, nothing is listened for. */
|
|
116
|
+
#notify;
|
|
117
|
+
#listening = null;
|
|
118
|
+
#reading = null;
|
|
119
|
+
#readAgain = false;
|
|
115
120
|
constructor(options) {
|
|
116
121
|
this.#options = options;
|
|
117
122
|
this.#conversation = options.conversation;
|
|
@@ -144,8 +149,95 @@ class Chat {
|
|
|
144
149
|
this.#live = true;
|
|
145
150
|
this.#settle();
|
|
146
151
|
}
|
|
147
|
-
|
|
152
|
+
this.#followPush();
|
|
153
|
+
return () => {
|
|
154
|
+
this.#listeners.delete(listener);
|
|
155
|
+
this.#followPush();
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
refresh = () => {
|
|
159
|
+
if (this.#reading) {
|
|
160
|
+
this.#readAgain = true;
|
|
161
|
+
return this.#reading;
|
|
162
|
+
}
|
|
163
|
+
this.#reading = this.#readHost().finally(() => {
|
|
164
|
+
this.#reading = null;
|
|
165
|
+
});
|
|
166
|
+
return this.#reading;
|
|
148
167
|
};
|
|
168
|
+
/** invariant: Reads never overlap, so an older view never draws over a newer one. */
|
|
169
|
+
async #readHost() {
|
|
170
|
+
do {
|
|
171
|
+
this.#readAgain = false;
|
|
172
|
+
await this.#readOnce();
|
|
173
|
+
} while (this.#readAgain);
|
|
174
|
+
}
|
|
175
|
+
async #readOnce() {
|
|
176
|
+
const { iface, transport } = this.#options;
|
|
177
|
+
const ref = this.#conversation;
|
|
178
|
+
if (!iface || ref === undefined || !keepsConversation(iface) || this.#busy)
|
|
179
|
+
return;
|
|
180
|
+
const before = this.#state.blocks;
|
|
181
|
+
let view;
|
|
182
|
+
try {
|
|
183
|
+
view = await transport.conversation({ conversation: ref });
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
// why: Nobody asked for this read, and the next one heals a passing failure.
|
|
187
|
+
if (isUnknownConversation(err))
|
|
188
|
+
this.#setPush(undefined);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// why: A run reads the conversation once it ends, later than this.
|
|
192
|
+
if (this.#busy)
|
|
193
|
+
return;
|
|
194
|
+
// why: Another read drew first. It may have started before the call ended, so read again.
|
|
195
|
+
if (this.#state.blocks !== before || this.#conversation !== ref) {
|
|
196
|
+
this.#readAgain = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.#draw(view, this.#state.session, []);
|
|
200
|
+
}
|
|
201
|
+
#setPush(notify) {
|
|
202
|
+
this.#notify = notify;
|
|
203
|
+
this.#followPush();
|
|
204
|
+
}
|
|
205
|
+
/** Holds the host's push open while the last view asks for it and someone is here to see it. */
|
|
206
|
+
#followPush() {
|
|
207
|
+
if (!this.#notify || this.#listeners.size === 0 || !this.#options.transport.notify) {
|
|
208
|
+
this.#listening?.abort();
|
|
209
|
+
this.#listening = null;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (this.#listening)
|
|
213
|
+
return;
|
|
214
|
+
const listening = new AbortController();
|
|
215
|
+
this.#listening = listening;
|
|
216
|
+
void this.#listen(listening);
|
|
217
|
+
}
|
|
218
|
+
async #listen(listening) {
|
|
219
|
+
const { signal } = listening;
|
|
220
|
+
while (!signal.aborted) {
|
|
221
|
+
try {
|
|
222
|
+
await this.#options.transport.notify?.(() => void this.refresh(), signal);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// why: The push is a convenience; the page still reads on its next turn or reload.
|
|
226
|
+
}
|
|
227
|
+
const retryMs = this.#notify?.retryMs;
|
|
228
|
+
if (signal.aborted || retryMs === undefined)
|
|
229
|
+
break;
|
|
230
|
+
await new Promise((resolve) => {
|
|
231
|
+
const timer = setTimeout(resolve, retryMs);
|
|
232
|
+
signal.addEventListener('abort', () => {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
resolve();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (this.#listening === listening)
|
|
239
|
+
this.#listening = null;
|
|
240
|
+
}
|
|
149
241
|
update = (options) => {
|
|
150
242
|
this.#options = options;
|
|
151
243
|
this.#warnPageTools();
|
|
@@ -435,6 +527,7 @@ class Chat {
|
|
|
435
527
|
session: this.#sessionAfter(session, view.paused?.events ?? events),
|
|
436
528
|
chatStarted: view.transcript.length > 0 || view.paused !== undefined,
|
|
437
529
|
});
|
|
530
|
+
this.#setPush(view.notify);
|
|
438
531
|
}
|
|
439
532
|
/**
|
|
440
533
|
* Keeps the model and effort valid as the interface changes: a pick still on the old defaults
|
package/client/index.d.ts
CHANGED
|
@@ -33,5 +33,5 @@ export { hasLead, layoutResult, type ResultChart, type ResultFigure, type Result
|
|
|
33
33
|
export type { AnsweringGate, ToolDecisionAction, ToolGateResolution, } from './tool-resume.ts';
|
|
34
34
|
export { continueGatedToolInvocation, type PersonGateResolution } from './tool-resume.ts';
|
|
35
35
|
export { createTraceFeed, recordedFeed, type TraceFeed } from './trace-feed.ts';
|
|
36
|
-
export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, postJson, postNdjson, readNdjsonStream, type StatedTurn, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, type TheoremStopRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
|
|
36
|
+
export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, type NotifyLine, postJson, postNdjson, readNdjsonStream, type StatedTurn, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, type TheoremStopRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
|
|
37
37
|
export { buildInvokeRequest, buildTurnRequest, foldAssistantTurn, issuedOperationId, type RunScope, type WalkAway, } from './turn-client.ts';
|
package/client/transport.d.ts
CHANGED
|
@@ -156,6 +156,10 @@ export type ConversationView = {
|
|
|
156
156
|
events: TurnEvent[];
|
|
157
157
|
blocks: TranscriptBlock[];
|
|
158
158
|
};
|
|
159
|
+
/** The host pushes the end of a call that still runs. `retryMs` is its wait to reopen the push. */
|
|
160
|
+
notify?: {
|
|
161
|
+
retryMs?: number;
|
|
162
|
+
};
|
|
159
163
|
};
|
|
160
164
|
/** Read a conversation: a `/conversation` body. */
|
|
161
165
|
export type TheoremConversationRequest = {
|
|
@@ -163,6 +167,14 @@ export type TheoremConversationRequest = {
|
|
|
163
167
|
};
|
|
164
168
|
/** A `/conversation` body as `createTheoremHandler` reads it. */
|
|
165
169
|
export declare const theoremConversationRequestSchema: z.ZodType<TheoremConversationRequest>;
|
|
170
|
+
/** Listen for the ends of this session's background calls: a `/notify` body. */
|
|
171
|
+
export type TheoremNotifyRequest = Record<string, never>;
|
|
172
|
+
/** A `/notify` body as `createTheoremHandler` reads it. */
|
|
173
|
+
export declare const theoremNotifyRequestSchema: z.ZodType<TheoremNotifyRequest>;
|
|
174
|
+
/** A line of `/notify`: something in the session changed, so read the conversation again. */
|
|
175
|
+
export type NotifyLine = {
|
|
176
|
+
type: 'changed';
|
|
177
|
+
};
|
|
166
178
|
/**
|
|
167
179
|
* Cut the conversation after transcript block `at` (-1 empties it): a
|
|
168
180
|
* `/rewind` body. A reply waiting on gates is walked away from first.
|
|
@@ -200,6 +212,8 @@ export interface TheoremTransport {
|
|
|
200
212
|
stop(request: TheoremStopRequest): Promise<void>;
|
|
201
213
|
/** The conversation under `ref`, as the host shows it. */
|
|
202
214
|
conversation(request: TheoremConversationRequest, signal?: AbortSignal): Promise<ConversationView>;
|
|
215
|
+
/** Calls `changed` for each line of the host's push, and settles when the push ends. */
|
|
216
|
+
notify?(changed: () => void, signal: AbortSignal): Promise<void>;
|
|
203
217
|
/** The conversation cut after a transcript block, under the ref the host issued for it. */
|
|
204
218
|
rewind(request: TheoremRewindRequest, signal?: AbortSignal): Promise<TheoremRewound>;
|
|
205
219
|
pageResult?(result: PageDispatchResult, signal?: AbortSignal): Promise<void>;
|
package/client/transport.js
CHANGED
|
@@ -111,12 +111,17 @@ const conversationView = z.object({
|
|
|
111
111
|
paused: z
|
|
112
112
|
.object({ events: z.array(turnEventSchema), blocks: z.array(transcriptBlockSchema) })
|
|
113
113
|
.optional(),
|
|
114
|
+
notify: z.object({ retryMs: z.number().optional() }).optional(),
|
|
114
115
|
});
|
|
115
116
|
true;
|
|
116
117
|
const theoremConversationRequest = z.object({ conversation: z.string().min(1) });
|
|
117
118
|
true;
|
|
118
119
|
/** A `/conversation` body as `createTheoremHandler` reads it. */
|
|
119
120
|
export const theoremConversationRequestSchema = theoremConversationRequest;
|
|
121
|
+
const theoremNotifyRequest = z.strictObject({});
|
|
122
|
+
/** A `/notify` body as `createTheoremHandler` reads it. */
|
|
123
|
+
export const theoremNotifyRequestSchema = theoremNotifyRequest;
|
|
124
|
+
const NOTIFY_LINES = { changed: z.object({ type: z.literal('changed') }) };
|
|
120
125
|
const theoremRewindRequest = z.object({
|
|
121
126
|
conversation: z.string().min(1),
|
|
122
127
|
at: z.number().int().min(-1),
|
|
@@ -348,6 +353,10 @@ export function createHttpTransport(options = {}) {
|
|
|
348
353
|
turn: ({ replay: _replay, ...body }, onEvent, signal) => postNdjson(`${base}/turn`, body, CHAT_LINES, onEvent, { ...options, signal }),
|
|
349
354
|
invoke: ({ replay: _replay, ...body }, onEvent, signal) => postNdjson(`${base}/invoke`, body, CHAT_LINES, onEvent, { ...options, signal }),
|
|
350
355
|
conversation: (body, signal) => fetchConversation(`${base}/conversation`, body, { ...options, signal }),
|
|
356
|
+
notify: (changed, signal) => postNdjson(`${base}/notify`, {}, NOTIFY_LINES, (line) => {
|
|
357
|
+
if (line.type === 'changed')
|
|
358
|
+
changed();
|
|
359
|
+
}, { ...options, signal }),
|
|
351
360
|
rewind: (body, signal) => fetchRewound(`${base}/rewind`, body, { ...options, signal }),
|
|
352
361
|
pageResult: (body, signal) => postJson(`${base}/page-result`, body, { ...options, signal }),
|
|
353
362
|
steer: (body) => postJson(`${base}/steer`, body, options),
|
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.755.g88665bd",
|
|
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.755.g88665bd"
|
|
29
29
|
}
|
|
30
30
|
}
|
package/server/conversation.js
CHANGED
|
@@ -363,8 +363,7 @@ function requireWalkAway(conversation, abandon) {
|
|
|
363
363
|
function requireWaitingOn(conversation, reference) {
|
|
364
364
|
if (waitingOn(conversation, reference))
|
|
365
365
|
return;
|
|
366
|
-
|
|
367
|
-
throw new TheoremError('request', `the reply is not waiting on ${reference.callId}`, {
|
|
366
|
+
throw new TheoremError('request', labelText('session.gate_expired'), {
|
|
368
367
|
copy: { key: 'session.gate_expired' },
|
|
369
368
|
});
|
|
370
369
|
}
|
package/server/handler.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { type PageToolBroker } from './page-dispatch.ts';
|
|
|
13
13
|
* - `POST <base>/stop` → stop a running turn where it stands
|
|
14
14
|
* - `POST <base>/page-result` → the page's result for a tool the turn ran in the page
|
|
15
15
|
* - `POST <base>/conversation` → the transcript and the paused reply, to draw
|
|
16
|
+
* - `POST <base>/notify` → NDJSON that stays open: one line now, and one each time a background
|
|
17
|
+
* call in this session ends. Set `notifier` to serve it
|
|
16
18
|
* - `POST <base>/rewind` → `{ conversation, view }` the conversation cut after one block, and its new ref
|
|
17
19
|
*
|
|
18
20
|
* `createTheoremHostHandler` serves a host profile the same way, with no model:
|
|
@@ -39,6 +41,7 @@ import { type ClientTurnOptions } from '@theoremjs/agents/host';
|
|
|
39
41
|
import { type ToolBackgroundService } from '@theoremjs/agents/kernel';
|
|
40
42
|
import { type TheoremConversationStore } from './conversation-store.ts';
|
|
41
43
|
import { type TheoremCredentialStore } from './credential-store.ts';
|
|
44
|
+
import type { TheoremNotifier } from './notifier.ts';
|
|
42
45
|
export { theoremSession, theoremSessionId } from './session-identity.ts';
|
|
43
46
|
import { type SessionToolOperation, type TheoremSessionStore } from './session-store.ts';
|
|
44
47
|
import { type SteerInbox } from './steer-inbox.ts';
|
|
@@ -127,6 +130,17 @@ export type TheoremHandlerOptions = {
|
|
|
127
130
|
request: Request;
|
|
128
131
|
sessionId: string;
|
|
129
132
|
}) => ToolBackgroundService;
|
|
133
|
+
/**
|
|
134
|
+
* Tells an open page that a background call ended, so it redraws with no turn or reload. Unset,
|
|
135
|
+
* the page reads the end on its next turn or reload. A sweep that marks a call itself calls
|
|
136
|
+
* `publish` too. `createMemoryNotifier` for one instance.
|
|
137
|
+
*/
|
|
138
|
+
notifier?: TheoremNotifier;
|
|
139
|
+
/**
|
|
140
|
+
* How long an open page waits to reopen `notifier`'s stream after it ends, in milliseconds.
|
|
141
|
+
* Hosting platforms cut long responses. Unset, the page does not reopen it.
|
|
142
|
+
*/
|
|
143
|
+
notifyRetryMs?: number;
|
|
130
144
|
/** Check current resource access before disclosing a saved tool result. This callback must be read-only. */
|
|
131
145
|
authorizeToolReplay?: (context: {
|
|
132
146
|
request: Request;
|
|
@@ -167,7 +181,7 @@ export type TheoremHandlerOptions = {
|
|
|
167
181
|
}) => void;
|
|
168
182
|
};
|
|
169
183
|
/** A host profile runs no model, and takes no turns to steer. */
|
|
170
|
-
export type TheoremHostHandlerOptions = Omit<TheoremHandlerOptions, 'profile' | 'provider' | 'steerInbox' | 'conversationStore' | 'prepare'> & {
|
|
184
|
+
export type TheoremHostHandlerOptions = Omit<TheoremHandlerOptions, 'profile' | 'provider' | 'steerInbox' | 'conversationStore' | 'prepare' | 'notifier' | 'notifyRetryMs'> & {
|
|
171
185
|
/** Host profile to serve. Registered with the kernel when the handler is created. */
|
|
172
186
|
profile: Extract<Profile, {
|
|
173
187
|
type: 'host';
|
package/server/handler.js
CHANGED
|
@@ -14,6 +14,8 @@ import { checkPageToolTimeout, createMemoryPageToolBroker, pageToolResultSchema,
|
|
|
14
14
|
* - `POST <base>/stop` → stop a running turn where it stands
|
|
15
15
|
* - `POST <base>/page-result` → the page's result for a tool the turn ran in the page
|
|
16
16
|
* - `POST <base>/conversation` → the transcript and the paused reply, to draw
|
|
17
|
+
* - `POST <base>/notify` → NDJSON that stays open: one line now, and one each time a background
|
|
18
|
+
* call in this session ends. Set `notifier` to serve it
|
|
17
19
|
* - `POST <base>/rewind` → `{ conversation, view }` the conversation cut after one block, and its new ref
|
|
18
20
|
*
|
|
19
21
|
* `createTheoremHostHandler` serves a host profile the same way, with no model:
|
|
@@ -40,7 +42,7 @@ import { caughtStatus, clientError, forClient, HTTP_NOT_FOUND, } from '@theoremj
|
|
|
40
42
|
import { clientProfileInterface, gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
|
|
41
43
|
import { answerGatedCall, resolveGateTtlMs, } from '@theoremjs/agents/kernel';
|
|
42
44
|
import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
|
|
43
|
-
import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
45
|
+
import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremNotifyRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
44
46
|
import { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, requiredOperationId, rewindConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
|
|
45
47
|
import { unknownConversation, } from "./conversation-store.js";
|
|
46
48
|
import { createMemoryCredentialStore, } from "./credential-store.js";
|
|
@@ -94,6 +96,7 @@ function routeOf(request) {
|
|
|
94
96
|
last === 'steer' ||
|
|
95
97
|
last === 'stop' ||
|
|
96
98
|
last === 'conversation' ||
|
|
99
|
+
last === 'notify' ||
|
|
97
100
|
last === 'rewind' ||
|
|
98
101
|
last === 'call' ||
|
|
99
102
|
last === 'page-result'
|
|
@@ -304,8 +307,7 @@ function answerPendingGate(state, request, profileId) {
|
|
|
304
307
|
? state.gates[request.operationId]
|
|
305
308
|
: undefined;
|
|
306
309
|
if (!pending || pending.profileId !== profileId || pending.callId !== request.callId) {
|
|
307
|
-
|
|
308
|
-
throw new TheoremError('request', `gate ${request.callId} is not pending`, {
|
|
310
|
+
throw new TheoremError('request', labelText('session.gate_expired'), {
|
|
309
311
|
copy: { key: 'session.gate_expired' },
|
|
310
312
|
});
|
|
311
313
|
}
|
|
@@ -400,9 +402,23 @@ async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answe
|
|
|
400
402
|
}
|
|
401
403
|
/** The host's service for a call in this session, for a request that runs a tool. */
|
|
402
404
|
function backgroundWiring(ctx, request, sessionId) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
405
|
+
const service = ctx.options.background?.({ request, sessionId });
|
|
406
|
+
if (!service)
|
|
407
|
+
return {};
|
|
408
|
+
const { notifier } = ctx.options;
|
|
409
|
+
if (!notifier)
|
|
410
|
+
return { background: service };
|
|
411
|
+
return {
|
|
412
|
+
background: {
|
|
413
|
+
detached(call) {
|
|
414
|
+
service.detached(call);
|
|
415
|
+
void call.done
|
|
416
|
+
.then(() => notifier.publish(sessionId))
|
|
417
|
+
.catch((err) => ctx.options.onError?.(err, { request }));
|
|
418
|
+
},
|
|
419
|
+
event: (event, call) => service.event(event, call),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
406
422
|
}
|
|
407
423
|
/** A settled call's answer, streamed again. */
|
|
408
424
|
async function* once(event, labels) {
|
|
@@ -525,32 +541,94 @@ async function conversation(ctx, request, session) {
|
|
|
525
541
|
if (!host)
|
|
526
542
|
throw statelessRefusal('read');
|
|
527
543
|
const opened = await openConversation(host, body.conversation, { kind: 'read' });
|
|
528
|
-
return jsonResponse(200, await currentView(ctx, session, host, opened.conversation));
|
|
544
|
+
return jsonResponse(200, await currentView(ctx, request, session, host, opened.conversation));
|
|
545
|
+
}
|
|
546
|
+
const CHANGED_LINE = new TextEncoder().encode('{"type":"changed"}\n');
|
|
547
|
+
/**
|
|
548
|
+
* One line now, then one for each background call that ends. The stream names no conversation,
|
|
549
|
+
* because a store's ref can change with each write; the page reads under the ref it holds.
|
|
550
|
+
* invariant: The first line follows the subscribe, so a call that ends before the page's first
|
|
551
|
+
* read is never missed.
|
|
552
|
+
*/
|
|
553
|
+
async function notify(ctx, request, session, notifier) {
|
|
554
|
+
await readBody(request, theoremNotifyRequestSchema, ctx.options.maxRequestBytes);
|
|
555
|
+
let send = () => { };
|
|
556
|
+
const unsubscribe = await notifier.subscribe(session.id, () => send());
|
|
557
|
+
let open = true;
|
|
558
|
+
const end = () => {
|
|
559
|
+
if (!open)
|
|
560
|
+
return;
|
|
561
|
+
open = false;
|
|
562
|
+
unsubscribe();
|
|
563
|
+
};
|
|
564
|
+
const stream = new ReadableStream({
|
|
565
|
+
start(controller) {
|
|
566
|
+
send = () => {
|
|
567
|
+
if (open)
|
|
568
|
+
controller.enqueue(CHANGED_LINE);
|
|
569
|
+
};
|
|
570
|
+
send();
|
|
571
|
+
const leave = () => {
|
|
572
|
+
if (!open)
|
|
573
|
+
return;
|
|
574
|
+
end();
|
|
575
|
+
controller.close();
|
|
576
|
+
};
|
|
577
|
+
if (request.signal.aborted)
|
|
578
|
+
leave();
|
|
579
|
+
else
|
|
580
|
+
request.signal.addEventListener('abort', leave, { once: true });
|
|
581
|
+
},
|
|
582
|
+
cancel: end,
|
|
583
|
+
});
|
|
584
|
+
return new Response(stream, { headers: NDJSON_HEADERS });
|
|
529
585
|
}
|
|
530
586
|
/**
|
|
531
587
|
* why: The transcript keeps a detached call as the turn left it. The session holds what became of
|
|
532
588
|
* it, so a call that has ended does not read as running.
|
|
533
589
|
*/
|
|
534
|
-
async function currentView(ctx, session, host, conversation) {
|
|
590
|
+
async function currentView(ctx, request, session, host, conversation) {
|
|
535
591
|
const view = conversationView(host, conversation);
|
|
536
592
|
if (!view.transcript.some(isDetachedBlock))
|
|
537
593
|
return view;
|
|
538
594
|
const { operations } = await ctx.sessions.read(session.id);
|
|
595
|
+
const transcript = await Promise.all(view.transcript.map((block) => isDetachedBlock(block) ? lateBlock(ctx, request, block, operations, host) : block));
|
|
596
|
+
const { notifier, notifyRetryMs } = ctx.options;
|
|
539
597
|
return {
|
|
540
598
|
...view,
|
|
541
|
-
transcript
|
|
599
|
+
transcript,
|
|
600
|
+
...(notifier && transcript.some(isDetachedBlock)
|
|
601
|
+
? { notify: notifyRetryMs === undefined ? {} : { retryMs: notifyRetryMs } }
|
|
602
|
+
: {}),
|
|
542
603
|
};
|
|
543
604
|
}
|
|
544
605
|
function isDetachedBlock(block) {
|
|
545
606
|
return block.kind === 'tool' && block.tool.state?.phase === 'background';
|
|
546
607
|
}
|
|
547
608
|
/** A detached call's block as its operation stands. One the session no longer holds has no known end. */
|
|
548
|
-
function lateBlock(block, operations, host
|
|
609
|
+
async function lateBlock(ctx, request, block, operations, host) {
|
|
549
610
|
const { tool } = block;
|
|
550
611
|
const id = tool.operationId;
|
|
551
612
|
const operation = id && Object.hasOwn(operations, id) ? operations[id] : undefined;
|
|
552
613
|
if (operation?.status === 'running' || operation?.status === 'background')
|
|
553
614
|
return block;
|
|
615
|
+
const { labels } = ctx.profile;
|
|
616
|
+
if (operation?.status === 'settled' &&
|
|
617
|
+
ctx.options.authorizeToolReplay &&
|
|
618
|
+
!(await ctx.options.authorizeToolReplay({ request, operation }))) {
|
|
619
|
+
const message = labelText('error.blocked', {}, labels);
|
|
620
|
+
const failure = { code: 'outcome_withheld', kind: 'blocked', message };
|
|
621
|
+
const { name, callId } = tool;
|
|
622
|
+
const at = operation.settledAt;
|
|
623
|
+
return {
|
|
624
|
+
...block,
|
|
625
|
+
tool: {
|
|
626
|
+
...tool,
|
|
627
|
+
state: { name, callId, at, phase: 'error', failure, readBack: message },
|
|
628
|
+
endedAt: at,
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
554
632
|
if (operation?.status === 'settled') {
|
|
555
633
|
const settled = host.forReader(operation.outcome.event);
|
|
556
634
|
if (settled.type !== 'tool')
|
|
@@ -560,7 +638,7 @@ function lateBlock(block, operations, host, profile) {
|
|
|
560
638
|
return block;
|
|
561
639
|
return { ...block, tool: { ...tool, state, endedAt: operation.settledAt } };
|
|
562
640
|
}
|
|
563
|
-
const message = labelText('tool.execution_uncertain', {},
|
|
641
|
+
const message = labelText('tool.execution_uncertain', {}, labels);
|
|
564
642
|
const failure = {
|
|
565
643
|
code: operation?.status === 'uncertain' ? operation.reason : 'outcome_not_retained',
|
|
566
644
|
kind: 'internal',
|
|
@@ -598,7 +676,7 @@ async function rewind(ctx, request, session) {
|
|
|
598
676
|
throw new TheoremError('internal', labelText('error.internal'));
|
|
599
677
|
const rewound = {
|
|
600
678
|
conversation: next.ref,
|
|
601
|
-
view: await currentView(ctx, session, host, next.conversation),
|
|
679
|
+
view: await currentView(ctx, request, session, host, next.conversation),
|
|
602
680
|
};
|
|
603
681
|
return jsonResponse(200, rewound);
|
|
604
682
|
}
|
|
@@ -792,6 +870,12 @@ async function route(ctx, request, session) {
|
|
|
792
870
|
return invoke(ctx, request, session);
|
|
793
871
|
if (target === 'conversation')
|
|
794
872
|
return conversation(ctx, request, session);
|
|
873
|
+
if (target === 'notify') {
|
|
874
|
+
const { notifier } = ctx.options;
|
|
875
|
+
if (!notifier || !chat.conversations)
|
|
876
|
+
return refused(ctx);
|
|
877
|
+
return notify(ctx, request, session, notifier);
|
|
878
|
+
}
|
|
795
879
|
if (target === 'rewind')
|
|
796
880
|
return rewind(ctx, request, session);
|
|
797
881
|
if (target === 'stop')
|
|
@@ -850,6 +934,13 @@ export function hostTool(name) {
|
|
|
850
934
|
const registered = defaultKernelScope.tools.get(name);
|
|
851
935
|
return registered?.type === 'builtin' ? undefined : registered;
|
|
852
936
|
}
|
|
937
|
+
function checkNotifyRetry(ms) {
|
|
938
|
+
if (ms === undefined || (Number.isFinite(ms) && ms >= 0))
|
|
939
|
+
return;
|
|
940
|
+
throw new TheoremError('config', labelText('error.config'), {
|
|
941
|
+
cause: { code: 'notify_retry' },
|
|
942
|
+
});
|
|
943
|
+
}
|
|
853
944
|
function serve(name, profile, options, chatOptions = {}) {
|
|
854
945
|
const gateTtlMs = resolveGateTtlMs(name, options.gateTtlMs);
|
|
855
946
|
const operationRetentionMs = options.operationRetentionMs ?? DEFAULT_OPERATION_RETENTION_MS;
|
|
@@ -860,6 +951,7 @@ function serve(name, profile, options, chatOptions = {}) {
|
|
|
860
951
|
}
|
|
861
952
|
requestByteLimit(options.maxRequestBytes);
|
|
862
953
|
checkPageToolTimeout(options.pageToolTimeoutMs);
|
|
954
|
+
checkNotifyRetry(options.notifyRetryMs);
|
|
863
955
|
registerProfile(profile);
|
|
864
956
|
const ctx = {
|
|
865
957
|
options,
|
package/server/mod.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { theoremHostCallRequestSchema } from '../client/host-transport.ts';
|
|
9
9
|
export type { LiveClientMessage, LiveOpenMessage } from '../client/live-messages.ts';
|
|
10
|
-
export { type StatedTurn, theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
|
|
10
|
+
export { type StatedTurn, theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremNotifyRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
|
|
11
11
|
export { answerReply, type ConversationHost, type ConversationUse, conversationView, type IssuedWalkAway, messageReply, type OpenedConversation, openConversation, type PostedMessage, postedMessage, type ReplyLine, type ReplyRunner, type ReplyTurn, readerConversationHost, rewindConversation, startConversation, statelessRefusal, type WalkedAwayCall, waitingCalls, } from './conversation.ts';
|
|
12
12
|
export { type ConversationSnapshot, type ConversationWriteResult, createMemoryConversationStore, createSealedConversationStore, emptyConversation, type PausedReply, type SealedConversationStoreOptions, type StoredConversation, storedConversationSchema, type TheoremConversationStore, unknownConversation, } from './conversation-store.ts';
|
|
13
13
|
export { createMemoryCredentialStore, type TheoremCredentialStore, type TheoremCredentials, } from './credential-store.ts';
|
|
@@ -15,6 +15,7 @@ export { createTheoremDecisionHandler, type DecisionQuestionsBuilder, isQuestion
|
|
|
15
15
|
export { createTheoremEmbeddingHandler, embedBodySchema, embedRequest, type TheoremEmbeddingHandlerOptions, } from './embedding-handler.ts';
|
|
16
16
|
export { createTheoremHandler, createTheoremHostHandler, readBody, type TheoremHandlerOptions, type TheoremHostHandlerOptions, type TheoremRequestContext, theoremSession, theoremSessionId, } from './handler.ts';
|
|
17
17
|
export { createTheoremMcpHandler, type TheoremMcpHandlerOptions } from './mcp-handler.ts';
|
|
18
|
+
export { createMemoryNotifier, type TheoremNotifier } from './notifier.ts';
|
|
18
19
|
export { createMemoryPageToolBroker, type PageToolBroker, type PageToolResult, pageToolResultSchema, sessionPageDispatch, } from './page-dispatch.ts';
|
|
19
20
|
export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from './request-check.ts';
|
|
20
21
|
export { sessionStateStore } from './session-authority.ts';
|
package/server/mod.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @module
|
|
7
7
|
*/
|
|
8
8
|
export { theoremHostCallRequestSchema } from "../client/host-transport.js";
|
|
9
|
-
export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
9
|
+
export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremNotifyRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
10
10
|
export { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, rewindConversation, startConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
|
|
11
11
|
export { createMemoryConversationStore, createSealedConversationStore, emptyConversation, storedConversationSchema, unknownConversation, } from "./conversation-store.js";
|
|
12
12
|
export { createMemoryCredentialStore, } from "./credential-store.js";
|
|
@@ -14,6 +14,7 @@ export { createTheoremDecisionHandler, isQuestionsBuilder, } from "./decision-ha
|
|
|
14
14
|
export { createTheoremEmbeddingHandler, embedBodySchema, embedRequest, } from "./embedding-handler.js";
|
|
15
15
|
export { createTheoremHandler, createTheoremHostHandler, readBody, theoremSession, theoremSessionId, } from "./handler.js";
|
|
16
16
|
export { createTheoremMcpHandler } from "./mcp-handler.js";
|
|
17
|
+
export { createMemoryNotifier } from "./notifier.js";
|
|
17
18
|
export { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
|
|
18
19
|
export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from "./request-check.js";
|
|
19
20
|
export { sessionStateStore } from "./session-authority.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a session's open pages hear that a background call ended. The line carries no content: a
|
|
3
|
+
* page that hears it reads the conversation, so one read decides what a reader sees.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Keyed by session id. The default lives in process memory, which reaches only pages connected to
|
|
9
|
+
* the process that ran the call. Bring a shared one (Redis, a Durable Object) for several instances.
|
|
10
|
+
*/
|
|
11
|
+
export interface TheoremNotifier {
|
|
12
|
+
/** Calls `changed` for each `publish` under `sessionId`, until the returned function runs. */
|
|
13
|
+
subscribe(sessionId: string, changed: () => void): (() => void) | Promise<() => void>;
|
|
14
|
+
publish(sessionId: string): void | Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function createMemoryNotifier(): TheoremNotifier;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a session's open pages hear that a background call ended. The line carries no content: a
|
|
3
|
+
* page that hears it reads the conversation, so one read decides what a reader sees.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
export function createMemoryNotifier() {
|
|
8
|
+
const listeners = new Map();
|
|
9
|
+
return {
|
|
10
|
+
subscribe(sessionId, changed) {
|
|
11
|
+
const listening = listeners.get(sessionId) ?? new Set();
|
|
12
|
+
listeners.set(sessionId, listening);
|
|
13
|
+
listening.add(changed);
|
|
14
|
+
return () => {
|
|
15
|
+
listening.delete(changed);
|
|
16
|
+
if (listening.size === 0 && listeners.get(sessionId) === listening)
|
|
17
|
+
listeners.delete(sessionId);
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
publish(sessionId) {
|
|
21
|
+
for (const changed of [...(listeners.get(sessionId) ?? [])])
|
|
22
|
+
changed();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|