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
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../domain/conversation.ts";
16
16
  import { AgentRunError } from "../domain/errors.ts";
17
17
  import type { TurnRequest } from "../domain/ports.ts";
18
+ import type { IdentityService } from "../identity/identity-port.ts";
18
19
  import { silentLogger } from "../log.ts";
19
20
  import { StoredSkillRegistry as SkillRegistry } from "../modules/skills/skill-registry.ts";
20
21
  import { SkillStore } from "../modules/skills/skill-store.ts";
@@ -37,12 +38,12 @@ import type {
37
38
  CategoryLayout,
38
39
  ChannelMessage,
39
40
  } from "./agent-ports.ts";
40
- import type { AgentSeed } from "./agent-rules.ts";
41
41
  import { PgAgentStore as AgentStore } from "./agent-store.ts";
42
42
  import {
43
43
  DiscordAgentTeam as AgentTeam,
44
44
  type AgentTeamOptions,
45
45
  } from "./agent-team.ts";
46
+ import { seeds } from "./agent-team-seeds.ts";
46
47
  import { discordKey } from "./team-keys.ts";
47
48
 
48
49
  export const ENTRY = "1000";
@@ -261,6 +262,7 @@ export const script: {
261
262
  };
262
263
  let queue: ChannelQueue;
263
264
  let team: AgentTeam;
265
+ let build: () => AgentTeam;
264
266
  let skillStore: TestStore<SkillStore>;
265
267
  let skills: SkillRegistry;
266
268
  export const removedSchedules: number[] = [];
@@ -271,34 +273,6 @@ export const eventLog: string[] = [];
271
273
  /** What a plugin adds to every agent's tools: a tool of its own and a group of another server's. */
272
274
  const PLUGIN_SELECTION = { tools: ["memory_add"], groups: ["workspace"] };
273
275
 
