pi-roundtable 0.6.1 → 0.7.2

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.
@@ -0,0 +1,107 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { ReplyFile, TurnResult } from "./domain/conversation.ts";
3
+
4
+ /** Limits across all tools in one turn, independent of the surface's own limits. */
5
+ export const REPLY_FILE_LIMITS = Object.freeze({
6
+ maxFiles: 10,
7
+ maxFileBytes: 10 * 1024 * 1024,
8
+ maxTotalBytes: 50 * 1024 * 1024,
9
+ });
10
+
11
+ /** An attachment was refused; its message names the reason instead of dropping the file. */
12
+ export class ReplyFileError extends Error {
13
+ override name = "ReplyFileError";
14
+ }
15
+
16
+ interface ReplyFiles {
17
+ active: boolean;
18
+ supported: boolean;
19
+ files: ReplyFile[];
20
+ bytes: number;
21
+ }
22
+
23
+ const current = new AsyncLocalStorage<ReplyFiles | undefined>();
24
+
25
+ /**
26
+ * Queues a copy of a file for the current turn's successful reply. Session tools and Pi package
27
+ * tools may call this directly; outside a supported running turn it throws ReplyFileError.
28
+ */
29
+ export function attachReplyFile(file: ReplyFile): void {
30
+ const turn = current.getStore();
31
+ if (!turn?.active)
32
+ throw new ReplyFileError(
33
+ "attachReplyFile requires a running conversation turn; await it inside the turn's tool call.",
34
+ );
35
+ if (!turn.supported)
36
+ throw new ReplyFileError(
37
+ "this turn's chat surface does not support reply files.",
38
+ );
39
+ if (
40
+ !file ||
41
+ typeof file.name !== "string" ||
42
+ !file.name.trim() ||
43
+ file.name.length > 255 ||
44
+ /[\\/]/.test(file.name) ||
45
+ [...file.name].some(
46
+ (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
47
+ ) ||
48
+ file.name === "." ||
49
+ file.name === ".."
50
+ )
51
+ throw new ReplyFileError(
52
+ "a reply file needs a non-empty filename of at most 255 characters, without paths or control characters.",
53
+ );
54
+ if (!(file.data instanceof Uint8Array) || file.data.byteLength === 0)
55
+ throw new ReplyFileError("a reply file needs non-empty Uint8Array data.");
56
+ if (turn.files.length >= REPLY_FILE_LIMITS.maxFiles)
57
+ throw new ReplyFileError(
58
+ `a turn may attach at most ${REPLY_FILE_LIMITS.maxFiles} reply files.`,
59
+ );
60
+ if (file.data.byteLength > REPLY_FILE_LIMITS.maxFileBytes)
61
+ throw new ReplyFileError(
62
+ `a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
63
+ );
64
+ if (turn.bytes + file.data.byteLength > REPLY_FILE_LIMITS.maxTotalBytes)
65
+ throw new ReplyFileError(
66
+ `a turn's reply files may contain at most ${REPLY_FILE_LIMITS.maxTotalBytes} bytes in total.`,
67
+ );
68
+ turn.files.push({ name: file.name, data: new Uint8Array(file.data) });
69
+ turn.bytes += file.data.byteLength;
70
+ }
71
+
72
+ /** Used by test fixtures to forward attachments only when called inside a turn. */
73
+ export function hasReplyFileScope(): boolean {
74
+ return current.getStore()?.active === true;
75
+ }
76
+
77
+ /** Whether an otherwise empty final answer has files to show. */
78
+ export function hasReplyFiles(): boolean {
79
+ const turn = current.getStore();
80
+ return turn?.active === true && turn.files.length > 0;
81
+ }
82
+
83
+ /** One isolated collector per turn, also when turns run concurrently or nest. */
84
+ export async function withReplyFiles(
85
+ supported: boolean,
86
+ run: () => Promise<TurnResult>,
87
+ ): Promise<TurnResult> {
88
+ const turn: ReplyFiles = { active: true, supported, files: [], bytes: 0 };
89
+ return current.run(turn, async () => {
90
+ try {
91
+ const result = await run();
92
+ if (!result.ok) return result;
93
+ // A replacement runtime may produce files in its result rather than calling the helper.
94
+ for (const file of result.files ?? []) attachReplyFile(file);
95
+ return turn.files.length ? { ...result, files: [...turn.files] } : result;
96
+ } finally {
97
+ turn.active = false;
98
+ turn.files = [];
99
+ turn.bytes = 0;
100
+ }
101
+ });
102
+ }
103
+
104
+ /** A transient task reports text, not a conversation reply: it cannot borrow its parent's collector. */
105
+ export function withoutReplyFiles<T>(run: () => Promise<T>): Promise<T> {
106
+ return current.run(undefined, run);
107
+ }
@@ -7,6 +7,7 @@ import type { Logger } from "../log.ts";
7
7
  import type { EventSink } from "../plugin.ts";
8
8
  import { splitReply } from "../presentation/reply-splitter.ts";
9
9
  import { thinkingLine } from "../presentation/thinking-line.ts";
10
+ import { withReplyFiles } from "../reply-files.ts";
10
11
  import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
11
12
  import type { Speaker } from "../speakers.ts";
12
13
  import { endOf, settleTurn } from "./settle-turn.ts";
@@ -85,20 +86,24 @@ export function conversationTurns(
85
86
  try {
86
87
  result = await settleTurn(
87
88
  () =>
88
- runtime.runTurn({
89
- channel,
90
- kind,
91
- selection: input.selection ?? {
92
- id: DEFAULT_SELECTION,
93
- ...options.selection(),
94
- },
95
- text: input.text,
96
- speaker,
97
- ...(input.attachments ? { attachments: input.attachments } : {}),
98
- ...(input.confirmed ? { confirmed: true } : {}),
99
- ...(input.steerable ? { steerable: true } : {}),
100
- ...(input.interactive ? { interactive: true } : {}),
101
- }),
89
+ withReplyFiles(surfaces.of(channel)?.supportsFiles === true, () =>
90
+ runtime.runTurn({
91
+ channel,
92
+ kind,
93
+ selection: input.selection ?? {
94
+ id: DEFAULT_SELECTION,
95
+ ...options.selection(),
96
+ },
97
+ text: input.text,
98
+ speaker,
99
+ ...(input.attachments
100
+ ? { attachments: input.attachments }
101
+ : {}),
102
+ ...(input.confirmed ? { confirmed: true } : {}),
103
+ ...(input.steerable ? { steerable: true } : {}),
104
+ ...(input.interactive ? { interactive: true } : {}),
105
+ }),
106
+ ),
102
107
  "conversation turn",
103
108
  );
104
109
  } finally {
@@ -135,5 +140,6 @@ function replyOf(result: TurnResult) {
135
140
  return {
136
141
  ...(thinking ? { thinking } : {}),
137
142
  chunks: splitReply(result.text),
143
+ ...(result.files?.length ? { files: result.files } : {}),
138
144
  };
139
145
  }
@@ -24,8 +24,14 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
24
24
  };
25
25
  return {
26
26
  of,
27
- sendReply: async (channel, reply) =>
28
- served(channel).sendReply(channel, reply),
27
+ sendReply: async (channel, reply) => {
28
+ const surface = served(channel);
29
+ if (reply.files?.length && surface.supportsFiles !== true)
30
+ throw new PluginError(
31
+ `surface ${surface.surface} does not support reply files; declare supportsFiles: true and deliver every file in sendReply.`,
32
+ );
33
+ await surface.sendReply(channel, reply);
34
+ },
29
35
  startTyping: (channel) =>
30
36
  of(channel)?.startTyping?.(channel) ?? (() => undefined),
31
37
  showStop: (channel) =>
@@ -24,6 +24,7 @@ import {
24
24
  type ThinkingLevel,
25
25
  type ThinkingSetting,
26
26
  } from "../models.ts";
27
+ import { withoutReplyFiles } from "../reply-files.ts";
27
28
  import { planOrder, type TransientTask } from "../sessions.ts";
28
29
  import { textOf } from "../shared/session-messages.ts";
29
30
  import { addressee, type Speaker, type Tier } from "../speakers.ts";
@@ -352,7 +353,7 @@ export class PiAgentRuntime implements AgentRuntime {
352
353
  this.#tiers.allows(tier, name),
353
354
  );
354
355
  session.setActiveToolsByName([...worker.tools]);
355
- await session.prompt(task.text);
356
+ await withoutReplyFiles(() => session.prompt(task.text));
356
357
  return workerReport(
357
358
  session.messages,
358
359
  "the worker stopped before it reported",
@@ -1,5 +1,6 @@
1
1
  import type { TurnResult } from "../domain/conversation.ts";
2
2
  import { AgentRunError } from "../domain/errors.ts";
3
+ import { hasReplyFiles } from "../reply-files.ts";
3
4
  import { lastAssistant, textOf } from "../shared/session-messages.ts";
4
5
 
5
6
  interface MessagePart {
@@ -67,7 +68,7 @@ export function turnAnswer(
67
68
  .join("\n\n")
68
69
  : textOf(last.message.content)
69
70
  ).trim();
70
- if (!text)
71
+ if (!text && !hasReplyFiles())
71
72
  return {
72
73
  ok: false,
73
74
  error: new AgentRunError("the final assistant message has no text"),
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export type {
77
77
  HeldCall,
78
78
  OutboundReply,
79
79
  PendingConfirmation,
80
+ ReplyFile,
80
81
  TranscriptEntry,
81
82
  TurnResult,
82
83
  } from "./core/domain/conversation.ts";
@@ -157,6 +158,11 @@ export type {
157
158
  TurnEndEvent,
158
159
  TurnEvent,
159
160
  } from "./core/plugin.ts";
161
+ export {
162
+ attachReplyFile,
163
+ REPLY_FILE_LIMITS,
164
+ ReplyFileError,
165
+ } from "./core/reply-files.ts";
160
166
  export type {
161
167
  ConversationTurnInput,
162
168
  ConversationTurns,
package/src/testing.ts CHANGED
@@ -25,7 +25,10 @@ import type {
25
25
  InteractionContribution,
26
26
  } from "./core/discord/interaction-module.ts";
27
27
  import { OwnerGuard, ownerRootCommand } from "./core/discord/owner-command.ts";
28
- import type { PendingConfirmation } from "./core/domain/conversation.ts";
28
+ import type {
29
+ PendingConfirmation,
30
+ ReplyFile,
31
+ } from "./core/domain/conversation.ts";
29
32
  import { NotLinkedError, PluginError } from "./core/errors.ts";
30
33
  import { EventBus } from "./core/events.ts";
31
34
  import type { HoldCheck } from "./core/holds.ts";
@@ -48,6 +51,11 @@ import {
48
51
  } from "./core/registry/contributions.ts";
49
52
  import { resolveProviders } from "./core/registry/providers.ts";
50
53
  import { ServiceRegistry } from "./core/registry/services.ts";
54
+ import {
55
+ attachReplyFile,
56
+ hasReplyFileScope,
57
+ withReplyFiles,
58
+ } from "./core/reply-files.ts";
51
59
  import { ChannelQueue } from "./core/routing/channel-queue.ts";
52
60
  import { ChannelRouter } from "./core/routing/channel-router.ts";
53
61
  import {
@@ -203,6 +211,8 @@ export interface TestPluginResult {
203
211
  tools: readonly string[];
204
212
  tiers: ToolTierTable;
205
213
  events: RecordedEvent[];
214
+ /** Files accepted by successful runTool calls, copied and recorded with their channel. */
215
+ files: { channel: ChannelKey; file: ReplyFile }[];
206
216
  runTool(
207
217
  name: string,
208
218
  args: Record<string, unknown>,
@@ -233,7 +243,10 @@ function unlinked(part: keyof typeof NOT_LINKED): never {
233
243
  const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
234
244
 
235
245
  /** A service the test gave only some members of: reading another names the option that adds it. */
236
- function partialService(key: ServiceKey<unknown>, given: object): object {
246
+ function partialService<T extends object>(
247
+ key: ServiceKey<unknown>,
248
+ given: T,
249
+ ): T {
237
250
  return new Proxy(given, {
238
251
  get(target, member, receiver) {
239
252
  if (member in target || typeof member === "symbol" || PROBED.has(member))
@@ -495,6 +508,7 @@ export async function testPlugin(
495
508
  for (const surface of options.surfaces ?? []) await surface.start(deliver);
496
509
  await runtime?.preflight?.();
497
510
  let stopped = false;
511
+ const files: { channel: ChannelKey; file: ReplyFile }[] = [];
498
512
  return {
499
513
  contribution,
500
514
  holds: linked.holds,
@@ -505,6 +519,7 @@ export async function testPlugin(
505
519
  tools: registry.tools.map((tool) => tool.name),
506
520
  tiers,
507
521
  events,
522
+ files,
508
523
  async runTool(name, args, runOptions) {
509
524
  const tool = registry.tools.find((item) => item.name === name);
510
525
  if (!tool) throw new PluginError(`tool ${name} is not registered`);
@@ -539,8 +554,23 @@ export async function testPlugin(
539
554
  const execute = registered[0]?.execute;
540
555
  if (!execute)
541
556
  throw new PluginError(`tool ${name} did not register in the session`);
542
- const result = await execute("test-call", args);
543
- return result.content.map((item) => item.text ?? "").join("\n");
557
+ const result = await withReplyFiles(
558
+ surfaces.of(channel)?.supportsFiles === true,
559
+ async () => {
560
+ const output = await execute("test-call", args);
561
+ return {
562
+ ok: true,
563
+ text: output.content.map((item) => item.text ?? "").join("\n"),
564
+ };
565
+ },
566
+ );
567
+ if (!result.ok) throw result.error;
568
+ for (const file of result.files ?? []) {
569
+ files.push({ channel, file });
570
+ // When runTool is used by a fake runtime, forward into the enclosing real turn.
571
+ if (hasReplyFileScope()) attachReplyFile(file);
572
+ }
573
+ return result.text;
544
574
  },
545
575
  async stop() {
546
576
  if (stopped) return;
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
3
3
  "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
4
4
  "formatter": { "indentStyle": "tab" },
5
5
  "linter": { "rules": { "preset": "recommended" } },
@@ -0,0 +1,248 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ AGENT_SERVER_PLUGIN,
7
+ AGENT_TEAM_SERVICE,
8
+ AGENTS,
9
+ type AgentTeam,
10
+ } from "pi-roundtable";
11
+ import { partial, servicePair, testPlugin } from "pi-roundtable/testing";
12
+ import { createReleaseNotice, noticeText } from "./release-notice.ts";
13
+
14
+ let dir: string;
15
+ let posted: string[];
16
+
17
+ beforeEach(async () => {
18
+ dir = await mkdtemp(join(tmpdir(), "release-notice-"));
19
+ posted = [];
20
+ });
21
+ afterEach(() => rm(dir, { recursive: true, force: true }));
22
+
23
+ const releaseFile = () => join(dir, "release.json");
24
+ const dataDir = () => join(dir, "data");
25
+ const writeRelease = (sha: string, commits: string[]) =>
26
+ writeFile(releaseFile(), JSON.stringify({ sha, commits }));
27
+
28
+ /** The plugin over the temporary directory, posting into `posted`; `fail` makes the post throw. */
29
+ async function started(fail = false) {
30
+ const harness = await testPlugin(
31
+ createReleaseNotice({
32
+ releaseFile: releaseFile(),
33
+ dataDir: dataDir(),
34
+ announce: async (text) => {
35
+ if (fail) throw new Error("the channel is gone");
36
+ posted.push(text);
37
+ },
38
+ }),
39
+ );
40
+ const events = harness.contribution.events;
41
+ return {
42
+ harness,
43
+ /** What the host delivers when the agent server's channels are up. */
44
+ ready: () =>
45
+ events?.serviceStarted?.({
46
+ plugin: AGENT_SERVER_PLUGIN,
47
+ service: AGENT_TEAM_SERVICE,
48
+ outcome: "ready",
49
+ }),
50
+ /** What the host delivers when the shutdown drain ends. */
51
+ shutdown: (left: string[]) => events?.shutdown?.(left),
52
+ };
53
+ }
54
+
55
+ test("a start with no release.json announces nothing", async () => {
56
+ const run = await started();
57
+ await run.ready();
58
+ expect(posted).toEqual([]);
59
+ await run.harness.stop();
60
+ });
61
+
62
+ test("a new release is announced once, with its commits", async () => {
63
+ await writeRelease("abc1234", ["fix: b", "feat: a"]);
64
+ const first = await started();
65
+ await first.ready();
66
+ expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b\n- feat: a"]);
67
+ await first.harness.stop();
68
+ // The next start of the same release says nothing.
69
+ const second = await started();
70
+ await second.ready();
71
+ expect(posted).toHaveLength(1);
72
+ await second.harness.stop();
73
+ // A different release is announced again.
74
+ await writeRelease("def5678", ["feat: c"]);
75
+ const third = await started();
76
+ await third.ready();
77
+ expect(posted[1]).toBe("🔄 Updated to `def5678`\n- feat: c");
78
+ await third.harness.stop();
79
+ });
80
+
81
+ test("only the agent server's ready event announces", async () => {
82
+ await writeRelease("abc1234", ["fix: b"]);
83
+ const harness = await testPlugin(
84
+ createReleaseNotice({
85
+ releaseFile: releaseFile(),
86
+ dataDir: dataDir(),
87
+ announce: async (text) => {
88
+ posted.push(text);
89
+ },
90
+ }),
91
+ );
92
+ const serviceStarted = harness.contribution.events?.serviceStarted;
93
+ await serviceStarted?.({
94
+ plugin: AGENT_SERVER_PLUGIN,
95
+ service: AGENT_TEAM_SERVICE,
96
+ outcome: "failed",
97
+ });
98
+ await serviceStarted?.({
99
+ plugin: "other",
100
+ service: AGENT_TEAM_SERVICE,
101
+ outcome: "ready",
102
+ });
103
+ await serviceStarted?.({
104
+ plugin: AGENT_SERVER_PLUGIN,
105
+ service: "other",
106
+ outcome: "ready",
107
+ });
108
+ expect(posted).toEqual([]);
109
+ await harness.stop();
110
+ });
111
+
112
+ test("work a shutdown cut short is reported once, after a restart of the same release", async () => {
113
+ await writeRelease("abc1234", ["fix: b"]);
114
+ const first = await started();
115
+ await first.ready();
116
+ await first.shutdown(["discord:111", "agentgroup:222.infra", "discord:111"]);
117
+ const second = await started();
118
+ await second.ready();
119
+ expect(posted[1]).toBe(
120
+ "🔄 Restarted (`abc1234`)\n-# Cut short by the restart, still running when the shutdown wait ended: <#111>, <#222>",
121
+ );
122
+ await second.harness.stop();
123
+ const third = await started();
124
+ await third.ready();
125
+ expect(posted).toHaveLength(2);
126
+ await third.harness.stop();
127
+ });
128
+
129
+ test("a shutdown that left nothing records nothing", async () => {
130
+ await writeRelease("abc1234", []);
131
+ const first = await started();
132
+ await first.ready();
133
+ await first.shutdown([]);
134
+ await first.harness.stop();
135
+ const second = await started();
136
+ await second.ready();
137
+ expect(posted).toHaveLength(1);
138
+ await second.harness.stop();
139
+ });
140
+
141
+ test("a post that fails leaves the release unannounced, so the next start tries again", async () => {
142
+ await writeRelease("abc1234", ["fix: b"]);
143
+ const failing = await started(true);
144
+ await expect(failing.ready()).rejects.toThrow("the channel is gone");
145
+ await failing.harness.stop();
146
+ expect(await Bun.file(join(dataDir(), "announced-release")).exists()).toBe(
147
+ false,
148
+ );
149
+ const next = await started();
150
+ await next.ready();
151
+ expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
152
+ await next.harness.stop();
153
+ });
154
+
155
+ test("a release.json that is not a valid description fails with its path and announces nothing", async () => {
156
+ for (const bad of [
157
+ "",
158
+ "{ not json",
159
+ "null",
160
+ "[]",
161
+ "{}",
162
+ '{"sha":"","commits":[]}',
163
+ '{"sha":"abc1234"}',
164
+ '{"sha":"abc1234","commits":"fix: b"}',
165
+ '{"sha":"abc1234","commits":["fix: b",null,{}]}',
166
+ ]) {
167
+ await writeFile(releaseFile(), bad);
168
+ const run = await started();
169
+ await expect(run.ready()).rejects.toThrow(
170
+ `${releaseFile()} is not a valid release description`,
171
+ );
172
+ await run.harness.stop();
173
+ }
174
+ expect(posted).toEqual([]);
175
+ });
176
+
177
+ test("a recorded list that is not a list of strings fails with its path", async () => {
178
+ await writeRelease("abc1234", []);
179
+ await mkdir(dataDir(), { recursive: true });
180
+ await writeFile(join(dataDir(), "aborted-on-shutdown.json"), '["a", 1]');
181
+ const run = await started();
182
+ await expect(run.ready()).rejects.toThrow("is not a list of strings");
183
+ await run.harness.stop();
184
+ });
185
+
186
+ test("a shutdown during the post is not lost, and one that ends before the next start adds to the unannounced", async () => {
187
+ await writeRelease("abc1234", ["fix: b"]);
188
+ let finish = () => {};
189
+ const harness = await testPlugin(
190
+ createReleaseNotice({
191
+ releaseFile: releaseFile(),
192
+ dataDir: dataDir(),
193
+ announce: (text) =>
194
+ new Promise<void>((resolve) => {
195
+ posted.push(text);
196
+ finish = resolve;
197
+ }),
198
+ }),
199
+ );
200
+ const events = harness.contribution.events;
201
+ const announcing = events?.serviceStarted?.({
202
+ plugin: AGENT_SERVER_PLUGIN,
203
+ service: AGENT_TEAM_SERVICE,
204
+ outcome: "ready",
205
+ });
206
+ // The host shuts down while the notice is still being posted.
207
+ while (posted.length === 0) await Bun.sleep(1);
208
+ await events?.shutdown?.(["discord:111"]);
209
+ finish();
210
+ await announcing;
211
+ await events?.shutdown?.(["discord:333"]);
212
+ await harness.stop();
213
+ const next = await started();
214
+ await next.ready();
215
+ expect(posted[1]).toContain("<#111>, <#333>");
216
+ await next.harness.stop();
217
+ });
218
+
219
+ test("without an announce option the notice goes to the agent team", async () => {
220
+ await writeRelease("abc1234", ["fix: b"]);
221
+ const harness = await testPlugin(
222
+ createReleaseNotice({ releaseFile: releaseFile(), dataDir: dataDir() }),
223
+ {
224
+ services: [
225
+ servicePair(AGENTS, {
226
+ team: partial<AgentTeam>({
227
+ announce: async (text) => {
228
+ posted.push(text);
229
+ },
230
+ }),
231
+ }),
232
+ ],
233
+ },
234
+ );
235
+ await harness.contribution.events?.serviceStarted?.({
236
+ plugin: AGENT_SERVER_PLUGIN,
237
+ service: AGENT_TEAM_SERVICE,
238
+ outcome: "ready",
239
+ });
240
+ expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
241
+ await harness.stop();
242
+ });
243
+
244
+ test("a channel key that is not Discord's is named as it is", () => {
245
+ expect(noticeText({ sha: "a", commits: [] }, false, ["slack:C1"])).toContain(
246
+ "slack:C1",
247
+ );
248
+ });