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
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, test } from "bun:test";
|
|
2
|
+
import type { InboundMessage } from "../contract/channels.ts";
|
|
3
|
+
import type { ChatSurface } from "../contract/surface.ts";
|
|
4
|
+
import { parseChannelKey } from "../contract/surface.ts";
|
|
5
|
+
import type { Approval } from "../domain/owner-prompts.ts";
|
|
6
|
+
import type { TurnProgress } from "../domain/progress.ts";
|
|
7
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
8
|
+
import type { Speaker } from "../speakers.ts";
|
|
9
|
+
|
|
10
|
+
/** What the person on a surface's network has seen, as the contract reads it. */
|
|
11
|
+
export type SurfaceObservation =
|
|
12
|
+
| { kind: "reply"; text: string; files: readonly string[] }
|
|
13
|
+
| { kind: "typing"; on: boolean }
|
|
14
|
+
| { kind: "stop"; on: boolean }
|
|
15
|
+
| { kind: "progress"; event: TurnProgress }
|
|
16
|
+
| { kind: "prompt"; id: string; title: string }
|
|
17
|
+
| { kind: "prompt_closed"; id: string };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A surface under the contract, and one person on its network the contract acts as. The
|
|
21
|
+
* contract starts the surface, then calls `join`; it stops the surface after `close`.
|
|
22
|
+
*/
|
|
23
|
+
export interface SurfaceContractSubject {
|
|
24
|
+
surface: ChatSurface;
|
|
25
|
+
/** A channel of the surface the person takes part in. */
|
|
26
|
+
channel: ChannelKey;
|
|
27
|
+
/** The person joins `channel`, once the surface has started. */
|
|
28
|
+
join?(): Promise<void>;
|
|
29
|
+
/** The person writes `text` in `channel`; the surface delivers it to the host. */
|
|
30
|
+
write(text: string): Promise<void>;
|
|
31
|
+
/** Everything the person has seen so far, oldest first. */
|
|
32
|
+
observations(): readonly SurfaceObservation[];
|
|
33
|
+
/** The person answers an open approval; without it the prompt checks are skipped. */
|
|
34
|
+
answer?(prompt: string, approved: boolean): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* The person as the speaker of the turns that ask them, given to `surface.prompts`; default an
|
|
37
|
+
* owner-tier speaker. Every approval the contract asks for needs exactly the speaker's tier, so
|
|
38
|
+
* it is theirs to answer.
|
|
39
|
+
*/
|
|
40
|
+
speaker?: Speaker;
|
|
41
|
+
/**
|
|
42
|
+
* Someone else on the surface's network, not the owner, who knows the id of the person's open
|
|
43
|
+
* prompt and answers it; without it the responder-scope check is skipped.
|
|
44
|
+
*/
|
|
45
|
+
stranger?: {
|
|
46
|
+
/** They join the surface, once it has started. */
|
|
47
|
+
join?(): Promise<void>;
|
|
48
|
+
answer(prompt: string, approved: boolean): Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
close?(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One way the subject broke the contract. */
|
|
54
|
+
export interface SurfaceContractFailure {
|
|
55
|
+
name: string;
|
|
56
|
+
error: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Running {
|
|
60
|
+
subject: SurfaceContractSubject;
|
|
61
|
+
delivered: InboundMessage[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Check {
|
|
65
|
+
name: string;
|
|
66
|
+
/** Whether the check applies to this subject; a check that does not is skipped. */
|
|
67
|
+
applies?(subject: SurfaceContractSubject): boolean;
|
|
68
|
+
run(running: Running): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class ContractBroken extends Error {
|
|
72
|
+
override name = "ContractBroken";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function expectThat(condition: boolean, message: string): void {
|
|
76
|
+
if (!condition) throw new ContractBroken(message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function until(
|
|
80
|
+
done: () => boolean,
|
|
81
|
+
what: string,
|
|
82
|
+
ms = 2000,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const deadline = Date.now() + ms;
|
|
85
|
+
while (!done()) {
|
|
86
|
+
if (Date.now() > deadline)
|
|
87
|
+
throw new ContractBroken(`timed out waiting until ${what}`);
|
|
88
|
+
await Bun.sleep(5);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
|
|
93
|
+
running.subject.observations().filter((o) => o.kind === kind);
|
|
94
|
+
|
|
95
|
+
const speakerOf = (subject: SurfaceContractSubject): Speaker =>
|
|
96
|
+
subject.speaker ?? { id: "contract-speaker", name: "Ada", tier: "owner" };
|
|
97
|
+
|
|
98
|
+
/** The newest prompt the person has seen, once one more than `before` is open. */
|
|
99
|
+
async function nextPrompt(running: Running, before: number): Promise<string> {
|
|
100
|
+
await until(
|
|
101
|
+
() => seen(running, "prompt").length > before,
|
|
102
|
+
"the person sees the approval",
|
|
103
|
+
);
|
|
104
|
+
const prompt = seen(running, "prompt").at(-1);
|
|
105
|
+
if (prompt?.kind !== "prompt") throw new ContractBroken("no prompt seen");
|
|
106
|
+
return prompt.id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Asks for one approval and answers it as the person does. */
|
|
110
|
+
async function approval(running: Running, approved: boolean) {
|
|
111
|
+
const { surface, channel, answer } = running.subject;
|
|
112
|
+
const prompts = surface.prompts?.(channel, speakerOf(running.subject));
|
|
113
|
+
if (!prompts || !answer)
|
|
114
|
+
throw new ContractBroken("the surface shows no prompts");
|
|
115
|
+
const before = seen(running, "prompt").length;
|
|
116
|
+
const result = prompts.confirm(
|
|
117
|
+
"Approve?",
|
|
118
|
+
"Send the report.",
|
|
119
|
+
undefined,
|
|
120
|
+
speakerOf(running.subject).tier,
|
|
121
|
+
);
|
|
122
|
+
await answer(await nextPrompt(running, before), approved);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const CHECKS: readonly Check[] = [
|
|
127
|
+
{
|
|
128
|
+
name: "names a surface prefix without a colon, and owns the subject's channel",
|
|
129
|
+
run: async ({ subject }) => {
|
|
130
|
+
const { surface } = subject.surface;
|
|
131
|
+
expectThat(
|
|
132
|
+
surface !== "" && !surface.includes(":"),
|
|
133
|
+
`"${surface}" is not a surface name`,
|
|
134
|
+
);
|
|
135
|
+
expectThat(
|
|
136
|
+
parseChannelKey(subject.channel).surface === surface,
|
|
137
|
+
`${subject.channel} is not a channel of ${surface}`,
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "delivers what the person writes, in their channel",
|
|
143
|
+
run: async (running) => {
|
|
144
|
+
await running.subject.write("hello from the contract");
|
|
145
|
+
await until(
|
|
146
|
+
() => running.delivered.length > 0,
|
|
147
|
+
"the surface delivers the message",
|
|
148
|
+
);
|
|
149
|
+
const [message] = running.delivered;
|
|
150
|
+
expectThat(
|
|
151
|
+
message?.channel === running.subject.channel,
|
|
152
|
+
`delivered to ${message?.channel}, not ${running.subject.channel}`,
|
|
153
|
+
);
|
|
154
|
+
expectThat(
|
|
155
|
+
message?.text === "hello from the contract",
|
|
156
|
+
`delivered the text ${JSON.stringify(message?.text)}`,
|
|
157
|
+
);
|
|
158
|
+
expectThat(
|
|
159
|
+
Boolean(message?.messageId) && Boolean(message?.authorId),
|
|
160
|
+
"a delivered message names its id and its author",
|
|
161
|
+
);
|
|
162
|
+
expectThat(message?.authorIsBot === false, "the person is not a bot");
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "shows the person every chunk of a reply",
|
|
167
|
+
run: async (running) => {
|
|
168
|
+
const { surface, channel } = running.subject;
|
|
169
|
+
await surface.sendReply(channel, { chunks: ["first part", "second"] });
|
|
170
|
+
await until(
|
|
171
|
+
() => seen(running, "reply").length > 0,
|
|
172
|
+
"the person sees the reply",
|
|
173
|
+
);
|
|
174
|
+
const reply = seen(running, "reply")[0];
|
|
175
|
+
expectThat(
|
|
176
|
+
reply?.kind === "reply" &&
|
|
177
|
+
reply.text.includes("first part") &&
|
|
178
|
+
reply.text.includes("second"),
|
|
179
|
+
"the reply shows both chunks",
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "delivers a reply's files when it supports them",
|
|
185
|
+
applies: (subject) => subject.surface.supportsFiles === true,
|
|
186
|
+
run: async (running) => {
|
|
187
|
+
const { surface, channel } = running.subject;
|
|
188
|
+
await surface.sendReply(channel, {
|
|
189
|
+
chunks: ["see the file"],
|
|
190
|
+
files: [{ name: "notes.txt", data: new TextEncoder().encode("hi") }],
|
|
191
|
+
});
|
|
192
|
+
await until(
|
|
193
|
+
() =>
|
|
194
|
+
seen(running, "reply").some(
|
|
195
|
+
(o) => o.kind === "reply" && o.files.includes("notes.txt"),
|
|
196
|
+
),
|
|
197
|
+
"the person receives the file",
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "a typing indicator ends when its handle is called, and calling it again is harmless",
|
|
203
|
+
applies: (subject) => subject.surface.startTyping !== undefined,
|
|
204
|
+
run: async (running) => {
|
|
205
|
+
const { surface, channel } = running.subject;
|
|
206
|
+
const stop = surface.startTyping?.(channel);
|
|
207
|
+
stop?.();
|
|
208
|
+
stop?.();
|
|
209
|
+
await until(
|
|
210
|
+
() => seen(running, "typing").some((o) => o.kind === "typing" && !o.on),
|
|
211
|
+
"the person sees typing end",
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "a stop control ends when its handle is called, and calling it again is harmless",
|
|
217
|
+
applies: (subject) => subject.surface.showStop !== undefined,
|
|
218
|
+
run: async (running) => {
|
|
219
|
+
const { surface, channel } = running.subject;
|
|
220
|
+
const hide = surface.showStop?.(channel);
|
|
221
|
+
hide?.();
|
|
222
|
+
hide?.();
|
|
223
|
+
await until(
|
|
224
|
+
() => seen(running, "stop").some((o) => o.kind === "stop" && !o.on),
|
|
225
|
+
"the person sees the stop control go",
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "shows a running turn's progress",
|
|
231
|
+
applies: (subject) => subject.surface.progress !== undefined,
|
|
232
|
+
run: async (running) => {
|
|
233
|
+
const { surface, channel } = running.subject;
|
|
234
|
+
await surface.progress?.(channel, { type: "text", delta: "Look" });
|
|
235
|
+
await surface.progress?.(channel, {
|
|
236
|
+
type: "tool_start",
|
|
237
|
+
id: "t1",
|
|
238
|
+
tool: "search",
|
|
239
|
+
});
|
|
240
|
+
await until(
|
|
241
|
+
() => seen(running, "progress").length >= 2,
|
|
242
|
+
"the person sees the progress",
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "an approval resolves as the person answers it, and closes",
|
|
248
|
+
applies: (subject) =>
|
|
249
|
+
subject.surface.prompts !== undefined && subject.answer !== undefined,
|
|
250
|
+
run: async (running) => {
|
|
251
|
+
const approved = await approval(running, true);
|
|
252
|
+
expectThat(approved === "approved", `approving answered ${approved}`);
|
|
253
|
+
const declined = await approval(running, false);
|
|
254
|
+
expectThat(declined === "declined", `declining answered ${declined}`);
|
|
255
|
+
await until(
|
|
256
|
+
() => seen(running, "prompt_closed").length >= 2,
|
|
257
|
+
"both approvals close",
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "a stopped turn's approval resolves cancelled and closes",
|
|
263
|
+
applies: (subject) =>
|
|
264
|
+
subject.surface.prompts !== undefined && subject.answer !== undefined,
|
|
265
|
+
run: async (running) => {
|
|
266
|
+
const { surface, channel } = running.subject;
|
|
267
|
+
const prompts = surface.prompts?.(channel, speakerOf(running.subject));
|
|
268
|
+
if (!prompts) throw new ContractBroken("the surface shows no prompts");
|
|
269
|
+
const stop = new AbortController();
|
|
270
|
+
const before = seen(running, "prompt").length;
|
|
271
|
+
const result = prompts.confirm(
|
|
272
|
+
"Approve?",
|
|
273
|
+
"Send it.",
|
|
274
|
+
stop.signal,
|
|
275
|
+
speakerOf(running.subject).tier,
|
|
276
|
+
);
|
|
277
|
+
const id = await nextPrompt(running, before);
|
|
278
|
+
stop.abort();
|
|
279
|
+
const answer: Approval = await result;
|
|
280
|
+
expectThat(answer === "cancelled", `a stopped turn answered ${answer}`);
|
|
281
|
+
await until(
|
|
282
|
+
() =>
|
|
283
|
+
seen(running, "prompt_closed").some(
|
|
284
|
+
(o) => o.kind === "prompt_closed" && o.id === id,
|
|
285
|
+
),
|
|
286
|
+
"the stopped approval closes",
|
|
287
|
+
);
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: "another person cannot answer the person's approval",
|
|
292
|
+
applies: (subject) =>
|
|
293
|
+
subject.surface.prompts !== undefined &&
|
|
294
|
+
subject.answer !== undefined &&
|
|
295
|
+
subject.stranger !== undefined,
|
|
296
|
+
run: async (running) => {
|
|
297
|
+
const { surface, channel, answer, stranger } = running.subject;
|
|
298
|
+
const prompts = surface.prompts?.(channel, speakerOf(running.subject));
|
|
299
|
+
if (!prompts || !answer || !stranger)
|
|
300
|
+
throw new ContractBroken("the surface shows no prompts");
|
|
301
|
+
await stranger.join?.();
|
|
302
|
+
const before = seen(running, "prompt").length;
|
|
303
|
+
let settled: Approval | undefined;
|
|
304
|
+
const result = prompts
|
|
305
|
+
.confirm(
|
|
306
|
+
"Approve?",
|
|
307
|
+
"Send the report.",
|
|
308
|
+
undefined,
|
|
309
|
+
speakerOf(running.subject).tier,
|
|
310
|
+
)
|
|
311
|
+
.then((value) => {
|
|
312
|
+
settled = value;
|
|
313
|
+
return value;
|
|
314
|
+
});
|
|
315
|
+
const id = await nextPrompt(running, before);
|
|
316
|
+
await stranger.answer(id, true);
|
|
317
|
+
await Bun.sleep(50);
|
|
318
|
+
expectThat(
|
|
319
|
+
settled === undefined,
|
|
320
|
+
`another person's answer settled the approval as ${settled}`,
|
|
321
|
+
);
|
|
322
|
+
await answer(id, false);
|
|
323
|
+
const own = await result;
|
|
324
|
+
expectThat(own === "declined", `the person's answer came back ${own}`);
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
async function runCheck(
|
|
330
|
+
check: Check,
|
|
331
|
+
make: () => Promise<SurfaceContractSubject>,
|
|
332
|
+
): Promise<"passed" | "skipped"> {
|
|
333
|
+
const subject = await make();
|
|
334
|
+
const running: Running = { subject, delivered: [] };
|
|
335
|
+
try {
|
|
336
|
+
if (check.applies && !check.applies(subject)) return "skipped";
|
|
337
|
+
await subject.surface.start((message) => running.delivered.push(message));
|
|
338
|
+
await subject.join?.();
|
|
339
|
+
await check.run(running);
|
|
340
|
+
return "passed";
|
|
341
|
+
} finally {
|
|
342
|
+
await subject.close?.();
|
|
343
|
+
await subject.surface.stop?.();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Runs the chat surface contract on fresh subjects from `make`, one per check, and returns the
|
|
349
|
+
* checks the surface broke. `describeSurfaceContract` registers the same checks as tests.
|
|
350
|
+
*/
|
|
351
|
+
export async function checkSurfaceContract(
|
|
352
|
+
make: () => Promise<SurfaceContractSubject>,
|
|
353
|
+
): Promise<SurfaceContractFailure[]> {
|
|
354
|
+
const failures: SurfaceContractFailure[] = [];
|
|
355
|
+
for (const check of CHECKS) {
|
|
356
|
+
try {
|
|
357
|
+
await runCheck(check, make);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
failures.push({ name: check.name, error });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return failures;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Registers the chat surface contract as one test per check, under `name`: the surface names a
|
|
367
|
+
* prefix and owns its channel, delivers what its person writes, shows every chunk of a reply, and,
|
|
368
|
+
* where it offers them, delivers files, ends typing and stop controls idempotently, shows
|
|
369
|
+
* progress, resolves approvals as the person answers or as a stopped turn cancels them, and
|
|
370
|
+
* ignores another person's answer to the person's approval. Each
|
|
371
|
+
* check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
|
|
372
|
+
*/
|
|
373
|
+
export function describeSurfaceContract(
|
|
374
|
+
name: string,
|
|
375
|
+
make: () => Promise<SurfaceContractSubject>,
|
|
376
|
+
): void {
|
|
377
|
+
describe(`${name} keeps the chat surface contract`, () => {
|
|
378
|
+
for (const check of CHECKS)
|
|
379
|
+
test(check.name, async () => {
|
|
380
|
+
await runCheck(check, make);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
@@ -43,8 +43,13 @@ export interface TestHostOptions {
|
|
|
43
43
|
* having none, whatever login the machine holds.
|
|
44
44
|
*/
|
|
45
45
|
apiKeys?: Readonly<Record<string, string>>;
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* What the stand-in Discord hands out. `false` boots a host without Discord: no `discord`,
|
|
48
|
+
* `http`, or `agents` in the configuration, so no Discord plugin and no agent server.
|
|
49
|
+
*/
|
|
50
|
+
discord?:
|
|
51
|
+
| false
|
|
52
|
+
| Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export interface TestHost {
|
|
@@ -68,8 +73,15 @@ export interface TestHost {
|
|
|
68
73
|
stop(): Promise<void>;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
function defaultConfig(): RoundtableConfig {
|
|
76
|
+
function defaultConfig(withDiscord: boolean): RoundtableConfig {
|
|
72
77
|
const dataDir = mkdtempSync(join(tmpdir(), "roundtable-test-host-"));
|
|
78
|
+
if (!withDiscord)
|
|
79
|
+
return {
|
|
80
|
+
owner: { id: "100000000000000001", name: "Ada" },
|
|
81
|
+
database: { url: testDatabaseUrl },
|
|
82
|
+
dataDir,
|
|
83
|
+
model: "anthropic/claude-sonnet-5-5",
|
|
84
|
+
};
|
|
73
85
|
return {
|
|
74
86
|
owner: { id: "100000000000000001", name: "Ada" },
|
|
75
87
|
discord: {
|
|
@@ -197,14 +209,19 @@ export async function testHost(
|
|
|
197
209
|
return { services: [{ name: "probe" }] };
|
|
198
210
|
},
|
|
199
211
|
};
|
|
200
|
-
const
|
|
212
|
+
const withDiscord = options.discord !== false;
|
|
213
|
+
const given = options.discord === false ? undefined : options.discord;
|
|
214
|
+
const base: RoundtableConfig = {
|
|
215
|
+
...defaultConfig(withDiscord),
|
|
216
|
+
...options.config,
|
|
217
|
+
};
|
|
201
218
|
const resolved = resolveConfig(base);
|
|
202
|
-
const
|
|
219
|
+
const rootCommand = resolved.slug;
|
|
203
220
|
const guard = new OwnerGuard(resolved.owner.id, silentLogger(), rootCommand);
|
|
204
221
|
const config: RoundtableConfig = {
|
|
205
222
|
...base,
|
|
206
223
|
plugins: [
|
|
207
|
-
standInDiscord(commands, guard,
|
|
224
|
+
...(withDiscord ? [standInDiscord(commands, guard, given)] : []),
|
|
208
225
|
{
|
|
209
226
|
name: "test-runtime",
|
|
210
227
|
providers: { runtime: () => runtime },
|
|
@@ -227,9 +244,10 @@ export async function testHost(
|
|
|
227
244
|
await roundtable.run();
|
|
228
245
|
if (!captured) throw new Error("the probe was not set up");
|
|
229
246
|
const context = captured;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
247
|
+
// Without Discord the owner's session is a conversation of a plugin's surface.
|
|
248
|
+
const ownerChannel = withDiscord
|
|
249
|
+
? await (given?.ownerChannel ?? (async () => "discord:owner-dm" as const))()
|
|
250
|
+
: ("test:owner" as const);
|
|
233
251
|
/** A group seat's turns run in the group's channel; an agent's in its own. */
|
|
234
252
|
const turnChannelOf = (scope?: AgentTurnScope) =>
|
|
235
253
|
scope?.group ? scope.session : scope?.home;
|
package/src/discord/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export type { DiscordServices } from "../core/builtin/discord.ts";
|
|
5
5
|
export { DISCORD } from "../core/builtin/discord.ts";
|
|
6
|
+
export type { DiscordAdapterConfig } from "../core/config/config.ts";
|
|
6
7
|
export type { AgentPanelMessage } from "../core/discord/agent-commands.ts";
|
|
7
8
|
export type {
|
|
8
9
|
AgentPanel,
|
|
@@ -33,6 +34,7 @@ export type {
|
|
|
33
34
|
ChannelInfo,
|
|
34
35
|
DiscordConnection,
|
|
35
36
|
} from "../core/discord/connection.ts";
|
|
37
|
+
export { discord } from "../core/discord/discord-adapter.ts";
|
|
36
38
|
export type {
|
|
37
39
|
CommandGuard,
|
|
38
40
|
CommandRegistrar,
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export {
|
|
|
21
21
|
AGENT_TEAM_SERVICE,
|
|
22
22
|
} from "./core/builtin/agent-server.ts";
|
|
23
23
|
export type {
|
|
24
|
+
AdapterConfig,
|
|
25
|
+
DiscordConfig,
|
|
24
26
|
Pronouns,
|
|
25
27
|
RoundtableConfig,
|
|
26
28
|
TierConfig,
|
|
@@ -57,6 +59,12 @@ export type { ServiceKey, Services } from "./core/contract/services.ts";
|
|
|
57
59
|
export { serviceKey } from "./core/contract/services.ts";
|
|
58
60
|
export type { ChatSurface, SurfacePort } from "./core/contract/surface.ts";
|
|
59
61
|
export { channelKey, parseChannelKey } from "./core/contract/surface.ts";
|
|
62
|
+
export type {
|
|
63
|
+
ConversationRecord,
|
|
64
|
+
ConversationRegistration,
|
|
65
|
+
ConversationRegistry,
|
|
66
|
+
ConversationVisibility,
|
|
67
|
+
} from "./core/conversations/conversation-registry.ts";
|
|
60
68
|
export type { Migration, MigrationReport } from "./core/db/migrations.ts";
|
|
61
69
|
export { migrateDatabase } from "./core/db/migrations.ts";
|
|
62
70
|
export type { ToolContribution, ToolSpec, ToolTurn } from "./core/define.ts";
|
|
@@ -101,6 +109,7 @@ export type {
|
|
|
101
109
|
OwnerQuestion,
|
|
102
110
|
} from "./core/domain/owner-prompts.ts";
|
|
103
111
|
export type { TurnRequest } from "./core/domain/ports.ts";
|
|
112
|
+
export type { TurnProgress } from "./core/domain/progress.ts";
|
|
104
113
|
export type { DrainOptions } from "./core/drain.ts";
|
|
105
114
|
export {
|
|
106
115
|
JudgeError,
|
|
@@ -117,6 +126,12 @@ export type {
|
|
|
117
126
|
ListenerAddress,
|
|
118
127
|
ListenerConfig,
|
|
119
128
|
} from "./core/http/listeners.ts";
|
|
129
|
+
export type {
|
|
130
|
+
RouteSocket,
|
|
131
|
+
WebSocketAccept,
|
|
132
|
+
WebSocketRoute,
|
|
133
|
+
WebSocketSendResult,
|
|
134
|
+
} from "./core/http/websocket.ts";
|
|
120
135
|
export type { Locale } from "./core/i18n/index.ts";
|
|
121
136
|
export type { JudgeModel } from "./core/judging/model-judge.ts";
|
|
122
137
|
export type { LogEntry, LogFn, Logger } from "./core/log.ts";
|
|
@@ -177,6 +192,7 @@ export type {
|
|
|
177
192
|
ServiceStartOutcome,
|
|
178
193
|
TurnEndEvent,
|
|
179
194
|
TurnEvent,
|
|
195
|
+
TurnProgressEvent,
|
|
180
196
|
} from "./core/plugin.ts";
|
|
181
197
|
export {
|
|
182
198
|
attachReplyFile,
|
|
@@ -205,9 +221,11 @@ export type {
|
|
|
205
221
|
export {
|
|
206
222
|
AGENTS,
|
|
207
223
|
BACKGROUND_TURNS,
|
|
224
|
+
CONVERSATIONS,
|
|
208
225
|
DELEGATION,
|
|
209
226
|
MEMORY,
|
|
210
227
|
PRECHECKS,
|
|
228
|
+
RUNTIME,
|
|
211
229
|
SCHEDULES,
|
|
212
230
|
SKILLS,
|
|
213
231
|
} from "./core/services.ts";
|
package/src/testing.ts
CHANGED
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
RoundtablePlugin,
|
|
45
45
|
TurnEndEvent,
|
|
46
46
|
TurnEvent,
|
|
47
|
+
TurnProgressEvent,
|
|
47
48
|
} from "./core/plugin.ts";
|
|
48
49
|
import {
|
|
49
50
|
collectContributions,
|
|
@@ -63,7 +64,13 @@ import {
|
|
|
63
64
|
conversationTurns,
|
|
64
65
|
} from "./core/routing/conversation-turns.ts";
|
|
65
66
|
import { surfacePort } from "./core/routing/surface-port.ts";
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
AGENTS,
|
|
69
|
+
type AgentServer,
|
|
70
|
+
BACKGROUND_TURNS,
|
|
71
|
+
CONVERSATIONS,
|
|
72
|
+
RUNTIME,
|
|
73
|
+
} from "./core/services.ts";
|
|
67
74
|
import type { ChannelKey, SessionContext } from "./core/sessions.ts";
|
|
68
75
|
import { type Speaker, speakerPolicy } from "./core/speakers.ts";
|
|
69
76
|
import { type ToolTierTable, toolTiers } from "./core/tool-tiers.ts";
|
|
@@ -99,6 +106,15 @@ export type {
|
|
|
99
106
|
RecordingLogger,
|
|
100
107
|
} from "./core/testing/recording-logger.ts";
|
|
101
108
|
export { recordingLogger } from "./core/testing/recording-logger.ts";
|
|
109
|
+
export type {
|
|
110
|
+
SurfaceContractFailure,
|
|
111
|
+
SurfaceContractSubject,
|
|
112
|
+
SurfaceObservation,
|
|
113
|
+
} from "./core/testing/surface-contract.ts";
|
|
114
|
+
export {
|
|
115
|
+
checkSurfaceContract,
|
|
116
|
+
describeSurfaceContract,
|
|
117
|
+
} from "./core/testing/surface-contract.ts";
|
|
102
118
|
export type { TestHost, TestHostOptions } from "./core/testing/test-host.ts";
|
|
103
119
|
export { testHost } from "./core/testing/test-host.ts";
|
|
104
120
|
export type { FakeThreadHost } from "./core/testing/thread-host.ts";
|
|
@@ -177,8 +193,10 @@ export interface TestPluginOptions {
|
|
|
177
193
|
surfaces?: readonly ChatSurface[];
|
|
178
194
|
/**
|
|
179
195
|
* What the plugin reads from `context.services`, one `servicePair(KEY, { ... })` per service.
|
|
180
|
-
* `servicePair(
|
|
181
|
-
*
|
|
196
|
+
* `servicePair(RUNTIME, runtime)` is the runtime that `context.turns` runs turns on, given
|
|
197
|
+
* whole rather than member by member; `servicePair(AGENTS, { runtime })` still works when no
|
|
198
|
+
* `RUNTIME` is given. Without either, a plugin that fills the `runtime` slot gets the runtime its
|
|
199
|
+
* provider builds, under both keys. A service
|
|
182
200
|
* not given here reads as absent to `find`, and `get` names this option, except for what the
|
|
183
201
|
* host supplies anyway: `BACKGROUND_TURNS`, the real background turns over the test's router,
|
|
184
202
|
* and, once `AGENTS` is given, its `approvals` (the real confirmation judge over
|
|
@@ -323,6 +341,9 @@ export async function testPlugin(
|
|
|
323
341
|
stop: (channel) => (router ?? unlinked("conversations")).stop(channel),
|
|
324
342
|
postsInPlace: (channel) =>
|
|
325
343
|
(router ?? unlinked("conversations")).postsInPlace(channel),
|
|
344
|
+
owns: (channel) => (router ?? unlinked("conversations")).owns(channel),
|
|
345
|
+
takesBackground: (channel) =>
|
|
346
|
+
(router ?? unlinked("conversations")).takesBackground(channel),
|
|
326
347
|
...options.conversations,
|
|
327
348
|
};
|
|
328
349
|
const surfaces = surfacePort(() =>
|
|
@@ -339,6 +360,8 @@ export async function testPlugin(
|
|
|
339
360
|
events.push({ name: "turnEnded", turn });
|
|
340
361
|
bus.sink.turnEnded(turn);
|
|
341
362
|
},
|
|
363
|
+
// Delivered to the plugin's handlers, not recorded: a turn reports many.
|
|
364
|
+
turnProgress: (event: TurnProgressEvent) => bus.sink.turnProgress?.(event),
|
|
342
365
|
changed: () => {
|
|
343
366
|
events.push({ name: "changed" });
|
|
344
367
|
bus.sink.changed();
|
|
@@ -350,7 +373,14 @@ export async function testPlugin(
|
|
|
350
373
|
linked: () => {
|
|
351
374
|
if (!linked) unlinked("turns");
|
|
352
375
|
},
|
|
353
|
-
runtime: () =>
|
|
376
|
+
runtime: () => {
|
|
377
|
+
const given = services.find(RUNTIME);
|
|
378
|
+
if (given) return given;
|
|
379
|
+
const server = services.find(AGENTS);
|
|
380
|
+
return server ? server.runtime : services.get(RUNTIME);
|
|
381
|
+
},
|
|
382
|
+
// A test that gives `CONVERSATIONS` sees each turn record its conversation.
|
|
383
|
+
registry: () => services.find(CONVERSATIONS),
|
|
354
384
|
surfaces,
|
|
355
385
|
events: sink,
|
|
356
386
|
selection: () => (linked ?? unlinked("sessions")).agentSelection(),
|
|
@@ -391,6 +421,8 @@ export async function testPlugin(
|
|
|
391
421
|
};
|
|
392
422
|
runtime = providers.runtime(deps);
|
|
393
423
|
// The provider's runtime serves `context.turns` unless the test gave another.
|
|
424
|
+
if (!given.has(RUNTIME.id))
|
|
425
|
+
given.set(RUNTIME.id, { key: RUNTIME, given: runtime });
|
|
394
426
|
if (!agents || !("runtime" in agents.given))
|
|
395
427
|
given.set(AGENTS.id, {
|
|
396
428
|
key: AGENTS,
|
|
@@ -411,8 +443,12 @@ export async function testPlugin(
|
|
|
411
443
|
}),
|
|
412
444
|
} satisfies Partial<AgentServer>,
|
|
413
445
|
});
|
|
446
|
+
// A runtime is given whole: a class's private members do not survive the partial service's proxy.
|
|
414
447
|
for (const { key, given: members } of given.values())
|
|
415
|
-
services.preset(
|
|
448
|
+
services.preset(
|
|
449
|
+
key,
|
|
450
|
+
key.id === RUNTIME.id ? members : partialService(key, members),
|
|
451
|
+
);
|
|
416
452
|
// The turns nobody wrote run through the same router, unless the test gave others or the plugin provides them.
|
|
417
453
|
if (
|
|
418
454
|
!given.has(BACKGROUND_TURNS.id) &&
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy this file to .env and fill it in. Bun loads .env by itself; never commit it.
|
|
2
|
+
|
|
3
|
+
# You: the operator who runs this host, named in the assistant's prompts.
|
|
4
|
+
OWNER_NAME=
|
|
5
|
+
|
|
6
|
+
# PostgreSQL. This matches docker-compose.yml: run `docker compose up -d`.
|
|
7
|
+
DATABASE_URL=postgres://roundtable:roundtable@localhost:5432/roundtable
|
|
8
|
+
|
|
9
|
+
# The assistant's model as <provider>/<id>, and the provider's API key (or log in with Pi and leave the key out).
|
|
10
|
+
MODEL=anthropic/claude-sonnet-5-5
|
|
11
|
+
# ANTHROPIC_API_KEY=
|
|
12
|
+
|
|
13
|
+
# Your OpenID Connect provider: register this API there, then copy its issuer (iss), the API's
|
|
14
|
+
# audience (aud), and the provider's signing keys address (jwks_uri in its discovery document).
|
|
15
|
+
OIDC_ISSUER=
|
|
16
|
+
OIDC_AUDIENCE=
|
|
17
|
+
OIDC_JWKS_URL=
|
|
18
|
+
|
|
19
|
+
# Who may chat: the role names your provider puts in the token's roles claim, comma-separated.
|
|
20
|
+
CHAT_MEMBER_ROLES=
|
|
21
|
+
# CHAT_ADMIN_ROLES=
|
|
22
|
+
|
|
23
|
+
# The origins of the web pages that open the chat, comma-separated, such as https://chat.example.com.
|
|
24
|
+
CHAT_ORIGINS=
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# __PROJECT__
|
|
2
|
+
|
|
3
|
+
A web chat agent server built on [pi-roundtable](https://www.npmjs.com/package/pi-roundtable) and [pi-roundtable-webchat](https://www.npmjs.com/package/pi-roundtable-webchat).
|
|
4
|
+
People your OpenID Connect provider signs in chat with the assistant in private conversations; there is no Discord.
|
|
5
|
+
|
|
6
|
+
1. Install: `bun install`, then copy `.env.example` to `.env` and fill it in.
|
|
7
|
+
2. Check: `bunx roundtable doctor` says what is still missing and how to fix it.
|
|
8
|
+
3. Run: `bunx roundtable start`, behind a reverse proxy that serves it over HTTPS.
|
|
9
|
+
|
|
10
|
+
The chat is under `/chat`: a web page gets a one-time ticket from `POST /chat/tickets` with the person's token, and opens `/chat/socket` with it.
|
|
11
|
+
The protocol is in `node_modules/pi-roundtable-webchat/README.md`.
|
|
12
|
+
|
|
13
|
+
`roundtable.config.ts` holds the settings and lists the plugins; `plugins/` holds yours, and `persona/assistant.md` is the assistant's prompt.
|
|
14
|
+
Add another plugin with `bunx roundtable add plugin <name>`, and test it with `bun test`.
|
|
15
|
+
The plugin guide is in `node_modules/pi-roundtable/docs/plugins.md` and on the package's page.
|