@theoremjs/web 0.3.4-next.649.gf83cea1 → 0.3.4-next.671.gfa09ab2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,6 +150,27 @@ for await (const event of session.events()) {
150
150
  - `open.host` is the `openMessage` that the page's `connection` returned.
151
151
  - Read later messages with `parseLiveClientMessage`. Give context messages to `session.sendContext({ client: message.context })`.
152
152
 
153
+ ### The live client
154
+
155
+ `LiveSessionClient` opens the call from the page. It runs each call the model makes on the relay:
156
+
157
+ ```ts
158
+ import { LiveSessionClient } from '@theoremjs/web/client';
159
+
160
+ const client = new LiveSessionClient({
161
+ profile: 'guide',
162
+ pageTools: {
163
+ highlight: (args) => ({ output: { highlighted: String(args.target) } }),
164
+ },
165
+ });
166
+ await client.connect();
167
+ ```
168
+
169
+ - A call dispatched to the page runs the `pageTools` function of the same name. Its `output` goes to the model.
170
+ - A page tool with no function, or a function that throws, answers with no answer. The console names a page tool that has no function. The call does not wait.
171
+ - `onGate` answers every other gate: ask the user, then return what `continueGatedToolInvocation` makes of the answer. Without `onGate`, the client refuses every gate.
172
+ - When the connection closes, the calls still open fail, and the relay closes the session.
173
+
153
174
  ## A chat with no framework
154
175
 
155
176
  Use `createChatController` when you draw the chat yourself, in any framework or none. It holds all of the chat's logic, and it draws nothing:
@@ -188,13 +209,14 @@ await chat.submit();
188
209
  | `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
210
  | `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
211
  | `enqueue('stash')` | Keeps the draft without sending it. `queuePending(id)` moves it to the queue. |
212
+ | `pend(message)` | Steers, queues, or stashes a message you made with `createComposerPendingMessage`, as `enqueue` does the draft. A message queued on an idle chat sends at once. A composer shared by several chats gives each the same message, so its id names it in all of them. |
191
213
  | `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
214
  | `movePending(id, direction)` | Moves a pending message one place `'up'` or `'down'` among the messages of its kind. `composerPendingMoves(messages, id)` from `@theoremjs/agents/interface` says which ways it can go, so a control that can do nothing is not drawn. |
193
215
  | `restorePending(id)` | Puts a pending message back in the composer. The draft that was there is stashed first, so the user loses nothing. |
194
216
  | `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. |
195
217
  | `decide(action)`, `authenticate(secret?)` | Answer the gate that the reply waits on. |
196
218
  | `branch(index)` | Cuts the conversation after transcript block `index`. |
197
- | `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. |
219
+ | `sendText(text, options?)` | Sends a message from your code. `options` can add `files`, `voice`, a `model` and its `effort`. The model becomes the chat's pick, as if chosen in the composer. An `effort` without a `model` applies to the chat's current model. A model the profile does not have is refused by the turn, not replaced by the default. It resolves with the blocks the turn added, or `null` when the chat cannot take a message now. |
198
220
 
199
221
  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.
200
222
 
@@ -245,7 +267,7 @@ const transport = withPageToolDispatch(
245
267
  | `authorizationUrl` | Returns the sign-in URL for an OAuth gate. Without it, an OAuth gate has no URL. |
246
268
  | `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
269
  | `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. |
270
+ | `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. The browser clients answer every call they get, so only a page function that never returns holds a turn. |
249
271
  | `operationRetentionMs` | How long a settled tool result is kept. Default: 24 hours. |
250
272
  | `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. |
251
273
  | `onError` | Receives each error that the handler catches. A visitor sees the wording of the profile for that kind of error, never the error. |
@@ -334,6 +356,7 @@ interface ConversationDatabase {
334
356
  - A null `ref` creates a conversation. Return a new opaque `ref` and `revision`.
335
357
  - Otherwise, write only while the stored revision is still `expectedRevision`, as one atomic operation. Return `{ written: false }` on a conflict.
336
358
  - Check what you read back with `storedConversationSchema`.
359
+ - Keep `id` as the handler gives it. It names the conversation in each turn's trace as `gen_ai.conversation.id`, and stays the same through every save and rewind.
337
360
 
338
361
  ### Share session authority
339
362
 
@@ -429,7 +452,7 @@ Forward live page results to `answerPageResult`. Preserve checkpoint ownership t
429
452
  The default `PageToolBroker` keeps active result waiters in process memory. Several workers need a shared broker or request affinity.
430
453
  Use `pageToolBroker` to supply that broker. It carries results; the atomic session store retains execution authority.
431
454
  Lost result delivery does not permit another dispatch. The operation retains its claim or an uncertain outcome.
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.
455
+ Set `pageToolTimeoutMs` to give the page a deadline for a result. Unset, a dispatch waits until the page answers or the turn stops. The browser clients answer every dispatch they get, with no answer when the page has no function for the tool, so only a page function that never returns holds a turn. An expired or interrupted result cannot reopen approval.
433
456
 
434
457
  ## Build your own server
435
458
 
package/client/chat.d.ts CHANGED
@@ -131,6 +131,11 @@ export type ChatController = {
131
131
  stop(): void;
132
132
  /** Steers, queues, or stashes the composer's draft. A steer goes to the running turn. */
133
133
  enqueue(kind: 'queue' | 'steer' | 'stash'): Promise<void>;
134
+ /**
135
+ * Steers, queues, or stashes a message made elsewhere, as `enqueue` does the draft: a composer
136
+ * shared by several chats gives each the same message, so its id names it in all of them.
137
+ */
138
+ pend(message: ComposerPendingMessage): Promise<void>;
134
139
  /**
135
140
  * Sends a pending message, or the composer's draft, now: a streaming reply stops first, and a
136
141
  * reply that waits on gates is walked away from.
@@ -159,10 +164,21 @@ export type ChatController = {
159
164
  }): void;
160
165
  pickSlot(name: string, value: string): void;
161
166
  /**
162
- * Sends `text` and optional files as the composer would, and resolves once the reply is done;
163
- * `null` when it can't go now (a reply is streaming, or waits on a gate).
167
+ * Sends `text`, and what `options` adds, as the composer would, and resolves once the reply is
168
+ * done; `null` when it can't go now (a reply is streaming, or waits on a gate).
164
169
  */
165
- sendText(text: string, files?: readonly File[]): Promise<SentTurn | null>;
170
+ sendText(text: string, options?: SendTextOptions): Promise<SentTurn | null>;
171
+ };
172
+ /**
173
+ * What `sendText` sends with its text. A `model` becomes the chat's pick, as if chosen in the
174
+ * composer; one the profile doesn't have is refused by the turn, not swapped for its default.
175
+ */
176
+ export type SendTextOptions = {
177
+ files?: readonly File[];
178
+ voice?: readonly File[];
179
+ model?: string;
180
+ /** The model's effort. Left out, the model's default. */
181
+ effort?: string;
166
182
  };
167
183
  /** A chat for `options`. Framework-free: see `ChatController`. */
168
184
  export declare function createChatController(options: ChatOptions): ChatController;
package/client/chat.js CHANGED
@@ -199,23 +199,35 @@ class Chat {
199
199
  if (!composerHasPayload(draftText, pendingFiles, pendingVoice))
200
200
  return;
201
201
  this.#set({ issues: [] });
202
+ let message;
202
203
  try {
203
204
  const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
204
- const message = createComposerPendingMessage({ kind, draft });
205
- this.#set({
206
- pendingMessages: orderComposerPendingMessages([...this.#state.pendingMessages, message]),
207
- ...this.#clearedComposer(),
208
- });
209
- if (kind !== 'steer')
210
- return;
211
- const turnId = this.#turnId;
212
- if (!turnId) {
213
- this.#steerAfterTurnEnded();
214
- return;
215
- }
216
- const inject = userDraftToSteerInject(draft);
217
- if (inject.length === 0)
218
- return;
205
+ message = createComposerPendingMessage({ kind, draft });
206
+ }
207
+ catch (err) {
208
+ this.#set({ failure: clientFailure(err, this.#options.iface?.labels) });
209
+ return;
210
+ }
211
+ this.#set(this.#clearedComposer());
212
+ await this.pend(message);
213
+ };
214
+ pend = async (message) => {
215
+ this.#set({
216
+ pendingMessages: orderComposerPendingMessages([...this.#state.pendingMessages, message]),
217
+ });
218
+ if (message.kind === 'queue' && this.#state.phase === 'idle')
219
+ this.#drainArmed = true;
220
+ if (message.kind !== 'steer')
221
+ return;
222
+ const turnId = this.#turnId;
223
+ if (!turnId) {
224
+ this.#steerAfterTurnEnded();
225
+ return;
226
+ }
227
+ const inject = userDraftToSteerInject(message.draft);
228
+ if (inject.length === 0)
229
+ return;
230
+ try {
219
231
  await this.#options.transport.steer({ turnId, id: message.id, inject });
220
232
  }
221
233
  catch (err) {
@@ -327,11 +339,20 @@ class Chat {
327
339
  this.#set({ slots: slotsOf(this.#options.iface, this.#options.slots, this.#picked).slots });
328
340
  this.#options.onSlotsChange?.({ ...before, [name]: value });
329
341
  };
330
- sendText = async (text, files = []) => {
342
+ sendText = async (text, options = {}) => {
331
343
  if (!this.#options.iface || this.#state.phase !== 'idle')
332
344
  return null;
345
+ // invariant: the turn reads the pick from the session, before `#followDefaults` next runs.
346
+ const modelId = options.model ??
347
+ (options.effort === undefined ? undefined : this.#state.session.selectedModel);
348
+ if (modelId !== undefined)
349
+ this.setGeneration({ modelId, effort: options.effort });
333
350
  const before = this.#state.blocks.length;
334
- await this.#sendFields({ text, files: [...files], voice: [] });
351
+ await this.#sendFields({
352
+ text,
353
+ files: [...(options.files ?? [])],
354
+ voice: [...(options.voice ?? [])],
355
+ });
335
356
  return { blocks: this.#state.blocks.slice(before) };
336
357
  };
