pi-roundtable 0.26.0 → 0.26.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/docs/plugins.md +39 -7
  3. package/package.json +1 -1
  4. package/src/core/agents/agent-claim.ts +1 -0
  5. package/src/core/agents/agent-conversations.ts +94 -0
  6. package/src/core/agents/agent-messages.ts +247 -56
  7. package/src/core/agents/agent-outbox.ts +91 -0
  8. package/src/core/agents/agent-prompt.ts +40 -1
  9. package/src/core/agents/agent-schema.ts +6 -0
  10. package/src/core/agents/agent-store.ts +22 -3
  11. package/src/core/agents/agent-team-fixture.ts +81 -88
  12. package/src/core/agents/agent-team-seeds.ts +29 -0
  13. package/src/core/agents/agent-team.ts +17 -14
  14. package/src/core/agents/agent-tools.ts +34 -5
  15. package/src/core/agents/group-turns.ts +1 -1
  16. package/src/core/agents/message-target.ts +79 -0
  17. package/src/core/agents/team-editing.ts +30 -2
  18. package/src/core/agents/team-keys.ts +7 -0
  19. package/src/core/agents/team-options.ts +3 -0
  20. package/src/core/agents/team-text.ts +4 -0
  21. package/src/core/agents/team-turn-types.ts +28 -0
  22. package/src/core/agents/team-turns.ts +36 -25
  23. package/src/core/assets/prompts/shared-guest.md +1 -3
  24. package/src/core/assets/prompts/shared.md +1 -3
  25. package/src/core/builtin/agent-server.ts +5 -0
  26. package/src/core/builtin/modules.ts +6 -0
  27. package/src/core/config/config.ts +10 -2
  28. package/src/core/config/conversations.ts +21 -2
  29. package/src/core/contract/channels.ts +5 -0
  30. package/src/core/contract/surface.ts +5 -0
  31. package/src/core/conversations/conversation-retention.ts +22 -6
  32. package/src/core/conversations/conversations-plugin.ts +7 -1
  33. package/src/core/define-roundtable.ts +5 -1
  34. package/src/core/discord/channel-context.ts +3 -1
  35. package/src/core/discord/embed-text.ts +45 -0
  36. package/src/core/discord/inbound-message.ts +7 -2
  37. package/src/core/domain/ports.ts +17 -0
  38. package/src/core/domain/turn-timeout.ts +2 -0
  39. package/src/core/holds.ts +9 -0
  40. package/src/core/i18n/turn-timeout.ts +8 -2
  41. package/src/core/modules/background/background-turns.ts +3 -0
  42. package/src/core/modules/schedules/precheck-catalog.ts +49 -0
  43. package/src/core/modules/schedules/schedule-store.ts +27 -3
  44. package/src/core/modules/schedules/schedule-tools.ts +43 -86
  45. package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
  46. package/src/core/modules/schedules/schedules.ts +26 -2
  47. package/src/core/modules/schedules/time-limit.ts +27 -0
  48. package/src/core/presentation/reply-body.ts +8 -0
  49. package/src/core/routing/conversation-turns.ts +21 -2
  50. package/src/core/runtime/extensions/ask-user.ts +1 -1
  51. package/src/core/runtime/extensions/confirmation-gate.ts +36 -36
  52. package/src/core/runtime/extensions/gate-refusals.ts +32 -0
  53. package/src/core/runtime/extensions/hold-lookups.ts +31 -2
  54. package/src/core/runtime/extensions/late-answer.ts +5 -2
  55. package/src/core/runtime/extensions/late-releases.ts +76 -0
  56. package/src/core/runtime/pi-agent-runtime.ts +21 -19
  57. package/src/core/runtime/turn-answer.ts +5 -1
  58. package/src/core/runtime/turn-budget.ts +1 -0
  59. package/src/core/runtime/turn-limits.ts +12 -2
  60. package/src/core/runtime/turn-tools.ts +26 -0
  61. package/src/core/shared/schedule-tools.ts +25 -1
