pi-roundtable 0.26.3 → 0.27.1

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 (84) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +23 -0
  3. package/README.zh-TW.md +31 -8
  4. package/docs/plugins.md +176 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-book.ts +34 -1
  7. package/src/core/agents/agent-claim.ts +25 -5
  8. package/src/core/agents/agent-dashboard.ts +23 -2
  9. package/src/core/agents/agent-messages.ts +28 -10
  10. package/src/core/agents/agent-ports.ts +10 -0
  11. package/src/core/agents/agent-prompt.ts +17 -1
  12. package/src/core/agents/agent-rules.ts +11 -1
  13. package/src/core/agents/agent-schema.ts +22 -0
  14. package/src/core/agents/agent-statements.ts +16 -2
  15. package/src/core/agents/agent-store.ts +30 -20
  16. package/src/core/agents/agent-team-fake-runtime.ts +48 -0
  17. package/src/core/agents/agent-team-fixture.ts +32 -44
  18. package/src/core/agents/agent-team.ts +69 -74
  19. package/src/core/agents/agent-tool-schema.ts +84 -0
  20. package/src/core/agents/agent-tools.ts +50 -85
  21. package/src/core/agents/group-turns.ts +132 -41
  22. package/src/core/agents/helper-create.ts +180 -0
  23. package/src/core/agents/helper-ops.ts +33 -0
  24. package/src/core/agents/helper-test-kit.ts +72 -0
  25. package/src/core/agents/helper-tools.ts +104 -0
  26. package/src/core/agents/helper-work.ts +101 -0
  27. package/src/core/agents/message-target.ts +15 -23
  28. package/src/core/agents/routes.ts +347 -0
  29. package/src/core/agents/team-approval.ts +32 -0
  30. package/src/core/agents/team-editing.ts +97 -32
  31. package/src/core/agents/team-helpers.ts +216 -0
  32. package/src/core/agents/team-lifecycle.ts +26 -1
  33. package/src/core/agents/team-options.ts +8 -0
  34. package/src/core/agents/team-prompt.ts +75 -0
  35. package/src/core/agents/team-selection.ts +31 -13
  36. package/src/core/agents/team-status.ts +6 -0
  37. package/src/core/agents/team-text.ts +20 -4
  38. package/src/core/agents/team-turn-types.ts +12 -1
  39. package/src/core/agents/team-turns.ts +25 -31
  40. package/src/core/builtin/agent-server.ts +37 -2
  41. package/src/core/builtin/modules.ts +4 -4
  42. package/src/core/config/adapters.ts +12 -0
  43. package/src/core/config/agent-team.ts +2 -0
  44. package/src/core/config/config-schema.ts +4 -0
  45. package/src/core/config/config.ts +48 -2
  46. package/src/core/config/helpers.ts +60 -0
  47. package/src/core/config/ops.ts +8 -0
  48. package/src/core/config/routing.ts +296 -0
  49. package/src/core/contract/channels.ts +73 -2
  50. package/src/core/contract/surface.ts +31 -1
  51. package/src/core/define-roundtable.ts +8 -1
  52. package/src/core/discord/agent-discord.ts +67 -6
  53. package/src/core/discord/discord-surface.ts +24 -24
  54. package/src/core/discord/message-changes.ts +66 -0
  55. package/src/core/discord/poster/poster-checks.ts +62 -8
  56. package/src/core/discord/poster/poster.ts +41 -0
  57. package/src/core/discord/poster/rest-poster-read.ts +130 -0
  58. package/src/core/discord/poster/rest-poster.ts +38 -120
  59. package/src/core/domain/round.ts +31 -0
  60. package/src/core/host-registration.ts +15 -0
  61. package/src/core/host.ts +6 -0
  62. package/src/core/i18n/conversation.ts +3 -0
  63. package/src/core/i18n/dashboard.ts +4 -0
  64. package/src/core/ops/error-reporter.ts +4 -0
  65. package/src/core/ops/ops-alerts.ts +102 -6
  66. package/src/core/plugin.ts +17 -0
  67. package/src/core/registry/contributions.ts +1 -26
  68. package/src/core/registry/surface-service.ts +41 -0
  69. package/src/core/routing/channel-router.ts +54 -3
  70. package/src/core/routing/claim-events.ts +42 -0
  71. package/src/core/routing/message-observers.ts +86 -0
  72. package/src/core/routing/peer-bots.ts +149 -0
  73. package/src/core/routing/surface-port.ts +4 -0
  74. package/src/core/services.ts +7 -6
  75. package/src/core/testing/fake-poster-guild.ts +1 -1
  76. package/src/core/testing/fake-poster-reads.ts +96 -0
  77. package/src/core/testing/fake-poster.ts +62 -94
  78. package/src/core/tool-set.snapshot.json +8 -0
  79. package/src/discord/index.ts +2 -0
  80. package/src/index.ts +18 -1
  81. package/src/kit/channels.ts +1 -0
  82. package/src/kit/index.ts +5 -0
  83. package/src/kit/ops-alerts.ts +4 -0
  84. package/src/testing.ts +18 -1