337
358
  #set(patch) {
@@ -396,6 +417,14 @@ class Chat {
396
417
  });
397
418
  }
398
419
  /** Draws the conversation as the host shows it: its transcript, and the reply that waits on gates. */
420
+ /**
421
+ * The session a run leaves, with the model and effort picked now.
422
+ * why: the run carries the pick it started with; one made while it ran must outlast it.
423
+ */
424
+ #sessionAfter(session, events) {
425
+ const { selectedModel, selectedEffort } = this.#state.session;
426
+ return { ...sessionAfter(session, events), selectedModel, selectedEffort };
427
+ }
399
428
  #draw(view, session, events) {
400
429
  const shown = [...this.#state.blocks, ...this.#state.streamBlocks];
401
430
  const blocks = keepIds(shown, view.transcript);
@@ -403,15 +432,19 @@ class Chat {
403
432
  blocks,
404
433
  streamBlocks: keepIds(shown.slice(blocks.length), view.paused?.blocks ?? []),
405
434
  // why: A reply at rest leaves the run's own events (tokens, an ask); a paused one, the host's.
406
- session: sessionAfter(session, view.paused?.events ?? events),
435
+ session: this.#sessionAfter(session, view.paused?.events ?? events),
407
436
  chatStarted: view.transcript.length > 0 || view.paused !== undefined,
408
437
  });