@@ -1,9 +1,13 @@
1
1
  import type { ChannelKey } from "../domain/conversation.ts";
2
- import { AgentError } from "../domain/errors.ts";
2
+ import { AgentError, IdentityError } from "../domain/errors.ts";
3
3
  import type { AgentTurnScope } from "../domain/ports.ts";
4
+ import { HostStoppingError } from "../errors.ts";
4
5
  import { messages } from "../i18n/index.ts";
6
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
7
+ import { isQuiet } from "../presentation/reply-body.ts";
5
8
  import { splitReply } from "../presentation/reply-splitter.ts";
6
- import { addressee } from "../speakers.ts";
9
+ import { addressee, type Speaker } from "../speakers.ts";
10
+ import type { OutboxEntry, StoredOutboxEntry } from "./agent-outbox.ts";
7
11
  import {
8
12
  agentFailureText,
9
13
  agentMessageText,
@@ -13,13 +17,8 @@ import {
13
17
  returnedAnswerPost,
14
18
  } from "./agent-prompt.ts";
15
19
  import type { Agent } from "./agent-store.ts";
16
- import {
17
- agentRoomKey,
18
- discordKey,
19
- homeScope,
20
- roomScope,
21
- roomSurfaceOf,
22
- } from "./team-keys.ts";
20
+ import { conversationLabel, messageTarget } from "./message-target.ts";
21
+ import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
23
22
  import {
24
23
  type Chain,
25
24
  type Exchange,
@@ -30,22 +29,43 @@ import {
30
29
  /** Messages between agents: each runs the target's turn, then wakes the sender with the answer. */
31
30
  export class AgentMessages {
32
31
  readonly #host: TurnHost;
32
+ /** The writes of entries the host refused while it stopped, which the shutdown waits for. */
33
+ readonly #keeping = new Set<Promise<void>>();
34
+ /** The last write, which the next waits for: ids follow the order the queue refused the entries in. */
35
+ #lastKept: Promise<void> = Promise.resolve();
33
36
 
34
37
  constructor(host: TurnHost) {
35
38
  this.#host = host;
36
39
  }
37
40
 
38
- message(caller: AgentTurnScope, to: string, text: string): string {
39
- const { store, queue, logger } = this.#host.options;
41
+ /** What is still being written to the outbox; the shutdown drain waits until it is empty. */
42
+ busy(): string[] {
43
+ return this.#keeping.size > 0 ? ["agent messages being kept"] : [];
44
+ }
45
+
46
+ message(
47
+ caller: AgentTurnScope,
48
+ to: string,
49
+ text: string,
50
+ conversation?: string,
51
+ ): string {
52
+ const { store, queue, logger, surfaces } = this.#host.options;
40
53
  if (to === caller.name)
41
54
  throw new AgentError("message_agent is for other agents, not yourself.");
42
55
  const target = store.activeAgent(to);
43
56
  const sender = store.activeAgent(caller.name);
44
- // A turn in a room of another surface talks to the other agents' rooms there, never in Discord.
57
+ // The sender writes from its turn channel, to which the answer returns.
45
58
  const parent = this.#host.turnChannel(caller);
46
- const via = roomSurfaceOf(parent);
47
- if (via === undefined && !target.channelId)
48
- throw new AgentError(`${to} has no channel yet.`);
59
+ const targetChannel = messageTarget(
60
+ {
61
+ store,
62
+ roomSurfaces: surfaces?.roomSurfaces() ?? [],
63
+ latest: store.conversations.latest(target.name),
64
+ from: parent,
65
+ },
66
+ target,
67
+ conversation,
68
+ );
49
69
  // Every agent turn runs through #turn, which registers its chain; a message from anywhere
50
70
  // else would start an uncounted chain, so it is refused.
51
71
  const chain = this.#host.chains.get(caller.session);
@@ -58,32 +78,52 @@ export class AgentMessages {
58
78
  chainLimitReason(addressee(chain.speaker, this.#host.options.owner)),
59
79
  );
60
80
  chain.messages += 1;
61
- const targetChannel = this.#home(target, via);
62
81
  // The thread opens in the sender's turn channel, on its surface when that hosts threads; a
63
- // surface that hosts none leaves the answer returning to the sender's room.
82
+ // surface that hosts none leaves the answer returning to the sender's conversation.
64
83
  const exchange = this.#openExchange(parent, sender, target, text);
65
84
  void queue
66
85
  .run(targetChannel, async () =>
67
- this.#deliver(sender, target, text, chain, await exchange, via),
86
+ this.#deliver(
87
+ sender,
88
+ target,
89
+ text,
90
+ chain,
91
+ await exchange,
92
+ targetChannel,
93
+ parent,
94
+ ),
68
95
  )
69
- .catch((error: unknown) =>
96
+ .catch((error: unknown) => {
97
+ if (error instanceof HostStoppingError) {
98
+ // Refused before it started: the thread it opened has nothing more to hold.
99
+ void exchange.then((opened) => opened?.thread.close());
100
+ this.#keep({
101
+ kind: "message",
102
+ sender: sender.name,
103
+ target: target.name,
104
+ channel: targetChannel,
105
+ text,
106
+ origin: parent,
107
+ speaker: chain.speaker,
108
+ messages: chain.messages,
109
+ });
110
+ return;
111
+ }
70
112
  logger.error(
71
113
  { from: sender.name, to: target.name, err: error },
72
114
  "agent message not delivered",
73
- ),
74
- );
115
+ );
116
+ });
75
117
  logger.info(
76
- { from: sender.name, to: target.name, chain: chain.messages },
118
+ {
119
+ from: sender.name,
120
+ to: target.name,
121
+ channel: targetChannel,
122
+ chain: chain.messages,
123
+ },
77
124
  "agent message queued",
78
125
  );
79
- return `Sent to "${target.displayName}". It works in its own channel; its answer comes back to you as a new turn.`;
80
- }
81
-
82
- /** The agent's own channel: its Discord channel, or its room on the surface of a room turn. */
83
- #home(agent: Agent, via: string | undefined): ChannelKey {
84
- return via === undefined
85
- ? discordKey(agent.channelId ?? "")
86
- : agentRoomKey(via, agent.name);
126
+ return `Sent to "${target.displayName}" in its ${conversationLabel(targetChannel)}. It works there; its answer comes back to you as a new turn.`;
87
127
  }
88
128
 
89
129
  /**
@@ -125,10 +165,19 @@ export class AgentMessages {
125
165
  text: string,
126
166
  chain: Chain,
127
167
  exchange: Exchange | undefined,
128
- via: string | undefined,
168
+ targetChannel: ChannelKey,
169
+ senderChannel: ChannelKey,
129
170
  ): Promise<void> {
130
171
  try {
131
- await this.#exchange(sender, target, text, chain, exchange, via);
172
+ await this.#exchange(
173
+ sender,
174
+ target,
175
+ text,
176
+ chain,
177
+ exchange,
178
+ targetChannel,
179
+ senderChannel,
180
+ );
132
181
  } finally {
133
182
  await exchange?.thread.close();
134
183
  }
@@ -140,10 +189,10 @@ export class AgentMessages {
140
189
  text: string,
141
190
  chain: Chain,
142
191
  exchange: Exchange | undefined,
143
- via: string | undefined,
192
+ targetChannel: ChannelKey,
193
+ senderChannel: ChannelKey,
144
194
  ): Promise<void> {
145
195
  const { queue, logger } = this.#host.options;
146
- const targetChannel = this.#home(target, via);
147
196
  await this.#host.post(targetChannel, this.#host.current(sender), {
148
197
  chunks: splitReply(
149
198
  deliveredMessagePost(this.#host.current(target), text),
@@ -151,7 +200,7 @@ export class AgentMessages {
151
200
  });
152
201
  const result = await this.#host.turn(
153
202
  target,
154
- via === undefined ? homeScope(target) : roomScope(target, targetChannel),
203
+ scopeOf(target, targetChannel),
155
204
  targetChannel,
156
205
  agentMessageText(
157
206
  this.#host.current(sender),
@@ -159,21 +208,25 @@ export class AgentMessages {
159
208
  addressee(chain.speaker, this.#host.options.owner),
160
209
  ),
161
210
  chain,
211
+ { unattended: true },
162
212
  );
163
213
  const thread = exchange?.thread;
164
214
  if (thread && !result.ok)
165
215
  await thread.close(messages().agentNoReply(result.error.message));
166
- const current = this.#host.options.store.agent(sender.name);
167
- if (
168
- current?.status !== "active" ||
169
- (via === undefined && !current.channelId)
170
- )
216
+ // A target with nothing to answer sends nothing back and wakes nobody.
217
+ if (isQuiet(result)) {
218
+ logger.info(
219
+ { from: sender.name, to: target.name },
220
+ "agent message answered with nothing; no follow-up",
221
+ );
171
222
  return;
172
- const senderChannel = this.#home(current, via);
223
+ }
224
+ const current = this.#host.options.store.agent(sender.name);
225
+ if (current?.status !== "active") return;
173
226
  // With a thread the answer goes there, leaving the sender's channel its own reply.
174
227
  if (result.ok)
175
228
  await this.#host
176
- .post(exchange?.parent ?? senderChannel, this.#host.current(target), {
229
+ .post(senderChannel, this.#host.current(target), {
177
230
  chunks: splitReply(returnedAnswerPost(result.text)),
178
231
  ...(thread ? { threadId: thread.id } : {}),
179
232
  })
@@ -197,21 +250,159 @@ export class AgentMessages {
197
250
  // The answer is a report back to the sender, so its turn may ask the owner on cards.
198
251
  void queue
199
252
  .run(senderChannel, () =>
200
- this.#host.turn(
201
- current,
202
- via === undefined
203
- ? homeScope(current)
204
- : roomScope(current, senderChannel),
205
- senderChannel,
206
- followUp,
207
- chain,
208
- {
209
- interactive: true,
210
- },
211
- ),
253
+ this.#followUp(current, senderChannel, followUp, chain),
212
254
  )
213
- .catch((error: unknown) =>
214
- logger.error({ err: error }, "agent follow-up failed"),
255
+ .catch((error: unknown) => {
256
+ if (error instanceof HostStoppingError) {
257
+ // The sender would wait for this answer forever, so it waits in the outbox for the next start.
258
+ this.#keep({
259
+ kind: "reply",
260
+ sender: current.name,
261
+ target: target.name,
262
+ channel: senderChannel,
263
+ text: followUp,
264
+ speaker: chain.speaker,
265
+ messages: chain.messages,
266
+ });
267
+ return;
268
+ }
269
+ logger.error({ err: error }, "agent follow-up failed");
270
+ });
271
+ }
272
+
273
+ /** The sender's turn that carries the target's answer; it may ask the owner on cards, as a report back does. */
274
+ #followUp(sender: Agent, channel: ChannelKey, text: string, chain: Chain) {
275
+ return this.#host.turn(
276
+ sender,
277
+ scopeOf(sender, channel),
278
+ channel,
279
+ text,
280
+ chain,
281
+ { interactive: true },
282
+ );
283
+ }
284
+
285
+ /** Writes an entry the queue refused to the outbox; the write is waited for by the shutdown. */
286
+ #keep(entry: OutboxEntry): void {
287
+ const { store, logger } = this.#host.options;
288
+ const write = this.#lastKept
289
+ .then(() => store.outbox.keep(entry))
290
+ .then(
291
+ () =>
292
+ logger.info(
293
+ { kind: entry.kind, from: entry.sender, to: entry.target },
294
+ "agent message kept for the next start",
295
+ ),
296
+ (error: unknown) =>
297
+ logger.error(
298
+ {
299
+ kind: entry.kind,
300
+ from: entry.sender,
301
+ to: entry.target,
302
+ err: error,
303
+ },
304
+ "agent message lost: not kept for the next start",
305
+ ),
215
306
  );
