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
@@ -0,0 +1,101 @@
1
+ import type { QueuePort } from "../contract/channels.ts";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import type { AgentTurnRunner } from "./agent-ports.ts";
5
+ import { isHelper } from "./agent-rules.ts";
6
+ import type { PgAgentStore } from "./agent-store.ts";
7
+ import { keysOf, scopeOf } from "./team-keys.ts";
8
+
9
+ export interface HelperWorkOptions {
10
+ store: Pick<PgAgentStore, "agent" | "group" | "setFinished">;
11
+ queue: Pick<QueuePort, "size">;
12
+ runtime: () => Pick<AgentTurnRunner, "heldActions">;
13
+ /** The surfaces that host rooms, which are places of a temp knight beside its Discord thread. */
14
+ roomSurfaces: () => readonly string[];
15
+ /** Called after a temp knight's state changed, so the pages and the dashboard redraw. */
16
+ changed: () => void;
17
+ logger: Logger;
18
+ }
19
+
20
+ /**
21
+ * What keeps a temp knight working: a turn that runs, a message from its parent that has not been
22
+ * answered, a turn or message queued in one of its places, or a held action that waits for the
23
+ * owner. The moment none holds, the knight is *finished* and the store says so; any work that
24
+ * begins makes it working again, so no one reopens it.
25
+ */
26
+ export class HelperWork {
27
+ readonly #options: HelperWorkOptions;
28
+ /** Turns and unanswered messages of each temp knight. */
29
+ readonly #running = new Map<string, number>();
30
+
31
+ constructor(options: HelperWorkOptions) {
32
+ this.#options = options;
33
+ }
34
+
35
+ /** A turn or a message to the agent begins; a finished temp knight is working again. */
36
+ begin(name: string): void {
37
+ if (!this.#helper(name)) return;
38
+ this.#running.set(name, (this.#running.get(name) ?? 0) + 1);
39
+ this.#stamp(name, false);
40
+ }
41
+
42
+ /** One piece of work of the agent ended; with nothing else holding it, a temp knight is finished. */
43
+ end(name: string): void {
44
+ const left = (this.#running.get(name) ?? 0) - 1;
45
+ if (left > 0) {
46
+ this.#running.set(name, left);
47
+ return;
48
+ }
49
+ this.#running.delete(name);
50
+ if (!this.#helper(name)) return;
51
+ void this.#settle(name);
52
+ }
53
+
54
+ /** Whether the agent is an active temp knight. */
55
+ #helper(name: string): boolean {
56
+ const agent = this.#options.store.agent(name);
57
+ return agent?.status === "active" && isHelper(agent);
58
+ }
59
+
60
+ async #settle(name: string): Promise<void> {
61
+ try {
62
+ if (await this.#waiting(name)) return;
63
+ // Work begun while the held actions were read keeps it working.
64
+ if ((this.#running.get(name) ?? 0) > 0) return;
65
+ this.#stamp(name, true);
66
+ } catch (error) {
67
+ this.#options.logger.warn(
68
+ { agent: name, err: error },
69
+ "temp knight's state not settled",
70
+ );
71
+ }
72
+ }
73
+
74
+ /** Whether something else waits: a turn queued behind this one, or an action held for the owner. */
75
+ async #waiting(name: string): Promise<boolean> {
76
+ const { store, queue, roomSurfaces } = this.#options;
77
+ const agent = store.agent(name);
78
+ if (!agent) return false;
79
+ const keys: ChannelKey[] = keysOf(store, name, roomSurfaces());
80
+ // The task that just ended still counts in its channel's queue until it returns.
81
+ if (keys.some((key) => queue.size(key) > 1)) return true;
82
+ const runtime = this.#options.runtime();
83
+ for (const key of keys)
84
+ if (await runtime.heldActions(scopeOf(agent, key).session)) return true;
85
+ return false;
86
+ }
87
+
88
+ #stamp(name: string, finished: boolean): void {
89
+ const { store, changed, logger } = this.#options;
90
+ store.setFinished(name, finished).then(
91
+ (moved) => {
92
+ if (moved) changed();
93
+ },
94
+ (error: unknown) =>
95
+ logger.warn(
96
+ { agent: name, err: error },
97
+ "temp knight's state not kept",
98
+ ),
99
+ );
100
+ }
101
+ }
@@ -2,13 +2,8 @@ import type { ChannelKey } from "../domain/conversation.ts";
2
2
  import { AgentError } from "../domain/errors.ts";
3
3
  import type { AgentDirectory } from "../services.ts";
4
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";
5
+ import { Routes } from "./routes.ts";
6
+ import { agentRoomKey, discordKey, keysOf } from "./team-keys.ts";
12
7
 
13
8
  /** What the choice of a message's conversation reads. */
14
9
  export interface MessageTargetContext {
@@ -19,6 +14,8 @@ export interface MessageTargetContext {
19
14
  latest: ChannelKey | undefined;
20
15
  /** The conversation the sender writes from. */
21
16
  from: ChannelKey;
17
+ /** The host's routing table; without it the defaults every host had before `routing`. */
18
+ routes?: Pick<Routes, "of">;
22
19
  }
23
20
 
24
21
  /** The word for the web app's conversation, accepted when exactly one surface hosts rooms. */
@@ -27,8 +24,9 @@ const WEB = "web";
27
24
  /**
28
25
  * The conversation of `target` that a message is delivered into. With `ask` (a surface name such
29
26
  * 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.
27
+ * one; otherwise the conversation the routing table picks (`Routes.of`): by default the one the
28
+ * target used most recently, which a message from a room never follows out to Discord, and, when
29
+ * it has run none yet, the sender's own surface, so a message between rooms stays among the rooms.
32
30
  */
33
31
  export function messageTarget(
34
32
  ctx: MessageTargetContext,
@@ -38,20 +36,14 @@ export function messageTarget(
38
36
  const keys = keysOf(ctx.store, target.name, ctx.roomSurfaces);
39
37
  const has = keys.length > 0 ? keys.join(", ") : "none";
40
38
  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);
39
+ const routes =
40
+ ctx.routes ??
41
+ new Routes({
42
+ store: ctx.store,
43
+ roomSurfaces: () => ctx.roomSurfaces,
44
+ latest: () => ctx.latest,
45
+ });
46
+ return routes.of(target.name, "agentMessage", ctx.from);
55
47
  }
56
48
  const wanted = ask.trim();
57
49
  const exact = keys.find((key) => key === wanted);
@@ -0,0 +1,347 @@
1
+ import type {
2
+ ResolvedAgentRoutes,
3
+ ResolvedRouting,
4
+ } from "../config/routing.ts";
5
+ import { ROUTE_WORDS } from "../config/routing.ts";
6
+ import { parseChannelKey } from "../contract/surface.ts";
7
+ import type { ChannelKey } from "../domain/conversation.ts";
8
+ import { AgentError, ConfigError } from "../domain/errors.ts";
9
+ import type { AgentDirectory } from "../services.ts";
10
+ import {
11
+ agentRoomKey,
12
+ discordKey,
13
+ keysOf,
14
+ roomSurfaceOf,
15
+ } from "./team-keys.ts";
16
+
17
+ /**
18
+ * The routing table: where an agent's work lands when no person is waiting on it. An agent's
19
+ * *place* on a surface is its Discord channel or its room there; its *primary place* is where
20
+ * unprompted work lands. Work a person started, or that waits on a person, is never routed.
21
+ * Read it as `AGENTS.routes`.
22
+ */
23
+ export interface RoutingTable {
24
+ /**
25
+ * The agent's primary place: the surface its rules name; with `"auto"` (the default) its Discord
26
+ * channel when it has one, else its room on the first surface that hosts rooms. Undefined for
27
+ * an unknown agent.
28
+ */
29
+ primary(agent: string): ChannelKey | undefined;
30
+ /** The agent's place on a surface (`"web"` for the one surface that hosts rooms); undefined when it has none there. */
31
+ placeOn(agent: string, surface: string): ChannelKey | undefined;
32
+ /**
33
+ * The conversation an event of `kind` for the agent lands in; `origin` is where it came in
34
+ * (a webhook post's conversation, the sender's turn channel, the conversation that asked for
35
+ * the agent). An `integration` event is never dropped: a vanished destination sends it to its
36
+ * origin. An `agentMessage` throws AgentError when the agent has no place; an `opening` task
37
+ * has none when the agent has no place at all.
38
+ */
39
+ of(agent: string, kind: "integration", origin: ChannelKey): ChannelKey;
40
+ of(agent: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
41
+ of(
42
+ agent: string,
43
+ kind: RouteKind,
44
+ origin?: ChannelKey,
45
+ ): ChannelKey | undefined;
46
+ /** A named destination of the host, such as `ops`; undefined when none is set or its agent is not active. */
47
+ place(name: string): ChannelKey | undefined;
48
+ }
49
+
50
+ /** What starts agent work with no person waiting on the answer, and so may be routed. */
51
+ export type RouteKind = "integration" | "agentMessage" | "opening";
52
+
53
+ /** The word for the web app's surface, taken for it when exactly one surface hosts rooms. */
54
+ const WEB = "web";
55
+
56
+ /** What the table writes to: a warning with its fields. */
57
+ export interface RoutesLogger {
58
+ warn(fields: Record<string, unknown>, message: string): void;
59
+ }
60
+
61
+ export interface RoutesOptions {
62
+ /** The checked `routing` block; without it every decision is what the host did before it existed. */
63
+ routing?: ResolvedRouting | undefined;
64
+ store: Pick<AgentDirectory, "agent" | "group">;
65
+ /** The surfaces that host agent rooms, read each time: they are only known once the plugins linked. */
66
+ roomSurfaces: () => readonly string[];
67
+ /** The conversation an agent last ran a turn in. */
68
+ latest?: (agent: string) => ChannelKey | undefined;
69
+ logger?: RoutesLogger;
70
+ }
71
+
72
+ /** What the surfaces the host registered can hold, read when the agent server links. */
73
+ export interface LinkedSurfaces {
74
+ /** Every registered surface. */
75
+ names: readonly string[];
76
+ /** Whether the agents have Discord channels. */
77
+ discordChannels: boolean;
78
+ }
79
+
80
+ /**
81
+ * The one table that says where an agent's work lands. An agent's *place* on a surface is its
82
+ * Discord channel or its room there; its *primary place* is where work no person started lands.
83
+ * Work a person started, or that waits on a person (a message, a card, a schedule's firing, an
84
+ * exchange's answer), is never routed: it stays in the conversation they see. Every decision here
85
+ * is what the host did before `routing` existed until the configuration says otherwise.
86
+ */
87
+ export class Routes implements RoutingTable {
88
+ readonly #routing: ResolvedRouting | undefined;
89
+ readonly #options: RoutesOptions;
90
+ readonly #warned = new Set<string>();
91
+
92
+ constructor(options: RoutesOptions) {
93
+ this.#options = options;
94
+ this.#routing = options.routing;
95
+ }
96
+
97
+ /**
98
+ * The rules of one agent: its own over the `"*"` entry, over the defaults. A temp knight has
99
+ * no row of its own and follows its parent's, so it lives where its parent does.
100
+ */
101
+ rules(agent: string): ResolvedAgentRoutes {
102
+ const all = this.#routing?.agents;
103
+ const parent = this.#options.store.agent(agent)?.parent;
104
+ return {
105
+ ...all?.["*"],
106
+ ...(parent === undefined ? undefined : all?.[parent]),
107
+ ...all?.[agent],
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The agent's place on a surface (`"web"` for the one surface that hosts rooms); undefined when
113
+ * the agent is unknown or has none there.
114
+ */
115
+ placeOn(name: string, surface: string): ChannelKey | undefined {
116
+ const agent = this.#options.store.agent(name);
117
+ if (!agent) return undefined;
118
+ const rooms = this.#options.roomSurfaces();
119
+ const wanted =
120
+ surface === WEB && !rooms.includes(WEB) && rooms.length === 1
121
+ ? (rooms[0] as string)
122
+ : surface;
123
+ if (wanted === "discord")
124
+ return agent.channelId ? discordKey(agent.channelId) : undefined;
125
+ return rooms.includes(wanted) ? agentRoomKey(wanted, name) : undefined;
126
+ }
127
+
128
+ /**
129
+ * The agent's primary place, where its unprompted work lands: the surface its rules name, and
130
+ * with `"auto"` its Discord channel when it has one, else its room on the first surface that
131
+ * hosts rooms. An unknown agent has none. A named surface the agent has no place on is logged,
132
+ * and its auto place is used.
133
+ */
134
+ primary(name: string): ChannelKey | undefined {
135
+ if (!this.#options.store.agent(name)) return undefined;
136
+ const wanted = this.rules(name).primary ?? "auto";
137
+ if (wanted !== "auto") {
138
+ const key = this.placeOn(name, wanted);
139
+ if (key) return key;
140
+ this.#warn(
141
+ `primary:${name}`,
142
+ { agent: name, surface: wanted },
143
+ "the agent has no place on its primary surface; its auto place is used",
144
+ );
145
+ }
146
+ return keysOf(this.#options.store, name, this.#options.roomSurfaces())[0];
147
+ }
148
+
149
+ /**
150
+ * The conversation an event of `kind` for the agent lands in. `origin` is where it came in: the
151
+ * conversation of a webhook post (`integration`), the sender's turn channel (`agentMessage`), or
152
+ * the conversation that asked for the agent (`opening`). A destination that has vanished sends
153
+ * an `integration` event to its origin, with a warning, and never drops it. Throws AgentError
154
+ * when an `agentMessage` finds no place; undefined when an `opening` finds none.
155
+ */
156
+ of(name: string, kind: "integration", origin: ChannelKey): ChannelKey;
157
+ of(name: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
158
+ of(
159
+ name: string,
160
+ kind: RouteKind,
161
+ origin?: ChannelKey,
162
+ ): ChannelKey | undefined;
163
+ of(
164
+ name: string,
165
+ kind: RouteKind,
166
+ origin?: ChannelKey,
167
+ ): ChannelKey | undefined {
168
+ if (kind === "integration") return this.#integration(name, origin);
169
+ if (kind === "opening") return this.#opening(name, origin);
170
+ return this.#agentMessage(name, origin);
171
+ }
172
+
173
+ #integration(
174
+ name: string,
175
+ origin: ChannelKey | undefined,
176
+ ): ChannelKey | undefined {
177
+ const to = this.rules(name).integration ?? "origin";
178
+ if (to === "origin" || origin === undefined) return origin;
179
+ const key = to === "primary" ? this.primary(name) : this.placeOn(name, to);
180
+ if (key) return key;
181
+ this.#warn(
182
+ `integration:${name}`,
183
+ { agent: name, to },
184
+ "the agent has no place for its integrations; the report runs where the post was made",
185
+ );
186
+ return origin;
187
+ }
188
+
189
+ #opening(
190
+ name: string,
191
+ origin: ChannelKey | undefined,
192
+ ): ChannelKey | undefined {
193
+ if (
194
+ (this.rules(name).opening ?? "origin-surface") === "origin-surface" &&
195
+ origin
196
+ ) {
197
+ const key = this.placeOn(name, parseChannelKey(origin).surface);
198
+ if (key) return key;
199
+ }
200
+ return this.primary(name);
201
+ }
202
+
203
+ /**
204
+ * The conversation a message to the agent lands in when the sender names none: with `latest`
205
+ * the one it last ran a turn in, except that a message from a room never follows it out to
206
+ * Discord, since the room's text may be for the owners alone; with none yet, the sender's own
207
+ * surface when it hosts rooms, so a message between rooms stays among them; otherwise its
208
+ * primary place.
209
+ */
210
+ #agentMessage(name: string, from: ChannelKey | undefined): ChannelKey {
211
+ const { store, roomSurfaces, latest } = this.#options;
212
+ const to = this.rules(name).agentMessage ?? "latest";
213
+ if (to !== "latest") {
214
+ const key =
215
+ to === "primary" ? this.primary(name) : this.placeOn(name, to);
216
+ if (key) return key;
217
+ this.#warn(
218
+ `message:${name}`,
219
+ { agent: name, to },
220
+ "the agent has no place for messages to it; the conversation it last ran a turn in is used",
221
+ );
222
+ }
223
+ const via = from === undefined ? undefined : roomSurfaceOf(from);
224
+ const last = latest?.(name);
225
+ const keys = keysOf(store, name, roomSurfaces());
226
+ const outward =
227
+ via !== undefined &&
228
+ last !== undefined &&
229
+ roomSurfaceOf(last) === undefined;
230
+ if (last !== undefined && keys.includes(last) && !outward) return last;
231
+ if (via === undefined) {
232
+ const primary = this.primary(name);
233
+ if (!primary) throw new AgentError(`${name} has no channel yet.`);
234
+ return primary;
235
+ }
236
+ return agentRoomKey(via, name);
237
+ }
238
+
239
+ /**
240
+ * Another agent's place for `schedule_list`: its room on the surface `near` is on when that
241
+ * surface hosts rooms, otherwise its primary place. Throws AgentError when it has none.
242
+ */
243
+ near(name: string, near?: ChannelKey): ChannelKey {
244
+ const surface = near === undefined ? undefined : roomSurfaceOf(near);
245
+ const room =
246
+ surface === undefined ? undefined : this.placeOn(name, surface);
247
+ const key = room ?? this.primary(name);
248
+ if (!key) throw new AgentError(`${name} has no channel yet`);
249
+ return key;
250
+ }
251
+
252
+ /**
253
+ * A named destination of the host (`ops`): its conversation, or the primary place of the agent
254
+ * it names while that agent is active; undefined when none is configured or it has none now.
255
+ */
256
+ place(name: string): ChannelKey | undefined {
257
+ const target = this.#routing?.places[name];
258
+ if (target === undefined) return undefined;
259
+ if (typeof target === "string") return target;
260
+ return this.#options.store.agent(target.agent)?.status === "active"
261
+ ? this.primary(target.agent)
262
+ : undefined;
263
+ }
264
+
265
+ /**
266
+ * Refuses a surface the rules name that is not registered, or that cannot hold an agent's place,
267
+ * naming the key and the fix; called once every plugin has registered its surfaces.
268
+ */
269
+ validate(linked: LinkedSurfaces): void {
270
+ if (!this.#routing) return;
271
+ const rooms = this.#options.roomSurfaces();
272
+ const holders = [...(linked.discordChannels ? ["discord"] : []), ...rooms];
273
+ for (const [agent, rules] of Object.entries(this.#routing?.agents ?? {}))
274
+ for (const key of ["primary", "integration", "agentMessage"] as const) {
275
+ const surface = rules[key];
276
+ if (
277
+ surface === undefined ||
278
+ (ROUTE_WORDS[key] as readonly string[]).includes(surface)
279
+ )
280
+ continue;
281
+ const path = `routing.agents.${agent}.${key}`;
282
+ if (surface === WEB && !linked.names.includes(WEB)) {
283
+ if (rooms.length !== 1)
284
+ throw new ConfigError(
285
+ `config ${path}: "web" stands for the one surface that hosts rooms, and ${rooms.length === 0 ? "none does" : `${rooms.length} do (${rooms.join(", ")})`}. Name the surface: ${holders.join(", ") || "none hosts agent places on this host"}.`,
286
+ );
287
+ continue;
288
+ }
289
+ if (!linked.names.includes(surface))
290
+ throw new ConfigError(
291
+ `config ${path}: no surface is registered as ${JSON.stringify(surface)}. The registered surfaces are ${linked.names.join(", ") || "none"}; name one of them, or add the plugin that brings ${JSON.stringify(surface)}.`,
292
+ );
293
+ if (!holders.includes(surface))
294
+ throw new ConfigError(
295
+ `config ${path}: the ${surface} surface holds no agent places, since it hosts no rooms${surface === "discord" ? " here (the agents have no Discord channels)" : ""}. Name one that does: ${holders.join(", ") || "none on this host"}.`,
296
+ );
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Warns of the agents the rules and places name that the team does not know yet, which may be
302
+ * created later: their rules apply once they exist.
303
+ */
304
+ warnUnknown(): void {
305
+ const names = [
306
+ ...Object.keys(this.#routing?.agents ?? {}).filter((n) => n !== "*"),
307
+ ...Object.values(this.#routing?.places ?? {}).flatMap((t) =>
308
+ typeof t === "object" ? [t.agent] : [],
309
+ ),
310
+ ];
311
+ const unknown = [...new Set(names)].filter(
312
+ (n) => !this.#options.store.agent(n),
313
+ );
314
+ if (unknown.length > 0)
315
+ this.#options.logger?.warn(
316
+ { agents: unknown },
317
+ "routing names agents the team does not have yet; their rules apply once they exist",
318
+ );
319
+ }
320
+
321
+ /** Warns once per key, so a repeated event does not flood the log. */
322
+ #warn(key: string, fields: Record<string, unknown>, message: string): void {
323
+ if (this.#warned.has(key)) return;
324
+ this.#warned.add(key);
325
+ this.#options.logger?.warn(fields, message);
326
+ }
327
+ }
328
+
329
+ /** The routing table of a team's options: the host's own, or, without one, the defaults. */
330
+ export function routesOf(options: {
331
+ routes?: Routes | undefined;
332
+ store: RoutesOptions["store"] & {
333
+ conversations: { latest(agent: string): ChannelKey | undefined };
334
+ };
335
+ surfaces?: { roomSurfaces(): readonly string[] } | undefined;
336
+ logger: RoutesLogger;
337
+ }): Routes {
338
+ return (
339
+ options.routes ??
340
+ new Routes({
341
+ store: options.store,
342
+ roomSurfaces: () => options.surfaces?.roomSurfaces() ?? [],
343
+ latest: (agent) => options.store.conversations.latest(agent),
344
+ logger: options.logger,
345
+ })
346
+ );
347
+ }
@@ -0,0 +1,32 @@
1
+ import type { PendingConfirmation } from "../domain/conversation.ts";
2
+ import { mayApproveNamed } from "../runtime/extensions/held-calls.ts";
3
+ import { type Speaker, tierAtLeast } from "../speakers.ts";
4
+ import type { ToolTiers } from "../tool-tiers.ts";
5
+
6
+ /**
7
+ * Whether the speaker may approve the held actions by a message, call by call: an owner, as the
8
+ * agents' channels are shared, or, for a call whose tool named its approvers, one of those
9
+ * principals, or, for any other call, the principal whose turn held them (by their id, for
10
+ * actions held before 0.9). Their tier must now hold every call. Nobody else in the channel
11
+ * may, so a call that named approvers is not its speaker's to approve unless named. The gate
12
+ * checks the named approvers again when the turn begins, whoever asked for the release.
13
+ */
14
+ export function mayApprove(
15
+ tiers: Pick<ToolTiers, "allows">,
16
+ speaker: Speaker,
17
+ pending: PendingConfirmation,
18
+ ): boolean {
19
+ const theirs =
20
+ pending.principalId !== undefined
21
+ ? speaker.principalId === pending.principalId
22
+ : speaker.id === pending.speakerId;
23
+ // Nothing held names an approver to go by: only the owners and the speaker whose turn held it.
24
+ if (pending.calls.length === 0) return speaker.tier === "owner" || theirs;
25
+ return pending.calls.every(
26
+ (call) =>
27
+ (speaker.tier === "owner" ||
28
+ (call.approverIds ? mayApproveNamed(speaker, call) : theirs)) &&
29
+ tiers.allows(speaker.tier, call.tool) &&
30
+ (!call.minTier || tierAtLeast(speaker.tier, call.minTier)),
31
+ );
32
+ }