pi-roundtable-webchat 0.9.10 → 0.9.12

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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.12] - 2026-10-11
9
+
10
+ - Release in lockstep with pi-roundtable 0.9.12; no package-specific behavior changes.
11
+
12
+ ## [0.9.11] - 2026-10-11
13
+
14
+ ### Added
15
+
16
+ - Client calls: a connection may declare functions it can run for the assistant's tools, and a tool asks it through `turn.client` (`ClientPort`, new in pi-roundtable 0.9.11; this package needs pi-roundtable 0.9.11 with it, because its source imports types that release adds, so upgrade the two together; on an older core at run time the field is ignored and tools see no client). The client sends `client_hello` (`{ version: 1, tools }`); a call becomes `client_call` (`{ conversation, call, name, args, timeoutMs }`) on the one connection the turn's message arrived on, never the person's others, and is answered with `client_result` (`{ call, ok, value, parts? }` or `{ call, ok: false, code, message? }`) from that same connection; `client_cancel` (`turn_stopped`, `timeout` or `turn_ended`) tells the client to stop. A connection that never sends `client_hello` is unaffected: its turns carry no client.
17
+ - `PUT <path>/conversations/<id>/client-calls/<call>/parts/<n>`: the binary parts of an answer, such as a screenshot, are uploaded over REST before the answer names them, since a frame stays at 64 KiB. Only `image/jpeg`, `image/png` and `image/webp` are accepted, with their bytes checked against the type.
18
+ - `ready.clientCalls` (`{ timeoutMs, maxTimeoutMs, maxValueBytes, maxParts, maxPartBytes }`) advertises what a call may take; a server before this omits it. The error code `unknown_call` answers a `client_result` for a call not open on that connection.
19
+ - `limits.clientCalls`: `timeoutMs` (default 30 seconds), `maxTimeoutMs` (60 seconds), `maxValueBytes` (48 KiB), `maxParts` (2), `maxPartBytes` (2 MiB) and `perMinute` (60 calls a minute per connection; the configuration is refused when it reaches the `rate` frame rate, since each answer is a frame). `ClientCallLimits` types it. One call is open on a connection at a time; a closed connection ends its call as `disconnected`, with no second chance after a reconnect.
20
+
21
+ ### Changed
22
+
23
+ - The default `rate.messages` is 120 frames a minute per socket, up from 60, since a client's answers count with the person's own frames. `maxMessageBytes` stays 64 KiB.
24
+ - The REST preflight also allows `PUT`.
25
+
8
26
  ## [0.9.10] - 2026-10-10
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -203,12 +203,14 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
203
203
  | `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
204
204
  | `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
205
205
  | `{ type: "auth", token }` | A fresh token for the same person, sent after `reauth`. |
206
+ | `{ type: "client_hello", version: 1, tools }` | Declares the functions this connection can run for the assistant's tools, such as reading the page the person has open: up to 64 names, each lowercase words joined by underscores. Send it after each `ready`. See [Client calls](#client-calls). |
207
+ | `{ type: "client_result", call, ok: true, value, parts? }` or `{ type: "client_result", call, ok: false, code, message? }` | The answer to a `client_call`: the JSON `value` and how many binary `parts` were uploaded first, or a failure `code` (`refused`, `failed`, `timeout`, or `stopped`) with a short `message`. |
206
208
 
207
209
  ### Server frames
208
210
 
209
211
  | Frame | Meaning |
210
212
  |---|---|
211
- | `{ type: "ready", protocol, speaker, personas, attachments, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), and when the token expires. Open prompts follow it. |
213
+ | `{ type: "ready", protocol, speaker, personas, attachments, clientCalls, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), what a client call may take (`clientCalls`, below; absent from a server before 0.9.11, which never sends calls), and when the token expires. Open prompts follow it. |
212
214
  | `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
213
215
  | `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
214
216
  | `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
@@ -219,7 +221,9 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
219
221
  | `{ type: "prompt_closed", conversation, prompt, outcome }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, or `cancelled` (the turn stopped). |
220
222
  | `{ type: "notice", notice: { id, text, createdAt, readAt } }` | A durable private inbox entry; `readAt` is `null` until read. Fetch the REST inbox to recover entries missed while offline. |