409
438
  }
410
439
  /**
411
440
  * Keeps the model and effort valid as the interface changes: a pick still on the old defaults
412
441
  * follows the new ones, and a pick the profile no longer has falls back to them.
442
+ * invariant: judged only when the interface changes. A pick made under this one stays, and a
443
+ * model it does not have is refused by the turn, not swapped for the default.
413
444
  */
414
445
  #followDefaults(iface) {
446
+ if (iface === this.#defaultsFrom)
447
+ return;
415
448
  const { session } = this.#state;
416
449
  const next = followGenerationDefaults(iface, { model: session.selectedModel, effort: session.selectedEffort }, this.#defaultsFrom);
417
450
  this.#defaultsFrom = iface;
@@ -789,7 +822,7 @@ class Chat {
789
822
  * Returns whether it waits.
790
823
  */
791
824
  #settleLocal(iface, outcome, worked) {
792
- const session = sessionAfter(outcome.session, outcome.events);
825
+ const session = this.#sessionAfter(outcome.session, outcome.events);
793
826
  const { failure } = outcome;
794
827
  const reply = stampWorked(foldAssistantTurn(iface, outcome.events), worked);
795
828
  if (session.gatedTool !== null) {
package/client/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { float32Rms, float32RmsToLevel, INPUT_LEVEL_GAIN, timeDomainBytesToLevel, } from './audio-level.ts';
2
- export { type ChatController, type ChatOptions, type ChatSlotDecl, type ChatSlots, type ChatState, type ChatUpdate, createChatController, type MessageDelivery, type SentTurn, } from './chat.ts';
2
+ export { type ChatController, type ChatOptions, type ChatSlotDecl, type ChatSlots, type ChatState, type ChatUpdate, createChatController, type MessageDelivery, type SendTextOptions, type SentTurn, } from './chat.ts';
3
3
  export { type ComposerDrawerKind, type ComposerDrawerSummary, composerDrawerSummary, } from './composer-drawer.ts';
4
4
  export { type ComposerHint, isStashShortcut, resolveComposerHint, STASH_SHORTCUT, } from './composer-hints.ts';
5
5
  export { type ComposerActionState, composerActionState, composerHasPayload, } from './composer-primary.ts';
@@ -16,7 +16,7 @@ export { createHostTransport, HOST_TOOL_KINDS, type HostInterface, type HostTool
16
16
  export type { LiveCallOptions, PageTool, PageToolAnswer, PageTools, } from './live/live-page-tool.ts';
17
17
  export { pageToolMismatch, warnPageToolMismatch } from './live/live-page-tool.ts';
18
18
  export { type LiveConnectPhase, type LiveSessionStatus, type LiveState, liveState, } from './live/live-state.ts';
19
- export type { LiveGateAnswer, LiveToolGatePrompt } from './live/live-tool.ts';
19
+ export type { LiveGateAnswer, LiveGateReply, LiveToolGatePrompt } from './live/live-tool.ts';
20
20
  export type { LiveConnection } from './live-client.ts';
21
21
  export { LiveSessionClient } from './live-client.ts';
22
22
  export type { ExecuteToolOnRelay, LiveSocket } from './live-messages.ts';
@@ -9,3 +9,14 @@ export type LiveToolGatePrompt = {
9
9
  };
10
10
  /** The user's answer to a live gate, or `withdrawn`: the model cancelled the call, so nothing is asked. */
11
11
  export type LiveGateAnswer = ToolGateResolution | 'withdrawn';
12
+ /**
13
+ * What the host sends for a gate: a refusal, or an approval (with the key the user typed at a
14
+ * sign-in gate), or `withdrawn` when the model cancelled the call. `continueGatedToolInvocation`
15
+ * turns the user's answer into one.
16
+ */
17
+ export type LiveGateReply = {
18
+ decision: 'deny';
19
+ } | {
20
+ decision: 'approve';
21
+ secret?: string;
22
+ } | 'withdrawn';
@@ -0,0 +1,18 @@
1
+ import type { ExecuteToolOnRelay } from '../live-messages.ts';
2
+ import type { PageTools } from './live-page-tool.ts';
3
+ import type { LiveGateReply, LiveToolGatePrompt } from './live-tool.ts';
4
+ /**
5
+ * One call the model made, run to its end: the relay checks it, the page answers a call
6
+ * dispatched to it, and `onGate` answers any other gate. Every step is answered: a page tool
7
+ * with no handler, or one that throws, answers with no answer, and with no `onGate` a gate is
8
+ * refused. A call the model cancels while its gate is open is gone: nothing is sent.
9
+ */
10
+ export declare function runLiveToolCall(args: {
11
+ executeToolOnRelay: ExecuteToolOnRelay;
12
+ pageTools?: PageTools;
13
+ onGate?: (prompt: LiveToolGatePrompt) => Promise<LiveGateReply>;
14
+ name: string;
15
+ toolArgs: Record<string, unknown>;
16
+ callId: string;
17
+ operationId: string;
18
+ }): Promise<void>;
@@ -0,0 +1,65 @@
1
+ /** Page tools already said to have no handler, so the console says it once for each name. */
2
+ const unhandled = new Set();
3
+ /**
4
+ * One call the model made, run to its end: the relay checks it, the page answers a call
5
+ * dispatched to it, and `onGate` answers any other gate. Every step is answered: a page tool
6
+ * with no handler, or one that throws, answers with no answer, and with no `onGate` a gate is
7
+ * refused. A call the model cancels while its gate is open is gone: nothing is sent.
8
+ */
9
+ export async function runLiveToolCall(args) {
10
+ const { executeToolOnRelay, name, toolArgs, callId, operationId } = args;
11
+ // why: A name from the model is looked up among the host's own keys only.
12
+ const pageTool = args.pageTools && Object.hasOwn(args.pageTools, name) ? args.pageTools[name] : undefined;
13
+ let step = await executeToolOnRelay({ callId, operationId });
14
+ const dispatched = new Set();
15
+ while (step.status !== 'settled') {
16
+ if (step.status === 'dispatched') {
17
+ if (dispatched.has(step.grantId))
18
+ return;
19
+ dispatched.add(step.grantId);
20
+ const grantId = step.grantId;
21
+ const input = step.arguments;
22
+ if (!pageTool && !unhandled.has(name)) {
23
+ unhandled.add(name);
24
+ // labels-exempt: builder diagnostic
25
+ console.warn(`Theorem: page tool '${name}' has no handler in pageTools; the agent gets no answer.`);
26
+ }
27
+ const answer = pageTool
28
+ ? await Promise.resolve()
29
+ .then(() => pageTool(input, { callId, operationId }))
30
+ .catch(() => undefined)
31
+ : undefined;
32
+ step = await executeToolOnRelay({
33
+ callId,
34
+ operationId,
35
+ grantId,
36
+ ...(answer ? { output: answer.output } : { unanswered: true }),
37
+ });
38
+ continue;
39
+ }
40
+ const { gate } = step;
41
+ // why: The page answers only a dispatched call; an old page gate is refused.
42
+ const reply = gate.kind === 'page' || !args.onGate
43
+ ? { decision: 'deny' }
44
+ : await args.onGate({
45
+ callId,
46
+ toolName: name,
47
+ input: gate.preparedArguments ?? toolArgs,
48
+ gate,
49
+ });
50
+ if (reply === 'withdrawn')
51
+ return;
52
+ if (reply.decision === 'deny') {
53
+ // why: The session settles the refusal; its tool event tells the user.
54
+ await executeToolOnRelay({ callId, operationId, decision: 'deny' });
55
+ return;
56
+ }
57
+ // why: Signed in: a typed key goes once, with the approval; after an OAuth callback there is none.
58
+ step = await executeToolOnRelay({
59
+ callId,
60
+ decision: 'approve',
61
+ operationId,
62
+ ...(reply.secret !== undefined ? { secret: reply.secret } : {}),
63
+ });
64
+ }
65
+ }
@@ -12,8 +12,9 @@
12
12
  * @module
13
13
  */
14
14
  import { type SessionEventOf, type TraceRecord } from '@theoremjs/agents';
15
+ import type { PageTools } from './live/live-page-tool.ts';
15
16
  import type { LiveConnectPhase, LiveSessionStatus } from './live/live-state.ts';
16
- import { type ExecuteToolOnRelay, type LiveToolStep } from './live-messages.ts';
17
+ import type { LiveGateReply, LiveToolGatePrompt } from './live/live-tool.ts';
17
18
  import { type ClientTurnEvent } from './transport.ts';
18
19
  export type { LiveConnection, LiveSocket } from './live-messages.ts';
19
20
  import type { LiveSocket } from './live-messages.ts';
@@ -52,13 +53,16 @@ export interface LiveClientOptions {
52
53
  */
53
54
  onSessionEnded?: (session: SessionEventOf<'ended'>) => void;
54
55
  /**
55
- * The model called a tool. The host runs it with `executeToolOnRelay` and
56
- * answers its gates; the session answers the model. A throw reaches `onError`.
56
+ * The page's tools, by name. The client runs each call the model makes on the relay, and
57
+ * answers a call dispatched to the page with the tool of that name. A call the page has no
58
+ * tool for, or whose tool throws, is answered with no answer, so the turn never waits on it.
57
59
  */
58
- onToolCall: (name: string, args: Record<string, unknown>, meta: {
59
- callId: string;
60
- operationId: string;
61
- }) => Promise<void>;
60
+ pageTools?: PageTools;
61
+ /**
62
+ * The host's answer to a gate on a call: ask the user, then send what
63
+ * `continueGatedToolInvocation` makes of their answer. Without it, every gate is refused.
64
+ */
65
+ onGate?: (prompt: LiveToolGatePrompt) => Promise<LiveGateReply>;
62
66
  /** The relay opened the call: the id it gave the call, and the profile it runs. */
63
67
  onSessionReady?: (info: {
64
68
  sessionId?: string;
@@ -167,7 +171,7 @@ export declare class LiveSessionClient {
167
171
  private handleDoneTurnEvent;
168
172
  private scheduleMediaChunks;
169
173
  /** The relay runs `LiveSession.executeTool`; see `ExecuteToolOnRelay`. */
170
- executeToolOnRelay(args: Parameters<ExecuteToolOnRelay>[0]): Promise<LiveToolStep>;
174
+ private executeToolOnRelay;
171
175
  private enqueueAudioChunk;
172
176
  cancelPlayback(): void;
173
177
  toggleMute(): boolean;
@@ -17,6 +17,7 @@ import { describeError, labelText, TheoremError, } from '@theoremjs/agents';
17
17
  import { base64ToBytes, bytesToBase64 } from '@theoremjs/agents/kernel';
18
18
  import { float32Rms, float32RmsToLevel, timeDomainBytesToLevel } from "./audio-level.js";
19
19
  import { applyLiveToolTurnEvent, liveTranscriptFromEvidence, shouldForwardMicFrame, } from "./live/live-mic-forward.js";
20
+ import { runLiveToolCall } from "./live/run-live-tool-call.js";
20
21
  import { isPermissionDeniedError } from "./live-errors.js";
21
22
  import { liveClientMessageSchema, parseLiveServerEnvelope, } from "./live-messages.js";
22
23
  import { MIC_CAPTURE_PROCESSOR, micCaptureWorkletUrl } from "./mic-capture.js";
@@ -462,7 +463,12 @@ export class LiveSessionClient {
462
463
  const runnableTools = accum.toolCalls.filter((call) => !accum.cancelledToolIds.has(call.id));
463
464
  for (const call of runnableTools) {
464
465
  this.toolChain = this.toolChain
465
- .then(() => this.options.onToolCall(call.name, call.arguments, {
466
+ .then(() => runLiveToolCall({
467
+ executeToolOnRelay: (args) => this.executeToolOnRelay(args),
468
+ pageTools: this.options.pageTools,
469
+ onGate: this.options.onGate,
470
+ name: call.name,
471
+ toolArgs: call.arguments,
466
472
  callId: call.id,
467
473
  operationId: issuedOperationId(call),
468
474
  }))
@@ -1,5 +1,5 @@
1
1
  import { labelText, TheoremError } from '@theoremjs/agents';
2
- import { foldTurnEvents, prepareUserTurn, streamThoughtsEnabled, } from '@theoremjs/agents/interface';
2
+ import { defaultInterfaceEffort, foldTurnEvents, prepareUserTurn, streamThoughtsEnabled, } from '@theoremjs/agents/interface';
3
3
  import { filesToPending } from "./encode-files.js";
4
4
  import { defaultModel } from "./generation-selection.js";
5
5
  export function issuedOperationId(call) {
@@ -11,17 +11,22 @@ function resolveModelId(iface, session) {
11
11
  return session.selectedModel ?? defaultModel(iface);
12
12
  }
13
13
  function resolveEffort(iface, session, modelId) {
14
+ const picked = session.selectedEffort;
14
15
  if (!modelId || !Object.hasOwn(iface.models, modelId))
15
- return undefined;
16
+ return picked;
16
17
  const binding = iface.models[modelId];
17
- if (!binding.allowEffortSelect)
18
- return undefined;
19
- return session.selectedEffort ?? binding.defaultEffort;
18
+ if (binding.allowEffortSelect)
19
+ return picked ?? binding.defaultEffort;
20
+ return picked === defaultInterfaceEffort(iface, modelId) ? undefined : picked;
20
21
  }
21
- /** Model / effort selection the host should honour for this session. */
22
+ /**
23
+ * Model / effort selection the host should honour for this session.
24
+ * invariant: a pick the profile cannot honour goes to the host, which refuses the turn; it is
25
+ * never dropped for the default. Its own defaults go unsaid where it lets no one pick.
26
+ */
22
27
  function generationFields(iface, session) {
23
28
  const modelId = resolveModelId(iface, session);
24
- const model = iface.allowModelSelect ? modelId : undefined;
29
+ const model = iface.allowModelSelect || modelId !== defaultModel(iface) ? modelId : undefined;
25
30
  const effort = resolveEffort(iface, session, modelId);
26
31
  return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
27
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/web",
3
- "version": "0.3.4-next.649.gf83cea1",
3
+ "version": "0.3.4-next.671.gfa09ab2",
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.649.gf83cea1"
28
+ "@theoremjs/agents": "^0.3.4-next.671.gfa09ab2"
29
29
  }
30
30
  }
@@ -29,7 +29,12 @@ export type PausedReply = {
29
29
  };
30
30
  /** One conversation as the host stores it. */
31
31
  export type StoredConversation = {
32
- version: 1;
32
+ version: 2;
33
+ /**
34
+ * The conversation's own id, kept through its rewinds. Each turn carries it as
35
+ * `conversationId`, which its trace records as `gen_ai.conversation.id`.
36
+ */
37
+ id: string;
33
38
  /** What the person reads, without the paused reply. */
34
39
  transcript: TranscriptBlock[];
35
40
  /** What the model reads on the next turn, compacted when the profile compacts. */
@@ -24,7 +24,8 @@ const pausedReply = z.object({
24
24
  });
25
25
  true;
26
26
  const storedConversation = z.object({
27
- version: z.literal(1),
27
+ version: z.literal(2),
28
+ id: z.string().min(1),
28
29
  transcript: z.array(transcriptBlockSchema),
29
30
  history: z.array(turnHistoryMessageSchema),
30
31
  providerState: providerCheckpointSchema.optional(),
@@ -37,7 +38,7 @@ true;
37
38
  export const storedConversationSchema = storedConversation;
38
39
  /** An empty conversation: what a chat with no ref starts from. */
39
40
  export function emptyConversation() {
40
- return { version: 1, transcript: [], history: [] };
41
+ return { version: 2, id: crypto.randomUUID(), transcript: [], history: [] };
41
42
  }
42
43
  /** Refuses a ref the store does not know or cannot open: the browser reads `session.conversation_unknown`. */
43
44
  export function unknownConversation(cause) {
@@ -114,6 +114,8 @@ export declare function conversationView(host: ConversationHost, conversation: S
114
114
  /** One model run of a reply: the kernel's turn input, the checkpoint it continues, and the generation. */
115
115
  export type ReplyTurn = {
116
116
  input: TurnInput;
117
+ /** The stored conversation's id; a run with no conversation has none. */
118
+ conversationId?: string;
117
119
  providerState?: ProviderCheckpoint;
118
120
  model?: string;
119
121
  effort?: string;
@@ -230,6 +230,7 @@ async function* runReply(host, opened, reply, runner, input, withMessage) {
230
230
  try {
231
231
  const events = runner.run({
232
232
  input,
233
+ conversationId: conversation.id,
233
234
  providerState: conversation.providerState,
234
235
  model: runner.model,
235
236
  effort: runner.effort,
@@ -519,7 +520,8 @@ export async function rewindConversation(args) {
519
520
  }
520
521
  const transcript = conversation.transcript.slice(0, at + 1);
521
522
  return save(host, opened, {
522
- version: 1,
523
+ version: 2,
524
+ id: conversation.id,
523
525
  transcript,
524
526
  history: historyFromTranscriptBlocks(transcript),
525
527
  });
package/server/handler.js CHANGED
@@ -411,6 +411,7 @@ async function* chatTurn(ctx, request, session, turn, key, stop) {
411
411
  execution: sessionToolCoordinator(ctx.sessions, session.id),
412
412
  pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker, ctx.options.pageToolTimeoutMs).execution,
413
413
  input: turn.input,
414
+ ...(turn.conversationId ? { conversationId: turn.conversationId } : {}),
414
415
  ...(turn.providerState ? { providerState: turn.providerState } : {}),
415
416
  sessionPermissions: state.permissions,
416
417
  signal: AbortSignal.any([request.signal, stop.signal]),