pi-roundtable 0.7.18 → 0.8.0
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 +44 -0
- package/README.md +44 -11
- package/README.zh-TW.md +44 -9
- package/docs/migrating-0.8.md +132 -0
- package/docs/plugins.md +268 -23
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +31 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/package.json +3 -2
- package/src/cli/cli.ts +15 -4
- package/src/cli/doctor.ts +21 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/project.ts +28 -2
- package/src/cli/templates.ts +46 -14
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +6 -1
- package/src/core/builtin/agent-server.ts +30 -95
- package/src/core/builtin/modules.ts +133 -34
- package/src/core/builtin/stores.ts +29 -14
- package/src/core/config/adapters.ts +57 -0
- package/src/core/config/config.ts +174 -77
- package/src/core/contract/channels.ts +4 -0
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +8 -4
- package/src/core/contract/surface.ts +13 -0
- package/src/core/conversations/conversation-registry.ts +42 -0
- package/src/core/conversations/conversation-store.ts +113 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +124 -54
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/owner-cards.ts +19 -8
- package/src/core/domain/conversation.ts +5 -0
- package/src/core/domain/owner-prompts.ts +1 -1
- package/src/core/domain/ports.ts +6 -0
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +6 -3
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +5 -7
- package/src/core/modules/memory/owner-memory.ts +5 -3
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +8 -0
- package/src/core/routing/conversation-turns.ts +55 -1
- package/src/core/routing/surface-port.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +42 -4
- package/src/core/runtime/pi-agent-runtime.ts +13 -6
- package/src/core/runtime/prompt-slot.ts +2 -2
- package/src/core/runtime/runtime-plugin.ts +160 -0
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +15 -1
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/testing/modules.ts +47 -23
- package/src/core/testing/surface-contract.ts +383 -0
- package/src/core/testing/test-host.ts +27 -9
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -0
- package/src/testing.ts +41 -5
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +15 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +55 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +2 -1
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { chmodSync } from "node:fs";
|
|
2
|
-
import type { Server } from "bun";
|
|
2
|
+
import type { Server, WebSocketHandler } from "bun";
|
|
3
3
|
import { PluginError } from "../errors.ts";
|
|
4
4
|
import type { Logger } from "../log.ts";
|
|
5
5
|
import { serveUnix } from "../shared/unix-server.ts";
|
|
6
|
+
import {
|
|
7
|
+
type Connection,
|
|
8
|
+
DEFAULT_MAX_BUFFERED_BYTES,
|
|
9
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
10
|
+
isUpgrade,
|
|
11
|
+
SocketSet,
|
|
12
|
+
type WebSocketRoute,
|
|
13
|
+
} from "./websocket.ts";
|
|
6
14
|
|
|
7
15
|
/** A handler a plugin attaches to one of the host's configured listeners. */
|
|
8
16
|
export interface HttpRoute {
|
|
@@ -13,6 +21,11 @@ export interface HttpRoute {
|
|
|
13
21
|
/** Omitted: every method, for a handler that answers the rest itself. */
|
|
14
22
|
methods?: readonly string[];
|
|
15
23
|
handle(request: Request): Response | Promise<Response>;
|
|
24
|
+
/**
|
|
25
|
+
* Takes the route's WebSocket upgrade requests (GET with `Upgrade: websocket`); every other
|
|
26
|
+
* request still reaches `handle`.
|
|
27
|
+
*/
|
|
28
|
+
websocket?: WebSocketRoute;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
31
|
/** Where a listener serves: a unix socket, reached from outside through a tunnel, or a TCP port. */
|
|
@@ -44,6 +57,37 @@ function overlaps(a: HttpRoute, b: HttpRoute): boolean {
|
|
|
44
57
|
return matches(a, pathOf(b)) || matches(b, pathOf(a));
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
/** A non-special scheme's origin, such as `chrome-extension://<id>`, which URL reports as "null". */
|
|
61
|
+
const OPAQUE_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/?#@:\s]+(?::\d+)?$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether `origin` is `scheme://host[:port]` exactly as a browser sends it: a special scheme's
|
|
65
|
+
* origin, or a non-special one in lowercase without path, query, fragment, or user.
|
|
66
|
+
*/
|
|
67
|
+
const isOrigin = (origin: string) => {
|
|
68
|
+
const url = URL.parse(origin);
|
|
69
|
+
if (!url) return false;
|
|
70
|
+
return url.origin === "null"
|
|
71
|
+
? OPAQUE_ORIGIN.test(origin)
|
|
72
|
+
: url.origin === origin;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Refuses a WebSocket limit that is not a positive integer, naming the route and field. */
|
|
76
|
+
function validateLimits(name: string, websocket: WebSocketRoute): void {
|
|
77
|
+
const limits: [string, number | undefined][] = [
|
|
78
|
+
["maxMessageBytes", websocket.maxMessageBytes],
|
|
79
|
+
["maxBufferedBytes", websocket.maxBufferedBytes],
|
|
80
|
+
["maxConnections", websocket.maxConnections],
|
|
81
|
+
["rate.messages", websocket.rate?.messages],
|
|
82
|
+
["rate.perMs", websocket.rate?.perMs],
|
|
83
|
+
];
|
|
84
|
+
for (const [field, value] of limits)
|
|
85
|
+
if (value !== undefined && !(Number.isSafeInteger(value) && value > 0))
|
|
86
|
+
throw new PluginError(
|
|
87
|
+
`route ${name} sets websocket ${field} to ${value}, which is not a positive integer`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
/** Refuses routes on unknown listeners, reused names, and any request two routes could both take. */
|
|
48
92
|
function validate(
|
|
49
93
|
listeners: readonly ListenerConfig[],
|
|
@@ -53,6 +97,18 @@ function validate(
|
|
|
53
97
|
if (ids.size !== listeners.length)
|
|
54
98
|
throw new PluginError("a listener is configured twice");
|
|
55
99
|
routes.forEach((route, index) => {
|
|
100
|
+
if (route.websocket && route.methods && !route.methods.includes("GET"))
|
|
101
|
+
throw new PluginError(
|
|
102
|
+
`route ${route.name} takes WebSockets, so its methods must include GET`,
|
|
103
|
+
);
|
|
104
|
+
if (route.websocket) validateLimits(route.name, route.websocket);
|
|
105
|
+
const { origins } = route.websocket ?? {};
|
|
106
|
+
if (origins && origins !== "any")
|
|
107
|
+
for (const origin of origins)
|
|
108
|
+
if (!isOrigin(origin))
|
|
109
|
+
throw new PluginError(
|
|
110
|
+
`route ${route.name} lists origin ${JSON.stringify(origin)}, which is not a scheme://host[:port] origin`,
|
|
111
|
+
);
|
|
56
112
|
if (!ids.has(route.listener))
|
|
57
113
|
throw new PluginError(
|
|
58
114
|
`route ${route.name} needs listener ${route.listener}, which is not configured`,
|
|
@@ -71,6 +127,16 @@ function validate(
|
|
|
71
127
|
const serverError = () =>
|
|
72
128
|
new Response("Internal Server Error", { status: 500 });
|
|
73
129
|
|
|
130
|
+
/** The route that takes a request, when one does. */
|
|
131
|
+
const routeFor = (routes: readonly HttpRoute[], request: Request) => {
|
|
132
|
+
// pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
|
|
133
|
+
const path = new URL(request.url).pathname;
|
|
134
|
+
return routes.find(
|
|
135
|
+
(r) =>
|
|
136
|
+
matches(r, path) && (!r.methods || r.methods.includes(request.method)),
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
74
140
|
/**
|
|
75
141
|
* Routes one listener's request; a request no route takes is 404, and a route that throws, or
|
|
76
142
|
* whose promise rejects, is 500 with a fixed body. The failure is logged with the route's name
|
|
@@ -82,12 +148,7 @@ export async function routeRequest(
|
|
|
82
148
|
listener: string,
|
|
83
149
|
logger: Logger,
|
|
84
150
|
): Promise<Response> {
|
|
85
|
-
|
|
86
|
-
const path = new URL(request.url).pathname;
|
|
87
|
-
const route = routes.find(
|
|
88
|
-
(r) =>
|
|
89
|
-
matches(r, path) && (!r.methods || r.methods.includes(request.method)),
|
|
90
|
-
);
|
|
151
|
+
const route = routeFor(routes, request);
|
|
91
152
|
if (!route) return new Response("Not found", { status: 404 });
|
|
92
153
|
try {
|
|
93
154
|
return await route.handle(request);
|
|
@@ -97,39 +158,87 @@ export async function routeRequest(
|
|
|
97
158
|
}
|
|
98
159
|
}
|
|
99
160
|
|
|
161
|
+
/** What every request on one listener is served with. */
|
|
162
|
+
interface ListenerContext {
|
|
163
|
+
listener: string;
|
|
164
|
+
logger: Logger;
|
|
165
|
+
sockets: SocketSet;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Routes one listener's request like `routeRequest`, except that a WebSocket upgrade for a route
|
|
170
|
+
* that takes them goes to its `websocket`; a failing `accept` answers and logs like a failing
|
|
171
|
+
* `handle`. Answers nothing once the socket is upgraded.
|
|
172
|
+
*/
|
|
173
|
+
async function serveRequest(
|
|
174
|
+
routes: readonly HttpRoute[],
|
|
175
|
+
request: Request,
|
|
176
|
+
server: Server<Connection>,
|
|
177
|
+
{ listener, logger, sockets }: ListenerContext,
|
|
178
|
+
): Promise<Response | undefined> {
|
|
179
|
+
const route = routeFor(routes, request);
|
|
180
|
+
if (!route?.websocket || !isUpgrade(request))
|
|
181
|
+
return routeRequest(routes, request, listener, logger);
|
|
182
|
+
try {
|
|
183
|
+
return await sockets.upgrade(route.name, route.websocket, request, server);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
logger.error({ err, route: route.name, listener }, "route failed");
|
|
186
|
+
return serverError();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type Fetch = (
|
|
191
|
+
request: Request,
|
|
192
|
+
server: Server<Connection>,
|
|
193
|
+
) => Promise<Response | undefined>;
|
|
194
|
+
|
|
100
195
|
/** Serves HTTP on a TCP port with the same idle setting as the unix sockets. */
|
|
101
196
|
function serveTcp(
|
|
102
197
|
port: number,
|
|
103
198
|
hostname: string | undefined,
|
|
104
|
-
fetch:
|
|
105
|
-
|
|
106
|
-
|
|
199
|
+
fetch: Fetch,
|
|
200
|
+
websocket: WebSocketHandler<Connection> | undefined,
|
|
201
|
+
): Server<Connection> {
|
|
202
|
+
const options = {
|
|
107
203
|
port,
|
|
108
204
|
...(hostname ? { hostname } : {}),
|
|
109
205
|
idleTimeout: 0,
|
|
110
206
|
fetch,
|
|
111
207
|
error: serverError,
|
|
112
|
-
|
|
208
|
+
...(websocket ? { websocket } : {}),
|
|
209
|
+
};
|
|
210
|
+
// SAFETY: a listener without WebSocket routes passes no handler and never upgrades.
|
|
211
|
+
return Bun.serve(
|
|
212
|
+
options as Parameters<typeof Bun.serve<Connection>>[0],
|
|
213
|
+
) as Server<Connection>;
|
|
113
214
|
}
|
|
114
215
|
|
|
216
|
+
/** How long a stop waits for WebSocket routes' handlers to settle before it returns anyway. */
|
|
217
|
+
const CLOSE_TIMEOUT_MS = 5_000;
|
|
218
|
+
|
|
115
219
|
/** The host's HTTP listeners, each serving only the routes attached to it. */
|
|
116
|
-
// pi-lens-ignore: large-class —
|
|
220
|
+
// pi-lens-ignore: large-class — four members: validation in the constructor, start, the WebSocket handler, and stop
|
|
117
221
|
export class HttpListeners {
|
|
118
222
|
readonly #listeners: readonly ListenerConfig[];
|
|
119
223
|
readonly #routes: readonly HttpRoute[];
|
|
120
224
|
readonly #logger: Logger;
|
|
121
|
-
#
|
|
225
|
+
readonly #sockets: SocketSet;
|
|
226
|
+
readonly #closeTimeoutMs: number;
|
|
227
|
+
#servers: Pick<Server<unknown>, "stop">[] = [];
|
|
122
228
|
|
|
123
|
-
/** Validates every route before any socket opens. */
|
|
229
|
+
/** Validates every route before any socket opens; `closeTimeoutMs` bounds how long a stop waits on WebSocket handlers. */
|
|
124
230
|
constructor(
|
|
125
231
|
listeners: readonly ListenerConfig[],
|
|
126
232
|
routes: readonly HttpRoute[],
|
|
127
233
|
logger: Logger,
|
|
234
|
+
options: { closeTimeoutMs?: number } = {},
|
|
128
235
|
) {
|
|
129
236
|
validate(listeners, routes);
|
|
130
237
|
this.#logger = logger;
|
|
131
238
|
this.#listeners = listeners;
|
|
132
239
|
this.#routes = routes;
|
|
240
|
+
this.#sockets = new SocketSet(logger);
|
|
241
|
+
this.#closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
|
|
133
242
|
}
|
|
134
243
|
|
|
135
244
|
/** Opens every listener; when one fails, the ones already open close again before the error is thrown. */
|
|
@@ -139,28 +248,72 @@ export class HttpListeners {
|
|
|
139
248
|
const routes = this.#routes.filter(
|
|
140
249
|
(route) => route.listener === listener.id,
|
|
141
250
|
);
|
|
142
|
-
const handle = (request
|
|
143
|
-
|
|
251
|
+
const handle: Fetch = (request, server) =>
|
|
252
|
+
serveRequest(routes, request, server, {
|
|
253
|
+
listener: listener.id,
|
|
254
|
+
logger: this.#logger,
|
|
255
|
+
sockets: this.#sockets,
|
|
256
|
+
});
|
|
257
|
+
const websocket = this.#websocketHandler(listener.id, routes);
|
|
144
258
|
if ("socketPath" in listener) {
|
|
145
259
|
this.#servers.push(
|
|
146
|
-
|
|
260
|
+
websocket
|
|
261
|
+
? serveUnix(listener.socketPath, handle, {
|
|
262
|
+
error: serverError,
|
|
263
|
+
websocket,
|
|
264
|
+
})
|
|
265
|
+
: serveUnix(
|
|
266
|
+
listener.socketPath,
|
|
267
|
+
(request) =>
|
|
268
|
+
routeRequest(routes, request, listener.id, this.#logger),
|
|
269
|
+
{ error: serverError },
|
|
270
|
+
),
|
|
147
271
|
);
|
|
148
272
|
chmodSync(listener.socketPath, listener.mode ?? 0o660);
|
|
149
273
|
} else {
|
|
150
274
|
this.#servers.push(
|
|
151
|
-
serveTcp(listener.port, listener.hostname, handle),
|
|
275
|
+
serveTcp(listener.port, listener.hostname, handle, websocket),
|
|
152
276
|
);
|
|
153
277
|
}
|
|
154
278
|
}
|
|
155
279
|
} catch (error) {
|
|
156
|
-
this.stop();
|
|
280
|
+
void this.stop();
|
|
157
281
|
throw error;
|
|
158
282
|
}
|
|
159
283
|
}
|
|
160
284
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
285
|
+
/** The listener's WebSocket handler, sized for its largest route limits; none without WebSocket routes. */
|
|
286
|
+
#websocketHandler(
|
|
287
|
+
listener: string,
|
|
288
|
+
routes: readonly HttpRoute[],
|
|
289
|
+
): WebSocketHandler<Connection> | undefined {
|
|
290
|
+
const websockets = routes.flatMap((route) =>
|
|
291
|
+
route.websocket ? [route.websocket] : [],
|
|
292
|
+
);
|
|
293
|
+
if (websockets.length === 0) return undefined;
|
|
294
|
+
return this.#sockets.handler(listener, {
|
|
295
|
+
maxMessageBytes: Math.max(
|
|
296
|
+
...websockets.map(
|
|
297
|
+
(w) => w.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES,
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
maxBufferedBytes: Math.max(
|
|
301
|
+
...websockets.map(
|
|
302
|
+
(w) => w.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
|
|
303
|
+
),
|
|
304
|
+
),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stops accepting at once and cuts open connections, such as event streams, without waiting on
|
|
310
|
+
* them. Open WebSockets get close code 1001 first, and the stop resolves once their routes'
|
|
311
|
+
* handlers have settled, or after the close timeout.
|
|
312
|
+
*/
|
|
313
|
+
async stop(): Promise<void> {
|
|
314
|
+
this.#sockets.closeAll();
|
|
163
315
|
for (const server of this.#servers) void server.stop(true);
|
|
164
316
|
this.#servers = [];
|
|
317
|
+
await this.#sockets.drain(this.#closeTimeoutMs);
|
|
165
318
|
}
|
|
166
319
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
2
|
+
import type { Logger } from "../log.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What became of one `send`: written to the connection, `"queued"` behind a client that reads
|
|
6
|
+
* slower than the route sends, or `"dropped"` because the socket is closed, or because
|
|
7
|
+
* `maxBufferedBytes` were already waiting and the socket was cut for it.
|
|
8
|
+
*/
|
|
9
|
+
export type WebSocketSendResult = "sent" | "queued" | "dropped";
|
|
10
|
+
|
|
11
|
+
/** One socket a WebSocket route accepted, with the data its `accept` gave. */
|
|
12
|
+
export interface RouteSocket<Data = unknown> {
|
|
13
|
+
readonly data: Data;
|
|
14
|
+
send(message: string | Uint8Array): WebSocketSendResult;
|
|
15
|
+
close(code?: number, reason?: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What `accept` answers: the socket's data to upgrade with, or a response that refuses it. */
|
|
19
|
+
export type WebSocketAccept<Data = unknown> =
|
|
20
|
+
| { data: Data; headers?: Record<string, string> }
|
|
21
|
+
| Response;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The WebSocket half of an HTTP route. An upgrade request on the route's path passes the Origin
|
|
25
|
+
* check, then `accept`, which authenticates it before any socket opens; other requests still
|
|
26
|
+
* reach the route's `handle`.
|
|
27
|
+
*/
|
|
28
|
+
export interface WebSocketRoute<Data = unknown> {
|
|
29
|
+
/**
|
|
30
|
+
* The `Origin` values a browser may connect from, each as `scheme://host[:port]` exactly as the
|
|
31
|
+
* browser sends it, such as `https://chat.example.com` or `chrome-extension://<id>`; a request
|
|
32
|
+
* with another Origin, or none, is refused with 403 before `accept`. `"any"` skips the check,
|
|
33
|
+
* for clients that are not browsers.
|
|
34
|
+
*/
|
|
35
|
+
origins: readonly string[] | "any";
|
|
36
|
+
/** Authenticates and authorizes the upgrade; a thrown error answers 500 like a failing `handle`. */
|
|
37
|
+
accept(
|
|
38
|
+
request: Request,
|
|
39
|
+
): WebSocketAccept<Data> | Promise<WebSocketAccept<Data>>;
|
|
40
|
+
/**
|
|
41
|
+
* The largest message in bytes; a larger one closes the socket with 1009. Default 64 KiB. A
|
|
42
|
+
* message over twice the listener's largest limit drops the connection before it is read.
|
|
43
|
+
*/
|
|
44
|
+
maxMessageBytes?: number;
|
|
45
|
+
/** At most `messages` per `perMs` from one socket; one more closes it with 1008. Default 120 a minute. */
|
|
46
|
+
rate?: { messages: number; perMs: number };
|
|
47
|
+
/**
|
|
48
|
+
* The most bytes one socket may have waiting for a client that reads slower than the route
|
|
49
|
+
* sends. It is checked before each `send`: a `send` that finds this many waiting returns
|
|
50
|
+
* `"dropped"` and cuts the socket without a close frame, so the client and the route's `close`
|
|
51
|
+
* both see 1006; one that finds fewer is sent whole, so the bytes waiting can reach this limit
|
|
52
|
+
* plus one message. Default 1 MiB.
|
|
53
|
+
*/
|
|
54
|
+
maxBufferedBytes?: number;
|
|
55
|
+
/** The most sockets the route holds open at once; another upgrade gets 503 before `accept`. Default 256. */
|
|
56
|
+
maxConnections?: number;
|
|
57
|
+
/** What a handler returns is ignored; a promise is awaited. */
|
|
58
|
+
open?(socket: RouteSocket<Data>): unknown | Promise<unknown>;
|
|
59
|
+
/** Called per message as it arrives, without waiting for the previous call to settle. */
|
|
60
|
+
message(
|
|
61
|
+
socket: RouteSocket<Data>,
|
|
62
|
+
message: string | Uint8Array,
|
|
63
|
+
): unknown | Promise<unknown>;
|
|
64
|
+
/** Called once the socket closed, also when the listener stops (code 1001); stop waits for it. */
|
|
65
|
+
close?(
|
|
66
|
+
socket: RouteSocket<Data>,
|
|
67
|
+
code: number,
|
|
68
|
+
reason: string,
|
|
69
|
+
): unknown | Promise<unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024;
|
|
73
|
+
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
74
|
+
const DEFAULT_MAX_CONNECTIONS = 256;
|
|
75
|
+
const DEFAULT_RATE = { messages: 120, perMs: 60_000 };
|
|
76
|
+
|
|
77
|
+
/** The close codes RFC 6455 names for the reasons this module closes a socket. */
|
|
78
|
+
const GOING_AWAY = 1001;
|
|
79
|
+
const POLICY_VIOLATION = 1008;
|
|
80
|
+
const TOO_BIG = 1009;
|
|
81
|
+
const INTERNAL_ERROR = 1011;
|
|
82
|
+
|
|
83
|
+
/** What the server keeps on each socket: its route, the route's socket, and its rate window. */
|
|
84
|
+
export interface Connection {
|
|
85
|
+
route: string;
|
|
86
|
+
websocket: WebSocketRoute;
|
|
87
|
+
data: unknown;
|
|
88
|
+
socket?: RouteSocket;
|
|
89
|
+
windowStart: number;
|
|
90
|
+
count: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether the request asks to become a WebSocket: a GET with `Upgrade: websocket`. */
|
|
94
|
+
export const isUpgrade = (request: Request): boolean =>
|
|
95
|
+
request.method === "GET" &&
|
|
96
|
+
request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
97
|
+
|
|
98
|
+
/** Whether an upgrade request carries the key and the version 13 that RFC 6455 requires. */
|
|
99
|
+
const completeHandshake = (request: Request): boolean =>
|
|
100
|
+
Boolean(request.headers.get("sec-websocket-key")) &&
|
|
101
|
+
request.headers.get("sec-websocket-version") === "13";
|
|
102
|
+
|
|
103
|
+
const originAllowed = (route: WebSocketRoute, origin: string | null) =>
|
|
104
|
+
route.origins === "any" ||
|
|
105
|
+
(origin !== null && route.origins.includes(origin));
|
|
106
|
+
|
|
107
|
+
const byteLength = (message: string | Uint8Array) =>
|
|
108
|
+
typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Sends one message unless the route's `maxBufferedBytes` already wait; then, or when Bun drops
|
|
112
|
+
* the message, the socket is cut (1006) rather than left open with a gap in what it was sent.
|
|
113
|
+
* Bun answers 0 for an empty message it did send, and for a message it dropped.
|
|
114
|
+
*/
|
|
115
|
+
function send(
|
|
116
|
+
ws: ServerWebSocket<Connection>,
|
|
117
|
+
message: string | Uint8Array,
|
|
118
|
+
): WebSocketSendResult {
|
|
119
|
+
if (ws.readyState !== WebSocket.OPEN) return "dropped";
|
|
120
|
+
const limit =
|
|
121
|
+
ws.data.websocket.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
122
|
+
if (ws.getBufferedAmount() < limit) {
|
|
123
|
+
const status = ws.send(message);
|
|
124
|
+
if (ws.readyState !== WebSocket.OPEN) return "dropped";
|
|
125
|
+
if (status > 0 || (status === 0 && byteLength(message) === 0))
|
|
126
|
+
return "sent";
|
|
127
|
+
if (status === -1) return "queued";
|
|
128
|
+
}
|
|
129
|
+
ws.terminate();
|
|
130
|
+
return "dropped";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resolves after `promise` settles or `ms` passes, whichever comes first. */
|
|
134
|
+
async function settledWithin(promise: Promise<unknown>, ms: number) {
|
|
135
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
136
|
+
await Promise.race([
|
|
137
|
+
promise,
|
|
138
|
+
new Promise<void>((resolve) => {
|
|
139
|
+
timer = setTimeout(resolve, ms);
|
|
140
|
+
}),
|
|
141
|
+
]);
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The open sockets of one host's listeners and the handler calls still running on them. A route
|
|
147
|
+
* handler that throws or rejects closes its socket with 1011 and logs the route and listener,
|
|
148
|
+
* never the URL.
|
|
149
|
+
*/
|
|
150
|
+
export class SocketSet {
|
|
151
|
+
readonly #open = new Set<ServerWebSocket<Connection>>();
|
|
152
|
+
readonly #running = new Set<Promise<void>>();
|
|
153
|
+
/** Each route's open sockets plus the upgrades still in `accept`. */
|
|
154
|
+
readonly #held = new Map<WebSocketRoute, number>();
|
|
155
|
+
readonly #logger: Logger;
|
|
156
|
+
|
|
157
|
+
constructor(logger: Logger) {
|
|
158
|
+
this.#logger = logger;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Upgrades a request for a WebSocket route. A handshake without a key or version 13 is 400, a
|
|
163
|
+
* refused Origin 403, and a route at `maxConnections` 503, all before `accept`; the response
|
|
164
|
+
* `accept` returns is sent as it is, and a throwing `accept` is passed to the caller like a
|
|
165
|
+
* failing `handle`. Answers nothing once the socket is upgraded.
|
|
166
|
+
*/
|
|
167
|
+
async upgrade(
|
|
168
|
+
name: string,
|
|
169
|
+
websocket: WebSocketRoute,
|
|
170
|
+
request: Request,
|
|
171
|
+
server: Server<Connection>,
|
|
172
|
+
): Promise<Response | undefined> {
|
|
173
|
+
if (!completeHandshake(request))
|
|
174
|
+
return new Response("Bad Request", { status: 400 });
|
|
175
|
+
if (!originAllowed(websocket, request.headers.get("origin")))
|
|
176
|
+
return new Response("Forbidden", { status: 403 });
|
|
177
|
+
const held = this.#held.get(websocket) ?? 0;
|
|
178
|
+
if (held >= (websocket.maxConnections ?? DEFAULT_MAX_CONNECTIONS))
|
|
179
|
+
return new Response("Service Unavailable", { status: 503 });
|
|
180
|
+
// The place is taken while `accept` runs, so concurrent upgrades cannot overshoot it.
|
|
181
|
+
this.#held.set(websocket, held + 1);
|
|
182
|
+
let upgraded = false;
|
|
183
|
+
try {
|
|
184
|
+
const accepted = await websocket.accept(request);
|
|
185
|
+
if (accepted instanceof Response) return accepted;
|
|
186
|
+
const connection: Connection = {
|
|
187
|
+
route: name,
|
|
188
|
+
websocket,
|
|
189
|
+
data: accepted.data,
|
|
190
|
+
windowStart: Date.now(),
|
|
191
|
+
count: 0,
|
|
192
|
+
};
|
|
193
|
+
// Bun runs `open` inside `upgrade`, and `close` releases the place from then on.
|
|
194
|
+
upgraded = server.upgrade(request, {
|
|
195
|
+
data: connection,
|
|
196
|
+
...(accepted.headers ? { headers: accepted.headers } : {}),
|
|
197
|
+
});
|
|
198
|
+
return upgraded
|
|
199
|
+
? undefined
|
|
200
|
+
: new Response("Bad Request", { status: 400 });
|
|
201
|
+
} finally {
|
|
202
|
+
if (!upgraded) this.#release(websocket);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#release(websocket: WebSocketRoute): void {
|
|
207
|
+
const held = (this.#held.get(websocket) ?? 1) - 1;
|
|
208
|
+
if (held > 0) this.#held.set(websocket, held);
|
|
209
|
+
else this.#held.delete(websocket);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The Bun handler for one listener's sockets, given its largest route limits. Bun drops a frame
|
|
214
|
+
* over twice the message limit unread; up to there each route's own limit closes with 1009.
|
|
215
|
+
* Bun drops a message only when the largest buffered limit already waits, which each route's
|
|
216
|
+
* `send` checks first at its own limit, so a single message larger than any limit still goes out.
|
|
217
|
+
*/
|
|
218
|
+
handler(
|
|
219
|
+
listener: string,
|
|
220
|
+
limits: { maxMessageBytes: number; maxBufferedBytes: number },
|
|
221
|
+
): WebSocketHandler<Connection> {
|
|
222
|
+
return {
|
|
223
|
+
maxPayloadLength: limits.maxMessageBytes * 2,
|
|
224
|
+
backpressureLimit: limits.maxBufferedBytes,
|
|
225
|
+
open: (ws) => {
|
|
226
|
+
this.#open.add(ws);
|
|
227
|
+
const connection = ws.data;
|
|
228
|
+
connection.socket = {
|
|
229
|
+
data: connection.data,
|
|
230
|
+
send: (message) => send(ws, message),
|
|
231
|
+
close: (code, reason) => ws.close(code, reason),
|
|
232
|
+
};
|
|
233
|
+
const { open } = connection.websocket;
|
|
234
|
+
if (open) this.#run(ws, listener, (socket) => open(socket));
|
|
235
|
+
},
|
|
236
|
+
message: (ws, message) => {
|
|
237
|
+
const connection = ws.data;
|
|
238
|
+
const { websocket } = connection;
|
|
239
|
+
const limit = websocket.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
240
|
+
if (byteLength(message) > limit) {
|
|
241
|
+
ws.close(TOO_BIG, "message too big");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const rate = websocket.rate ?? DEFAULT_RATE;
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
if (now - connection.windowStart >= rate.perMs) {
|
|
247
|
+
connection.windowStart = now;
|
|
248
|
+
connection.count = 0;
|
|
249
|
+
}
|
|
250
|
+
connection.count += 1;
|
|
251
|
+
if (connection.count > rate.messages) {
|
|
252
|
+
ws.close(POLICY_VIOLATION, "too many messages");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.#run(ws, listener, (socket) => websocket.message(socket, message));
|
|
256
|
+
},
|
|
257
|
+
close: (ws, code, reason) => {
|
|
258
|
+
this.#open.delete(ws);
|
|
259
|
+
this.#release(ws.data.websocket);
|
|
260
|
+
const { close } = ws.data.websocket;
|
|
261
|
+
if (close)
|
|
262
|
+
this.#run(ws, listener, (socket) => close(socket, code, reason));
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Runs one route handler, tracked until it settles; a failure closes the socket. */
|
|
268
|
+
#run(
|
|
269
|
+
ws: ServerWebSocket<Connection>,
|
|
270
|
+
listener: string,
|
|
271
|
+
call: (socket: RouteSocket) => unknown | Promise<unknown>,
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const connection = ws.data;
|
|
274
|
+
const running = (async () => {
|
|
275
|
+
try {
|
|
276
|
+
if (connection.socket) await call(connection.socket);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
this.#logger.error(
|
|
279
|
+
{ err, route: connection.route, listener },
|
|
280
|
+
"websocket handler failed",
|
|
281
|
+
);
|
|
282
|
+
ws.close(INTERNAL_ERROR, "internal error");
|
|
283
|
+
}
|
|
284
|
+
})();
|
|
285
|
+
this.#running.add(running);
|
|
286
|
+
void running.finally(() => this.#running.delete(running));
|
|
287
|
+
return running;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Closes every open socket with 1001, so each route's `close` runs. */
|
|
291
|
+
closeAll(): void {
|
|
292
|
+
for (const ws of this.#open) ws.close(GOING_AWAY, "server stopping");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Waits, at most `timeoutMs`, until every socket has closed and every handler call has settled;
|
|
297
|
+
* a close the server reports later, or a call it starts meanwhile, is waited for too.
|
|
298
|
+
*/
|
|
299
|
+
async drain(timeoutMs: number): Promise<void> {
|
|
300
|
+
const deadline = Date.now() + timeoutMs;
|
|
301
|
+
while (this.#open.size > 0 || this.#running.size > 0) {
|
|
302
|
+
const left = deadline - Date.now();
|
|
303
|
+
if (left <= 0) {
|
|
304
|
+
this.#logger.warn(
|
|
305
|
+
{ open: this.#open.size, running: this.#running.size },
|
|
306
|
+
"websocket handlers still running after the close timeout",
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await settledWithin(
|
|
311
|
+
Promise.allSettled([...this.#running]),
|
|
312
|
+
Math.min(left, 10),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -11,10 +11,9 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
11
11
|
cardStopped: "⏹️ This round was stopped.",
|
|
12
12
|
cardExpired: "⌛ Timed out with no answer.",
|
|
13
13
|
cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
|
|
14
|
-
cardApproversNote: (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`This card needs ${minTier} or above to answer.`,
|
|
14
|
+
cardApproversNote: (userId: string) => `-# Who can approve: <@${userId}>`,
|
|
15
|
+
cardApproversRefusal:
|
|
16
|
+
"This approval is for the speaker whose turn asked for it.",
|
|
18
17
|
cardAskerNote: (userId: string) => `-# Who can answer: <@${userId}>`,
|
|
19
18
|
cardAskerRefusal: "This question is for another speaker.",
|
|
20
19
|
cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
|
|
@@ -42,9 +41,8 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
|
|
|
42
41
|
cardStopped: "⏹️ 這一輪已停止。",
|
|
43
42
|
cardExpired: "⌛ 已逾時,沒有回答。",
|
|
44
43
|
cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
|
|
45
|
-
cardApproversNote: (
|
|
46
|
-
cardApproversRefusal:
|
|
47
|
-
`這張卡片要 ${minTier} 以上才能回答。`,
|
|
44
|
+
cardApproversNote: (userId: string) => `-# 可以核准的人:<@${userId}>`,
|
|
45
|
+
cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
|
|
48
46
|
cardAskerNote: (userId: string) => `-# 可以回答的人:<@${userId}>`,
|
|
49
47
|
cardAskerRefusal: "這張問題是問另一位發話者的。",
|
|
50
48
|
cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,
|
|
@@ -47,10 +47,12 @@ export function ownerMemoryExtension(
|
|
|
47
47
|
ownerId: string,
|
|
48
48
|
owner: OwnerIdentity,
|
|
49
49
|
/**
|
|
50
|
-
* The person the running turn is for,
|
|
51
|
-
* the owner's, is what the turn reads and changes; without it every turn is the owner's.
|
|
50
|
+
* The person the running turn is for, when it may be someone other than the owner. Their
|
|
51
|
+
* memory, not the owner's, is what the turn reads and changes; without it every turn is the owner's.
|
|
52
52
|
*/
|
|
53
53
|
speaker?: () => Speaker | undefined,
|
|
54
|
+
/** Whether the tools tell the model that each speaker has a memory of their own. */
|
|
55
|
+
describesSpeakers = speaker !== undefined,
|
|
54
56
|
): ExtensionFactory {
|
|
55
57
|
const o = ownerWords(owner);
|
|
56
58
|
/** The running turn's other speaker; undefined when the owner's own memory applies. */
|
|
@@ -59,7 +61,7 @@ export function ownerMemoryExtension(
|
|
|
59
61
|
return current && current.id !== ownerId ? current : undefined;
|
|
60
62
|
};
|
|
61
63
|
const storeOf = () => memories.forSpeaker(other()?.id ?? ownerId);
|
|
62
|
-
const shared =
|
|
64
|
+
const shared = describesSpeakers
|
|
63
65
|
? " Whoever is speaking has a memory of their own, shared by every agent; this reads and changes theirs, not someone else's."
|
|
64
66
|
: "";
|
|
65
67
|
return (pi) => {
|