@@ -5,7 +5,7 @@ import type { ThinkingSetting } from "../models.ts";
5
5
  import type { AgentDirectory } from "../services.ts";
6
6
  import { zonedStamp } from "../time.ts";
7
7
  import type { ChannelMessage } from "./agent-ports.ts";
8
- import { characterOf } from "./agent-rules.ts";
8
+ import { characterOf, isHelper } from "./agent-rules.ts";
9
9
  import { describeThinking } from "./agent-settings.ts";
10
10
  import type { Agent } from "./agent-store.ts";
11
11
 
@@ -34,10 +34,21 @@ export function channelIdFromRef(channel: string): string {
34
34
 
35
35
  /** Every agent and group as agent_list shows them, before the category layout. */
36
36
  export function teamListText(store: Pick<AgentDirectory, "agents" | "groups">) {
37
- const agents = store.agents().map((a) => {
37
+ const all = store.agents();
38
+ const line = (a: Agent, indent: string) => {
38
39
  const channel = a.channelId ? `<#${a.channelId}>` : "no channel";
39
- return `- ${a.name} "${a.displayName}" — ${channel}, ${a.status}`;
40
- });
40
+ const temp = isHelper(a)
41
+ ? ` (temp, of ${a.parent}, ${a.finishedAt ? "finished" : "working"})`
42
+ : "";
43
+ return `${indent}- ${a.name}${temp} "${a.displayName}" — ${channel}, ${a.status}`;
44
+ };
45
+ // A temp knight is listed under its parent.
46
+ const agents = all
47
+ .filter((a) => !isHelper(a))
48
+ .flatMap((a) => [
49
+ line(a, ""),
50
+ ...all.filter((h) => h.parent === a.name).map((h) => line(h, " ")),
51
+ ]);
41
52
  const groups = store
42
53
  .groups()
43
54
  .map(
@@ -63,6 +74,11 @@ export function agentDetails(
63
74
  `Name: ${agent.name}`,
64
75
  `Display name: ${agent.displayName}`,
65
76
  `Status: ${agent.status}`,
77
+ ...(isHelper(agent)
78
+ ? [
79
+ `Temp knight of: ${agent.parent} (${agent.finishedAt ? "finished" : "working"})`,
80
+ ]
81
+ : []),
66
82
  `Channel: ${agent.channelId ? `<#${agent.channelId}>` : "none"}`,
67
83
  ...(conversations.length === 0
68
84
  ? []
@@ -22,6 +22,8 @@ import type { ToolTiers } from "../tool-tiers.ts";
22
22
  import type { AgentChannels, AgentTurnRunner } from "./agent-ports.ts";
23
23
  import type { Agent, PgAgentStore } from "./agent-store.ts";
24
24
  import type { RelevanceScorer } from "./group-round.ts";
25
+ import type { HelperWork } from "./helper-work.ts";
26
+ import type { Routes } from "./routes.ts";
25
27
 
26
28
  /** Agent messages without an owner message in between (spec behavior 13). */
27
29
  export const MAX_CHAIN_MESSAGES = 8;
@@ -62,9 +64,14 @@ export interface TeamTurnsOptions {
62
64
  */
63
65
  surfaces?: Pick<
64
66
  SurfacePort,
65
- "sendReply" | "interim" | "roomSurfaces" | "of" | "progress"
67
+ "sendReply" | "interim" | "roomSurfaces" | "of" | "progress" | "groupRound"
66
68
  >;
67
69
  studio: Pick<AvatarStudio, "url">;
70
+ /**
71
+ * The host's routing table, which says where work no person started lands. Without one the team
72
+ * builds the defaults, every place being what the host chose before `routing` existed.
73
+ */
74
+ routes?: Routes;
68
75
  /** Set once the runtime exists, which itself needs the team's tools. */
69
76
  runtime: () => AgentTurnRunner;
70
77
  /** The tools every agent turn runs with. */
@@ -150,4 +157,8 @@ export interface TurnHost {
150
157
  /** Whether the speaker's tier holds every tool of the held actions. */
151
158
  mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean;
152
159
  turnChannel(scope: AgentTurnScope): ChannelKey;
160
+ /** The routing table. */
161
+ readonly routes: Routes;
162
+ /** What keeps a temp knight working; a turn or a message to one brackets itself with it. */
163
+ readonly helpers: Pick<HelperWork, "begin" | "end">;
153
164
  }
@@ -15,12 +15,14 @@ import { isQuiet, replyBody } from "../presentation/reply-body.ts";
15
15
  import { splitReply } from "../presentation/reply-splitter.ts";
16
16
  import { withReplyFiles } from "../reply-files.ts";
17
17
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
18
- import { mayApproveNamed } from "../runtime/extensions/held-calls.ts";
19
- import { type Speaker, tierAtLeast } from "../speakers.ts";
18
+ import type { Speaker } from "../speakers.ts";
20
19
  import { toolTiers } from "../tool-tiers.ts";
21
20
  import { AgentMessages } from "./agent-messages.ts";
22
21
  import type { Agent } from "./agent-store.ts";
23
22
  import { GroupTurns } from "./group-turns.ts";
23
+ import { HelperWork } from "./helper-work.ts";
24
+ import { type Routes, routesOf } from "./routes.ts";
25
+ import { mayApprove } from "./team-approval.ts";
24
26
  import {
25
27
  agentOfChannel,
26
28
  channelIdOf,
@@ -28,8 +30,6 @@ import {
28
30
  coordinatorOf,
29
31
  discordIdOf,
30
32
  discordKey,
31
- keysOf,
32
- roomScope,
33
33
  scopeOf,
34
34
  } from "./team-keys.ts";
35
35
  import {
@@ -49,6 +49,9 @@ export { MAX_CHAIN_MESSAGES, type TeamTurnsOptions };
49
49
  */
50
50
  export class TeamTurns {
51
51
  readonly #options: TeamTurnsOptions;
52
+ readonly routes: Routes;
53
+ /** What keeps each temp knight working, and so when it is finished. */
54
+ readonly helpers: HelperWork;
52
55
  /** The chain of each running turn, by session key, read by message_agent. */
53
56
  readonly #chains = new Map<ChannelKey, Chain>();
54
57
  /** The channel each working agent's turn posts in. */
@@ -63,8 +66,19 @@ export class TeamTurns {
63
66
 
64
67
  constructor(options: TeamTurnsOptions) {
65
68
  this.#options = options;
69
+ this.routes = routesOf(options);
70
+ this.helpers = new HelperWork({
71
+ store: options.store,
72
+ queue: options.queue,
73
+ runtime: options.runtime,
74
+ roomSurfaces: () => options.surfaces?.roomSurfaces() ?? [],
75
+ changed: () => options.changed(),
76
+ logger: options.logger,
77
+ });
66
78
  const host: TurnHost = {
67
79
  options,
80
+ routes: this.routes,
81
+ helpers: this.helpers,
68
82
  chains: this.#chains,
69
83
  turn: (agent, scope, postTo, text, chain, extra) =>
70
84
  this.#turn(agent, scope, postTo, text, chain, extra),
@@ -151,29 +165,8 @@ export class TeamTurns {
151
165
  );
152
166
  }
153
167
 
154
- /**
155
- * Whether the speaker may approve the held actions by a message, call by call: an owner, as the
156
- * agents' channels are shared, or, for a call whose tool named its approvers, one of those
157
- * principals, or, for any other call, the principal whose turn held them (by their id, for
158
- * actions held before 0.9). Their tier must now hold every call. Nobody else in the channel
159
- * may, so a call that named approvers is not its speaker's to approve unless named. The gate
160
- * checks the named approvers again when the turn begins, whoever asked for the release.
161
- */
162
168
  #mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
163
- const theirs =
164
- pending.principalId !== undefined
165
- ? speaker.principalId === pending.principalId
166
- : speaker.id === pending.speakerId;
167
- const tiers = this.#options.toolTiers ?? toolTiers();
168
- // Nothing held names an approver to go by: only the owners and the speaker whose turn held it.
169
- if (pending.calls.length === 0) return speaker.tier === "owner" || theirs;
170
- return pending.calls.every(
171
- (call) =>
172
- (speaker.tier === "owner" ||
173
- (call.approverIds ? mayApproveNamed(speaker, call) : theirs)) &&
174
- tiers.allows(speaker.tier, call.tool) &&
175
- (!call.minTier || tierAtLeast(speaker.tier, call.minTier)),
176
- );
169
+ return mayApprove(this.#options.toolTiers ?? toolTiers(), speaker, pending);
177
170
  }
178
171
 
179
172
  /** The surfaces that host rooms; none for a team without a surface port. */
@@ -207,6 +200,8 @@ export class TeamTurns {
207
200
  ): Promise<TurnResult> {
208
201
  const { logger, startTyping, showStop } = this.#options;
209
202
  const stopTyping = startTyping(postTo);
203
+ // A temp knight is working for as long as a turn of it runs.
204
+ this.helpers.begin(agent.name);
210
205
  // A group round has several sessions; only an agent's own channel maps to one.
211
206
  const hideStop = scope.group ? undefined : showStop(postTo);
212
207
  this.#chains.set(scope.session, chain);
@@ -250,6 +245,7 @@ export class TeamTurns {
250
245
  this.#posting.delete(scope.session);
251
246
  this.#working.delete(agent.name);
252
247
  this.#active(agent.name, scope.group);
248
+ this.helpers.end(agent.name);
253
249
  }
254
250
  this.#options.events?.turnEnded({
255
251
  ...turn,
@@ -450,11 +446,7 @@ export class TeamTurns {
450
446
  const home =
451
447
  entryChannelId !== undefined
452
448
  ? discordKey(entryChannelId)
453
- : keysOf(
454
- store,
455
- coordinator.name,
456
- this.#options.surfaces?.roomSurfaces() ?? [],
457
- )[0];
449
+ : this.routes.primary(coordinator.name);
458
450
  if (!home)
459
451
  throw new AgentError(`${coordinator.name} has no room to post in`);
460
452
  await this.#post(home, coordinator, { chunks: splitReply(text) });
@@ -478,6 +470,8 @@ export class TeamTurns {
478
470
  * One owner message in a group channel: scored, then answered by the chosen members one at
479
471
  * a time, each seeing every group message since its last turn. Call inside the queue.
480
472
  */
473
+ /** Ends the group channel's running round on Stop: the sessions to stop, or undefined when none runs. */
474
+ readonly stopRound = (channel: ChannelKey) => this.#groups.stopRound(channel);
481
475
  answerGroup(
482
476
  channel: ChannelKey,
483
477
  speaker: Speaker,
@@ -9,8 +9,11 @@ import { DiscordAgentTeam } from "../agents/agent-team.ts";
9
9
  import { AGENT_TOOLS } from "../agents/agent-tools.ts";
10
10
  import { FileAvatarStudio } from "../agents/avatar-studio.ts";
11
11
  import { RelevanceScorer } from "../agents/group-round.ts";
12
- import { discordKey } from "../agents/team-keys.ts";
12
+ import { Routes } from "../agents/routes.ts";
13
+ import { agentOfChannel, discordKey } from "../agents/team-keys.ts";
13
14
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
15
+ import type { ResolvedHelpers } from "../config/helpers.ts";
16
+ import type { ResolvedRouting } from "../config/routing.ts";
14
17
  import type { AgentSessions } from "../contract/runtime.ts";
15
18
  import type { ChannelKey } from "../domain/conversation.ts";
16
19
  import { ConfigError } from "../domain/errors.ts";
@@ -116,6 +119,10 @@ export interface AgentServerOptions {
116
119
  avatarNeutral?: string;
117
120
  /** Reports the process's own errors to an agent; without one nothing is reported. */
118
121
  errorReporter?: ErrorReporter;
122
+ /** The checked `routing` block: where work no person started lands. Without it every place is what it was before `routing`. */
123
+ routing?: ResolvedRouting;
124
+ /** The temp knights' limits; `false` leaves `helper_create` and `helper_end` out. Without it the defaults apply. */
125
+ helpers?: ResolvedHelpers | false;
119
126
  }
120
127
 
121
128
  /** The name of the agent server's plugin, as `serviceStarted` events name it. */
@@ -147,6 +154,9 @@ const AGENT_TIERS: Readonly<Record<string, Tier>> = {
147
154
  agent_get: "member",
148
155
  message_agent: "member",
149
156
  channel_read: "member",
157
+ // A knight may make temp knights for whoever is talking to it; the limits hold the cost.
158
+ helper_create: "member",
159
+ helper_end: "member",
150
160
  };
151
161
 
152
162
  /** The tables the agent server keeps: its agents and groups. */
@@ -186,6 +196,7 @@ export function agentServerPlugin(
186
196
  const teamId = options.guildId ?? LOCAL_TEAM_ID;
187
197
  /** Set once the plugin has set up; the preflight reads it after every plugin linked. */
188
198
  let hostsRooms: (() => boolean) | undefined;
199
+ let linkRoutes: (() => void) | undefined;
189
200
  return {
190
201
  name: AGENT_SERVER_PLUGIN,
191
202
  // The agent server's own tables; the host runs them before any setup, in this order. The
@@ -200,6 +211,8 @@ export function agentServerPlugin(
200
211
  throw new ConfigError(
201
212
  "the agents need a surface that hosts their rooms, such as pi-roundtable-web, and none is installed. Without Discord channels there is nowhere to talk to them; add a plugin whose chat surface declares agentRooms: true, or configure discord.",
202
213
  );
214
+ // The surfaces the rules name exist only once every plugin has registered its own.
215
+ linkRoutes?.();
203
216
  },
204
217
  setup: async (context) => {
205
218
  // Absent on a host without Discord: then the team has no channels or dashboard.
@@ -261,7 +274,21 @@ export function agentServerPlugin(
261
274
  : undefined;
262
275
  const sharedPrompt = shared ? () => shared.text() : undefined;
263
276
  const guestPrompt = guest ? () => guest.text() : undefined;
277
+ const routes = new Routes({
278
+ routing: options.routing,
279
+ store,
280
+ roomSurfaces: () => surfaces.roomSurfaces(),
281
+ latest: (agent) => store.conversations.latest(agent),
282
+ logger,
283
+ });
284
+ // The surfaces the rules name are registered by the time every plugin has set up.
285
+ linkRoutes = () =>
286
+ routes.validate({
287
+ names: surfaces.names?.() ?? [],
288
+ discordChannels: connection !== undefined,
289
+ });
264
290
  const built: DiscordAgentTeam = new DiscordAgentTeam({
291
+ routes,
265
292
  guildId: teamId,
266
293
  ...(options.entryChannelId !== undefined
267
294
  ? { entryChannelId: options.entryChannelId }
@@ -319,6 +346,7 @@ export function agentServerPlugin(
319
346
  : {}),
320
347
  ...(skills ? { skills } : {}),
321
348
  modes: () => context.services.find(MODES),
349
+ ...(options.helpers !== undefined ? { helpers: options.helpers } : {}),
322
350
  principals: () => context.services.find(IDENTITY),
323
351
  ...(threads ? { threads } : {}),
324
352
  logger,
@@ -349,6 +377,7 @@ export function agentServerPlugin(
349
377
  runtime: running,
350
378
  approvals: confirmations,
351
379
  avatars: studio,
380
+ routes,
352
381
  workspace: {
353
382
  workDir: options.workDir,
354
383
  ...(agentSessions.scratchDir
@@ -373,7 +402,7 @@ export function agentServerPlugin(
373
402
  options.errorReporter?.connect({
374
403
  channelOf: (name) =>
375
404
  store.agent(name)?.status === "active"
376
- ? built.roomsOf(name)[0]
405
+ ? routes.primary(name)
377
406
  : undefined,
378
407
  post: (channel, text) =>
379
408
  surfaces.sendReply(channel, { chunks: splitReply(text) }),
@@ -411,6 +440,12 @@ export function agentServerPlugin(
411
440
  team: built,
412
441
  runtime: running,
413
442
  surface: context.surfaces,
443
+ integrationChannel: (origin) => {
444
+ const agent = agentOfChannel(store, surfaces, origin);
445
+ return agent
446
+ ? routes.of(agent.name, "integration", origin)
447
+ : origin;
448
+ },
414
449
  attachmentDir,
415
450
  ...(options.attachmentDownload
416
451
  ? { attachmentDownload: options.attachmentDownload }
@@ -233,20 +233,20 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
233
233
  reportsReach = () => {
234
234
  if (!surfaces.of(channel))
235
235
  throw new ConfigError(
236
- `config ops.conversation: no chat surface serves ${JSON.stringify(channel)}, so its error reports could never be posted. Name a conversation of a surface a plugin brings, or leave ops out.`,
236
+ `config ${reporter.configKey}: no chat surface serves ${JSON.stringify(channel)}, so its error reports could never be posted. Name a conversation of a surface a plugin brings, or leave ops out.`,
237
237
  );
238
238
  if (!conversations.owns(channel))
239
239
  throw new ConfigError(
240
- `config ops.conversation: no plugin's conversations own ${JSON.stringify(channel)}, so nothing answers its error reports. Name a conversation a plugin's claim owns, or leave ops out.`,
240
+ `config ${reporter.configKey}: no plugin's conversations own ${JSON.stringify(channel)}, so nothing answers its error reports. Name a conversation a plugin's claim owns, or leave ops out.`,
241
241
  );
242
242
  if (!conversations.takesBackground(channel))
243
243
  throw new ConfigError(
244
- `config ops.conversation: the claim that owns ${JSON.stringify(channel)} takes no background turns, so nothing answers its error reports. Name a conversation whose claim takes background turns, or leave ops out.`,
244
+ `config ${reporter.configKey}: the claim that owns ${JSON.stringify(channel)} takes no background turns, so nothing answers its error reports. Name a conversation whose claim takes background turns, or leave ops out.`,
245
245
  );
246
246
  // A report turn is the system principal's, which a conversation private to its person refuses.
247
247
  if (!conversations.takesSystemReports(channel))
248
248
  throw new ConfigError(
249
- `config ops.conversation: the claim that owns ${JSON.stringify(channel)} keeps its conversations private to their people, so it takes no system error reports and each would only be logged. Name a conversation of a surface whose conversations are shared, such as a Discord channel, or report to an agent with ops.agent.`,
249
+ `config ${reporter.configKey}: the claim that owns ${JSON.stringify(channel)} keeps its conversations private to their people, so it takes no system error reports and each would only be logged. Name a conversation of a surface whose conversations are shared, such as a Discord channel, or report to an agent with ops.agent.`,
250
250
  );
251
251
  };
252
252
  }
@@ -1,5 +1,6 @@
1
1
  import type { ChannelContextOptions } from "../contract/channel-context.ts";
2
2
  import { ConfigError } from "../domain/errors.ts";
3
+ import { type PeerBotsConfig, peerBotsShape } from "../routing/peer-bots.ts";
3
4
  import {
4
5
  bool,
5
6
  guarded,
@@ -51,6 +52,14 @@ export interface DiscordConfig {
51
52
  * for the agent server and every plugin's claim.
52
53
  */
53
54
  channelContext?: ChannelContextOptions | false;
55
+ /**
56
+ * The other bots whose mentions and replies the host answers like a guest's, so separate bots
57
+ * can talk to each other. Explicit Discord user ids only, each for every server or limited to
58
+ * some; there is no wildcard and no "all bots", and left out, no bot is answered. A peer is
59
+ * served only as far as `access` serves its identity (`discord:<id>`), and a channel answers at
60
+ * most `maxRounds` (default 3) of their messages in a row until a person posts.
61
+ */
62
+ peerBots?: PeerBotsConfig;
54
63
  /**
55
64
  * The divider the assistant posts in a server channel or thread when its conversation starts
56
65
  * over (a new conversation by an owner's command), which ends the window channel context reads;
@@ -101,6 +110,7 @@ export interface ResolvedDiscord {
101
110
  agentSpeakers?: "everyone" | "owners";
102
111
  /** As configured: `{}` when left out, so the defaults apply, or `false`. */
103
112
  channelContext: NonNullable<DiscordConfig["channelContext"]>;
113
+ peerBots?: PeerBotsConfig;
104
114
  freshMarker?: string | false;
105
115
  formatting?: string | false;
106
116
  cardGraceSeconds: number;
@@ -133,6 +143,7 @@ export function resolvedDiscord(
133
143
  ? {}
134
144
  : { agentSpeakers: discord.agentSpeakers }),
135
145
  channelContext: discord.channelContext ?? {},
146
+ ...(discord.peerBots === undefined ? {} : { peerBots: discord.peerBots }),
136
147
  ...(discord.freshMarker === undefined
137
148
  ? {}
138
149
  : { freshMarker: discord.freshMarker }),
@@ -180,6 +191,7 @@ export const discordShape = shape({
180
191
  agentMemory: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
181
192
  agentSpeakers: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
182
193
  channelContext: optional(orOff(channelContextShape)),
194
+ peerBots: optional(peerBotsShape),
183
195
  freshMarker: optional(orOff(text)),
184
196
  formatting: optional(orOff(text)),
185
197
  cardGraceSeconds: optional(integer(1, 86_400)),
@@ -46,6 +46,7 @@ export function checkAgentTeamKeys(
46
46
  interface AgentOnlyKeys {
47
47
  agents?: readonly unknown[];
48
48
  skills?: unknown;
49
+ helpers?: unknown;
49
50
  ops?: { agent?: string; conversation?: string };
50
51
  discord?: {
51
52
  agentMemory?: unknown;
@@ -63,6 +64,7 @@ export function checkAgentServerOff(config: AgentOnlyKeys): void {
63
64
  const used: [string, boolean][] = [
64
65
  ["agents", (config.agents?.length ?? 0) > 0],
65
66
  ["skills", config.skills !== undefined && config.skills !== false],
67
+ ["helpers", config.helpers !== undefined && config.helpers !== false],
66
68
  ["ops.agent", config.ops?.agent !== undefined],
67
69
  ["discord.agentMemory", config.discord?.agentMemory !== undefined],
68
70
  ["discord.agentSpeakers", config.discord?.agentSpeakers !== undefined],
@@ -8,8 +8,10 @@ import { discordShape } from "./adapters.ts";
8
8
  import { attachmentsShape } from "./attachments.ts";
9
9
  import type { Pronouns } from "./config.ts";
10
10
  import { conversationsShape } from "./conversations.ts";
11
+ import { helpersField } from "./helpers.ts";
11
12
  import { limitsShape } from "./limits.ts";
12
13
  import { profilesShape } from "./profiles.ts";
14
+ import { routingShape } from "./routing.ts";
13
15
  import {
14
16
  bool,
15
17
  characterInput,
@@ -142,6 +144,8 @@ export const configSchema = shape({
142
144
  skills: optional(skillsField),
143
145
  memory: optional(bool),
144
146
  ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
147
+ routing: routingShape,
148
+ helpers: optional(helpersField),
145
149
  drainSeconds: optional(integer(1, 3_600)),
146
150
  ...limitsShape,
147
151
  interimText: optional(oneOf<InterimTextMode>("on", "off")),
@@ -30,9 +30,19 @@ import { checkAgentServerOff, checkAgentTeamKeys } from "./agent-team.ts";
30
30
  import type { AttachmentsConfig } from "./attachments.ts";
31
31
  import { configSchema } from "./config-schema.ts";
32
32
  import type { ConversationsConfig } from "./conversations.ts";
33
+ import {
34
+ type HelpersConfig,
35
+ helpersOf,
36
+ type ResolvedHelpers,
37
+ } from "./helpers.ts";
33
38
  import { type LimitsConfig, limitsOf, type ResolvedLimits } from "./limits.ts";
34
- import { opsOf, type ResolvedOps } from "./ops.ts";
39
+ import { opsOf, opsOfPlace, type ResolvedOps } from "./ops.ts";
35
40
  import { type ProfilesConfig, profileRulesOf } from "./profiles.ts";
41
+ import {
42
+ type ResolvedRouting,
43
+ type RoutingConfig,
44
+ routingOf,
45
+ } from "./routing.ts";
36
46
  import type { SkillsConfig } from "./skills-config.ts";
37
47
 
38
48
  export type { DiscordConfig };
@@ -217,8 +227,22 @@ export interface RoundtableConfig extends LimitsConfig {
217
227
  * `discord`), or to a conversation, by its key `<surface>:<id>`, as a visible message and a
218
228
  * report turn its claim answers. The host does not start when no chat surface serves the
219
229
  * conversation or no plugin's claim owns it; the web chat takes no error reports.
230
+ * @deprecated An alias of `routing.places.ops` since 0.27, which also accepts an agent whose
231
+ * primary place is not Discord; removed in 1.0. Setting both stops the start.
220
232
  */
221
233
  ops?: { agent: string } | { conversation: string };
234
+ /**
235
+ * Where events no person waits on land: the agents' primary places, where a webhook's report,
236
+ * a `message_agent` call and an opening task go, and the named places such as `ops`. Left out,
237
+ * every one lands where it did before the key existed.
238
+ */
239
+ routing?: RoutingConfig;
240
+ /**
241
+ * Temp knights: helper knights an agent makes for a task with `helper_create` and clears with
242
+ * `helper_end`. Left out, the defaults apply (4 per knight, 12 on the host, finished ones
243
+ * archived after 24 hours); `false` leaves the tools out.
244
+ */
245
+ helpers?: false | HelpersConfig;
222
246
  /**
223
247
  * Seconds a shutdown waits for running turns before stopping them; default 180. Give the
224
248
  * service manager a stop timeout a minute longer, such as systemd's `TimeoutStopSec`.
@@ -235,6 +259,10 @@ export interface RoundtableConfig extends LimitsConfig {
235
259
  plugins?: RoundtablePlugin[];
236
260
  }
237
261
 
262
+ /** Logged once when the top-level `ops` is set: it is an alias of the `ops` place. */
263
+ export const OPS_DEPRECATION =
264
+ "`ops` is an alias of `routing.places.ops` since 0.27 and goes away in 1.0; move it there";
265
+
238
266
  /** Logged once when `avatar` is set: agents are composed characters and no picture is drawn from it. */
239
267
  export const AVATAR_DEPRECATION =
240
268
  "agents are composed characters since 0.23; `avatar` is ignored";
@@ -310,6 +338,12 @@ export interface ResolvedConfig extends ResolvedLimits {
310
338
  skills: false | SkillsConfig;
311
339
  memory: boolean;
312
340
  ops?: ResolvedOps;
341
+ /** The config key `ops` came from, as error messages name it. */
342
+ opsKey: "ops" | "routing.places.ops";
343
+ /** The checked `routing` block; undefined when the configuration has none. */
344
+ routing?: ResolvedRouting;
345
+ /** The temp knights' limits, or `false` when the tools are left out. */
346
+ helpers: ResolvedHelpers | false;
313
347
  drainMs: number;
314
348
  interimText: InterimTextMode;
315
349
  interimPrimaryChars: number;
@@ -353,7 +387,15 @@ export function resolveConfig(input: unknown): ResolvedConfig {
353
387
  checkAgentTeamKeys(config, discord !== undefined);
354
388
  const slug =
355
389
  discord?.rootCommand ?? name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
356
- const ops = opsOf(config.ops, discord !== undefined);
390
+ const routing = routingOf(config.routing, {
391
+ withDiscord: discord !== undefined,
392
+ agentServer:
393
+ agentServer &&
394
+ (discord !== undefined || (config.agents?.length ?? 0) > 0),
395
+ topLevelOps: config.ops !== undefined,
396
+ });
397
+ const ops =
398
+ opsOf(config.ops, discord !== undefined) ?? opsOfPlace(routing?.places.ops);
357
399
  const access = accessOf(config, discord !== undefined);
358
400
  const { primaryOwner } = access;
359
401
  return {
@@ -364,6 +406,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
364
406
  deprecations: [
365
407
  ...access.deprecations,
366
408
  ...(config.avatar ? [AVATAR_DEPRECATION] : []),
409
+ ...(config.ops ? [OPS_DEPRECATION] : []),
367
410
  ],
368
411
  owner: {
369
412
  id: primaryOwner.id,
@@ -444,6 +487,9 @@ export function resolveConfig(input: unknown): ResolvedConfig {
444
487
  : false,
445
488
  memory: config.memory ?? true,
446
489
  ...(ops ? { ops } : {}),
490
+ opsKey: config.ops ? "ops" : "routing.places.ops",
491
+ ...(routing ? { routing } : {}),
492
+ helpers: agentServer ? helpersOf(config.helpers) : false,
447
493
  drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
448
494
  ...limitsOf(config),
449
495
  interimText: config.interimText ?? "on",
@@ -0,0 +1,60 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import { type Field, integer, optional, orOff, shape } from "./schema.ts";
3
+
4
+ /**
5
+ * Temp knights: helper knights an agent makes for a task and clears when they are done. Left out,
6
+ * the defaults below apply; `false` stops the tools `helper_create` and `helper_end` being offered.
7
+ */
8
+ export interface HelpersConfig {
9
+ /** The most active temp knights one knight has at once, 1 to 8; default 4. */
10
+ perParent?: number;
11
+ /** The most active temp knights on the whole host, 1 to 50; default 12. */
12
+ perHost?: number;
13
+ /**
14
+ * Hours a finished temp knight stays before the host archives it, 1 to 720; `false` never does.
15
+ * Default 24. An archived one can be restored from the archive page.
16
+ */
17
+ archiveFinishedAfterHours?: number | false;
18
+ }
19
+
20
+ /** What the team reads: every limit filled in. */
21
+ export interface ResolvedHelpers {
22
+ perParent: number;
23
+ perHost: number;
24
+ archiveFinishedAfterHours: number | false;
25
+ }
26
+
27
+ const DEFAULT_HELPERS: ResolvedHelpers = Object.freeze({
28
+ perParent: 4,
29
+ perHost: 12,
30
+ archiveFinishedAfterHours: 24,
31
+ });
32
+
33
+ const afterHours: Field<number | false> = orOff(integer(1, 720));
34
+
35
+ export const helpersField = orOff(
36
+ shape({
37
+ perParent: optional(integer(1, 8)),
38
+ perHost: optional(integer(1, 50)),
39
+ archiveFinishedAfterHours: optional(afterHours),
40
+ }),
41
+ );
42
+
43
+ /** The checked `helpers` block with the defaults filled in; `false` when it is switched off. */
44
+ export function helpersOf(
45
+ config: HelpersConfig | false | undefined,
46
+ ): ResolvedHelpers | false {
47
+ if (config === false) return false;
48
+ const resolved: ResolvedHelpers = {
49
+ perParent: config?.perParent ?? DEFAULT_HELPERS.perParent,
50
+ perHost: config?.perHost ?? DEFAULT_HELPERS.perHost,
51
+ archiveFinishedAfterHours:
52
+ config?.archiveFinishedAfterHours ??
53
+ DEFAULT_HELPERS.archiveFinishedAfterHours,
54
+ };
55
+ if (resolved.perParent > resolved.perHost)
56
+ throw new ConfigError(
57
+ `config helpers.perParent: ${resolved.perParent} is more than helpers.perHost (${resolved.perHost}), which counts all of them. Lower perParent or raise perHost.`,
58
+ );
59
+ return resolved;
60
+ }
@@ -32,3 +32,11 @@ export function opsOf(
32
32
  );
33
33
  return { conversation: conversation as ChannelKey };
34
34
  }
35
+
36
+ /** The error reports' destination that `routing.places.ops` names: a conversation, or an agent's primary place. */
37
+ export function opsOfPlace(
38
+ place: ChannelKey | { agent: string } | undefined,
39
+ ): ResolvedOps | undefined {
40
+ if (place === undefined) return undefined;
41
+ return typeof place === "string" ? { conversation: place } : place;
42
+ }