307
+ this.#lastKept = write;
308
+ this.#keeping.add(write);
309
+ void write.finally(() => this.#keeping.delete(write));
310
+ }
311
+
312
+ /**
313
+ * Delivers what the last run kept, oldest first, each through the queue of the conversation it
314
+ * addressed; never rejects. An entry is taken out of the outbox when its turn starts, so it runs
315
+ * once even with a second replay or another host reading the same table; one the queue refuses
316
+ * stays for the next start.
317
+ */
318
+ async replay(): Promise<void> {
319
+ const { store, queue, logger } = this.#host.options;
320
+ // A failed read leaves the entries kept for the next start and does not fail this one.
321
+ const entries = await store.outbox.pending().catch((error: unknown) => {
322
+ logger.error({ err: error }, "kept agent messages not read");
323
+ return [];
324
+ });
325
+ if (entries.length === 0) return;
326
+ logger.info({ count: entries.length }, "replaying agent messages");
327
+ for (const entry of entries)
328
+ void queue
329
+ .run(entry.channel, () => this.#replayOne(entry))
330
+ .catch((error: unknown) => {
331
+ if (error instanceof HostStoppingError)
332
+ logger.info(
333
+ { id: entry.id },
334
+ "agent message stays kept: the host is stopping",
335
+ );
336
+ else
337
+ logger.error(
338
+ { id: entry.id, err: error },
339
+ "agent message not replayed",
340
+ );
341
+ });
342
+ }
343
+
344
+ /** The kept speaker as they stand now, like a background turn's author; undefined when gone or disabled. */
345
+ async #stillRunsAs(kept: Speaker): Promise<Speaker | undefined> {
346
+ const principals = this.#host.options.principals?.();
347
+ if (!principals || kept.principalId === SYSTEM_PRINCIPAL) return kept;
348
+ try {
349
+ const now = await principals.speakerFor(kept.principalId, kept.tier);
350
+ return { ...kept, tier: now.tier, principalId: now.principalId };
351
+ } catch (error) {
352
+ if (error instanceof IdentityError) return undefined;
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ async #replayOne(entry: StoredOutboxEntry): Promise<void> {
358
+ const { store, logger } = this.#host.options;
359
+ if (!(await store.outbox.claim(entry.id))) return;
360
+ const sender = store.agent(entry.sender);
361
+ const target = store.agent(entry.target);
362
+ // An agent archived or moved to another channel since has no turn to give the entry.
363
+ const has = (agent: Agent | undefined, channel: ChannelKey) =>
364
+ agent?.status === "active" &&
365
+ (discordIdOf(channel) === undefined ||
366
+ (agent.channelId !== undefined &&
367
+ discordKey(agent.channelId) === channel));
368
+ const origin = entry.origin ?? entry.channel;
369
+ const intact =
370
+ entry.kind === "message"
371
+ ? has(target, entry.channel) && has(sender, origin)
372
+ : has(sender, entry.channel);
373
+ if (!sender || !target || !intact) {
374
+ logger.warn(
375
+ { id: entry.id, from: entry.sender, to: entry.target },
376
+ "kept agent message dropped: its agent or channel is gone",
377
+ );
378
+ return;
379
+ }
380
+ logger.info(
381
+ { id: entry.id, kind: entry.kind, from: entry.sender, to: entry.target },
382
+ "replaying a kept agent message",
383
+ );
384
+ const speaker = await this.#stillRunsAs(entry.speaker);
385
+ if (!speaker) {
386
+ logger.warn(
387
+ { id: entry.id, from: entry.sender },
388
+ "kept agent message dropped: its speaker may no longer run",
389
+ );
390
+ return;
391
+ }
392
+ const chain: Chain = { messages: entry.messages, speaker };
393
+ if (entry.kind === "reply") {
394
+ await this.#followUp(sender, entry.channel, entry.text, chain);
395
+ return;
396
+ }
397
+ // Its thread was closed when the host refused it, so the answer returns to the sender's conversation.
398
+ await this.#exchange(
399
+ sender,
400
+ target,
401
+ entry.text,
402
+ chain,
403
+ undefined,
404
+ entry.channel,
405
+ origin,
406
+ );
216
407
  }