274
- /** The team a fresh database starts with: a coordinator in the entry channel, then two specialists. */
275
- function seeds(entryChannelId: string | undefined): AgentSeed[] {
276
- return [
277
- {
278
- name: "coordinator",
279
- displayName: "Coordinator",
280
- ...(entryChannelId ? { channelId: entryChannelId } : {}),
281
- prompt:
282
- "You are the chief of staff of the owner's agent team. You hand work that belongs to a specialist to that agent with message_agent, one owner per task, and propose a new agent when a lasting responsibility has no owner.",
283
- character: { class: "navigator", palette: "amber" },
284
- },
285
- {
286
- name: "doctor",
287
- displayName: "Doctor",
288
- prompt:
289
- "You keep the owner's agents healthy: read an agent's prompt with agent_get, find the instruction that caused a misbehavior, and fix it with agent_update.",
290
- character: { class: "healer", palette: "rose" },
291
- },
292
- {
293
- name: "infra",
294
- displayName: "Infra",
295
- prompt:
296
- "You manage the owner's servers. Check before you change: read state first, state what you will change and why, then act.",
297
- character: { class: "engineer", palette: "purpure" },
298
- },
299
- ];
300
- }
301
-
302
276
  /**
303
277
  * Waits until every queued channel task has settled and every active agent has its avatar,
304
278
  * which startup draws in the background; a fixed sleep was too short on a slow runner.
@@ -337,6 +311,8 @@ export function useTeamFixture(
337
311
  "agent_groups",
338
312
  "agent_group_messages",
339
313
  "agent_group_cursors",
314
+ "agent_outbox",
315
+ "agent_latest_conversation",
340
316
  "prompt_history",
341
317
  "skills",
342
318
  "skill_versions",
@@ -381,68 +357,72 @@ export function useTeamFixture(
381
357
  script.guest = "";
382
358
  queue = channelQueue();
383
359
  removedSchedules.length = 0;
360
+ identity.current = undefined;
384
361
  stops.length = 0;
385
362
  eventLog.length = 0;
386
- team = new AgentTeam({
387
- owner: TEST_OWNER,
388
- toolTiers: FIXTURE_TIERS,
389
- shellUser: "mcops",
390
- guildId: local ? "local" : "1",
391
- ...(local
392
- ? { coordinator: "coordinator" }
393
- : { entryChannelId: ENTRY, channels }),
394
- seeds: () => seeds(local ? undefined : ENTRY),
395
- store,
396
- surfaces,
397
- studio: options.studio?.() ?? {
398
- url: (hash) => `https://x/avatars/${hash ?? "default"}.png`,
399
- render: async (spec) =>
400
- new Bun.CryptoHasher("sha256")
401
- .update(JSON.stringify(spec))
402
- .digest("hex"),
403
- draw: async () => "a".repeat(64),
404
- edit: async () => "b".repeat(64),
405
- },
406
- runtime: () => runtime,
407
- pluginSelection: () => PLUGIN_SELECTION,
408
- confirmations: { approves: async (_p, reply) => reply.includes("yes") },
409
- scorer: { score: async () => script.scores },
410
- events: {
411
- turnStarted: (turn) => eventLog.push(`turnStarted ${turn.agent}`),
412
- turnEnded: (turn) =>
413
- eventLog.push(`turnEnded ${turn.agent} ${turn.result}`),
414
- changed: () => eventLog.push("changed"),
415
- },
416
- promptSections: () => script.prompt,
417
- pluginTools: () => script.tools,
418
- models: {
419
- defaults: {
420
- model: "claude-bridge/claude-opus-5-5",
421
- thinking: "medium",
363
+ build = () =>
364
+ new AgentTeam({
365
+ owner: TEST_OWNER,
366
+ toolTiers: FIXTURE_TIERS,
367
+ principals: () => identity.current,
368
+ shellUser: "mcops",
369
+ guildId: local ? "local" : "1",
370
+ ...(local
371
+ ? { coordinator: "coordinator" }
372
+ : { entryChannelId: ENTRY, channels }),
373
+ seeds: () => seeds(local ? undefined : ENTRY),
374
+ store,
375
+ surfaces,
376
+ studio: options.studio?.() ?? {
377
+ url: (hash) => `https://x/avatars/${hash ?? "default"}.png`,
378
+ render: async (spec) =>
379
+ new Bun.CryptoHasher("sha256")
380
+ .update(JSON.stringify(spec))
381
+ .digest("hex"),
382
+ draw: async () => "a".repeat(64),
383
+ edit: async () => "b".repeat(64),
422
384
  },
423
- usable: async () => USABLE,
424
- },
425
- schedules: {
426
- forChannel: async (channel) =>
427
- channel === discordKey("2000")
428
- ? [{ id: 7 }]
429
- : channel === "app:agent.doctor"
430
- ? [{ id: 8 }]
431
- : [],
432
- remove: async (id) => removedSchedules.push(id),
433
- },
434
- queue,
435
- startTyping: () => () => undefined,
436
- showStop: (channel) => {
437
- stops.push(`show ${channel}`);
438
- return () => stops.push(`hide ${channel}`);
439
- },
440
- workDir: "/tmp/agents/work",
441
- sharedPrompt: () => script.shared,
442
- guestPrompt: () => script.guest,
443
- skills,
444
- logger: silentLogger(),
445
- });
385
+ runtime: () => runtime,
386
+ pluginSelection: () => PLUGIN_SELECTION,
387
+ confirmations: { approves: async (_p, reply) => reply.includes("yes") },
388
+ scorer: { score: async () => script.scores },
389
+ events: {
390
+ turnStarted: (turn) => eventLog.push(`turnStarted ${turn.agent}`),
391
+ turnEnded: (turn) =>
392
+ eventLog.push(`turnEnded ${turn.agent} ${turn.result}`),
393
+ changed: () => eventLog.push("changed"),
394
+ },
395
+ promptSections: () => script.prompt,
396
+ pluginTools: () => script.tools,
397
+ models: {
398
+ defaults: {
399
+ model: "claude-bridge/claude-opus-5-5",
400
+ thinking: "medium",
401
+ },
402
+ usable: async () => USABLE,
403
+ },
404
+ schedules: {
405
+ forChannel: async (channel) =>
406
+ channel === discordKey("2000")
407
+ ? [{ id: 7 }]
408
+ : channel === "app:agent.doctor"
409
+ ? [{ id: 8 }]
410
+ : [],
411
+ remove: async (id) => removedSchedules.push(id),
412
+ },
413
+ queue,
414
+ startTyping: () => () => undefined,
415
+ showStop: (channel) => {
416
+ stops.push(`show ${channel}`);
417
+ return () => stops.push(`hide ${channel}`);
418
+ },
419
+ workDir: "/tmp/agents/work",
420
+ sharedPrompt: () => script.shared,
421
+ guestPrompt: () => script.guest,
422
+ skills,
423
+ logger: silentLogger(),
424
+ });
425
+ team = build();
446
426
  await team.start();
447
427
  Object.assign(fx, {
448
428
  store,
@@ -458,6 +438,19 @@ export function useTeamFixture(
458
438
  }, HOOK_TIMEOUT_MS);
459
439
  }
460
440
 
441
+ /** Starts the team again over the same database, as a restarted host: a new queue, runtime and team. */
442
+ /** The identity service the team's replay asks; none until a test sets one. */
443
+ export const identity: { current?: Pick<IdentityService, "speakerFor"> } = {};
444
+
445
+ export async function restart(): Promise<void> {
446
+ queue = channelQueue();
447
+ runtime = new FakeRuntime();
448
+ team = build();
449
+ await team.start();
450
+ Object.assign(fx, { queue, runtime, team });
451
+ await settle();
452
+ }
453
+
461
454
  export const opsGroup = () => {
462
455
  const group = store.group("ops");
463
456
  if (!group) throw new Error("no ops group");
@@ -0,0 +1,29 @@
1
+ import type { AgentSeed } from "./agent-rules.ts";
2
+
3
+ /** The team a fresh database starts with: a coordinator in the entry channel, then two specialists. */
4
+ export function seeds(entryChannelId: string | undefined): AgentSeed[] {
5
+ return [
6
+ {
7
+ name: "coordinator",
8
+ displayName: "Coordinator",
9
+ ...(entryChannelId ? { channelId: entryChannelId } : {}),
10
+ prompt:
11
+ "You are the chief of staff of the owner's agent team. You hand work that belongs to a specialist to that agent with message_agent, one owner per task, and propose a new agent when a lasting responsibility has no owner.",
12
+ character: { class: "navigator", palette: "amber" },
13
+ },
14
+ {
15
+ name: "doctor",
16
+ displayName: "Doctor",
17
+ prompt:
18
+ "You keep the owner's agents healthy: read an agent's prompt with agent_get, find the instruction that caused a misbehavior, and fix it with agent_update.",
19
+ character: { class: "healer", palette: "rose" },
20
+ },
21
+ {
22
+ name: "infra",
23
+ displayName: "Infra",
24
+ prompt:
25
+ "You manage the owner's servers. Check before you change: read state first, state what you will change and why, then act.",
26
+ character: { class: "engineer", palette: "purpure" },
27
+ },
28
+ ];
29
+ }
@@ -10,7 +10,7 @@ import type { ResolvedSkill } from "../modules/skills/skill-rules.ts";
10
10
  import type { AgentChange, AgentTeam } from "../services.ts";
11
11
  import type { TurnSelection } from "../sessions.ts";
12
12
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
13
- import { agentSystemPrompt } from "./agent-prompt.ts";
13
+ import { agentSystemPrompt, placeOf } from "./agent-prompt.ts";
14
14
  import type { Agent } from "./agent-store.ts";
15
15
  import {
16
16
  type AgentOps,
@@ -150,6 +150,8 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
150
150
  shellUser,
151
151
  scratchDir,
152
152
  agentTools,
153
+ surfaces,
154
+ channels,
153
155
  } = this.#options;
154
156
  const agent = store.agent(scope.name);
155
157
  if (!agent) throw new AgentError(`no agent ${scope.name}`);
@@ -172,6 +174,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
172
174
  ...(scratchDir ? { scratchDir } : {}),
173
175
  images: this.#images(),
174
176
  ...(agentTools ? { agentTools } : {}),
177
+ ...placeOf(this.#turns.turnChannel(scope), surfaces, !!channels),
175
178
  ...(group
176
179
  ? {
177
180
  group: {
@@ -264,7 +267,12 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
264
267
  * character in the background.
265
268
  */
266
269
  start(): Promise<void> {
267
- return this.#lifecycle.start();
270
+ return this.#lifecycle.start().then(() => this.#turns.replayMessages());
271
+ }
272
+
273
+ /** What the team still writes that a shutdown must wait for. */
274
+ busy(): string[] {
275
+ return this.#turns.busy();
268
276
  }
269
277
 
270
278
  /** Ends the background work the team started. */
@@ -299,10 +307,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
299
307
 
300
308
  // ── Turns, run by TeamTurns ────────────────────────────────────────────
301
309
 
302
- /**
303
- * The owner's message in an agent's channel; call inside the channel's queue. `text`
304
- * carries the reference, `replyText` is what they typed.
305
- */
310
+ /** The owner's message in an agent's channel; call inside its queue. `text` has the reference, `replyText` what they typed. */
306
311
  answerOwner(
307
312
  channel: ChannelKey,
308
313
  speaker: Speaker,
@@ -321,15 +326,12 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
321
326
 
322
327
  /**
323
328
  * A schedule's or a report's turn in an agent's channel; call inside its queue. A report's
324
- * turn is interactive: it may ask the owner on cards.
329
+ * turn is interactive: it may ask the owner on cards. `timeLimitMs` is a schedule's own limit.
325
330
  */
326
331
  answerBackground(
327
- channel: ChannelKey,
328
- speaker: Speaker,
329
- text: string,
330
- interactive = false,
332
+ ...args: Parameters<TeamTurns["answerBackground"]>
331
333
  ): Promise<TurnResult> {
332
- return this.#turns.answerBackground(channel, speaker, text, interactive);
334
+ return this.#turns.answerBackground(...args);
333
335
  }
334
336
 
335
337
  /** One owner message in a group channel, answered by a round of members; call inside the queue. */
@@ -366,8 +368,8 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
366
368
  return this.#turns.announce(text);
367
369
  }
368
370
 
369
- message(caller: AgentTurnScope, to: string, text: string): string {
370
- return this.#turns.message(caller, to, text);
371
+ message(...args: Parameters<TeamTurns["message"]>): string {
372
+ return this.#turns.message(...args);
371
373
  }
372
374
 
373
375
  async read(
@@ -430,6 +432,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
430
432
  agent,
431
433
  models.defaults,
432
434
  skills?.describeCarried(agent.name),
435
+ store.conversations.mark(agent.name, this.roomsOf(name)),
433
436
  );
434
437
  }
435
438
 
@@ -79,12 +79,23 @@ export interface AgentOps {
79
79
  task: string;
80
80
  category?: string;
81
81
  skills?: string[];
82
+ /** Turn long-running mode on for its conversations; an owner's turn only. */
83
+ longRunning?: boolean;
82
84
  },
83
85
  ): Promise<string>;
84
86
  /** Changes an agent's look; a field left out stays as it is. */
85
87
  avatar(name: string, look: CharacterInput): Promise<string>;
86
88
  /** Queues the message and returns at once. */
87
- message(caller: AgentTurnScope, to: string, text: string): string;
89
+ /**
90
+ * Hands a request to another agent, in the conversation it used most recently, or in the
91
+ * `conversation` named: "discord", "web" (or a room surface's name), or one of its conversation keys.
92
+ */
93
+ message(
94
+ caller: AgentTurnScope,
95
+ to: string,
96
+ text: string,
97
+ conversation?: string,
98
+ ): string;
88
99
  createGroup(input: {
89
100
  name: string;
90
101
  displayName: string;
@@ -112,7 +123,7 @@ const NAME = Type.String({
112
123
  const DISPLAY = Type.String({
113
124
  minLength: 1,
114
125
  maxLength: 32,
115
- description: "The name shown on its Discord messages; any language.",
126
+ description: "The name shown on its messages; any language.",
116
127
  });
117
128
  const PROMPT = Type.String({
118
129
  minLength: 1,
@@ -263,7 +274,7 @@ function toolDefs(
263
274
  name: "agent_create",
264
275
  label: "Create agent",
265
276
  description:
266
- "Create a new agent with its own channel and a character picture; its look follows its role unless class and color are given. It starts at once on the opening task in its channel. Its name is permanent.",
277
+ "Create a new agent with its own channel and a character picture; its look follows its role unless class and color are given. It starts at once on the opening task in its channel, which is stopped after 10 minutes unless long_running is set. Its name is permanent.",
267
278
  parameters: Type.Object({
268
279
  name: Type.String({
269
280
  pattern: "^[a-z0-9-]{1,32}$",
@@ -280,6 +291,11 @@ function toolDefs(
280
291
  description: "Its opening task, self-contained.",
281
292
  }),
282
293
  category: Type.Optional(category("Agents")),
294
+ long_running: Type.Optional(
295
+ Type.Boolean({
296
+ description: `Turn on long-running mode for its conversations: its turns, such as the opening task, go on past the usual 10-minute limit by themselves, up to the host's cap (four hours by default). Set it for an agent whose tasks run long, such as research or large changes.`,
297
+ }),
298
+ ),
283
299
  ...(skillsOn
284
300
  ? {
285
301
  skills: Type.Optional(
@@ -295,6 +311,7 @@ function toolDefs(
295
311
  const category = optionalString(i, "category");
296
312
  const skills = stringList(i, "skills");
297
313
  const look = lookOf(i);
314
+ const longRunning = i.long_running === true;
298
315
  return ops.create(scope, {
299
316
  name: requiredString(i, "name"),
300
317
  displayName: requiredString(i, "display_name"),
@@ -303,6 +320,7 @@ function toolDefs(
303
320
  task: requiredString(i, "task"),
304
321
  ...(category ? { category } : {}),
305
322
  ...(skills && skills.length > 0 ? { skills } : {}),
323
+ ...(longRunning ? { longRunning } : {}),
306
324
  });
307
325
  },
308
326
  },
@@ -442,13 +460,24 @@ function toolDefs(
442
460
  name: MESSAGE_AGENT_TOOL,
443
461
  label: "Message agent",
444
462
  description:
445
- "Send a self-contained request to another agent. It returns at once; the agent works in its own channel and its answer comes back to you as a new turn. Its channel shows the exchange, and so does a thread in your channel.",
463
+ 'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in the one it used most recently, so work it started in the web app is answered there. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The exchange shows in its conversation, and a thread in yours.',
446
464
  parameters: Type.Object({
447
465
  to: NAME,
448
466
  text: Type.String({ minLength: 1, maxLength: 4000 }),
467
+ conversation: Type.Optional(
468
+ Type.String({
469
+ description:
470
+ 'Which of the agent\'s conversations to deliver into: "discord", "web" (or the name of another surface that hosts rooms), or a key from agent_get. Leave it out to reach the one it used most recently.',
471
+ }),
472
+ ),
449
473
  }),
450
474
  run: (i) =>
451
- ops.message(scope, requiredString(i, "to"), requiredString(i, "text")),
475
+ ops.message(
476
+ scope,
477
+ requiredString(i, "to"),
478
+ requiredString(i, "text"),
479
+ optionalString(i, "conversation"),
480
+ ),
452
481
  });
453
482
  return defs;
454
483
  }
@@ -118,7 +118,7 @@ export class GroupTurns {
118
118
  if (backlog.lastId !== undefined)
119
119
  await store.advanceCursor(log, name, backlog.lastId);
120
120
  // Attachments and a confirmation belong to a member's first turn of the round only.
121
- const extra: TurnExtra = { interactive: true };
121
+ const extra: TurnExtra = { interactive: true, byMessage: true };
122
122
  if (first) {
123
123
  extra.attachments = attachments;
124
124
  if (approvals.get(name)) extra.confirmed = true;
@@ -0,0 +1,79 @@
1
+ import type { ChannelKey } from "../domain/conversation.ts";
2
+ import { AgentError } from "../domain/errors.ts";
3
+ import type { AgentDirectory } from "../services.ts";
4
+ import type { Agent } from "./agent-store.ts";
5
+ import {
6
+ agentRoomKey,
7
+ discordIdOf,
8
+ discordKey,
9
+ keysOf,
10
+ roomSurfaceOf,
11
+ } from "./team-keys.ts";
12
+
13
+ /** What the choice of a message's conversation reads. */
14
+ export interface MessageTargetContext {
15
+ store: Pick<AgentDirectory, "agent" | "group">;
16
+ /** The surfaces that host agent rooms. */
17
+ roomSurfaces: readonly string[];
18
+ /** The conversation the target last ran a turn in, if it ran one since the host started. */
19
+ latest: ChannelKey | undefined;
20
+ /** The conversation the sender writes from. */
21
+ from: ChannelKey;
22
+ }
23
+
24
+ /** The word for the web app's conversation, accepted when exactly one surface hosts rooms. */
25
+ const WEB = "web";
26
+
27
+ /**
28
+ * The conversation of `target` that a message is delivered into. With `ask` (a surface name such
29
+ * as "discord", "web", or the surface of a room, or one of the target's conversation keys) that
30
+ * one; otherwise the conversation the target used most recently, except that a message from a room
31
+ * never follows it out to Discord, and, when it has run none yet, the sender's own surface, so a message between rooms stays among the rooms.
32
+ */
33
+ export function messageTarget(
34
+ ctx: MessageTargetContext,
35
+ target: Agent,
36
+ ask: string | undefined,
37
+ ): ChannelKey {
38
+ const keys = keysOf(ctx.store, target.name, ctx.roomSurfaces);
39
+ const has = keys.length > 0 ? keys.join(", ") : "none";
40
+ if (ask === undefined || ask.trim() === "") {
41
+ const via = roomSurfaceOf(ctx.from);
42
+ // From a room, never out to Discord: its content may be for the owners alone.
43
+ const outward =
44
+ via !== undefined &&
45
+ ctx.latest !== undefined &&
46
+ discordIdOf(ctx.latest) !== undefined;
47
+ if (ctx.latest !== undefined && keys.includes(ctx.latest) && !outward)
48
+ return ctx.latest;
49
+ if (via === undefined) {
50
+ if (!target.channelId)
51
+ throw new AgentError(`${target.name} has no channel yet.`);
52
+ return discordKey(target.channelId);
53
+ }
54
+ return agentRoomKey(via, target.name);
55
+ }
56
+ const wanted = ask.trim();
57
+ const exact = keys.find((key) => key === wanted);
58
+ if (exact !== undefined) return exact;
59
+ if (wanted === "discord") {
60
+ if (!target.channelId)
61
+ throw new AgentError(
62
+ `${target.name} has no Discord channel; its conversations: ${has}.`,
63
+ );
64
+ return discordKey(target.channelId);
65
+ }
66
+ const surface = ctx.roomSurfaces.includes(wanted)
67
+ ? wanted
68
+ : wanted === WEB && ctx.roomSurfaces.length === 1
69
+ ? ctx.roomSurfaces[0]
70
+ : undefined;
71
+ if (surface !== undefined) return agentRoomKey(surface, target.name);
72
+ throw new AgentError(
73
+ `${target.name} has no conversation "${wanted}"; its conversations: ${has}. Give "discord", "web" (or the surface's name), or one of those keys.`,
74
+ );
75
+ }
76
+
77
+ /** How a conversation reads to the model that sent a message there: its surface and key. */
78
+ export const conversationLabel = (key: ChannelKey): string =>
79
+ `${key.slice(0, key.indexOf(":"))} conversation (${key})`;
@@ -8,6 +8,8 @@ import {
8
8
  import { parseChannelKey } from "../contract/surface.ts";
9
9
  import { AgentError } from "../domain/errors.ts";
10
10
  import type { AgentTurnScope } from "../domain/ports.ts";
11
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
12
+ import type { ConversationModes } from "../modes/conversation-modes.ts";
11
13
  import type { AgentChange } from "../services.ts";
12
14
  import { addressee, type Speaker } from "../speakers.ts";
13
15
  import { openingTaskText } from "./agent-prompt.ts";
@@ -95,6 +97,8 @@ export class TeamEditing {
95
97
  task: string;
96
98
  category?: string;
97
99
  skills?: string[];
100
+ /** Turn long-running mode on for its conversations; the speaker must be an owner. */
101
+ longRunning?: boolean;
98
102
  },
99
103
  ): Promise<string> {
100
104
  this.#refuseSkillsOff(input.skills);
@@ -149,6 +153,23 @@ export class TeamEditing {
149
153
  return made.agent;
150
154
  }
151
155
 
156
+ /**
157
+ * The modes to turn long-running on in, once the speaker may: an owner other than the host's
158
+ * own turns, as for any change of a conversation's modes. Refused before anything is made.
159
+ */
160
+ #longRunning(speaker: Speaker): ConversationModes {
161
+ const modes = this.#ctx.options.modes?.();
162
+ if (!modes)
163
+ throw new AgentError(
164
+ "This host has no conversation modes, so long_running cannot be set. Create the agent without it.",
165
+ );
166
+ if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL)
167
+ throw new AgentError(
168
+ "Only an owner's turn may create an agent with long_running on. Create it without, and ask the owner to turn it on.",
169
+ );
170
+ return modes;
171
+ }
172
+
152
173
  /** An agent cannot carry skills on a host with skills switched off. */
153
174
  #refuseSkillsOff(carried: string[] | undefined): void {
154
175
  if (carried && carried.length > 0 && !this.#ctx.options.skills)
@@ -175,6 +196,7 @@ export class TeamEditing {
175
196
  task?: string;
176
197
  category?: string;
177
198
  skills?: string[];
199
+ longRunning?: boolean;
178
200
  },
179
201
  settings: {
180
202
  model?: string | undefined;
@@ -190,8 +212,10 @@ export class TeamEditing {
190
212
  character: wanted,
191
213
  avatarPrompt: _ignored,
192
214
  task,
215
+ longRunning,
193
216
  ...fields
194
217
  } = input;
218
+ const modes = longRunning ? this.#longRunning(who.speaker) : undefined;
195
219
  const parent = teamCategory(category, "Agents");
196
220
  store.checkNewName(input.name);
197
221
  skills?.checkRegistered(carried);
@@ -233,7 +257,12 @@ export class TeamEditing {
233
257
  "agent created",
234
258
  );
235
259
  this.#ctx.changed();
236
- const avatar = `its picture is the ${character.class} look`;
260
+ const rooms = surfaces?.roomSurfaces() ?? [];
261
+ // Before the opening task starts, so its turn already runs under the mode.
262
+ if (modes)
263
+ for (const key of keysOf(store, agent.name, rooms))
264
+ await modes.set(key, { longRunning: true }, who.speaker);
265
+ const avatar = `its picture is the ${character.class} look${modes ? "; long-running mode is on" : ""}`;
237
266
  const where = channelId ? ` in <#${channelId}>` : "";
238
267
  if (!task)
239
268
  return {
@@ -242,7 +271,6 @@ export class TeamEditing {
242
271
  };
243
272
  // The opening task runs in the room of the surface that asked, else where the agent is first
244
273
  // reached: its channel, else its first room.
245
- const rooms = surfaces?.roomSurfaces() ?? [];
246
274
  const home =
247
275
  who.openIn !== undefined && rooms.includes(who.openIn)
248
276
  ? agentRoomKey(who.openIn, agent.name)
@@ -147,6 +147,13 @@ export function roomScope(agent: Agent, room: ChannelKey): AgentTurnScope {
147
147
  return { name: agent.name, session: room, home: room };
148
148
  }
149
149
 
150
+ /** The agent's own conversation in the channel: its Discord channel's, or its room's. */
151
+ export function scopeOf(agent: Agent, channel: ChannelKey): AgentTurnScope {
152
+ return discordIdOf(channel) === undefined
153
+ ? roomScope(agent, channel)
154
+ : homeScope(agent);
155
+ }
156
+
150
157
  /**
151
158
  * An agent's conversation inside a group: in a Discord group channel, or without a room, as
152
159
  * before; in a room, the room's seat, whose home is the agent's own room on that surface.
@@ -1,5 +1,6 @@
1
1
  import type { ChannelKey } from "../domain/conversation.ts";
2
2
  import type { ThinkingSetting } from "../models.ts";
3
+ import type { ConversationModes } from "../modes/conversation-modes.ts";
3
4
  import type { AgentTools, PromptSection } from "../plugin.ts";
4
5
  import type { AvatarStudio, SkillRegistry } from "../services.ts";
5
6
  import type { ToolSelection } from "../sessions.ts";
@@ -61,6 +62,8 @@ export interface AgentTeamOptions
61
62
  guestPrompt?: string | (() => string);
62
63
  /** The skill registry: what each agent carries. Absent when the skills addon is off. */
63
64
  skills?: SkillRegistry;
65
+ /** The conversation modes, read when an agent is created with long-running on; none, none is set. */
66
+ modes?: () => ConversationModes | undefined;
64
67
  }
65
68
 
66
69
  /** What the team's collaborators share: its options, its turns, and its change signal. */
@@ -57,12 +57,16 @@ export function agentDetails(
57
57
  agent: Agent,
58
58
  defaults: { model: string; thinking: ThinkingSetting },
59
59
  skills: string | undefined,
60
+ conversations: readonly string[] = [],
60
61
  ): string {
61
62
  return [
62
63
  `Name: ${agent.name}`,
63
64
  `Display name: ${agent.displayName}`,
64
65
  `Status: ${agent.status}`,
65
66
  `Channel: ${agent.channelId ? `<#${agent.channelId}>` : "none"}`,
67
+ ...(conversations.length === 0
68
+ ? []
69
+ : [`Conversations: ${conversations.join(", ")}`]),
66
70
  `Model: ${agent.model ?? `${assistantName()}'s (${defaults.model})`}`,
67
71
  `Thinking: ${agent.thinking ?? `${assistantName()}'s (${describeThinking(defaults.thinking)})`}`,
68
72
  ...(skills === undefined ? [] : [`Skills: ${skills}`]),