221
223
  | `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
222
- | `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
224
+ | `{ type: "client_call", conversation, call, name, args, timeoutMs }` | The assistant asks this connection, and no other, to run the function `name` it declared and to answer with `client_result` within `timeoutMs`. See [Client calls](#client-calls). |
225
+ | `{ type: "client_cancel", call, reason }` | The call is no longer wanted (`turn_stopped`, `timeout`, or `turn_ended`): stop running it; an answer is dropped. |
226
+ | `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id, prompt id or call id it was about. |
223
227
 
224
228
  An approval prompt's `message` is the card as markdown, as it always was, and its long input values are cut there (each marked with its length). `approval`, when present, is the same approval as data, so a client need not read it back out of the markdown:
225
229
 
@@ -237,7 +241,7 @@ approval?: {
237
241
 
238
242
  The frame's size is bounded: when `approval` serialized as UTF-8 JSON would pass 256 KiB (`APPROVAL_MAX_BYTES` in `protocol.ts`), every string value of `input` longer than what fits is cut to its start plus `… [N chars]` (N counts code points; a cut never splits a surrogate pair) and `approval.truncated` is `true`. Then `input` is no longer whole: show `message`, or tell the person it is cut. If even the shortest cut cannot fit, `input` is `{}` and `files` are left out.
239
243
 
240
- Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
244
+ Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), `unknown_call` (a `client_result` named a call that is not open on this connection: it ended, was answered already, or was sent to another connection), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
241
245
 
242
246
  Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
243
247
  The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
@@ -253,6 +257,7 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
253
257
  | `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
254
258
  | `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
255
259
  | `POST <path>/conversations/<conversation>/files?name=<file name>` with the file's bytes as the body and its type as `Content-Type` | 201 `{ file, name, contentType, size }`: the file waits for your next message that names `file`. 400 `bad_request` (no name, or a name over 255 characters), 403 (not your conversation), 404 (unknown conversation), 413 `payload_too_large`, 415 `unsupported_media_type`, 429 `too_many_uploads`. See [Attachments](#attachments). |
260
+ | `PUT <path>/conversations/<conversation>/client-calls/<call>/parts/<n>` with the part's bytes as the body and an image type as `Content-Type` | 201 `{ part: n }`: part `n` (0-based) of the answer to an open [client call](#client-calls). 400 `bad_request` (`n` is not below `maxParts`), 403 (not your conversation), 404 `unknown_call` (not open, not yours, or not in that conversation), 409 `duplicate_part`, 413 `payload_too_large`, 415 `unsupported_media_type` (not `image/jpeg`, `image/png` or `image/webp`, or bytes that are not the type). |
256
261
  | `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
257
262
  | `GET <path>/notices?limit=50&before=<id>` | `{ notices: [{ id, text, createdAt, readAt }] }`: only your principal's entries on this surface, newest first, at most 100. Omit `before` for the first page; use its last id to fetch the next. |
258
263
  | `POST <path>/notices/<id>/read` | `{ notice }` with `readAt` set. Idempotent; unknown ids or another principal's notice return 404. |
@@ -309,6 +314,24 @@ This needs a pi-roundtable release that has `attachments.retention`; on an earli
309
314
  Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
310
315
  Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
311
316
 
317
+ ## Client calls
318
+
319
+ A tool may need something only the person's own client can do, such as a browser extension reading the page they have open.
320
+ A client that can says so, and the assistant's tools then call it:
321
+
322
+ 1. After `ready`, the client sends `client_hello` with the names of the functions it can run (`ready.clientCalls` says the server supports calls; a server before 0.9.11 omits it, so show no such feature then). A connection that declares none is never asked anything, and its turns carry no client: `turn.client` is undefined for a tool, which should fail closed.
323
+ 2. A message sent on that connection starts a turn whose tools read `turn.client` (`ClientPort`: `names` and `call(name, args, { timeoutMs?, maxValueBytes? })`). A call becomes a `client_call` frame **on the connection that sent the message, and on no other connection of the person**; an old client that never sent `client_hello` is unaffected.
324
+ 3. The client runs the function and answers with `client_result` on the same connection. A binary result, such as a screenshot, does not fit a frame: it `PUT`s each part to `<path>/conversations/<conversation>/client-calls/<call>/parts/<n>` first (see the [REST API](#rest-api)), then names the count in `parts`.
325
+ 4. The tool gets `{ ok: true, value, parts }`, or `{ ok: false, error }`: `refused` or `failed` as the client reported, `timeout` (no answer in time; the client gets `client_cancel`), `cancelled` (the turn was stopped, or the client reported `stopped`; the client gets `client_cancel`), `disconnected` (the connection closed first), `too_large` (the `value` passed `maxValueBytes`), `busy` (a call is already open on this connection, or the connection was asked `perMinute` calls in the last minute), `no_client`, or `failed` for a bad argument or a part that did not arrive.
326
+
327
+ This is the opposite of an approval card: a card goes to every connection of the person and survives a reconnect, a call goes to one connection, ends when it closes, and is never sent again after a reconnect. Only the connection a call was sent to may answer it, and only as the same person; an answer from another connection, even the same person's, gets `error` `unknown_call`. A part is accepted for an open call of that person and conversation, whose id only the right connection received.
328
+ The `value`, the parts and the `message` come from the client, so the tool treats them as untrusted data; the server accepts only JSON of at most `maxValueBytes`, images of the three types whose bytes match, and a message cut to one plain line of 300 characters.
329
+ Stopping the conversation, or the turn ending, cancels the call in flight.
330
+
331
+ Client calls need pi-roundtable 0.9.11 or later: the source of this package uses types that release adds, so type-check both together and upgrade them together.
332
+
333
+ `ready.clientCalls` is `{ timeoutMs, maxTimeoutMs, maxValueBytes, maxParts, maxPartBytes }`; `limits.clientCalls` sets them and `perMinute` (below).
334
+
312
335
  ## Security model
313
336
 
314
337
  - **Private conversations.** A conversation belongs to the person who opened it.
@@ -327,6 +350,7 @@ Only the person who uploaded a file can use it, and only in the conversation it
327
350
  - **Uploads.** Only a conversation's own person may upload to it, a file waits for that person and conversation alone, and a message names it by an id nobody can guess.
328
351
  Type, size, rate and the waiting total are limited per person, and the bytes of an image or PDF are checked against the type.
329
352
  See [Attachments](#attachments).
353
+ - **Client calls.** A call goes only to the connection that started the turn and is answered only by that same connection and person, so another tab, another device, or another person cannot answer it or add a part. Each connection has one call open at a time and `perMinute` calls a minute; an answer is bounded in bytes and a part's bytes are checked against its image type. A connection that declared nothing is never asked.
330
354
  - **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
331
355
  A card whose tier the person lacks is never shown: its prompt resolves `unavailable` (no frame is sent), without owner escalation.
332
356
  - **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
@@ -345,7 +369,13 @@ Only the person who uploaded a file can use it, and only in the conversation it
345
369
  | `reauthLeadMs` | `reauth` 60 seconds before the token expires |
346
370
  | `maxConnections` | 256 sockets on the route |
347
371
  | `maxMessageBytes` | 64 KiB per client frame |
348
- | `rate` | 60 frames a minute per socket |
372
+ | `rate` | 120 frames a minute per socket |
373
+ | `clientCalls.timeoutMs` | 30 seconds a [client call](#client-calls) waits for its answer unless the tool names a time |
374
+ | `clientCalls.maxTimeoutMs` | 60 seconds, the longest any call waits |
375
+ | `clientCalls.maxValueBytes` | 48 KiB of JSON in an answer's `value`, under `maxMessageBytes` |
376
+ | `clientCalls.maxParts` | 2 binary parts per answer (at most 16) |
377
+ | `clientCalls.maxPartBytes` | 2 MiB per part (at most 16 MiB) |
378
+ | `clientCalls.perMinute` | 60 calls a minute per connection; it must be under the `rate` frame rate, since each answer is a frame |
349
379
  | `maxBufferedBytes` | 4 MiB waiting for a slow client |
350
380
  | `ticketTtlMs` | 30 seconds |
351
381
  | `attachmentBytes` | 10 MiB per uploaded file; never above the core's 25 MiB |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-webchat",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "@earendil-works/pi-ai": ">=1.0.0 <2",
44
44
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
45
45
  "@types/bun": "1.4.2",
46
- "pi-roundtable": "0.9.10",
46
+ "pi-roundtable": "0.9.12",
47
47
  "typebox": "1.3.34",
48
48
  "typescript": "7.0.2"
49
49
  }
package/src/chat.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type TurnResult,
23
23
  } from "pi-roundtable";
24
24
  import { RateWindow, TurnBudget } from "./budget.ts";
25
+ import { type ClientCallLimits, ClientDesk } from "./client-calls.ts";
25
26
  import { type Connection, Connections } from "./connections.ts";
26
27
  import {
27
28
  identityActor,
@@ -78,6 +79,8 @@ export interface WebChatLimits extends UploadLimits {
78
79
  promptTimeoutMs: number;
79
80
  /** How long before a token expires the client is asked for a fresh one; default 60 seconds. */
80
81
  reauthLeadMs: number;
82
+ /** What the assistant's tools may ask a client that declared functions to run. */
83
+ clientCalls: ClientCallLimits;
81
84
  }
82
85
 
83
86
  export interface WebChatDeps {
@@ -112,6 +115,8 @@ interface Minted {
112
115
 
113
116
  interface Pending {
114
117
  speaker: Speaker;
118
+ /** The connection the message arrived on, which a call the turn makes to its client goes to. */
119
+ connection: Connection;
115
120
  /** The origin of the connection the message arrived on. */
116
121
  origin?: string;
117
122
  persona: WebPersona;
@@ -194,6 +199,8 @@ export class WebChat {
194
199
  readonly uploads: Uploads;
195
200
  readonly connections: Connections;
196
201
  readonly desk: PromptDesk;
202
+ /** The calls the assistant's tools make to the client that started their turn. */
203
+ readonly clients: ClientDesk;
197
204
  readonly #deps: WebChatDeps;
198
205
  readonly #personas: Map<string, WebPersona>;
199
206
  /** Conversations opened but not yet written in, so the registry has no record of them. */
@@ -233,6 +240,11 @@ export class WebChat {
233
240
  send,
234
241
  timeoutMs: deps.limits.promptTimeoutMs,
235
242
  });
243
+ this.clients = new ClientDesk({
244
+ limits: deps.limits.clientCalls,
245
+ send: (connection, frame) => this.connections.send(connection, frame),
246
+ ...(deps.now ? { now: deps.now } : {}),
247
+ });
236
248
  this.surface = new WebSurface({
237
249
  surface: deps.surface,
238
250
  principalOf: (conversation) => this.#owners.get(conversation),
@@ -355,6 +367,28 @@ export class WebChat {
355
367
  );
356
368
  }
357
369
 
370
+ /**
371
+ * Keeps a binary part of the answer to a call the assistant made to the speaker's client, for the
372
+ * conversation it was made in; a Refusal when the conversation is not theirs, a ClientPartRefusal
373
+ * when the part is not accepted.
374
+ */
375
+ async clientPart(
376
+ speaker: Speaker,
377
+ conversation: string,
378
+ call: string,
379
+ index: number,
380
+ request: Request,
381
+ ): Promise<void> {
382
+ await this.own(speaker, conversation);
383
+ await this.clients.receivePart(
384
+ speaker.principalId,
385
+ conversation,
386
+ call,
387
+ index,
388
+ request,
389
+ );
390
+ }
391
+
358
392
  /** The speaker's web conversations, the most recently active first. */
359
393
  async list(speaker: Speaker): Promise<ConversationRecord[]> {
360
394
  const records = await this.#deps
@@ -398,6 +432,7 @@ export class WebChat {
398
432
  speaker: { ...speaker },
399
433
  personas: this.personasFor(speaker),
400
434
  attachments: this.uploads.advertised,
435
+ clientCalls: this.clients.advertised,
401
436
  expiresAt: identity.expiresAt.toISOString(),
402
437
  });
403
438
  for (const frame of this.desk.openFor(speaker.principalId))
@@ -437,6 +472,7 @@ export class WebChat {
437
472
  }
438
473
 
439
474
  closed(socket: RouteSocket<Connection>): void {
475
+ this.clients.closed(socket.data);
440
476
  this.connections.closed(socket.data);
441
477
  }
442
478
 
@@ -460,7 +496,9 @@ export class WebChat {
460
496
  ? frame.id
461
497
  : frame.type === "approval" || frame.type === "answer"
462
498
  ? frame.prompt
463
- : undefined;
499
+ : frame.type === "client_result"
500
+ ? frame.call
501
+ : undefined;
464
502
  this.connections.send(connection, {
465
503
  type: "error",
466
504
  code: error.code,
@@ -509,6 +547,14 @@ export class WebChat {
509
547
  if (refused) throw new Refusal(refused);
510
548
  return;
511
549
  }
550
+ case "client_hello":
551
+ connection.clientTools = frame.tools;
552
+ return;
553
+ case "client_result": {
554
+ const refused = this.clients.result(connection, frame);
555
+ if (refused) throw new Refusal(refused);
556
+ return;
557
+ }
512
558
  case "answer": {
513
559
  const refused = this.desk.answer(speaker, frame.prompt, {
514
560
  choices: frame.choices,
@@ -602,6 +648,7 @@ export class WebChat {
602
648
  const messageId = crypto.randomUUID();
603
649
  this.#pending.set(messageId, {
604
650
  speaker,
651
+ connection,
605
652
  ...(connection.origin ? { origin: connection.origin } : {}),
606
653
  persona,
607
654
  title: minted?.title ?? titleOf(text),
@@ -803,31 +850,38 @@ export class WebChat {
803
850
  throw new Refusal("forbidden");
804
851
  }
805
852
  this.#registered.add(channel);
806
- await this.#deps.turns().run({
807
- channel,
808
- kind: persona.kind,
809
- text,
810
- speaker,
811
- ...(pending.origin ? { origin: pending.origin } : {}),
812
- interactive: true,
813
- ...(pending.attachments ? { attachments: pending.attachments } : {}),
814
- ...(persona.selection
815
- ? {
816
- selection: {
817
- id: `webchat:${persona.kind}`,
818
- ...persona.selection,
819
- },
820
- }
821
- : {}),
822
- conversation: {
823
- visibility: "private",
824
- ...(pending.fresh && pending.title ? { title: pending.title } : {}),
825
- },
826
- reply: async (result) => {
827
- answered = true;
828
- this.#reply(speaker.principalId, conversation, result);
829
- },
830
- });
853
+ // What the connection that sent the message can do for the turn's tools, until the turn ends.
854
+ const client = this.clients.portFor(pending.connection, conversation);
855
+ try {
856
+ await this.#deps.turns().run({
857
+ channel,
858
+ kind: persona.kind,
859
+ text,
860
+ speaker,
861
+ ...(pending.origin ? { origin: pending.origin } : {}),
862
+ ...(client ? { client } : {}),
863
+ interactive: true,
864
+ ...(pending.attachments ? { attachments: pending.attachments } : {}),
865
+ ...(persona.selection
866
+ ? {
867
+ selection: {
868
+ id: `webchat:${persona.kind}`,
869
+ ...persona.selection,
870
+ },
871
+ }
872
+ : {}),
873
+ conversation: {
874
+ visibility: "private",
875
+ ...(pending.fresh && pending.title ? { title: pending.title } : {}),
876
+ },
877
+ reply: async (result) => {
878
+ answered = true;
879
+ this.#reply(speaker.principalId, conversation, result);
880
+ },
881
+ });
882
+ } finally {
883
+ client?.end();
884
+ }
831
885
  } catch (error) {
832
886
  // A turn the host refused before it ran, such as one whose conversation could not be
833
887
  // recorded, has no reply: the person is told it failed, and the router logs the cause.
@@ -0,0 +1,407 @@
1
+ import type {
2
+ ClientCallError,
3
+ ClientCallOptions,
4
+ ClientCallResult,
5
+ ClientPart,
6
+ ClientPort,
7
+ } from "pi-roundtable";
8
+ import { RateWindow } from "./budget.ts";
9
+ import type { Connection } from "./connections.ts";
10
+ import { baseType, bytesMatchType } from "./file-types.ts";
11
+ import type {
12
+ ClientCallsAdvertised,
13
+ ClientCancelReason,
14
+ ClientFailure,
15
+ ClientFrame,
16
+ ErrorCode,
17
+ ServerFrame,
18
+ } from "./protocol.ts";
19
+ import { readLimited } from "./uploads.ts";
20
+
21
+ /** The limits of the calls the assistant's tools make to a client. */
22
+ export interface ClientCallLimits extends ClientCallsAdvertised {
23
+ /** Calls one connection may be asked to run in any minute; default 60. */
24
+ perMinute: number;
25
+ }
26
+
27
+ /** The types a binary part may be: the images a model reads, as a screenshot is. */
28
+ const PART_TYPES = ["image/jpeg", "image/png", "image/webp"];
29
+ /** The most bytes of the arguments a call carries (UTF-8, as JSON). */
30
+ const MAX_ARGS_BYTES = 16 * 1024;
31
+ /** The most characters of a client's failure message a tool sees. */
32
+ const MESSAGE_CHARS = 300;
33
+ const MINUTE_MS = 60_000;
34
+
35
+ /** Control characters, line and paragraph separators, and bidi controls: none reaches a tool as it is. */
36
+ const UNSAFE_CHARS =
37
+ /[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
38
+
39
+ /** `text` on one plain line, cut to `MESSAGE_CHARS` characters. */
40
+ function plain(text: string): string {
41
+ const line = text.replace(UNSAFE_CHARS, " ").replace(/ +/g, " ").trim();
42
+ const points = Array.from(line);
43
+ return points.length > MESSAGE_CHARS
44
+ ? points.slice(0, MESSAGE_CHARS).join("")
45
+ : line;
46
+ }
47
+
48
+ const bytesOf = (text: string) => new TextEncoder().encode(text).length;
49
+
50
+ const failure = (
51
+ error: ClientCallError,
52
+ message: string,
53
+ ): ClientCallResult => ({
54
+ ok: false,
55
+ error,
56
+ message,
57
+ });
58
+
59
+ /** How each failure a client reports reads to a tool. */
60
+ const REPORTED: Record<ClientFailure, ClientCallError> = {
61
+ refused: "refused",
62
+ failed: "failed",
63
+ timeout: "timeout",
64
+ stopped: "cancelled",
65
+ };
66
+
67
+ /** Why a binary part was refused, as the HTTP status and error code the client reads. */
68
+ export class ClientPartRefusal extends Error {
69
+ override name = "ClientPartRefusal";
70
+ readonly status: number;
71
+ readonly code:
72
+ | "unknown_call"
73
+ | "bad_request"
74
+ | "duplicate_part"
75
+ | "payload_too_large"
76
+ | "unsupported_media_type";
77
+
78
+ constructor(status: number, code: ClientPartRefusal["code"]) {
79
+ super(code);
80
+ this.status = status;
81
+ this.code = code;
82
+ }
83
+ }
84
+
85
+ /** The port of one turn: the assistant's tools call through it, and the turn's end closes it. */
86
+ export interface TurnClient extends ClientPort {
87
+ /** The turn is over: the call in flight is cancelled, and every later call finds no client. */
88
+ end(): void;
89
+ }
90
+
91
+ interface Open {
92
+ id: string;
93
+ connection: Connection;
94
+ principal: string;
95
+ conversation: string;
96
+ /** The most bytes of JSON this call's answer may take. */
97
+ maxValueBytes: number;
98
+ /** The binary parts uploaded for this call, by index. */
99
+ parts: Map<number, ClientPart>;
100
+ /** The indexes whose body is being read, so one index is never buffered twice at once. */
101
+ receiving: Set<number>;
102
+ /** The turn's port that made the call. */
103
+ owner: object;
104
+ /** Ends the call with `result`; tells the client to stop when `cancel` says why. */
105
+ settle(result: ClientCallResult, cancel?: ClientCancelReason): void;
106
+ }
107
+
108
+ export interface ClientDeskOptions {
109
+ limits: ClientCallLimits;
110
+ /** Sends a frame on one connection; never to the person's other connections. */
111
+ send(connection: Connection, frame: ServerFrame): void;
112
+ /** The clock, in milliseconds; default `Date.now`. */
113
+ now?(): number;
114
+ }
115
+
116
+ /**
117
+ * The calls the assistant's tools make to the client that started their turn, such as a browser
118
+ * extension reading the page the person has open. It is the opposite of `PromptDesk`: a call goes
119
+ * to the one connection the turn started on, never to the person's others; it is answered only by
120
+ * that same connection; and it ends when the connection closes, with no second chance after a
121
+ * reconnect. One call is open on a connection at a time, and each connection may be asked a
122
+ * limited number of calls a minute.
123
+ */
124
+ export class ClientDesk {
125
+ readonly #limits: ClientCallLimits;
126
+ readonly #send: ClientDeskOptions["send"];
127
+ readonly #now: () => number;
128
+ readonly #open = new Map<string, Open>();
129
+ readonly #byConnection = new Map<Connection, Open>();
130
+ readonly #gone = new WeakSet<Connection>();
131
+ readonly #rates = new WeakMap<Connection, RateWindow>();
132
+
133
+ constructor(options: ClientDeskOptions) {
134
+ this.#limits = options.limits;
135
+ this.#send = options.send;
136
+ this.#now = options.now ?? Date.now;
137
+ }
138
+
139
+ /** What `ready` tells a client about the calls it may be asked to run. */
140
+ get advertised(): ClientCallsAdvertised {
141
+ const { timeoutMs, maxTimeoutMs, maxValueBytes, maxParts, maxPartBytes } =
142
+ this.#limits;
143
+ return { timeoutMs, maxTimeoutMs, maxValueBytes, maxParts, maxPartBytes };
144
+ }
145
+
146
+ /**
147
+ * The port of a turn that started on `connection` in `conversation`, naming the functions the
148
+ * connection declared; undefined when it declared none, so the turn carries no client.
149
+ */
150
+ portFor(
151
+ connection: Connection,
152
+ conversation: string,
153
+ ): TurnClient | undefined {
154
+ const names = connection.clientTools;
155
+ if (!names || names.length === 0) return undefined;
156
+ const declared = [...names];
157
+ const owner = {};
158
+ let ended = false;
159
+ return {
160
+ names: declared,
161
+ call: (name, args, options) => {
162
+ if (ended)
163
+ return Promise.resolve(failure("no_client", "the turn has ended"));
164
+ return this.#call(
165
+ { connection, conversation, declared, owner },
166
+ name,
167
+ args,
168
+ options,
169
+ );
170
+ },
171
+ end: () => {
172
+ ended = true;
173
+ const open = this.#byConnection.get(connection);
174
+ if (open?.owner === owner)
175
+ open.settle(failure("cancelled", "the turn has ended"), "turn_ended");
176
+ },
177
+ };
178
+ }
179
+
180
+ #call(
181
+ turn: {
182
+ connection: Connection;
183
+ conversation: string;
184
+ declared: readonly string[];
185
+ owner: object;
186
+ },
187
+ name: string,
188
+ args: unknown,
189
+ options: ClientCallOptions | undefined,
190
+ ): Promise<ClientCallResult> {
191
+ const { connection } = turn;
192
+ const refuse = (error: ClientCallError, message: string) =>
193
+ Promise.resolve(failure(error, message));
194
+ if (this.#gone.has(connection))
195
+ return refuse("disconnected", "the client's connection is closed");
196
+ if (options?.signal?.aborted)
197
+ return refuse("cancelled", "the turn was stopped");
198
+ if (!turn.declared.includes(name))
199
+ return refuse("refused", "the client declared no function of that name");
200
+ let argsJson: string | undefined;
201
+ try {
202
+ argsJson = JSON.stringify(args ?? null);
203
+ } catch {
204
+ return refuse("failed", "the arguments are not JSON");
205
+ }
206
+ if (argsJson === undefined || bytesOf(argsJson) > MAX_ARGS_BYTES)
207
+ return refuse(
208
+ "failed",
209
+ `the arguments must be JSON of at most ${MAX_ARGS_BYTES} bytes`,
210
+ );
211
+ if (this.#byConnection.has(connection))
212
+ return refuse("busy", "another call is open on this client");
213
+ let rate = this.#rates.get(connection);
214
+ if (!rate) {
215
+ rate = new RateWindow(this.#limits.perMinute, MINUTE_MS, this.#now);
216
+ this.#rates.set(connection, rate);
217
+ }
218
+ if (!rate.take("calls"))
219
+ return refuse("busy", "this client was asked too many calls this minute");
220
+ const timeoutMs = bounded(
221
+ options?.timeoutMs,
222
+ this.#limits.timeoutMs,
223
+ this.#limits.maxTimeoutMs,
224
+ );
225
+ const maxValueBytes = bounded(
226
+ options?.maxValueBytes,
227
+ this.#limits.maxValueBytes,
228
+ this.#limits.maxValueBytes,
229
+ );
230
+ return new Promise((resolve) => {
231
+ const id = crypto.randomUUID();
232
+ const signal = options?.signal;
233
+ const onAbort = () =>
234
+ open.settle(
235
+ failure("cancelled", "the turn was stopped"),
236
+ "turn_stopped",
237
+ );
238
+ const timer = setTimeout(
239
+ () =>
240
+ open.settle(
241
+ failure("timeout", "the client did not answer in time"),
242
+ "timeout",
243
+ ),
244
+ timeoutMs,
245
+ );
246
+ const open: Open = {
247
+ id,
248
+ connection,
249
+ principal: connection.speaker.principalId,
250
+ conversation: turn.conversation,
251
+ maxValueBytes,
252
+ parts: new Map(),
253
+ receiving: new Set(),
254
+ owner: turn.owner,
255
+ settle: (result, cancel) => {
256
+ if (this.#open.get(id) !== open) return;
257
+ this.#open.delete(id);
258
+ this.#byConnection.delete(connection);
259
+ clearTimeout(timer);
260
+ signal?.removeEventListener("abort", onAbort);
261
+ if (cancel)
262
+ this.#send(connection, {
263
+ type: "client_cancel",
264
+ call: id,
265
+ reason: cancel,
266
+ });
267
+ resolve(result);
268
+ },
269
+ };
270
+ this.#open.set(id, open);
271
+ this.#byConnection.set(connection, open);
272
+ signal?.addEventListener("abort", onAbort, { once: true });
273
+ this.#send(connection, {
274
+ type: "client_call",
275
+ conversation: turn.conversation,
276
+ call: id,
277
+ name,
278
+ args: JSON.parse(argsJson),
279
+ timeoutMs,
280
+ });
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Takes a client's answer; `unknown_call` unless the call is open on exactly this connection and
286
+ * this person's, so another connection of the same person, or anyone else's, cannot answer it.
287
+ */
288
+ result(
289
+ connection: Connection,
290
+ frame: Extract<ClientFrame, { type: "client_result" }>,
291
+ ): ErrorCode | undefined {
292
+ const open = this.#open.get(frame.call);
293
+ if (
294
+ !open ||
295
+ open.connection !== connection ||
296
+ open.principal !== connection.speaker.principalId
297
+ )
298
+ return "unknown_call";
299
+ if (!frame.ok) {
300
+ open.settle(
301
+ failure(
302
+ REPORTED[frame.code],
303
+ plain(frame.message ?? "") || `the client reported: ${frame.code}`,
304
+ ),
305
+ );
306
+ return undefined;
307
+ }
308
+ const size = bytesOf(JSON.stringify(frame.value) ?? "null");
309
+ if (size > open.maxValueBytes) {
310
+ open.settle(
311
+ failure("too_large", `the answer is over ${open.maxValueBytes} bytes`),
312
+ );
313
+ return undefined;
314
+ }
315
+ const declared = frame.parts ?? 0;
316
+ if (declared > this.#limits.maxParts) {
317
+ open.settle(
318
+ failure(
319
+ "failed",
320
+ `the answer declared ${declared} parts, over the limit of ${this.#limits.maxParts}`,
321
+ ),
322
+ );
323
+ return undefined;
324
+ }
325
+ const parts: ClientPart[] = [];
326
+ for (let index = 0; index < declared; index++) {
327
+ const part = open.parts.get(index);
328
+ if (!part) {
329
+ open.settle(
330
+ failure(
331
+ "failed",
332
+ `the answer declared ${declared} parts, and part ${index} did not arrive`,
333
+ ),
334
+ );
335
+ return undefined;
336
+ }
337
+ parts.push(part);
338
+ }
339
+ open.settle({ ok: true, value: frame.value, parts });
340
+ return undefined;
341
+ }
342
+
343
+ /**
344
+ * Keeps a binary part of the answer to an open call, from the REST request that carries it;
345
+ * throws a ClientPartRefusal. The call's id is known only to the connection it was sent to, and
346
+ * the call must be this person's, in this conversation, and still open.
347
+ */
348
+ async receivePart(
349
+ principal: string,
350
+ conversation: string,
351
+ call: string,
352
+ index: number,
353
+ request: Request,
354
+ ): Promise<void> {
355
+ const open = this.#open.get(call);
356
+ if (
357
+ !open ||
358
+ open.principal !== principal ||
359
+ open.conversation !== conversation
360
+ )
361
+ throw new ClientPartRefusal(404, "unknown_call");
362
+ const { maxParts, maxPartBytes } = this.#limits;
363
+ if (!Number.isInteger(index) || index < 0 || index >= maxParts)
364
+ throw new ClientPartRefusal(400, "bad_request");
365
+ const type = baseType(request.headers.get("content-type"));
366
+ if (!type || !PART_TYPES.includes(type))
367
+ throw new ClientPartRefusal(415, "unsupported_media_type");
368
+ if (open.parts.has(index) || open.receiving.has(index))
369
+ throw new ClientPartRefusal(409, "duplicate_part");
370
+ const header = request.headers.get("content-length");
371
+ const length = header === null ? Number.NaN : Number(header);
372
+ if (Number.isFinite(length) && length > maxPartBytes)
373
+ throw new ClientPartRefusal(413, "payload_too_large");
374
+ // The index is held while the body is read, so parallel uploads of it buffer one body.
375
+ open.receiving.add(index);
376
+ let bytes: Uint8Array | undefined;
377
+ try {
378
+ bytes = await readLimited(request, maxPartBytes);
379
+ } finally {
380
+ open.receiving.delete(index);
381
+ }
382
+ if (!bytes) throw new ClientPartRefusal(413, "payload_too_large");
383
+ // The call may have ended, or the same part arrived, while the body was read.
384
+ if (this.#open.get(call) !== open)
385
+ throw new ClientPartRefusal(404, "unknown_call");
386
+ if (open.parts.has(index))
387
+ throw new ClientPartRefusal(409, "duplicate_part");
388
+ if (!bytesMatchType(type, bytes))
389
+ throw new ClientPartRefusal(415, "unsupported_media_type");
390
+ open.parts.set(index, { mimeType: type, data: bytes });
391
+ }
392
+
393
+ /** The connection closed: its open call ends as disconnected, and so does every later one. */
394
+ closed(connection: Connection): void {
395
+ this.#gone.add(connection);
396
+ this.#byConnection
397
+ .get(connection)
398
+ ?.settle(failure("disconnected", "the client's connection closed"));
399
+ }
400
+ }
401
+
402
+ /** `asked` as a whole number of at most `max`, or `fallback` when it is no positive number. */
403
+ function bounded(asked: number | undefined, fallback: number, max: number) {
404
+ if (typeof asked !== "number" || !Number.isFinite(asked) || asked < 1)
405
+ return Math.min(fallback, max);
406
+ return Math.min(Math.floor(asked), max);
407
+ }
@@ -12,6 +12,12 @@ export interface Connection {
12
12
  * connection's life: it comes from the transport, never from a frame.
13
13
  */
14
14
  origin?: string;
15
+ /**
16
+ * The functions the client declared with `client_hello` it can run for the assistant's tools;
17
+ * undefined until it declares, and then it is a client's: a connection that declares none is
18
+ * never asked to run anything.
19
+ */
20
+ clientTools?: readonly string[];
15
21
  socket?: RouteSocket<Connection>;
16
22
  /** Asks for a fresh token, then closes the socket when the token expires. */
17
23
  timers: ReturnType<typeof setTimeout>[];
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type { WebChatLimits, WebPersona } from "./chat.ts";
2
+ export type { ClientCallLimits } from "./client-calls.ts";
2
3
  export type { Notice } from "./notices.ts";
3
4
  export type {
4
5
  OidcJwtVerifierOptions,
@@ -15,6 +16,9 @@ export {
15
16
  export type { WebChatOptions, WebChatRouteLimits } from "./plugin.ts";
16
17
  export { webChat } from "./plugin.ts";
17
18
  export type {
19
+ ClientCallsAdvertised,
20
+ ClientCancelReason,
21
+ ClientFailure,
18
22
  ClientFrame,
19
23
  ErrorCode,
20
24
  PersonaSummary,
package/src/plugin.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type WebChatLimits,
18
18
  type WebPersona,
19
19
  } from "./chat.ts";
20
+ import type { ClientCallLimits } from "./client-calls.ts";
20
21
  import type { Connection } from "./connections.ts";
21
22
  import { webDirectChannel } from "./direct-channel.ts";
22
23
  import { PgNotices } from "./notices.ts";
@@ -59,7 +60,9 @@ export interface WebChatOptions {
59
60
  path?: string;
60
61
  /** The key prefix of the conversations; default `web`. Two web chats on one host need two. */
61
62
  surface?: string;
62
- limits?: Partial<WebChatLimits & WebChatRouteLimits>;
63
+ limits?: Partial<Omit<WebChatLimits & WebChatRouteLimits, "clientCalls">> & {
64
+ clientCalls?: Partial<ClientCallLimits>;
65
+ };
63
66
  }
64
67
 
65
68
  const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
@@ -70,6 +73,14 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
70
73
  messageChars: 32_000,
71
74
  promptTimeoutMs: 30 * 60_000,
72
75
  reauthLeadMs: 60_000,
76
+ clientCalls: {
77
+ timeoutMs: 30_000,
78
+ maxTimeoutMs: 60_000,
79
+ maxValueBytes: 48 * 1024,
80
+ maxParts: 2,
81
+ maxPartBytes: 2 * 1024 * 1024,
82
+ perMinute: 60,
83
+ },
73
84
  attachmentBytes: 10 * 1024 * 1024,
74
85
  attachmentsPerMessage: 8,
75
86
  uploadsPerHour: 60,
@@ -79,7 +90,7 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
79
90
  unsentUploadTtlMs: 24 * 60 * 60_000,
80
91
  maxConnections: 256,
81
92
  maxMessageBytes: 64 * 1024,
82
- rate: { messages: 60, perMs: 60_000 },
93
+ rate: { messages: 120, perMs: 60_000 },
83
94
  maxBufferedBytes: 4 * 1024 * 1024,
84
95
  ticketTtlMs: 30_000,
85
96
  };
@@ -145,6 +156,52 @@ function checkAttachmentLimits(limits: WebChatLimits): void {
145
156
  );
146
157
  }
147
158
 
159
+ /** The most binary parts one answer may declare; see the protocol. */
160
+ const MAX_PARTS = 16;
161
+ /** The most bytes of one binary part, which the chat holds in memory until the call ends. */
162
+ const MAX_PART_BYTES = 16 * 1024 * 1024;
163
+
164
+ /** Refuses a client-call limit that is not a positive whole number, or one the protocol cannot carry. */
165
+ function checkClientCallLimits(
166
+ limits: ClientCallLimits,
167
+ maxMessageBytes: number,
168
+ rate: { messages: number; perMs: number },
169
+ ): void {
170
+ for (const key of [
171
+ "timeoutMs",
172
+ "maxTimeoutMs",
173
+ "maxValueBytes",
174
+ "maxParts",
175
+ "maxPartBytes",
176
+ "perMinute",
177
+ ] as const)
178
+ if (!Number.isInteger(limits[key]) || limits[key] < 1)
179
+ throw new Error(
180
+ `webChat: limits.clientCalls.${key} must be a positive whole number; got ${String(limits[key])}`,
181
+ );
182
+ if (limits.timeoutMs > limits.maxTimeoutMs)
183
+ throw new Error(
184
+ `webChat: limits.clientCalls.timeoutMs (${limits.timeoutMs}) may be at most maxTimeoutMs (${limits.maxTimeoutMs})`,
185
+ );
186
+ if (limits.maxValueBytes >= maxMessageBytes)
187
+ throw new Error(
188
+ `webChat: limits.clientCalls.maxValueBytes (${limits.maxValueBytes}) must be under maxMessageBytes (${maxMessageBytes}), the most a client's answer frame can carry`,
189
+ );
190
+ const framesPerMinute = (rate.messages * 60_000) / rate.perMs;
191
+ if (limits.perMinute >= framesPerMinute)
192
+ throw new Error(
193
+ `webChat: limits.clientCalls.perMinute (${limits.perMinute}) must be under the frame rate of ${framesPerMinute} a minute (limits.rate), since each answer to a call is a frame the socket counts; a busy client would be disconnected. Lower perMinute or raise rate.messages`,
194
+ );
195
+ if (limits.maxParts > MAX_PARTS)
196
+ throw new Error(
197
+ `webChat: limits.clientCalls.maxParts may be at most ${MAX_PARTS}; got ${limits.maxParts}`,
198
+ );
199
+ if (limits.maxPartBytes > MAX_PART_BYTES)
200
+ throw new Error(
201
+ `webChat: limits.clientCalls.maxPartBytes may be at most ${MAX_PART_BYTES} (16 MiB); got ${limits.maxPartBytes}`,
202
+ );
203
+ }
204
+
148
205
  /** The subprotocols a WebSocket upgrade offers. */
149
206
  function offered(request: Request): string[] {
150
207
  return (request.headers.get("sec-websocket-protocol") ?? "")
@@ -164,10 +221,22 @@ function offered(request: Request): string[] {
164
221
  */
165
222
  export function webChat(options: WebChatOptions): RoundtablePlugin {
166
223
  const { path, surface } = checkOptions(options);
167
- const limits = { ...DEFAULT_LIMITS, ...options.limits };
224
+ const limits = {
225
+ ...DEFAULT_LIMITS,
226
+ ...options.limits,
227
+ clientCalls: {
228
+ ...DEFAULT_LIMITS.clientCalls,
229
+ ...options.limits?.clientCalls,
230
+ },
231
+ };
168
232
  // The personas are checked now, so a broken list stops the configuration rather than the boot.
169
233
  checkPersonas(options.personas);
170
234
  checkAttachmentLimits(limits);
235
+ checkClientCallLimits(
236
+ limits.clientCalls,
237
+ limits.maxMessageBytes,
238
+ limits.rate,
239
+ );
171
240
  return definePlugin({
172
241
  name: surface === "web" ? "webchat" : `webchat-${surface}`,
173
242
  requires: [IDENTITY, CONVERSATIONS, RUNTIME],
package/src/protocol.ts CHANGED
@@ -79,6 +79,43 @@ export type PromptOutcome =
79
79
  | "expired"
80
80
  | "cancelled";
81
81
 
82
+ /** The most functions a client may declare. */
83
+ export const MAX_CLIENT_TOOLS = 64;
84
+ /** The longest function name a client may declare. */
85
+ const CLIENT_TOOL_CHARS = 64;
86
+ /** A function name: lowercase words joined by underscores, as a tool's name is. */
87
+ const CLIENT_TOOL_NAME = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
88
+ /** The most binary parts one answer may declare, whatever the server's own limit is. */
89
+ const MAX_FRAME_PARTS = 16;
90
+ /** The longest failure message accepted from a client, in characters. */
91
+ const CLIENT_MESSAGE_CHARS = 2000;
92
+
93
+ /** Why a client says a call failed; the server maps each to a `ClientCallError`. */
94
+ export const CLIENT_FAILURES = [
95
+ "refused",
96
+ "failed",
97
+ "timeout",
98
+ "stopped",
99
+ ] as const;
100
+ export type ClientFailure = (typeof CLIENT_FAILURES)[number];
101
+
102
+ /** Why the server cancels a call it sent. */
103
+ export type ClientCancelReason = "turn_stopped" | "timeout" | "turn_ended";
104
+
105
+ /** What the server tells a client about the calls it may be asked to run. */
106
+ export interface ClientCallsAdvertised {
107
+ /** How long a call waits for an answer unless it names a time, in milliseconds. */
108
+ timeoutMs: number;
109
+ /** The longest a call waits, in milliseconds. */
110
+ maxTimeoutMs: number;
111
+ /** The most bytes (UTF-8, as JSON) an answer's `value` may take. */
112
+ maxValueBytes: number;
113
+ /** The most binary parts one answer may carry. */
114
+ maxParts: number;
115
+ /** The most bytes of one part. */
116
+ maxPartBytes: number;
117
+ }
118
+
82
119
  /** Why the server refused a frame or a request. */
83
120
  export type ErrorCode =
84
121
  | "bad_frame"
@@ -88,6 +125,8 @@ export type ErrorCode =
88
125
  | "unknown_prompt"
89
126
  | "too_many_conversations"
90
127
  | "busy"
128
+ /** A `client_result` named a call that is not open on this connection. */
129
+ | "unknown_call"
91
130
  /** A `send` named a file that is not waiting for this person in this conversation; the whole message is refused. */
92
131
  | "unknown_attachment"
93
132
  /** A `send` would take the person past `usedAttachmentBytesPerPrincipal`; the whole message is refused and its files stay waiting. */
@@ -118,7 +157,32 @@ export type ClientFrame =
118
157
  /** Approves or declines an approval prompt. */
119
158
  | { type: "approval"; prompt: string; approved: boolean }
120
159
  /** Answers a question prompt: the chosen options' labels and, where allowed, their own text. */
121
- | { type: "answer"; prompt: string; choices: string[]; text?: string };
160
+ | { type: "answer"; prompt: string; choices: string[]; text?: string }
161
+ /**
162
+ * Declares the functions this connection can run for the assistant's tools, such as reading the
163
+ * page the person has open. Send it after each `ready`. A connection that declares none is never
164
+ * asked to run anything, and its turns carry no client.
165
+ */
166
+ | { type: "client_hello"; version: 1; tools: string[] }
167
+ /**
168
+ * The answer to a `client_call`, sent on the connection the call arrived on. Binary parts are
169
+ * uploaded first (`PUT <path>/conversations/<id>/client-calls/<call>/parts/<n>`), and `parts`
170
+ * says how many; a failure names why, and `message` is shown to the assistant as data.
171
+ */
172
+ | {
173
+ type: "client_result";
174
+ call: string;
175
+ ok: true;
176
+ value: unknown;
177
+ parts?: number;
178
+ }
179
+ | {
180
+ type: "client_result";
181
+ call: string;
182
+ ok: false;
183
+ code: ClientFailure;
184
+ message?: string;
185
+ };
122
186
 
123
187
  /** What the server sends: one JSON object per WebSocket text message. */
124
188
  export type ServerFrame =
@@ -140,6 +204,11 @@ export type ServerFrame =
140
204
  };
141
205
  /** When the token expires, as an ISO time. */
142
206
  expiresAt: string;
207
+ /**
208
+ * What a call to this client may take. Absent from a server before 0.9.11, which never sends
209
+ * `client_call`: show no feature that needs the page's help then.
210
+ */
211
+ clientCalls?: ClientCallsAdvertised;
143
212
  }
144
213
  /** The message `id` was taken into `conversation`, a new one when the client named none. */
145
214
  | { type: "accepted"; id: string; conversation: string }
@@ -171,7 +240,21 @@ export type ServerFrame =
171
240
  | { type: "notice"; notice: Notice }
172
241
  /** The token expires soon: send `auth` with a fresh one before `expiresAt`, or the server closes with 4401. */
173
242
  | { type: "reauth"; expiresAt: string }
174
- /** A frame was refused; `ref` is the `send` id or prompt id it was about. */
243
+ /**
244
+ * The assistant asks this connection, and no other, to run a function it declared and answer
245
+ * with `client_result` within `timeoutMs`. One call is open on a connection at a time.
246
+ */
247
+ | {
248
+ type: "client_call";
249
+ conversation: string;
250
+ call: string;
251
+ name: string;
252
+ args: unknown;
253
+ timeoutMs: number;
254
+ }
255
+ /** The call is no longer wanted: stop running it; any answer is dropped. */
256
+ | { type: "client_cancel"; call: string; reason: ClientCancelReason }
257
+ /** A frame was refused; `ref` is the `send` id, prompt id or call id it was about. */
175
258
  | { type: "error"; code: ErrorCode; ref?: string };
176
259
 
177
260
  /** The longest client reference, conversation id, or prompt id accepted. */
@@ -259,7 +342,66 @@ export function parseClientFrame(
259
342
  ...(text === undefined ? {} : { text }),
260
343
  };
261
344
  }
345
+ case "client_hello":
346
+ return parseHello(value);
347
+ case "client_result":
348
+ return parseResult(value);
262
349
  default:
263
350
  return undefined;
264
351
  }
265
352
  }
353
+
354
+ function parseHello(value: Record<string, unknown>): ClientFrame | undefined {
355
+ const { version, tools } = value;
356
+ if (version !== 1 || !Array.isArray(tools)) return undefined;
357
+ if (tools.length > MAX_CLIENT_TOOLS) return undefined;
358
+ for (const tool of tools)
359
+ if (
360
+ typeof tool !== "string" ||
361
+ tool.length > CLIENT_TOOL_CHARS ||
362
+ !CLIENT_TOOL_NAME.test(tool)
363
+ )
364
+ return undefined;
365
+ return {
366
+ type: "client_hello",
367
+ version: 1,
368
+ tools: [...new Set(tools as string[])],
369
+ };
370
+ }
371
+
372
+ function parseResult(value: Record<string, unknown>): ClientFrame | undefined {
373
+ const { call, ok, message } = value;
374
+ if (!isId(call) || typeof ok !== "boolean") return undefined;
375
+ if (ok) {
376
+ const { parts } = value;
377
+ if (
378
+ parts !== undefined &&
379
+ (typeof parts !== "number" ||
380
+ !Number.isInteger(parts) ||
381
+ parts < 0 ||
382
+ parts > MAX_FRAME_PARTS)
383
+ )
384
+ return undefined;
385
+ return {
386
+ type: "client_result",
387
+ call,
388
+ ok: true,
389
+ value: value.value === undefined ? null : value.value,
390
+ ...(parts === undefined ? {} : { parts }),
391
+ };
392
+ }
393
+ const { code } = value;
394
+ if (!CLIENT_FAILURES.includes(code as ClientFailure)) return undefined;
395
+ if (
396
+ message !== undefined &&
397
+ (typeof message !== "string" || message.length > CLIENT_MESSAGE_CHARS)
398
+ )
399
+ return undefined;
400
+ return {
401
+ type: "client_result",
402
+ call,
403
+ ok: false,
404
+ code: code as ClientFailure,
405
+ ...(message === undefined ? {} : { message }),
406
+ };
407
+ }
package/src/rest.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ConversationRecord, Logger } from "pi-roundtable";
2
2
  import { type Admitted, Refusal, type WebChat } from "./chat.ts";
3
+ import { ClientPartRefusal } from "./client-calls.ts";
3
4
  import { MAX_NOTICES, type PgNotices } from "./notices.ts";
4
5
  import { TokenRefused } from "./oidc.ts";
5
6
  import type { TicketBook } from "./tickets.ts";
@@ -61,6 +62,7 @@ const STATUS: Record<string, number> = {
61
62
  bad_frame: 400,
62
63
  forbidden: 403,
63
64
  unknown_conversation: 404,
65
+ unknown_call: 404,
64
66
  unknown_persona: 404,
65
67
  too_many_conversations: 429,
66
68
  unknown_attachment: 400,
@@ -74,7 +76,10 @@ const STATUS: Record<string, number> = {
74
76
  * (`?limit=`, its last messages), and `POST conversations/<id>/files?name=<file name>` (the body is
75
77
  * the file's bytes, its `Content-Type` the file's type; answers 201 with `{ file, name, contentType,
76
78
  * size }`, 413 over the size limit, 415 for a type that is not accepted, 429 over the person's
77
- * allowance). A browser on another origin gets CORS headers when its origin
79
+ * allowance), and `PUT conversations/<id>/client-calls/<call>/parts/<n>` (the body is a binary part of
80
+ * the answer to a call the assistant made to the caller's client, its `Content-Type` an image type;
81
+ * answers 201, 404 for a call that is not open, 409 for a part sent twice, 413 over the size limit,
82
+ * 415 for a type that is not an image or bytes that are not the type). A browser on another origin gets CORS headers when its origin
78
83
  * is allowed and 403 otherwise.
79
84
  */
80
85
  export function restHandler(options: RestOptions) {
@@ -159,6 +164,20 @@ export function restHandler(options: RestOptions) {
159
164
  );
160
165
  return json(uploaded, 201, headers);
161
166
  }
167
+ const part =
168
+ /^conversations\/([A-Za-z0-9-]{1,128})\/client-calls\/([A-Za-z0-9-]{1,128})\/parts\/(\d{1,3})$/.exec(
169
+ rest,
170
+ );
171
+ if (part?.[1] && part[2] && part[3] && request.method === "PUT") {
172
+ await chat.clientPart(
173
+ speaker,
174
+ part[1],
175
+ part[2],
176
+ Number(part[3]),
177
+ request,
178
+ );
179
+ return json({ part: Number(part[3]) }, 201, headers);
180
+ }
162
181
  return json({ error: "not_found" }, 404, headers);
163
182
  }
164
183
  return async (request: Request): Promise<Response> => {
@@ -169,7 +188,7 @@ export function restHandler(options: RestOptions) {
169
188
  status: 204,
170
189
  headers: {
171
190
  ...headers,
172
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
191
+ "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS",
173
192
  "Access-Control-Allow-Headers": "authorization, content-type",
174
193
  "Access-Control-Max-Age": "600",
175
194
  },
@@ -195,7 +214,7 @@ export function restHandler(options: RestOptions) {
195
214
  try {
196
215
  return await route(request, url, who, headers);
197
216
  } catch (error) {
198
- if (error instanceof UploadRefusal)
217
+ if (error instanceof UploadRefusal || error instanceof ClientPartRefusal)
199
218
  return json({ error: error.code }, error.status, headers);
200
219
  if (!(error instanceof Refusal)) throw error;
201
220
  return json({ error: error.code }, STATUS[error.code] ?? 400, headers);
package/src/uploads.ts CHANGED
@@ -93,7 +93,7 @@ function safeName(name: string | null): string {
93
93
  }
94
94
 
95
95
  /** The request body, or undefined once it passes `max` bytes; the rest of the stream is not read. */
96
- async function readLimited(
96
+ export async function readLimited(
97
97
  request: Request,
98
98
  max: number,
99
99
  ): Promise<Uint8Array | undefined> {