pi-roundtable-webchat 0.9.9 → 0.9.11

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,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.11] - 2026-10-11
9
+
10
+ ### Added
11
+
12
+ - 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.
13
+ - `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.
14
+ - `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.
15
+ - `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.
16
+
17
+ ### Changed
18
+
19
+ - 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.
20
+ - The REST preflight also allows `PUT`.
21
+
22
+ ## [0.9.10] - 2026-10-10
23
+
24
+ ### Added
25
+
26
+ - A turn run for a message carries the `Origin` header its connection's upgrade was accepted with, as `origin` on `context.turns.run`, so a tool reads it as `ToolTurn.origin`. It is trimmed and lower-cased and kept only when it is a serialized `scheme://host[:port]` of at most 256 characters; a connection that sent none, or an `Origin` of another shape, leaves it undefined. It is read from the transport at the upgrade, never from a frame or the message text. Needs pi-roundtable 0.9.10 or later to reach tools; on an older core the extra field is ignored.
27
+
8
28
  ## [0.9.9] - 2026-10-10
9
29
 
10
30
  ### Added
package/README.md CHANGED
@@ -169,6 +169,9 @@ The token is never read from a URL.
169
169
  - **Another client**, such as a service, sends `Authorization: Bearer <token>` on the upgrade and offers `roundtable.webchat.v1`.
170
170
 
171
171
  The server echoes `roundtable.webchat.v1`.
172
+ The `Origin` an accepted upgrade carried is kept on the connection, trimmed and lower-cased, and each message's turn passes it to the core as `origin`, so a tool reads it as `turn.origin` (for example to write where an issue was filed from).
173
+ It is read from the transport only, never from a frame or the message text.
174
+ A client that sent none, which only a route with `origins: "any"` admits, or a header that is not a serialized `scheme://host[:port]`, leaves `turn.origin` undefined; a tool that needs it should fail closed then.
172
175
  An upgrade from an origin not in `origins`, or without one, is refused with 403; without a valid ticket or token with 401; for a person the core policy does not admit with 403; past the person's connection limit with 429.
173
176
 
174
177
  ```js
@@ -200,12 +203,14 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
200
203
  | `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
201
204
  | `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
202
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`. |
203
208
 
204
209
  ### Server frames
205
210
 
206
211
  | Frame | Meaning |
207
212
  |---|---|
208
- | `{ 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. |
209
214
  | `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
210
215
  | `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
211
216
  | `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
@@ -216,7 +221,9 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
216
221
  | `{ type: "prompt_closed", conversation, prompt, outcome }` | The prompt closed: `approved`, `declined`, `answered`, `expired`, or `cancelled` (the turn stopped). |
217
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. |
218
223
  | `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
219
- | `{ 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. |
220
227
 
221
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:
222
229
 
@@ -234,7 +241,7 @@ approval?: {
234
241
 
235
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.
236
243
 
237
- 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).
238
245
 
239
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.
240
247
  The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
@@ -250,6 +257,7 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
250
257
  | `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
251
258
  | `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
252
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). |
253
261
  | `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
254
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. |
255
263
  | `POST <path>/notices/<id>/read` | `{ notice }` with `readAt` set. Idempotent; unknown ids or another principal's notice return 404. |
@@ -306,6 +314,24 @@ This needs a pi-roundtable release that has `attachments.retention`; on an earli
306
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.
307
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.
308
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
+
309
335
  ## Security model
310
336
 
311
337
  - **Private conversations.** A conversation belongs to the person who opened it.
@@ -324,6 +350,7 @@ Only the person who uploaded a file can use it, and only in the conversation it
324
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.
325
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.
326
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.
327
354
  - **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
328
355
  A card whose tier the person lacks is never shown: its prompt resolves `unavailable` (no frame is sent), without owner escalation.
329
356
  - **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
@@ -342,7 +369,13 @@ Only the person who uploaded a file can use it, and only in the conversation it
342
369
  | `reauthLeadMs` | `reauth` 60 seconds before the token expires |
343
370
  | `maxConnections` | 256 sockets on the route |
344
371
  | `maxMessageBytes` | 64 KiB per client frame |
345
- | `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 |
346
379
  | `maxBufferedBytes` | 4 MiB waiting for a slow client |
347
380
  | `ticketTtlMs` | 30 seconds |
348
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.9",
3
+ "version": "0.9.11",
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.9",
46
+ "pi-roundtable": "0.9.11",
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,10 @@ 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;
120
+ /** The origin of the connection the message arrived on. */
121
+ origin?: string;
115
122
  persona: WebPersona;
