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.
- package/CHANGELOG.md +48 -0
- package/README.md +23 -0
- package/README.zh-TW.md +31 -8
- package/docs/plugins.md +176 -7
- package/package.json +1 -1
- package/src/core/agents/agent-book.ts +34 -1
- package/src/core/agents/agent-claim.ts +25 -5
- package/src/core/agents/agent-dashboard.ts +23 -2
- package/src/core/agents/agent-messages.ts +28 -10
- package/src/core/agents/agent-ports.ts +10 -0
- package/src/core/agents/agent-prompt.ts +17 -1
- package/src/core/agents/agent-rules.ts +11 -1
- package/src/core/agents/agent-schema.ts +22 -0
- package/src/core/agents/agent-statements.ts +16 -2
- package/src/core/agents/agent-store.ts +30 -20
- package/src/core/agents/agent-team-fake-runtime.ts +48 -0
- package/src/core/agents/agent-team-fixture.ts +32 -44
- package/src/core/agents/agent-team.ts +69 -74
- package/src/core/agents/agent-tool-schema.ts +84 -0
- package/src/core/agents/agent-tools.ts +50 -85
- package/src/core/agents/group-turns.ts +132 -41
- package/src/core/agents/helper-create.ts +180 -0
- package/src/core/agents/helper-ops.ts +33 -0
- package/src/core/agents/helper-test-kit.ts +72 -0
- package/src/core/agents/helper-tools.ts +104 -0
- package/src/core/agents/helper-work.ts +101 -0
- package/src/core/agents/message-target.ts +15 -23
- package/src/core/agents/routes.ts +347 -0
- package/src/core/agents/team-approval.ts +32 -0
- package/src/core/agents/team-editing.ts +97 -32
- package/src/core/agents/team-helpers.ts +216 -0
- package/src/core/agents/team-lifecycle.ts +26 -1
- package/src/core/agents/team-options.ts +8 -0
- package/src/core/agents/team-prompt.ts +75 -0
- package/src/core/agents/team-selection.ts +31 -13
- package/src/core/agents/team-status.ts +6 -0
- package/src/core/agents/team-text.ts +20 -4
- package/src/core/agents/team-turn-types.ts +12 -1
- package/src/core/agents/team-turns.ts +25 -31
- package/src/core/builtin/agent-server.ts +37 -2
- package/src/core/builtin/modules.ts +4 -4
- package/src/core/config/adapters.ts +12 -0
- package/src/core/config/agent-team.ts +2 -0
- package/src/core/config/config-schema.ts +4 -0
- package/src/core/config/config.ts +48 -2
- package/src/core/config/helpers.ts +60 -0
- package/src/core/config/ops.ts +8 -0
- package/src/core/config/routing.ts +296 -0
- package/src/core/contract/channels.ts +73 -2
- package/src/core/contract/surface.ts +31 -1
- package/src/core/define-roundtable.ts +8 -1
- package/src/core/discord/agent-discord.ts +67 -6
- package/src/core/discord/discord-surface.ts +24 -24
- package/src/core/discord/message-changes.ts +66 -0
- package/src/core/discord/poster/poster-checks.ts +62 -8
- package/src/core/discord/poster/poster.ts +41 -0
- package/src/core/discord/poster/rest-poster-read.ts +130 -0
- package/src/core/discord/poster/rest-poster.ts +38 -120
- package/src/core/domain/round.ts +31 -0
- package/src/core/host-registration.ts +15 -0
- package/src/core/host.ts +6 -0
- package/src/core/i18n/conversation.ts +3 -0
- package/src/core/i18n/dashboard.ts +4 -0
- package/src/core/ops/error-reporter.ts +4 -0
- package/src/core/ops/ops-alerts.ts +102 -6
- package/src/core/plugin.ts +17 -0
- package/src/core/registry/contributions.ts +1 -26
- package/src/core/registry/surface-service.ts +41 -0
- package/src/core/routing/channel-router.ts +54 -3
- package/src/core/routing/claim-events.ts +42 -0
- package/src/core/routing/message-observers.ts +86 -0
- package/src/core/routing/peer-bots.ts +149 -0
- package/src/core/routing/surface-port.ts +4 -0
- package/src/core/services.ts +7 -6
- package/src/core/testing/fake-poster-guild.ts +1 -1
- package/src/core/testing/fake-poster-reads.ts +96 -0
- package/src/core/testing/fake-poster.ts +62 -94
- package/src/core/tool-set.snapshot.json +8 -0
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -1
- package/src/kit/channels.ts +1 -0
- package/src/kit/index.ts +5 -0
- package/src/kit/ops-alerts.ts +4 -0
- package/src/testing.ts +18 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { ConfigError } from "../domain/errors.ts";
|
|
2
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
3
|
+
import {
|
|
4
|
+
either,
|
|
5
|
+
type Field,
|
|
6
|
+
list,
|
|
7
|
+
oneOf,
|
|
8
|
+
optional,
|
|
9
|
+
record,
|
|
10
|
+
shape,
|
|
11
|
+
text,
|
|
12
|
+
} from "./schema.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where an agent's unprompted work lands: how an event that no person is waiting on picks its
|
|
16
|
+
* conversation. Every key is optional, and a key left out keeps what the host did before `routing`
|
|
17
|
+
* existed. A surface is named by its key prefix, such as `discord` or `app`; `web` stands for the
|
|
18
|
+
* one surface that hosts rooms when exactly one does.
|
|
19
|
+
*/
|
|
20
|
+
export interface AgentRoutes {
|
|
21
|
+
/**
|
|
22
|
+
* The agent's primary place, where its unprompted work lands. `"auto"` (default) is its
|
|
23
|
+
* Discord channel when it has one, else its room on the first surface that hosts rooms; a
|
|
24
|
+
* surface name picks the agent's place there.
|
|
25
|
+
*/
|
|
26
|
+
primary?: "auto" | (string & {});
|
|
27
|
+
/**
|
|
28
|
+
* Where a foreign webhook's or integration's post in the agent's place runs its report turn.
|
|
29
|
+
* `"origin"` (default) is the conversation the post came in; `"primary"` the agent's primary
|
|
30
|
+
* place; a surface name the agent's place there. The raw post stays where it was posted.
|
|
31
|
+
*/
|
|
32
|
+
integration?: "origin" | "primary" | (string & {});
|
|
33
|
+
/**
|
|
34
|
+
* The conversation a `message_agent` call reaches when it names none. `"latest"` (default) is
|
|
35
|
+
* the one the agent last ran a turn in, never carrying a message from a room out to Discord;
|
|
36
|
+
* `"primary"` the agent's primary place; a surface name the agent's place there.
|
|
37
|
+
*/
|
|
38
|
+
agentMessage?: "latest" | "primary" | (string & {});
|
|
39
|
+
/**
|
|
40
|
+
* Where an agent's opening task (`agent_create task`) runs. `"origin-surface"` (default) is the
|
|
41
|
+
* agent's place on the surface the creation came from, else its primary place; `"primary"`
|
|
42
|
+
* always its primary place.
|
|
43
|
+
*/
|
|
44
|
+
opening?: "origin-surface" | "primary";
|
|
45
|
+
/**
|
|
46
|
+
* Reserved: surfaces that get a read-only copy of what the agent posts in its primary place.
|
|
47
|
+
* Not built yet; a non-empty list stops the start.
|
|
48
|
+
*/
|
|
49
|
+
mirror?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A named destination of the host: a conversation, or one agent's primary place. */
|
|
53
|
+
export type PlaceTarget = `${string}:${string}` | { agent: string };
|
|
54
|
+
|
|
55
|
+
/** What a surface offers agents; both keys are reserved and only the default is accepted today. */
|
|
56
|
+
export interface SurfaceRouting {
|
|
57
|
+
/** `"all"` (default): every agent has a place on the surface. `"none"` is reserved and stops the start. */
|
|
58
|
+
agentPlaces?: "all" | "none";
|
|
59
|
+
/** Reserved: which claim answers the surface's conversations. Not built yet; setting it stops the start. */
|
|
60
|
+
answeredBy?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The optional `routing` block: one declared table for where events land. Leaving it out keeps
|
|
65
|
+
* every default as it was before the block existed.
|
|
66
|
+
*/
|
|
67
|
+
export interface RoutingConfig {
|
|
68
|
+
/**
|
|
69
|
+
* Per-agent rules by agent name; `"*"` is the default of every agent. A name applies to the
|
|
70
|
+
* agent when it is created later too. A field the agent's entry leaves out falls to `"*"`.
|
|
71
|
+
*/
|
|
72
|
+
agents?: { "*"?: AgentRoutes } & Record<string, AgentRoutes | undefined>;
|
|
73
|
+
/**
|
|
74
|
+
* Named destinations of the host. `ops` is the process's error reports: a conversation key
|
|
75
|
+
* `<surface>:<id>`, or `{ agent }` for that agent's primary place.
|
|
76
|
+
*/
|
|
77
|
+
places?: Record<string, PlaceTarget>;
|
|
78
|
+
/** Per-surface settings, by surface name. */
|
|
79
|
+
surfaces?: Record<string, SurfaceRouting>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The places the core declares; a name outside this list is a typo until a plugin declares it. */
|
|
83
|
+
const DECLARED_PLACES: readonly string[] = ["ops"];
|
|
84
|
+
|
|
85
|
+
/** The agent-rule keys that name a destination, and the words each accepts besides a surface name. */
|
|
86
|
+
export const ROUTE_WORDS = {
|
|
87
|
+
primary: ["auto"],
|
|
88
|
+
integration: ["origin", "primary"],
|
|
89
|
+
agentMessage: ["latest", "primary"],
|
|
90
|
+
opening: ["origin-surface", "primary"],
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
type RouteKey = keyof typeof ROUTE_WORDS;
|
|
94
|
+
|
|
95
|
+
/** Every word with a meaning of its own; none of them may name a surface. */
|
|
96
|
+
const KEYWORDS: readonly string[] = [
|
|
97
|
+
...new Set(Object.values(ROUTE_WORDS).flat()),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const AGENT_NAME = /^[a-z0-9-]{1,32}$/;
|
|
101
|
+
const CONVERSATION = /^[^:]+:.+$/;
|
|
102
|
+
|
|
103
|
+
/** A destination of `key`: one of its words, or a surface name; another kind's word is refused. */
|
|
104
|
+
function destination(key: RouteKey): Field<string> {
|
|
105
|
+
const words = ROUTE_WORDS[key];
|
|
106
|
+
const describe =
|
|
107
|
+
key === "opening"
|
|
108
|
+
? `one of ${words.join(", ")}`
|
|
109
|
+
: `${words.map((w) => `"${w}"`).join(", ")}, or a surface name`;
|
|
110
|
+
return {
|
|
111
|
+
describe,
|
|
112
|
+
check: (value, path) => {
|
|
113
|
+
const given = text.check(value, path);
|
|
114
|
+
const word = given.trim();
|
|
115
|
+
const own = (words as readonly string[]).includes(word);
|
|
116
|
+
if (!own && (KEYWORDS.includes(word) || key === "opening"))
|
|
117
|
+
throw new ConfigError(
|
|
118
|
+
`config ${path}: ${JSON.stringify(word)} is not a value of ${key}. Use ${describe}.`,
|
|
119
|
+
);
|
|
120
|
+
return word;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const agentRoutes = shape({
|
|
126
|
+
primary: optional(destination("primary")),
|
|
127
|
+
integration: optional(destination("integration")),
|
|
128
|
+
agentMessage: optional(destination("agentMessage")),
|
|
129
|
+
opening: optional(destination("opening")),
|
|
130
|
+
mirror: optional(list(text)),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const placeTarget: Field<PlaceTarget> = either(
|
|
134
|
+
text as Field<`${string}:${string}`>,
|
|
135
|
+
shape({ agent: text }),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const surfaceRouting = shape({
|
|
139
|
+
agentPlaces: optional(oneOf("all" as const, "none" as const)),
|
|
140
|
+
answeredBy: optional(text),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** The `routing` key's shape. */
|
|
144
|
+
export const routingShape = optional(
|
|
145
|
+
shape({
|
|
146
|
+
agents: optional(record(agentRoutes)),
|
|
147
|
+
places: optional(record(placeTarget)),
|
|
148
|
+
surfaces: optional(record(surfaceRouting)),
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/** The rules of one agent after checking: the reserved keys are gone. */
|
|
153
|
+
export interface ResolvedAgentRoutes {
|
|
154
|
+
primary?: string;
|
|
155
|
+
integration?: string;
|
|
156
|
+
agentMessage?: string;
|
|
157
|
+
opening?: "origin-surface" | "primary";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The checked `routing` block the agent server reads; a host without the key has none. */
|
|
161
|
+
export interface ResolvedRouting {
|
|
162
|
+
agents: Readonly<Record<string, ResolvedAgentRoutes>>;
|
|
163
|
+
/** Named destinations: a conversation key, or an agent whose primary place it is. */
|
|
164
|
+
places: Readonly<Record<string, ChannelKey | { agent: string }>>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What the rest of the configuration says that the block is checked against. */
|
|
168
|
+
export interface RoutingContext {
|
|
169
|
+
/** Whether Discord is configured, so agents have Discord channels. */
|
|
170
|
+
withDiscord: boolean;
|
|
171
|
+
/** Whether the agent server runs: only then do agents and their places exist. */
|
|
172
|
+
agentServer: boolean;
|
|
173
|
+
/** Whether the top-level `ops` is set, which the `ops` place replaces. */
|
|
174
|
+
topLevelOps: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const refuse = (path: string, problem: string, fix: string): ConfigError =>
|
|
178
|
+
new ConfigError(`config ${path}: ${problem}. ${fix}`);
|
|
179
|
+
|
|
180
|
+
function checkReserved(config: RoutingConfig): void {
|
|
181
|
+
for (const [name, agent] of Object.entries(config.agents ?? {}))
|
|
182
|
+
if ((agent?.mirror?.length ?? 0) > 0)
|
|
183
|
+
throw refuse(
|
|
184
|
+
`routing.agents.${name}.mirror`,
|
|
185
|
+
"mirroring a place is not built yet, so it would do nothing",
|
|
186
|
+
"Remove mirror; the term and the key are reserved until a host needs the copies",
|
|
187
|
+
);
|
|
188
|
+
for (const [surface, rules] of Object.entries(config.surfaces ?? {})) {
|
|
189
|
+
if (rules.agentPlaces === "none")
|
|
190
|
+
throw refuse(
|
|
191
|
+
`routing.surfaces.${surface}.agentPlaces`,
|
|
192
|
+
'"none" is not built yet: Discord makes a channel for every agent, and a surface that hosts rooms hosts one for every agent',
|
|
193
|
+
'Remove agentPlaces or write "all", which is what every surface does',
|
|
194
|
+
);
|
|
195
|
+
if (rules.answeredBy !== undefined)
|
|
196
|
+
throw refuse(
|
|
197
|
+
`routing.surfaces.${surface}.answeredBy`,
|
|
198
|
+
"naming the claim that answers a surface is not built yet; claim priorities decide",
|
|
199
|
+
"Remove answeredBy; give your plugin's claim a priority above the agent server's to answer there",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Checks the block against the rest of the configuration, naming the key and the fix. Surfaces
|
|
206
|
+
* are checked again once the plugins have registered theirs, when the agent server links.
|
|
207
|
+
*/
|
|
208
|
+
export function routingOf(
|
|
209
|
+
config: RoutingConfig | undefined,
|
|
210
|
+
context: RoutingContext,
|
|
211
|
+
): ResolvedRouting | undefined {
|
|
212
|
+
if (!config) return undefined;
|
|
213
|
+
const agents = config.agents ?? {};
|
|
214
|
+
const places = config.places ?? {};
|
|
215
|
+
const surfaces = config.surfaces ?? {};
|
|
216
|
+
checkReserved(config);
|
|
217
|
+
for (const name of Object.keys(agents))
|
|
218
|
+
if (name !== "*" && !AGENT_NAME.test(name))
|
|
219
|
+
throw refuse(
|
|
220
|
+
`routing.agents.${name}`,
|
|
221
|
+
'not an agent name: lowercase letters, digits and dashes, 1 to 32, or "*" for every agent',
|
|
222
|
+
"Rename the key to the agent's name",
|
|
223
|
+
);
|
|
224
|
+
for (const name of Object.keys(places))
|
|
225
|
+
if (!DECLARED_PLACES.includes(name))
|
|
226
|
+
throw refuse(
|
|
227
|
+
`routing.places.${name}`,
|
|
228
|
+
`no place is called ${JSON.stringify(name)}`,
|
|
229
|
+
`The places are ${DECLARED_PLACES.join(", ")}`,
|
|
230
|
+
);
|
|
231
|
+
for (const [name, target] of Object.entries(places))
|
|
232
|
+
if (typeof target === "string" && !CONVERSATION.test(target))
|
|
233
|
+
throw refuse(
|
|
234
|
+
`routing.places.${name}`,
|
|
235
|
+
`expected a conversation key <surface>:<id> or { agent }, got ${JSON.stringify(target)}`,
|
|
236
|
+
"Write the surface's prefix, a colon, and the conversation's id, or name an agent",
|
|
237
|
+
);
|
|
238
|
+
if (places.ops !== undefined && context.topLevelOps)
|
|
239
|
+
throw refuse(
|
|
240
|
+
"routing.places.ops",
|
|
241
|
+
"the top-level ops is set as well, and both say where the error reports go",
|
|
242
|
+
"Keep one: move ops into routing.places.ops and remove the top-level ops",
|
|
243
|
+
);
|
|
244
|
+
const needsAgents =
|
|
245
|
+
Object.keys(agents).length > 0 ||
|
|
246
|
+
Object.keys(surfaces).length > 0 ||
|
|
247
|
+
(typeof places.ops === "object" && places.ops !== undefined);
|
|
248
|
+
if (needsAgents && !context.agentServer)
|
|
249
|
+
throw refuse(
|
|
250
|
+
"routing",
|
|
251
|
+
"agents, per-surface settings and an agent place need the agent server, and this host runs none (agentServer is false, or there is neither discord nor agents)",
|
|
252
|
+
"Remove those keys from routing, or remove agentServer: false and configure discord or agents",
|
|
253
|
+
);
|
|
254
|
+
if (!context.withDiscord) {
|
|
255
|
+
const named = Object.entries(agents).flatMap(([name, rules]) =>
|
|
256
|
+
(["primary", "integration", "agentMessage"] as const)
|
|
257
|
+
.filter((key) => rules?.[key] === "discord")
|
|
258
|
+
.map((key) => `routing.agents.${name}.${key}`),
|
|
259
|
+
);
|
|
260
|
+
if (named[0] || surfaces.discord)
|
|
261
|
+
throw refuse(
|
|
262
|
+
named[0] ?? "routing.surfaces.discord",
|
|
263
|
+
"names the discord surface, and Discord is not configured",
|
|
264
|
+
"Name a surface that hosts rooms instead, or configure discord",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
agents: Object.fromEntries(
|
|
269
|
+
Object.entries(agents).flatMap(([name, rules]) =>
|
|
270
|
+
rules === undefined
|
|
271
|
+
? []
|
|
272
|
+
: [
|
|
273
|
+
[
|
|
274
|
+
name,
|
|
275
|
+
{
|
|
276
|
+
...(rules.primary ? { primary: rules.primary } : {}),
|
|
277
|
+
...(rules.integration
|
|
278
|
+
? { integration: rules.integration }
|
|
279
|
+
: {}),
|
|
280
|
+
...(rules.agentMessage
|
|
281
|
+
? { agentMessage: rules.agentMessage }
|
|
282
|
+
: {}),
|
|
283
|
+
...(rules.opening ? { opening: rules.opening } : {}),
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
],
|
|
287
|
+
),
|
|
288
|
+
),
|
|
289
|
+
places: Object.fromEntries(
|
|
290
|
+
Object.entries(places).map(([name, target]) => [
|
|
291
|
+
name,
|
|
292
|
+
typeof target === "string" ? (target as ChannelKey) : target,
|
|
293
|
+
]),
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -47,8 +47,12 @@ export interface InboundMessage {
|
|
|
47
47
|
/**
|
|
48
48
|
* Who the author is, with their principal and tier, as the router resolved them before any
|
|
49
49
|
* claim admits the message: undefined when the access rules serve no one by them, and for a bot
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* the host does not list as a peer (`peerBot`) and for an integration, which are not resolved.
|
|
51
|
+
* A listed peer bot is resolved too, so a claim that checks only `speaker` answers it; use
|
|
52
|
+
* `answersAuthor` to tell a person from a peer. A peer is served by the access rules through its
|
|
53
|
+
* identity and `everyone`, never through its server roles. Only the router sets it; a surface's
|
|
54
|
+
* own value is dropped. A claim that serves only people the host serves answers only when it is
|
|
55
|
+
* set.
|
|
52
56
|
*/
|
|
53
57
|
speaker?: Speaker;
|
|
54
58
|
/** The id the surface knows the author by; who they are to the host is `speaker`. */
|
|
@@ -56,6 +60,14 @@ export interface InboundMessage {
|
|
|
56
60
|
/** How the author appears in the channel, for example a server nickname. */
|
|
57
61
|
authorName: string;
|
|
58
62
|
authorIsBot: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Set by the router when a bot wrote the message and the host lists that bot as a peer
|
|
65
|
+
* (`discord.peerBots`) for this channel, with its rounds not used up. Such a message is
|
|
66
|
+
* resolved to a `speaker` like a person's, and a claim that answers people may answer it too;
|
|
67
|
+
* a claim that answers a peer should still ask for a mention or a reply to the bot. Only the
|
|
68
|
+
* router sets it; a surface's own value is dropped.
|
|
69
|
+
*/
|
|
70
|
+
peerBot?: true;
|
|
59
71
|
/** The roles the author holds in the message's space; absent in direct messages. */
|
|
60
72
|
authorRoleIds?: readonly string[];
|
|
61
73
|
/**
|
|
@@ -231,6 +243,25 @@ export interface ChannelClaim {
|
|
|
231
243
|
backgroundRefusal?(
|
|
232
244
|
turn: BackgroundTurn & { speaker: Speaker },
|
|
233
245
|
): string | undefined;
|
|
246
|
+
/**
|
|
247
|
+
* A message in an owned channel was deleted, as its surface reports it (Discord does; a
|
|
248
|
+
* surface that cannot tell never does). It carries only the id, and also arrives for the
|
|
249
|
+
* assistant's own messages, since the surface cannot tell whose an uncached message was: a
|
|
250
|
+
* claim ignores an id it does not know. Delivered as it comes, not inside the channel's queue,
|
|
251
|
+
* so it is not held up behind a running turn; a throw or rejection is logged and isolated.
|
|
252
|
+
*/
|
|
253
|
+
onMessageDeleted?(
|
|
254
|
+
channel: ChannelKey,
|
|
255
|
+
messageId: string,
|
|
256
|
+
): Promise<void> | void;
|
|
257
|
+
/**
|
|
258
|
+
* A message in an owned channel was edited, as it reads now. The assistant's own edits are
|
|
259
|
+
* never reported, as its own messages never reach `admit`; an edit that changes neither the
|
|
260
|
+
* text, the embeds nor the attachments, such as a pin, is not either. The message carries no
|
|
261
|
+
* `speaker` and no `peerBot`, since an edit is never admitted and resolves nobody, and the
|
|
262
|
+
* router applies no peer rule to it. Delivered like `onMessageDeleted`.
|
|
263
|
+
*/
|
|
264
|
+
onMessageEdited?(message: InboundMessage): Promise<void> | void;
|
|
234
265
|
/** Starts the channel's conversation over, saying whose it was. */
|
|
235
266
|
startFresh(channel: ChannelKey): Promise<string>;
|
|
236
267
|
/**
|
|
@@ -285,8 +316,48 @@ export interface ConversationPort {
|
|
|
285
316
|
takesBackground(channel: ChannelKey): boolean;
|
|
286
317
|
/** Whether the claim that owns the channel takes the host's own turns; false when none owns it. */
|
|
287
318
|
takesSystemReports(channel: ChannelKey): boolean;
|
|
319
|
+
/**
|
|
320
|
+
* Tells the claim that owns the channel a message was deleted (`ChannelClaim.onMessageDeleted`);
|
|
321
|
+
* never rejects. Optional so a port written before 0.27 still type-checks; the host's has it.
|
|
322
|
+
*/
|
|
323
|
+
messageDeleted?(
|
|
324
|
+
channel: ChannelKey,
|
|
325
|
+
messageId: string,
|
|
326
|
+
space?: string,
|
|
327
|
+
): Promise<void>;
|
|
328
|
+
/** Tells the claim that owns the channel a message was edited (`ChannelClaim.onMessageEdited`); never rejects. */
|
|
329
|
+
messageEdited?(message: InboundMessage): Promise<void>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Which inbound messages an observer hears. Every field narrows; an empty filter hears every
|
|
334
|
+
* message in a channel some claim owns, in a server or workspace, never in a direct conversation
|
|
335
|
+
* and never in the rooms of a surface that hosts agent rooms (`agentRooms`), which are one
|
|
336
|
+
* person's or one team's. A filter that names that surface in `surface` hears them.
|
|
337
|
+
*/
|
|
338
|
+
export interface ObserveFilter {
|
|
339
|
+
/**
|
|
340
|
+
* Only this surface's channels, by its key prefix, such as "discord". Naming a surface that
|
|
341
|
+
* hosts agent rooms is how an observer opts in to hearing those rooms; without it they are
|
|
342
|
+
* left out.
|
|
343
|
+
*/
|
|
344
|
+
surface?: string;
|
|
345
|
+
/** Only this server or workspace, in the surface's own id. */
|
|
346
|
+
space?: string;
|
|
347
|
+
/** Only these channels. */
|
|
348
|
+
channels?: readonly ChannelKey[];
|
|
349
|
+
/**
|
|
350
|
+
* Also hear direct conversations. Off by default: a direct message is private to its two
|
|
351
|
+
* sides, and an observer that sees it must be one the people there would expect.
|
|
352
|
+
*/
|
|
353
|
+
includeDirect?: boolean;
|
|
288
354
|
}
|
|
289
355
|
|
|
356
|
+
/** Hears a message; a return is not read, and a throw or rejection is logged and isolated. */
|
|
357
|
+
export type MessageObserver = (
|
|
358
|
+
message: Readonly<InboundMessage>,
|
|
359
|
+
) => Promise<void> | void;
|
|
360
|
+
|
|
290
361
|
/** The host's channel queue, as plugins use it. */
|
|
291
362
|
export interface QueuePort {
|
|
292
363
|
run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OutboundReply, PostAuthor } from "../domain/conversation.ts";
|
|
2
2
|
import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
|
|
3
3
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
4
|
+
import type { RoundProgress } from "../domain/round.ts";
|
|
4
5
|
import { PluginError } from "../errors.ts";
|
|
5
6
|
import type { PromptScope, Prompts } from "../interactions/prompts.ts";
|
|
6
7
|
import type { ChannelKey } from "../sessions.ts";
|
|
@@ -71,6 +72,18 @@ export interface SurfaceThreads {
|
|
|
71
72
|
close(thread: string): Promise<void>;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* What a surface tells the host about messages already delivered, so the claim that owns the
|
|
77
|
+
* channel hears of it (`ChannelClaim.onMessageDeleted`, `onMessageEdited`). A surface that cannot
|
|
78
|
+
* tell, such as a web chat that keeps every message, never calls them.
|
|
79
|
+
*/
|
|
80
|
+
export interface SurfaceMessageEvents {
|
|
81
|
+
/** A message was deleted; `space` is its server or workspace, when the surface has one. */
|
|
82
|
+
deleted(channel: ChannelKey, messageId: string, space?: string): void;
|
|
83
|
+
/** A message's text or attachments changed; the message is as it reads now. */
|
|
84
|
+
edited(message: InboundMessage): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
/**
|
|
75
88
|
* One chat network the host talks through. The host ships Discord's; a plugin may contribute
|
|
76
89
|
* another with `surfaces`. A surface serves the channels whose key starts with its `surface`
|
|
@@ -122,7 +135,10 @@ export interface ChatSurface {
|
|
|
122
135
|
* Connects and delivers every incoming message; the host passes its conversation router. A
|
|
123
136
|
* message whose channel key has another prefix is logged and dropped.
|
|
124
137
|
*/
|
|
125
|
-
start(
|
|
138
|
+
start(
|
|
139
|
+
deliver: (message: InboundMessage) => void,
|
|
140
|
+
events?: SurfaceMessageEvents,
|
|
141
|
+
): Promise<void>;
|
|
126
142
|
/** Runs when the host stops, after the services started later have stopped. */
|
|
127
143
|
stop?(): Promise<void>;
|
|
128
144
|
sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void>;
|
|
@@ -186,6 +202,13 @@ export interface ChatSurface {
|
|
|
186
202
|
* turn goes on. Absent = the surface shows only the final reply.
|
|
187
203
|
*/
|
|
188
204
|
progress?(channel: ChannelKey, event: TurnProgress): Promise<void> | void;
|
|
205
|
+
/**
|
|
206
|
+
* Shows where a group's round stands: who speaks now, who is next, who has spoken. Called when
|
|
207
|
+
* the round is planned, before and after each member's turn, on a handoff, and when it ends;
|
|
208
|
+
* each call carries the whole state. A rejection is logged and the round goes on. Absent = the
|
|
209
|
+
* surface shows no rounds, as Discord does not.
|
|
210
|
+
*/
|
|
211
|
+
groupRound?(channel: ChannelKey, round: RoundProgress): Promise<void> | void;
|
|
189
212
|
}
|
|
190
213
|
|
|
191
214
|
/**
|
|
@@ -214,6 +237,11 @@ export interface SurfacePort {
|
|
|
214
237
|
): Prompts | undefined;
|
|
215
238
|
/** The surfaces that serve agent rooms (`agentRooms: true`); read after link. */
|
|
216
239
|
roomSurfaces(): readonly string[];
|
|
240
|
+
/**
|
|
241
|
+
* Every registered surface, by name; read after link. The host's port always has it; optional
|
|
242
|
+
* so a port written before 0.27 still type-checks.
|
|
243
|
+
*/
|
|
244
|
+
names?(): readonly string[];
|
|
217
245
|
/** The channel's interim posts, under `as` when given; undefined when its surface has none. */
|
|
218
246
|
interim(channel: ChannelKey, as?: PostAuthor): InterimPosts | undefined;
|
|
219
247
|
/**
|
|
@@ -222,4 +250,6 @@ export interface SurfacePort {
|
|
|
222
250
|
* type-checks, and a turn run over one without it shows no progress.
|
|
223
251
|
*/
|
|
224
252
|
progress?(channel: ChannelKey, event: TurnProgress): Promise<void>;
|
|
253
|
+
/** Shows where a group's round stands; a no-op when no surface serves the channel or its surface shows none. */
|
|
254
|
+
groupRound?(channel: ChannelKey, round: RoundProgress): Promise<void>;
|
|
225
255
|
}
|
|
@@ -245,6 +245,8 @@ function agentAssembly(
|
|
|
245
245
|
...(config.characters.refreshAvatars ? { refreshAvatars: true } : {}),
|
|
246
246
|
avatarReference: join(ASSETS, "neutral.png"),
|
|
247
247
|
avatarNeutral: join(ASSETS, "neutral.png"),
|
|
248
|
+
...(config.routing ? { routing: config.routing } : {}),
|
|
249
|
+
helpers: config.helpers,
|
|
248
250
|
// An ops agent's reports are the agent server's to deliver.
|
|
249
251
|
...(errorReporter && "agent" in errorReporter.destination
|
|
250
252
|
? { errorReporter }
|
|
@@ -280,7 +282,11 @@ export async function defineRoundtable(
|
|
|
280
282
|
}));
|
|
281
283
|
const registry = new ModelRegistry(modelRuntime);
|
|
282
284
|
const errorReporter = config.ops
|
|
283
|
-
? new ErrorReporter({
|
|
285
|
+
? new ErrorReporter({
|
|
286
|
+
destination: config.ops,
|
|
287
|
+
app: name,
|
|
288
|
+
configKey: config.opsKey === "ops" ? "ops.conversation" : config.opsKey,
|
|
289
|
+
})
|
|
284
290
|
: undefined;
|
|
285
291
|
const { errorSink } = overrides;
|
|
286
292
|
const logger: Logger =
|
|
@@ -334,6 +340,7 @@ export async function defineRoundtable(
|
|
|
334
340
|
judgeModel: judgeThrough(modelRuntime, config.judge.model),
|
|
335
341
|
apiKey: (provider) => registry.getApiKeyForProvider(provider),
|
|
336
342
|
drain: { limitMs: config.drainMs },
|
|
343
|
+
...(discord?.peerBots ? { peerBots: discord.peerBots } : {}),
|
|
337
344
|
...(overrides.aborted ? { aborted: overrides.aborted } : {}),
|
|
338
345
|
},
|
|
339
346
|
plugins: [
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
SeparatorSpacingSize,
|
|
14
14
|
type TextChannel,
|
|
15
15
|
TextDisplayBuilder,
|
|
16
|
+
ThreadAutoArchiveDuration,
|
|
16
17
|
type Webhook,
|
|
17
18
|
} from "discord.js";
|
|
18
19
|
import type {
|
|
@@ -39,19 +40,41 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
39
40
|
readonly #client: Client;
|
|
40
41
|
readonly #guildId: string;
|
|
41
42
|
readonly #webhooks = new Map<string, Promise<Webhook>>();
|
|
43
|
+
/** The parent channel of each thread the agents live in, and null for a channel that is none. */
|
|
44
|
+
readonly #parents = new Map<string, string | null>();
|
|
42
45
|
|
|
43
46
|
constructor(client: Client, guildId: string) {
|
|
44
47
|
this.#client = client;
|
|
45
48
|
this.#guildId = guildId;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Where a post to `channelId` is sent: the channel whose webhook speaks, and the thread it
|
|
53
|
+
* speaks into. A temp knight's place is a thread, which has no webhook of its own.
|
|
54
|
+
*/
|
|
55
|
+
async #route(
|
|
56
|
+
channelId: string,
|
|
57
|
+
threadId: string | undefined,
|
|
58
|
+
): Promise<{ hook: string; threadId?: string }> {
|
|
59
|
+
let parent = this.#parents.get(channelId);
|
|
60
|
+
if (parent === undefined) {
|
|
61
|
+
const channel = await this.#client.channels.fetch(channelId);
|
|
62
|
+
parent = channel?.isThread() ? (channel.parentId ?? null) : null;
|
|
63
|
+
this.#parents.set(channelId, parent);
|
|
64
|
+
}
|
|
65
|
+
if (parent === null)
|
|
66
|
+
return { hook: channelId, ...(threadId ? { threadId } : {}) };
|
|
67
|
+
return { hook: parent, threadId: channelId };
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
async post(channelId: string, post: AgentPost): Promise<void> {
|
|
49
|
-
const
|
|
71
|
+
const route = await this.#route(channelId, post.threadId);
|
|
72
|
+
const webhook = await this.#webhook(route.hook);
|
|
50
73
|
const as = {
|
|
51
74
|
username: post.name,
|
|
52
75
|
avatarURL: post.avatarUrl,
|
|
53
76
|
allowedMentions: { parse: ["users" as const] },
|
|
54
|
-
...(
|
|
77
|
+
...(route.threadId ? { threadId: route.threadId } : {}),
|
|
55
78
|
};
|
|
56
79
|
try {
|
|
57
80
|
if (post.thinking) await webhook.send({ ...as, content: post.thinking });
|
|
@@ -66,7 +89,7 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
66
89
|
});
|
|
67
90
|
} catch (error) {
|
|
68
91
|
// A webhook deleted by hand is found or made again on the next post.
|
|
69
|
-
this.#webhooks.delete(
|
|
92
|
+
this.#webhooks.delete(route.hook);
|
|
70
93
|
throw error;
|
|
71
94
|
}
|
|
72
95
|
}
|
|
@@ -75,10 +98,11 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
75
98
|
channelId: string,
|
|
76
99
|
as: Omit<AgentPost, "thinking" | "chunks" | "files">,
|
|
77
100
|
): InterimPosts {
|
|
78
|
-
const threadId = as.threadId ? { threadId: as.threadId } : {};
|
|
79
101
|
return {
|
|
80
102
|
post: async (text) => {
|
|
81
|
-
const
|
|
103
|
+
const route = await this.#route(channelId, as.threadId);
|
|
104
|
+
const threadId = route.threadId ? { threadId: route.threadId } : {};
|
|
105
|
+
const webhook = await this.#webhook(route.hook);
|
|
82
106
|
try {
|
|
83
107
|
const message = await webhook.send({
|
|
84
108
|
username: as.name,
|
|
@@ -96,13 +120,43 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
96
120
|
},
|
|
97
121
|
};
|
|
98
122
|
} catch (error) {
|
|
99
|
-
this.#webhooks.delete(
|
|
123
|
+
this.#webhooks.delete(route.hook);
|
|
100
124
|
throw error;
|
|
101
125
|
}
|
|
102
126
|
},
|
|
103
127
|
};
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
async createThread(parentChannelId: string, name: string): Promise<string> {
|
|
131
|
+
const parent = await this.#text(parentChannelId);
|
|
132
|
+
const thread = await parent.threads.create({
|
|
133
|
+
name: name.slice(0, 100),
|
|
134
|
+
// The longest a thread stays open untouched, so a quiet helper's place is not archived under it.
|
|
135
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
|
|
136
|
+
});
|
|
137
|
+
this.#parents.set(thread.id, parentChannelId);
|
|
138
|
+
return thread.id;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async closeThread(threadId: string): Promise<void> {
|
|
142
|
+
const thread = await this.#thread(threadId);
|
|
143
|
+
// Locking needs Manage Threads; without it the thread is only archived.
|
|
144
|
+
try {
|
|
145
|
+
await thread.edit({ archived: true, locked: true });
|
|
146
|
+
} catch {
|
|
147
|
+
await thread.setArchived(true);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async openThread(threadId: string): Promise<void> {
|
|
152
|
+
const thread = await this.#thread(threadId);
|
|
153
|
+
try {
|
|
154
|
+
await thread.edit({ archived: false, locked: false });
|
|
155
|
+
} catch {
|
|
156
|
+
await thread.setArchived(false);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
106
160
|
async createChannel(
|
|
107
161
|
name: string,
|
|
108
162
|
topic: string,
|
|
@@ -239,6 +293,13 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
239
293
|
);
|
|
240
294
|
}
|
|
241
295
|
|
|
296
|
+
async #thread(threadId: string) {
|
|
297
|
+
const channel = await this.#client.channels.fetch(threadId);
|
|
298
|
+
if (!channel?.isThread())
|
|
299
|
+
throw new AgentError(`channel ${threadId} is not a thread`);
|
|
300
|
+
return channel;
|
|
301
|
+
}
|
|
302
|
+
|
|
242
303
|
async #text(channelId: string): Promise<TextChannel> {
|
|
243
304
|
const channel = await this.#client.channels.fetch(channelId);
|
|
244
305
|
if (channel?.type !== ChannelType.GuildText)
|