@theoremjs/web 0.3.4-next.407.gd93ec2c → 0.3.4-next.413.gb7a2b75
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 +5 -5
- package/client/chat.js +27 -5
- package/client/index.d.ts +1 -1
- package/client/page-dispatch.js +1 -0
- package/client/run-commit.d.ts +5 -0
- package/client/run-commit.js +9 -0
- package/client/transport.d.ts +8 -0
- package/client/transport.js +5 -0
- package/package.json +2 -2
- package/server/conversation.js +14 -22
- package/server/handler.js +28 -10
- package/server/mod.d.ts +2 -2
- package/server/mod.js +2 -2
- package/server/steer-inbox.d.ts +10 -3
- package/server/steer-inbox.js +40 -9
package/README.md
CHANGED
|
@@ -185,17 +185,17 @@ await chat.submit();
|
|
|
185
185
|
| Method | What it does |
|
|
186
186
|
| --- | --- |
|
|
187
187
|
| `submit()` | At `idle`, sends the draft. Otherwise, queues it. |
|
|
188
|
-
| `enqueue('steer')` | Sends the draft to the running turn with `transport.steer`. The steer leaves `pendingMessages` when the turn's `stage` event names it in `injected
|
|
188
|
+
| `enqueue('steer')` | Sends the draft to the running turn with `transport.steer`. The steer leaves `pendingMessages` when the turn's `stage` event names it in `injected`, and joins the transcript there, between the reply before it and the reply after. A steer the turn never takes waits at the front of the queue. |
|
|
189
189
|
| `enqueue('queue')` | Keeps the draft to send after the reply. When the reply ends without a failure and no gate waits, the first queued message is sent. |
|
|
190
190
|
| `enqueue('stash')` | Keeps the draft without sending it. `queuePending(id)` moves it to the queue. |
|
|
191
|
-
| `sendNow(message?)` | Sends a pending message, or the draft, at once. A streaming reply is stopped first. A reply that waits on gates is left. |
|
|
191
|
+
| `sendNow(message?)` | Sends a pending message, or the draft, at once. A streaming reply is stopped first, as `stop()` stops it. A reply that waits on gates is left. |
|
|
192
192
|
| `restorePending(id)` | Puts a pending message back in the composer. The draft that was there is stashed first, so the user loses nothing. |
|
|
193
|
-
| `stop()` | Stops the streaming reply. |
|
|
193
|
+
| `stop()` | Stops the streaming reply with `transport.stop`. What the reply said before the stop stays in the transcript and the history, closed as cancelled. A queued message stays queued. When the host refuses the stop, the controller drops the stream instead, and a host that keeps the conversation keeps the stopped reply where the page can't read it. |
|
|
194
194
|
| `decide(action)`, `authenticate(secret?)` | Answer the gate that the reply waits on. |
|
|
195
195
|
| `branch(index)` | Cuts the conversation after transcript block `index`. |
|
|
196
196
|
| `sendText(text, files?)` | Sends a message from your code. It resolves with the blocks the turn added, or `null` when the chat cannot take a message now. |
|
|
197
197
|
|
|
198
|
-
The controller uses these methods of the transport: `turn`, `invoke` (to answer a gate), `steer`, `conversation`, and `rewind`. `conversation` and `rewind` are called only for a profile whose host keeps the conversation. `pageResult` is called when the reply hands a tool to the page.
|
|
198
|
+
The controller uses these methods of the transport: `turn`, `invoke` (to answer a gate), `steer`, `stop`, `conversation`, and `rewind`. `conversation` and `rewind` are called only for a profile whose host keeps the conversation. `pageResult` is called when the reply hands a tool to the page.
|
|
199
199
|
|
|
200
200
|
The controller writes no English text. The helpers below give the composer's controls as values, and you choose the words:
|
|
201
201
|
|
|
@@ -237,7 +237,7 @@ const transport = withPageToolDispatch(
|
|
|
237
237
|
| `host` | App data for the tool handlers (`ctx.host`), such as the signed-in user. |
|
|
238
238
|
| `pageToolBroker` | Delivers results to active page dispatches. Default: bounded process memory. |
|
|
239
239
|
| `conversationStore` | Where the conversation is kept. Required, except for a stateless profile, which must not have one. See [Keep the conversation](#keep-the-conversation). |
|
|
240
|
-
| `sessionStore`, `steerInbox` | Where open gates and
|
|
240
|
+
| `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`. |
|
|
241
241
|
| `credentialStore` | Where tool credentials are kept, by session. Default: process memory. |
|
|
242
242
|
| `authorizationUrl` | Returns the sign-in URL for an OAuth gate. Without it, an OAuth gate has no URL. |
|
|
243
243
|
| `gateTtlMs` | How long a gate waits for an answer. Default: 30 minutes. A later answer fails with `session.gate_expired`. |
|
package/client/chat.js
CHANGED
|
@@ -7,7 +7,7 @@ import { clientFailure, turnFailure } from "./failure.js";
|
|
|
7
7
|
import { followGenerationDefaults } from "./generation-selection.js";
|
|
8
8
|
import { warnPageToolMismatch } from "./live/live-page-tool.js";
|
|
9
9
|
import { withPageToolDispatch } from "./page-dispatch.js";
|
|
10
|
-
import { sessionAfter, stampWorked } from "./run-commit.js";
|
|
10
|
+
import { sessionAfter, stampWorked, stoppedReply } from "./run-commit.js";
|
|
11
11
|
import { keepsConversation, resumeInterfaceTool, streamInterfaceDraftTurn, streamInterfaceTurn, } from "./run-session.js";
|
|
12
12
|
import { isUnknownConversation, } from "./transport.js";
|
|
13
13
|
import { foldAssistantTurn, issuedOperationId } from "./turn-client.js";
|
|
@@ -90,6 +90,8 @@ class Chat {
|
|
|
90
90
|
#busy = false;
|
|
91
91
|
#abort = null;
|
|
92
92
|
#turnId = null;
|
|
93
|
+
/** The visitor stopped the run in flight, so the queue waits. */
|
|
94
|
+
#stopped = false;
|
|
93
95
|
/** The run in flight; it settles, never rejects. */
|
|
94
96
|
#run = null;
|
|
95
97
|
// why: A stateless reply's work across its runs: a gate splits a reply into runs, and the wait between doesn't count.
|
|
@@ -170,8 +172,27 @@ class Chat {
|
|
|
170
172
|
await this.#sendFields({ text: draftText, files: [...pendingFiles], voice: [...pendingVoice] });
|
|
171
173
|
};
|
|
172
174
|
stop = () => {
|
|
173
|
-
this.#
|
|
175
|
+
void this.#stop();
|
|
174
176
|
};
|
|
177
|
+
/** Asks the host to stop the turn, so the reply it leaves comes back; a host that can't drops the stream. */
|
|
178
|
+
async #stop() {
|
|
179
|
+
const abort = this.#abort;
|
|
180
|
+
const turnId = this.#turnId;
|
|
181
|
+
if (!abort)
|
|
182
|
+
return;
|
|
183
|
+
this.#stopped = true;
|
|
184
|
+
if (!turnId) {
|
|
185
|
+
abort.abort();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await this.#options.transport.stop({ turnId });
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// why: The host isn't running the turn here (it ended, or another instance runs it); the stream is dropped instead.
|
|
193
|
+
abort.abort();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
175
196
|
enqueue = async (kind) => {
|
|
176
197
|
const { draftText, pendingFiles, pendingVoice } = this.#state;
|
|
177
198
|
if (!composerHasPayload(draftText, pendingFiles, pendingVoice))
|
|
@@ -207,7 +228,7 @@ class Chat {
|
|
|
207
228
|
if (!draft)
|
|
208
229
|
return;
|
|
209
230
|
if (this.#busy) {
|
|
210
|
-
this.#
|
|
231
|
+
await this.#stop();
|
|
211
232
|
await this.#run;
|
|
212
233
|
}
|
|
213
234
|
this.#set({ pendingMessages: convertSteersToFrontQueued(this.#state.pendingMessages) });
|
|
@@ -652,6 +673,7 @@ class Chat {
|
|
|
652
673
|
this.#busy = true;
|
|
653
674
|
this.#abort = abort;
|
|
654
675
|
this.#turnId = turnId ?? null;
|
|
676
|
+
this.#stopped = false;
|
|
655
677
|
this.#drainArmed = false;
|
|
656
678
|
// why: A message streams once it posts, so the previous reply never shows as streaming before it.
|
|
657
679
|
this.#set({ failure: null, ...(message ? {} : { streaming: true }) });
|
|
@@ -691,7 +713,7 @@ class Chat {
|
|
|
691
713
|
else {
|
|
692
714
|
waits = this.#settleLocal(iface, outcome, { workedMs: this.#replyWorkedMs, endedAt });
|
|
693
715
|
}
|
|
694
|
-
this.#runEnded(!outcome.failure && !waits);
|
|
716
|
+
this.#runEnded(!outcome.failure && !waits && !this.#stopped);
|
|
695
717
|
}
|
|
696
718
|
catch (err) {
|
|
697
719
|
// why: The run left no view to draw; the composer says so, and the chat stays as it stood.
|
|
@@ -775,7 +797,7 @@ class Chat {
|
|
|
775
797
|
}
|
|
776
798
|
this.#setDelivery(null);
|
|
777
799
|
const closing = failure.aborted
|
|
778
|
-
?
|
|
800
|
+
? stampWorked(stoppedReply(reply), worked)
|
|
779
801
|
: withTurnError(reply, failure.error);
|
|
780
802
|
this.#set({
|
|
781
803
|
session,
|
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, 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 TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, 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, postJson, postNdjson, readNdjsonStream, 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/page-dispatch.js
CHANGED
|
@@ -56,6 +56,7 @@ export function withPageToolDispatch(transport, tools) {
|
|
|
56
56
|
invoke: (request, onLine, signal) => transport.invoke(request, chatSink(onLine, signal), signal),
|
|
57
57
|
turn: (request, onLine, signal) => transport.turn(request, chatSink(onLine, signal), signal),
|
|
58
58
|
steer: transport.steer.bind(transport),
|
|
59
|
+
stop: transport.stop.bind(transport),
|
|
59
60
|
conversation: transport.conversation.bind(transport),
|
|
60
61
|
rewind: transport.rewind.bind(transport),
|
|
61
62
|
};
|
package/client/run-commit.d.ts
CHANGED
|
@@ -32,3 +32,8 @@ export declare function stampWorked(blocks: TranscriptBlock[], worked: {
|
|
|
32
32
|
workedMs: number;
|
|
33
33
|
endedAt: number;
|
|
34
34
|
}): TranscriptBlock[];
|
|
35
|
+
/**
|
|
36
|
+
* A reply the person stopped, as far as it went: what they saw stays, and it closes as cancelled
|
|
37
|
+
* unless the stream already closed it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function stoppedReply(blocks: TranscriptBlock[]): TranscriptBlock[];
|
package/client/run-commit.js
CHANGED
|
@@ -47,3 +47,12 @@ export function stampWorked(blocks, worked) {
|
|
|
47
47
|
return blocks;
|
|
48
48
|
return blocks.map((block, index) => (index === last ? { ...block, ...worked } : block));
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* A reply the person stopped, as far as it went: what they saw stays, and it closes as cancelled
|
|
52
|
+
* unless the stream already closed it.
|
|
53
|
+
*/
|
|
54
|
+
export function stoppedReply(blocks) {
|
|
55
|
+
if (blocks.some((block) => block.kind === 'turn-done'))
|
|
56
|
+
return blocks;
|
|
57
|
+
return [...blocks, { id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } }];
|
|
58
|
+
}
|
package/client/transport.d.ts
CHANGED
|
@@ -120,6 +120,12 @@ export type TheoremSteerRequest = {
|
|
|
120
120
|
};
|
|
121
121
|
/** A `/steer` body as `createTheoremHandler` reads it. */
|
|
122
122
|
export declare const theoremSteerRequestSchema: z.ZodType<TheoremSteerRequest>;
|
|
123
|
+
/** Stop an in-flight turn where it stands; its stream ends with what the reply left. */
|
|
124
|
+
export type TheoremStopRequest = {
|
|
125
|
+
turnId: string;
|
|
126
|
+
};
|
|
127
|
+
/** A `/stop` body as `createTheoremHandler` reads it. */
|
|
128
|
+
export declare const theoremStopRequestSchema: z.ZodType<TheoremStopRequest>;
|
|
123
129
|
/**
|
|
124
130
|
* The ref the store issued for the conversation a run wrote: the client sends
|
|
125
131
|
* it with its next request. It comes before the run's error, which ends the stream.
|
|
@@ -181,6 +187,8 @@ export interface TheoremTransport {
|
|
|
181
187
|
turn(request: TheoremTurnRequest, onLine: ChatLineSink, signal?: AbortSignal): Promise<void>;
|
|
182
188
|
invoke(request: TheoremInvokeRequest, onLine: ChatLineSink, signal?: AbortSignal): Promise<void>;
|
|
183
189
|
steer(request: TheoremSteerRequest): Promise<void>;
|
|
190
|
+
/** Stops the turn; refused (`session.turn_ended`) when this host is not running it. */
|
|
191
|
+
stop(request: TheoremStopRequest): Promise<void>;
|
|
184
192
|
/** The conversation under `ref`, as the host shows it. */
|
|
185
193
|
conversation(request: TheoremConversationRequest, signal?: AbortSignal): Promise<ConversationView>;
|
|
186
194
|
/** The conversation cut after a transcript block, under the ref the host issued for it. */
|
package/client/transport.js
CHANGED
|
@@ -83,6 +83,10 @@ const theoremSteerRequest = z.object({
|
|
|
83
83
|
true;
|
|
84
84
|
/** A `/steer` body as `createTheoremHandler` reads it. */
|
|
85
85
|
export const theoremSteerRequestSchema = theoremSteerRequest;
|
|
86
|
+
const theoremStopRequest = z.object({ turnId: clientId });
|
|
87
|
+
true;
|
|
88
|
+
/** A `/stop` body as `createTheoremHandler` reads it. */
|
|
89
|
+
export const theoremStopRequestSchema = theoremStopRequest;
|
|
86
90
|
const conversationLine = z.object({
|
|
87
91
|
type: z.literal('conversation'),
|
|
88
92
|
ref: z.string().min(1),
|
|
@@ -339,5 +343,6 @@ export function createHttpTransport(options = {}) {
|
|
|
339
343
|
rewind: (body, signal) => fetchRewound(`${base}/rewind`, body, { ...options, signal }),
|
|
340
344
|
pageResult: (body, signal) => postJson(`${base}/page-result`, body, { ...options, signal }),
|
|
341
345
|
steer: (body) => postJson(`${base}/steer`, body, options),
|
|
346
|
+
stop: (body) => postJson(`${base}/stop`, body, options),
|
|
342
347
|
};
|
|
343
348
|
}
|
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.413.gb7a2b75",
|
|
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.413.gb7a2b75"
|
|
29
29
|
}
|
|
30
30
|
}
|
package/server/conversation.js
CHANGED
|
@@ -19,7 +19,7 @@ import { sanitizeTurnRequestWithEvents, withPublicLabels } from '@theoremjs/agen
|
|
|
19
19
|
import { forClient } from '@theoremjs/agents/host';
|
|
20
20
|
import { appendAssistantEventsToHistory, appendUserDraftToHistory, applyTurnEventsToSession, emptyInterfaceTurnSession, foldTurnEvents, gatedToolsFromEvents, historyFromTranscriptBlocks, prepareUserTurn, settlesToolCall, streamThoughtsEnabled, } from '@theoremjs/agents/interface';
|
|
21
21
|
import { base64ToBytes } from '@theoremjs/agents/kernel';
|
|
22
|
-
import { stampWorked } from "../client/run-commit.js";
|
|
22
|
+
import { stampWorked, stoppedReply } from "../client/run-commit.js";
|
|
23
23
|
import { emptyConversation, unknownConversation, } from "./conversation-store.js";
|
|
24
24
|
import { kernelTurnInput } from "./turn-input.js";
|
|
25
25
|
/** The paused call a reference names, when its call id and its operation both match. */
|
|
@@ -166,15 +166,15 @@ function metered(conversation, events) {
|
|
|
166
166
|
...(applied.historyTokens === undefined ? {} : { historyTokens: applied.historyTokens }),
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
-
/** The reply is over: its blocks join the transcript and its events the history. */
|
|
170
|
-
function committed(host, conversation, reply) {
|
|
169
|
+
/** The reply is over: its blocks, as `closing` ends them, join the transcript and its events the history. */
|
|
170
|
+
function committed(host, conversation, reply, closing = (blocks) => blocks) {
|
|
171
171
|
const { paused: _paused, providerState: _state, ...rest } = conversation;
|
|
172
172
|
return {
|
|
173
173
|
...rest,
|
|
174
174
|
...metered(conversation, reply.events),
|
|
175
175
|
transcript: [
|
|
176
176
|
...conversation.transcript,
|
|
177
|
-
...stampWorked(foldedFor(host, reply.events), {
|
|
177
|
+
...stampWorked(closing(foldedFor(host, reply.events)), {
|
|
178
178
|
workedMs: reply.workedMs,
|
|
179
179
|
endedAt: Date.now(),
|
|
180
180
|
}),
|
|
@@ -210,19 +210,11 @@ function failed(host, conversation, reply, failure) {
|
|
|
210
210
|
};
|
|
211
211
|
}
|
|
212
212
|
/**
|
|
213
|
-
* The person stopped the reply: it
|
|
214
|
-
*
|
|
213
|
+
* The person stopped the reply: what they saw of it stays, in the transcript and the history
|
|
214
|
+
* alike, closed as cancelled. A call still running when it stopped enters neither.
|
|
215
215
|
*/
|
|
216
|
-
function stopped(conversation, reply) {
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
...rest,
|
|
220
|
-
transcript: [
|
|
221
|
-
...conversation.transcript,
|
|
222
|
-
{ id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } },
|
|
223
|
-
],
|
|
224
|
-
history: appendAssistantEventsToHistory(reply.base, reply.events.slice(reply.baseEvents, Math.max(reply.baseEvents, reply.seed))),
|
|
225
|
-
};
|
|
216
|
+
function stopped(host, conversation, reply) {
|
|
217
|
+
return committed(host, conversation, reply, stoppedReply);
|
|
226
218
|
}
|
|
227
219
|
/**
|
|
228
220
|
* Runs the model for `reply` and writes what it leaves. A compaction moves the
|
|
@@ -258,14 +250,17 @@ async function* runReply(host, opened, reply, runner, input, withMessage) {
|
|
|
258
250
|
}
|
|
259
251
|
catch (err) {
|
|
260
252
|
current = await save(host, current, runner.signal.aborted
|
|
261
|
-
? stopped(conversation, worked())
|
|
253
|
+
? stopped(host, conversation, worked())
|
|
262
254
|
: failed(host, conversation, worked(), toErrorEvent(err)));
|
|
263
255
|
yield* refLine(current);
|
|
264
|
-
|
|
256
|
+
// why: A stopped reply is no failure; the browser draws what it left from the ref.
|
|
257
|
+
if (!runner.signal.aborted)
|
|
258
|
+
throw err;
|
|
259
|
+
return;
|
|
265
260
|
}
|
|
266
261
|
const settled = worked();
|
|
267
262
|
if (runner.signal.aborted) {
|
|
268
|
-
current = await save(host, current, stopped(conversation, settled));
|
|
263
|
+
current = await save(host, current, stopped(host, conversation, settled));
|
|
269
264
|
}
|
|
270
265
|
else if (errorEvent) {
|
|
271
266
|
current = await save(host, current, failed(host, conversation, settled, errorEvent));
|
|
@@ -396,7 +391,6 @@ function pausedReply(paused) {
|
|
|
396
391
|
base: paused.base,
|
|
397
392
|
events: [...paused.events],
|
|
398
393
|
baseEvents: paused.baseEvents,
|
|
399
|
-
seed: paused.events.length,
|
|
400
394
|
workedMs: paused.workedMs,
|
|
401
395
|
};
|
|
402
396
|
}
|
|
@@ -443,7 +437,6 @@ export async function* messageReply(args) {
|
|
|
443
437
|
base: withMessage(conversation.history),
|
|
444
438
|
events: [],
|
|
445
439
|
baseEvents: 0,
|
|
446
|
-
seed: 0,
|
|
447
440
|
workedMs: 0,
|
|
448
441
|
};
|
|
449
442
|
yield* runReply(host, opened, reply, runner, {
|
|
@@ -478,7 +471,6 @@ export async function* answerReply(args) {
|
|
|
478
471
|
yield event;
|
|
479
472
|
}
|
|
480
473
|
reply.workedMs += Date.now() - startedAt;
|
|
481
|
-
reply.seed = reply.events.length;
|
|
482
474
|
if (error || gatedToolsFromEvents(reply.events).length) {
|
|
483
475
|
// why: An answer that failed before its call settled leaves the reply as it was, to answer again.
|
|
484
476
|
const settledCall = reply.events.some((event) => settlesToolCall(event, args.call.callId));
|
package/server/handler.js
CHANGED
|
@@ -38,7 +38,7 @@ import { caughtStatus, clientError, forClient, HTTP_METHOD, } from '@theoremjs/a
|
|
|
38
38
|
import { clientProfileInterface, gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
|
|
39
39
|
import { answerGatedCall, resolveGateTtlMs, } from '@theoremjs/agents/kernel';
|
|
40
40
|
import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
|
|
41
|
-
import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
41
|
+
import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
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";
|
|
@@ -50,7 +50,7 @@ import { boundedRequestJson, requestByteLimit } from "./request-body.js";
|
|
|
50
50
|
import { checkRequest } from "./request-check.js";
|
|
51
51
|
import { resolveSessionLimits } from "./session-limits.js";
|
|
52
52
|
import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
|
|
53
|
-
import { createMemorySteerInbox, steerStage, steerUnitOf } from "./steer-inbox.js";
|
|
53
|
+
import { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
|
|
54
54
|
import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
|
|
55
55
|
const NDJSON_HEADERS = {
|
|
56
56
|
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
@@ -90,6 +90,7 @@ function routeOf(request) {
|
|
|
90
90
|
return last === 'turn' ||
|
|
91
91
|
last === 'invoke' ||
|
|
92
92
|
last === 'steer' ||
|
|
93
|
+
last === 'stop' ||
|
|
93
94
|
last === 'conversation' ||
|
|
94
95
|
last === 'rewind' ||
|
|
95
96
|
last === 'call' ||
|
|
@@ -386,13 +387,14 @@ async function* once(event, labels) {
|
|
|
386
387
|
yield withPublicLabels(event, labels);
|
|
387
388
|
}
|
|
388
389
|
/** One model run of a chat's reply, its gates recorded in the session as they pause. */
|
|
389
|
-
async function* chatTurn(ctx, request, session, turn,
|
|
390
|
+
async function* chatTurn(ctx, request, session, turn, key, stop) {
|
|
390
391
|
const state = await ctx.sessions.read(session.id);
|
|
391
392
|
const provider = await providerFor(ctx, request, turn.model);
|
|
392
393
|
const host = await ctx.options.host?.(request);
|
|
393
|
-
|
|
394
|
-
if (key)
|
|
394
|
+
if (key) {
|
|
395
395
|
await ctx.inbox.open(key);
|
|
396
|
+
stopOnRequest(ctx.inbox, key, stop);
|
|
397
|
+
}
|
|
396
398
|
try {
|
|
397
399
|
const events = runTurn({
|
|
398
400
|
profile: ctx.profile.id,
|
|
@@ -402,7 +404,7 @@ async function* chatTurn(ctx, request, session, turn, turnId) {
|
|
|
402
404
|
...(turn.providerState ? { providerState: turn.providerState } : {}),
|
|
403
405
|
sessionPermissions: state.permissions,
|
|
404
406
|
credentials: sessionCredentials(ctx, session.id),
|
|
405
|
-
signal: request.signal,
|
|
407
|
+
signal: AbortSignal.any([request.signal, stop.signal]),
|
|
406
408
|
host,
|
|
407
409
|
...(turn.model ? { model: turn.model } : {}),
|
|
408
410
|
...(turn.effort ? { effort: turn.effort } : {}),
|
|
@@ -419,24 +421,27 @@ async function* chatTurn(ctx, request, session, turn, turnId) {
|
|
|
419
421
|
await ctx.inbox.close(key);
|
|
420
422
|
}
|
|
421
423
|
}
|
|
422
|
-
/** How this request runs the model for a reply: its generation, and the request's
|
|
424
|
+
/** How this request runs the model for a reply: its generation, and its abort (the request's, or a stop). */
|
|
423
425
|
function chatRunner(ctx, request, session, generation) {
|
|
426
|
+
const key = generation.turnId ? inboxKey(session.id, generation.turnId) : undefined;
|
|
427
|
+
const stop = new AbortController();
|
|
428
|
+
const signal = AbortSignal.any([request.signal, stop.signal]);
|
|
424
429
|
return {
|
|
425
|
-
run: (turn) => chatTurn(ctx, request, session, turn,
|
|
430
|
+
run: (turn) => chatTurn(ctx, request, session, turn, key, stop),
|
|
426
431
|
async compact(history, tokens, model) {
|
|
427
432
|
const provider = await providerFor(ctx, request, model);
|
|
428
433
|
const compacted = await compactHistory({
|
|
429
434
|
profile: ctx.profile.id,
|
|
430
435
|
history,
|
|
431
436
|
tokens,
|
|
432
|
-
signal
|
|
437
|
+
signal,
|
|
433
438
|
...(model ? { model } : {}),
|
|
434
439
|
}, provider);
|
|
435
440
|
return compacted?.history;
|
|
436
441
|
},
|
|
437
442
|
...(generation.model ? { model: generation.model } : {}),
|
|
438
443
|
...(generation.effort ? { effort: generation.effort } : {}),
|
|
439
|
-
signal
|
|
444
|
+
signal,
|
|
440
445
|
};
|
|
441
446
|
}
|
|
442
447
|
/** The chat's conversation as this session's runs read and write it; undefined when the profile keeps none. */
|
|
@@ -541,6 +546,17 @@ async function steer(ctx, request, session) {
|
|
|
541
546
|
}
|
|
542
547
|
return jsonResponse(200, { ok: true });
|
|
543
548
|
}
|
|
549
|
+
/** Stops a running turn where it stands; its stream goes on to save and send what the reply left. */
|
|
550
|
+
async function stop(ctx, request, session) {
|
|
551
|
+
const body = await readBody(request, theoremStopRequestSchema, ctx.options.maxRequestBytes);
|
|
552
|
+
if (!(await ctx.inbox.stop(inboxKey(session.id, body.turnId)))) {
|
|
553
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
554
|
+
throw new TheoremError('request', `turn ${body.turnId} is not running`, {
|
|
555
|
+
copy: { key: 'session.turn_ended' },
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return jsonResponse(200, { ok: true });
|
|
559
|
+
}
|
|
544
560
|
/** One call of one of a host's tools, run as the page asked it; a gate it pauses on waits in the session. */
|
|
545
561
|
async function* callEvents(ctx, request, session, body) {
|
|
546
562
|
const state = await ctx.sessions.read(session.id);
|
|
@@ -688,6 +704,8 @@ async function route(ctx, request, session) {
|
|
|
688
704
|
return conversation(ctx, request, session);
|
|
689
705
|
if (target === 'rewind')
|
|
690
706
|
return rewind(ctx, request, session);
|
|
707
|
+
if (target === 'stop')
|
|
708
|
+
return stop(ctx, request, session);
|
|
691
709
|
return steer(ctx, request, session);
|
|
692
710
|
}
|
|
693
711
|
export function createTheoremHandler(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 { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
|
|
10
|
+
export { theoremConversationRequestSchema, theoremInvokeRequestSchema, 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';
|
|
@@ -22,6 +22,6 @@ export { createTheoremSessionBoundary, type PreparedLiveOpen, type SessionBounda
|
|
|
22
22
|
export type { TheoremSessionLimits } from './session-limits.ts';
|
|
23
23
|
export { resolveSessionLimits } 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
|
-
export { createMemorySteerInbox, type SteerInbox, type SteerUnit, steerStage, steerUnitOf, } from './steer-inbox.ts';
|
|
25
|
+
export { createMemorySteerInbox, type SteerInbox, type SteerUnit, steerStage, steerUnitOf, stopOnRequest, } from './steer-inbox.ts';
|
|
26
26
|
export { sessionToolCoordinator } from './tool-operations.ts';
|
|
27
27
|
export { kernelTurnInput, liveSessionOpen } from './turn-input.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, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
9
|
+
export { theoremConversationRequestSchema, theoremInvokeRequestSchema, 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";
|
|
@@ -20,6 +20,6 @@ export { sessionStateStore } from "./session-authority.js";
|
|
|
20
20
|
export { createTheoremSessionBoundary, } from "./session-boundary.js";
|
|
21
21
|
export { resolveSessionLimits } from "./session-limits.js";
|
|
22
22
|
export { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, theoremSessionStateSchema, } from "./session-store.js";
|
|
23
|
-
export { createMemorySteerInbox, steerStage, steerUnitOf, } from "./steer-inbox.js";
|
|
23
|
+
export { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
|
|
24
24
|
export { sessionToolCoordinator } from "./tool-operations.js";
|
|
25
25
|
export { kernelTurnInput, liveSessionOpen } from "./turn-input.js";
|
package/server/steer-inbox.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mid-turn steer inbox — the client posts user messages while a turn streams;
|
|
3
|
-
* the turn's stage handler drains one unit per inject-capable stage.
|
|
3
|
+
* the turn's stage handler drains one unit per inject-capable stage. The
|
|
4
|
+
* client's stop reaches the turn through it too.
|
|
4
5
|
*
|
|
5
6
|
* @module
|
|
6
7
|
*/
|
|
@@ -21,8 +22,8 @@ export declare function steerUnitOf(steer: Pick<TheoremSteerRequest, 'id' | 'inj
|
|
|
21
22
|
export declare function steerStage(inbox: SteerInbox, key: string): StageHandler;
|
|
22
23
|
/**
|
|
23
24
|
* Keyed by client turn id. The default lives in process memory, which is only
|
|
24
|
-
* correct when steer POSTs reach the same process as the turn — swap
|
|
25
|
-
* shared store (KV, Redis, Durable Object) for multi-instance hosts.
|
|
25
|
+
* correct when steer and stop POSTs reach the same process as the turn — swap
|
|
26
|
+
* in a shared store (KV, Redis, Durable Object) for multi-instance hosts.
|
|
26
27
|
*/
|
|
27
28
|
export interface SteerInbox {
|
|
28
29
|
open(turnId: string): void | Promise<void>;
|
|
@@ -30,6 +31,12 @@ export interface SteerInbox {
|
|
|
30
31
|
enqueue(turnId: string, unit: SteerUnit): boolean | Promise<boolean>;
|
|
31
32
|
/** Next unit, FIFO, or `undefined`. */
|
|
32
33
|
consume(turnId: string): SteerUnit | undefined | Promise<SteerUnit | undefined>;
|
|
34
|
+
/** Stops the turn. Returns `false` when no turn with this id is open. */
|
|
35
|
+
stop(turnId: string): boolean | Promise<boolean>;
|
|
36
|
+
/** Settles `true` once the turn is stopped (at once if it already is), `false` once it closes. */
|
|
37
|
+
stopped(turnId: string): Promise<boolean>;
|
|
33
38
|
close(turnId: string): void | Promise<void>;
|
|
34
39
|
}
|
|
35
40
|
export declare function createMemorySteerInbox(): SteerInbox;
|
|
41
|
+
/** Aborts `stop` once the inbox's turn is stopped. */
|
|
42
|
+
export declare function stopOnRequest(inbox: SteerInbox, turnId: string, stop: AbortController): void;
|
package/server/steer-inbox.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mid-turn steer inbox — the client posts user messages while a turn streams;
|
|
3
|
-
* the turn's stage handler drains one unit per inject-capable stage.
|
|
3
|
+
* the turn's stage handler drains one unit per inject-capable stage. The
|
|
4
|
+
* client's stop reaches the turn through it too.
|
|
4
5
|
*
|
|
5
6
|
* @module
|
|
6
7
|
*/
|
|
@@ -29,24 +30,54 @@ export function steerStage(inbox, key) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
export function createMemorySteerInbox() {
|
|
32
|
-
const
|
|
33
|
+
const turns = new Map();
|
|
33
34
|
return {
|
|
34
35
|
open(turnId) {
|
|
35
|
-
if (!
|
|
36
|
-
|
|
36
|
+
if (!turns.has(turnId))
|
|
37
|
+
turns.set(turnId, { queue: [], stopped: false, settle: new Set() });
|
|
37
38
|
},
|
|
38
39
|
enqueue(turnId, unit) {
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
40
|
+
const turn = turns.get(turnId);
|
|
41
|
+
if (!turn)
|
|
41
42
|
return false;
|
|
42
|
-
queue.push(structuredClone(unit));
|
|
43
|
+
turn.queue.push(structuredClone(unit));
|
|
43
44
|
return true;
|
|
44
45
|
},
|
|
45
46
|
consume(turnId) {
|
|
46
|
-
return
|
|
47
|
+
return turns.get(turnId)?.queue.shift();
|
|
48
|
+
},
|
|
49
|
+
stop(turnId) {
|
|
50
|
+
const turn = turns.get(turnId);
|
|
51
|
+
if (!turn)
|
|
52
|
+
return false;
|
|
53
|
+
turn.stopped = true;
|
|
54
|
+
for (const settle of turn.settle)
|
|
55
|
+
settle(true);
|
|
56
|
+
turn.settle.clear();
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
stopped(turnId) {
|
|
60
|
+
const turn = turns.get(turnId);
|
|
61
|
+
if (!turn)
|
|
62
|
+
return Promise.resolve(false);
|
|
63
|
+
if (turn.stopped)
|
|
64
|
+
return Promise.resolve(true);
|
|
65
|
+
return new Promise((resolve) => turn.settle.add(resolve));
|
|
47
66
|
},
|
|
48
67
|
close(turnId) {
|
|
49
|
-
|
|
68
|
+
const turn = turns.get(turnId);
|
|
69
|
+
for (const settle of turn?.settle ?? [])
|
|
70
|
+
settle(false);
|
|
71
|
+
turns.delete(turnId);
|
|
50
72
|
},
|
|
51
73
|
};
|
|
52
74
|
}
|
|
75
|
+
/** Aborts `stop` once the inbox's turn is stopped. */
|
|
76
|
+
export function stopOnRequest(inbox, turnId, stop) {
|
|
77
|
+
inbox.stopped(turnId).then((stopped) => {
|
|
78
|
+
if (stopped)
|
|
79
|
+
stop.abort();
|
|
80
|
+
},
|
|
81
|
+
// why: An inbox that can't say leaves the turn to run; the browser's fallback drops the stream.
|
|
82
|
+
() => { });
|
|
83
|
+
}
|