116
123
  title: string;
117
124
  /** The files the message references, moved into the conversation when it was accepted. */
@@ -192,6 +199,8 @@ export class WebChat {
192
199
  readonly uploads: Uploads;
193
200
  readonly connections: Connections;
194
201
  readonly desk: PromptDesk;
202
+ /** The calls the assistant's tools make to the client that started their turn. */
203
+ readonly clients: ClientDesk;
195
204
  readonly #deps: WebChatDeps;
196
205
  readonly #personas: Map<string, WebPersona>;
197
206
  /** Conversations opened but not yet written in, so the registry has no record of them. */
@@ -231,6 +240,11 @@ export class WebChat {
231
240
  send,
232
241
  timeoutMs: deps.limits.promptTimeoutMs,
233
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
+ });
234
248
  this.surface = new WebSurface({
235
249
  surface: deps.surface,
236
250
  principalOf: (conversation) => this.#owners.get(conversation),
@@ -353,6 +367,28 @@ export class WebChat {
353
367
  );
354
368
  }
355
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
+
356
392
  /** The speaker's web conversations, the most recently active first. */
357
393
  async list(speaker: Speaker): Promise<ConversationRecord[]> {
358
394
  const records = await this.#deps
@@ -396,6 +432,7 @@ export class WebChat {
396
432
  speaker: { ...speaker },
397
433
  personas: this.personasFor(speaker),
398
434
  attachments: this.uploads.advertised,
435
+ clientCalls: this.clients.advertised,
399
436
  expiresAt: identity.expiresAt.toISOString(),
400
437
  });
401
438
  for (const frame of this.desk.openFor(speaker.principalId))
@@ -435,6 +472,7 @@ export class WebChat {
435
472
  }
436
473
 
437
474
  closed(socket: RouteSocket<Connection>): void {
475
+ this.clients.closed(socket.data);
438
476
  this.connections.closed(socket.data);
439
477
  }
440
478
 
@@ -458,7 +496,9 @@ export class WebChat {
458
496
  ? frame.id
459
497
  : frame.type === "approval" || frame.type === "answer"
460
498
  ? frame.prompt
461
- : undefined;
499
+ : frame.type === "client_result"
500
+ ? frame.call
501
+ : undefined;
462
502
  this.connections.send(connection, {
463
503
  type: "error",
464
504
  code: error.code,
@@ -507,6 +547,14 @@ export class WebChat {
507
547
  if (refused) throw new Refusal(refused);
508
548
  return;
509
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
+ }
510
558
  case "answer": {
511
559
  const refused = this.desk.answer(speaker, frame.prompt, {
512
560
  choices: frame.choices,
@@ -600,6 +648,8 @@ export class WebChat {
600
648
  const messageId = crypto.randomUUID();
601
649
  this.#pending.set(messageId, {
602
650
  speaker,
651
+ connection,
652
+ ...(connection.origin ? { origin: connection.origin } : {}),
603
653
  persona,
604
654
  title: minted?.title ?? titleOf(text),
605
655
  fresh: record === undefined,
@@ -800,30 +850,38 @@ export class WebChat {
800
850
  throw new Refusal("forbidden");
801
851
  }
802
852
  this.#registered.add(channel);
803
- await this.#deps.turns().run({
804
- channel,
805
- kind: persona.kind,
806
- text,
807
- speaker,
808
- interactive: true,
809
- ...(pending.attachments ? { attachments: pending.attachments } : {}),
810
- ...(persona.selection
811
- ? {
812
- selection: {
813
- id: `webchat:${persona.kind}`,
814
- ...persona.selection,
815
- },
816
- }
817
- : {}),
818
- conversation: {
819
- visibility: "private",
820
- ...(pending.fresh && pending.title ? { title: pending.title } : {}),
821
- },
822
- reply: async (result) => {
823
- answered = true;
824
- this.#reply(speaker.principalId, conversation, result);
825
- },
826
- });
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
+ }
827
885
  } catch (error) {
828
886
  // A turn the host refused before it ran, such as one whose conversation could not be
829
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
+ }
@@ -6,6 +6,18 @@ import type { ServerFrame } from "./protocol.ts";
6
6
  export interface Connection {
7
7
  identity: WebIdentity;
8
8
  speaker: Speaker;
9
+ /**
10
+ * The normalized `Origin` the upgrade carried and the route accepted; undefined when it sent none
11
+ * (a client that is not a browser, on a route that admits `"any"` origin). Fixed for the
12
+ * connection's life: it comes from the transport, never from a frame.
13
+ */
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[];
9
21
  socket?: RouteSocket<Connection>;
10
22
  /** Asks for a fresh token, then closes the socket when the token expires. */
11
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/origin.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** The longest origin kept; a longer header is not a browser's. */
2
+ const MAX_ORIGIN_LENGTH = 256;
3
+
4
+ /** A serialized origin: a scheme, `://`, and a host with an optional port, with no path, query or space. */
5
+ const ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^\s/?#@]+$/;
6
+
7
+ /**
8
+ * The connection's `Origin` header as a tool may label a record with it: trimmed and lower-cased,
9
+ * or undefined when the header is absent, the opaque `null`, over-long, or not a serialized origin.
10
+ * Browsers always send one; a client that is not a browser sends any text it likes, so the shape
11
+ * is checked here and nothing else is trusted about it.
12
+ */
13
+ export function normalizeOrigin(header: string | null): string | undefined {
14
+ const value = header?.trim().toLowerCase();
15
+ if (!value || value.length > MAX_ORIGIN_LENGTH) return undefined;
16
+ return ORIGIN.test(value) ? value : undefined;
17
+ }
package/src/plugin.ts CHANGED
@@ -17,10 +17,12 @@ 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";
23
24
  import { TokenRefused, type TokenVerifier } from "./oidc.ts";
25
+ import { normalizeOrigin } from "./origin.ts";
24
26
  import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
25
27
  import { restHandler } from "./rest.ts";
26
28
  import { TicketBook } from "./tickets.ts";
@@ -58,7 +60,9 @@ export interface WebChatOptions {
58
60
  path?: string;
59
61
  /** The key prefix of the conversations; default `web`. Two web chats on one host need two. */
60
62
  surface?: string;
61
- limits?: Partial<WebChatLimits & WebChatRouteLimits>;
63
+ limits?: Partial<Omit<WebChatLimits & WebChatRouteLimits, "clientCalls">> & {
64
+ clientCalls?: Partial<ClientCallLimits>;
65
+ };
62
66
  }
63
67
 
64
68
  const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
@@ -69,6 +73,14 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
69
73
  messageChars: 32_000,
70
74
  promptTimeoutMs: 30 * 60_000,
71
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
+ },
72
84
  attachmentBytes: 10 * 1024 * 1024,
73
85
  attachmentsPerMessage: 8,
74
86
  uploadsPerHour: 60,
@@ -78,7 +90,7 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
78
90
  unsentUploadTtlMs: 24 * 60 * 60_000,
79
91
  maxConnections: 256,
80
92
  maxMessageBytes: 64 * 1024,
81
- rate: { messages: 60, perMs: 60_000 },
93
+ rate: { messages: 120, perMs: 60_000 },
82
94
  maxBufferedBytes: 4 * 1024 * 1024,
83
95
  ticketTtlMs: 30_000,
84
96
  };
@@ -144,6 +156,52 @@ function checkAttachmentLimits(limits: WebChatLimits): void {
144
156
  );
145
157
  }
146
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
+
147
205
  /** The subprotocols a WebSocket upgrade offers. */
148
206
  function offered(request: Request): string[] {
149
207
  return (request.headers.get("sec-websocket-protocol") ?? "")
@@ -163,10 +221,22 @@ function offered(request: Request): string[] {
163
221
  */
164
222
  export function webChat(options: WebChatOptions): RoundtablePlugin {
165
223
  const { path, surface } = checkOptions(options);
166
- 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
+ };
167
232
  // The personas are checked now, so a broken list stops the configuration rather than the boot.
168
233
  checkPersonas(options.personas);
169
234
  checkAttachmentLimits(limits);
235
+ checkClientCallLimits(
236
+ limits.clientCalls,
237
+ limits.maxMessageBytes,
238
+ limits.rate,
239
+ );
170
240
  return definePlugin({
171
241
  name: surface === "web" ? "webchat" : `webchat-${surface}`,
172
242
  requires: [IDENTITY, CONVERSATIONS, RUNTIME],
@@ -240,7 +310,12 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
240
310
  if (error instanceof Refusal) return refuse(403, "Forbidden");
241
311
  throw error;
242
312
  }
243
- const connection: Connection = { ...admitted, timers: [] };
313
+ const origin = normalizeOrigin(request.headers.get("origin"));
314
+ const connection: Connection = {
315
+ ...admitted,
316
+ ...(origin ? { origin } : {}),
317
+ timers: [],
318
+ };
244
319
  if (!chat.connections.reserve(connection))
245
320
  return refuse(429, "Too Many Connections");
246
321
  return {
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> {