217
408
  }
@@ -0,0 +1,91 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "../db/migrations.ts";
3
+ import type { ChannelKey } from "../domain/conversation.ts";
4
+ import type { Speaker } from "../speakers.ts";
5
+
6
+ /**
7
+ * An agent message the host stopped before it could deliver. `message` is the request to the
8
+ * target; `reply` is the follow-up turn that carries the target's answer back to the sender. Each
9
+ * is kept with the chain it belonged to, so the replayed turn counts against the same limit.
10
+ */
11
+ export interface OutboxEntry {
12
+ kind: "message" | "reply";
13
+ /** The agent that asked. */
14
+ sender: string;
15
+ /** The agent whose turn the entry starts. */
16
+ target: string;
17
+ /** The target's conversation: where the entry runs and posts. */
18
+ channel: ChannelKey;
19
+ /** What the turn is given: the request, or the sender's follow-up text. */
20
+ text: string;
21
+ /** For a `message`, the sender's conversation, where the answer returns. */
22
+ origin?: ChannelKey;
23
+ speaker: Speaker;
24
+ /** The messages the chain had sent when the entry was stored. */
25
+ messages: number;
26
+ }
27
+
28
+ /** A stored entry and its id, which orders the replay and makes it happen once. */
29
+ export interface StoredOutboxEntry extends OutboxEntry {
30
+ id: number;
31
+ }
32
+
33
+ interface OutboxRow {
34
+ id: string | number;
35
+ payload: OutboxEntry | string;
36
+ }
37
+
38
+ /** The table of undelivered agent messages, created after the guild scoping so it carries its guild from the start. */
39
+ export const outboxMigration = (): Migration => ({
40
+ name: "agent-outbox",
41
+ up: async (sql) => {
42
+ await sql`
43
+ CREATE TABLE IF NOT EXISTS agent_outbox (
44
+ id bigserial PRIMARY KEY,
45
+ guild_id text NOT NULL,
46
+ channel text NOT NULL,
47
+ payload jsonb NOT NULL,
48
+ created_at timestamptz NOT NULL DEFAULT now()
49
+ )`;
50
+ await sql`CREATE INDEX IF NOT EXISTS agent_outbox_guild ON agent_outbox (guild_id, id)`;
51
+ },
52
+ });
53
+
54
+ /**
55
+ * Agent messages and follow-ups the host refused while it stopped, kept for the next start.
56
+ * A row is removed by whoever claims it, so two hosts replaying the same table deliver each once.
57
+ */
58
+ export class AgentOutbox {
59
+ readonly #sql: SQL;
60
+ readonly #guild: string;
61
+
62
+ constructor(sql: SQL, guildId: string) {
63
+ this.#sql = sql;
64
+ this.#guild = guildId;
65
+ }
66
+
67
+ async keep(entry: OutboxEntry): Promise<void> {
68
+ await this.#sql`
69
+ INSERT INTO agent_outbox (guild_id, channel, payload)
70
+ VALUES (${this.#guild}, ${entry.channel}, ${JSON.stringify(entry)}::jsonb)`;
71
+ }
72
+
73
+ /** Every kept entry, oldest first. */
74
+ async pending(): Promise<StoredOutboxEntry[]> {
75
+ const rows: OutboxRow[] = await this.#sql`
76
+ SELECT id, payload FROM agent_outbox WHERE guild_id = ${this.#guild} ORDER BY id`;
77
+ return rows.map((row) => ({
78
+ ...(typeof row.payload === "string"
79
+ ? (JSON.parse(row.payload) as OutboxEntry)
80
+ : row.payload),
81
+ id: Number(row.id),
82
+ }));
83
+ }
84
+
85
+ /** Takes the entry out; false when another host or an earlier call already did. */
86
+ async claim(id: number): Promise<boolean> {
87
+ const rows: { id: string | number }[] = await this.#sql`
88
+ DELETE FROM agent_outbox WHERE guild_id = ${this.#guild} AND id = ${id} RETURNING id`;
89
+ return rows.length > 0;
90
+ }
91
+ }
@@ -1,3 +1,5 @@
1
+ import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
1
3
  import { messages } from "../i18n/index.ts";
2
4
  import { type OwnerIdentity, ownerWords } from "../identity.ts";
3
5
  import type { AgentTools } from "../plugin.ts";
@@ -28,6 +30,8 @@ export function agentSystemPrompt(input: {
28
30
  images?: boolean;
29
31
  /** "chat-only": the agent has no shell, file tools or workspace, and the prompt says so. Default "full". */
30
32
  agentTools?: AgentTools;
33
+ /** Where this conversation is, and the other surfaces the agent has places on. */
34
+ surface?: { label: string; key: string; others: readonly string[] };
31
35
  }): string {
32
36
  const { agent, coordinator, shared, workDir, owner, shellUser, group } =
33
37
  input;
@@ -37,7 +41,7 @@ export function agentSystemPrompt(input: {
37
41
  const guest = input.speaker && input.speaker.tier !== "owner";
38
42
  const parts = [
39
43
  (guest && input.guestShared) || shared,
40
- `You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
44
+ `You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} agent server.${placeText(input.surface)} Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
41
45
  roleText(agent, coordinator, who),
42
46
  `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; agent_avatar changes an agent's look (class, colors, parts); schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
43
47
  input.agentTools === "chat-only"
@@ -62,6 +66,19 @@ export function agentSystemPrompt(input: {
62
66
  return parts.join("\n\n");
63
67
  }
64
68
 
69
+ /** Names the surface of this conversation, and the others the agent has places on. */
70
+ function placeText(
71
+ surface:
72
+ | NonNullable<Parameters<typeof agentSystemPrompt>[0]["surface"]>
73
+ | undefined,
74
+ ): string {
75
+ if (!surface) return "";
76
+ const others = surface.others.length
77
+ ? ` You also have places on ${surface.others.join(", ")}.`
78
+ : "";
79
+ return ` This conversation is in ${surface.label} (${surface.key}).${others}`;
80
+ }
81
+
65
82
  /**
66
83
  * Says whom the turn is for when it is not the owner, after every operator text that names the
67
84
  * owner, so those texts read as the owner's and this one decides who is addressed.
@@ -169,3 +186,25 @@ export function chainLimitReason(who: OwnerIdentity): string {
169
186
  const o = ownerWords(who);
170
187
  return `This chain of agent messages has reached 8 messages without ${o.name} in between, so it is stopped. Tell ${o.him} what was done and ask whether to continue.`;
171
188
  }
189
+
190
+ /**
191
+ * Where a turn's conversation is: the surface of the turn's conversation (`home`: the agent's own
192
+ * channel or room, or the group's in a group round), named by its `label`, and the other surfaces the agents have places on, Discord when the team has
193
+ * channels and each surface that hosts rooms.
194
+ */
195
+ export function placeOf(
196
+ home: ChannelKey,
197
+ surfaces: Pick<SurfacePort, "of" | "roomSurfaces"> | undefined,
198
+ hasDiscord: boolean,
199
+ ): { surface: { label: string; key: string; others: readonly string[] } } {
200
+ const prefix = parseChannelKey(home).surface;
201
+ const label = (name: string) =>
202
+ surfaces?.of(`${name}:`)?.label ?? (name === "discord" ? "Discord" : name);
203
+ const names = [
204
+ ...(hasDiscord ? ["discord"] : []),
205
+ ...(surfaces?.roomSurfaces() ?? []),
206
+ ].filter((n, i, all) => n !== prefix && all.indexOf(n) === i);
207
+ return {
208
+ surface: { label: label(prefix), key: prefix, others: names.map(label) },
209
+ };
210
+ }
@@ -3,6 +3,8 @@ import { scopeToGuild } from "../db/guild-scope.ts";
3
3
  import type { Migration } from "../db/migrations.ts";
4
4
  import type { ThinkingLevel } from "../models.ts";
5
5
  import { promptHistoryMigration } from "../modules/prompts/prompt-history.ts";
6
+ import { AgentConversations } from "./agent-conversations.ts";
7
+ import { outboxMigration } from "./agent-outbox.ts";
6
8
  import type { Agent, AgentGroup, AgentStatus } from "./agent-rules.ts";
7
9
 
8
10
  export interface AgentRow {
@@ -136,5 +138,9 @@ export function agentMigrations(guildId: string): Migration[] {
136
138
  },
137
139
  // The change history of the agents' prompts; the prompts plugin creates the same table for its documents.
138
140
  promptHistoryMigration("agent-prompt-history"),
141
+ // Agent messages the host refused while it stopped, replayed at the next start.
142
+ outboxMigration(),
143
+ // Where each agent last ran a turn, so a message to it finds that conversation after a restart.
144
+ AgentConversations.migration,
139
145
  ];
140
146
  }
@@ -9,6 +9,8 @@ import {
9
9
  } from "../modules/prompts/prompt-history.ts";
10
10
  import type { AgentDirectory } from "../services.ts";
11
11
  import { AgentBook } from "./agent-book.ts";
12
+ import { AgentConversations } from "./agent-conversations.ts";
13
+ import { AgentOutbox } from "./agent-outbox.ts";
12
14
  import {
13
15
  type Agent,
14
16
  type AgentGroup,
@@ -68,14 +70,24 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
68
70
  readonly #statements: AgentStatements;
69
71
  readonly #messages: GroupMessages;
70
72
  readonly #prompts: PromptHistory;
73
+ /** Agent messages the host stopped before delivering them. */
74
+ readonly outbox: AgentOutbox;
75
+ /** The conversation each agent last ran a turn in. */
76
+ readonly conversations: AgentConversations;
71
77
 
72
- private constructor(sql: SQL, guildId: string) {
78
+ private constructor(
79
+ sql: SQL,
80
+ guildId: string,
81
+ conversations: AgentConversations,
82
+ ) {
73
83
  super();
84
+ this.conversations = conversations;
74
85
  this.#sql = sql;
75
86
  this.#guild = guildId;
76
87
  this.#statements = new AgentStatements(sql, guildId);
77
88
  this.#messages = new GroupMessages(sql, guildId);
78
89
  this.#prompts = new PromptHistory(sql, guildId);
90
+ this.outbox = new AgentOutbox(sql, guildId);
79
91
  }
80
92
 
81
93
  /** The store's tables; the host runs this before any store attaches. */
@@ -88,9 +100,16 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
88
100
 
89
101
  /** The store over the host's migrated pool, holding the rows of one guild. */
90
102
  static async attach(sql: SQL, guildId: string): Promise<PgAgentStore> {
91
- const store = new PgAgentStore(sql, guildId);
92
103
  // `Roundtable.check()` has no database to read the rows from: the store starts empty.
93
- if (!isCheckDatabase(sql)) await store.#reload();
104
+ const checking = isCheckDatabase(sql);
105
+ const store = new PgAgentStore(
106
+ sql,
107
+ guildId,
108
+ checking
109
+ ? AgentConversations.memory()
110
+ : await AgentConversations.load(sql, guildId),
111
+ );
112
+ if (!checking) await store.#reload();
94
113
  return store;
95
114
  }
96
115