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
@@ -17,7 +17,9 @@ import { characterOf, overBase } from "./agent-rules.ts";
17
17
  import { modelSetting, thinkingSetting } from "./agent-settings.ts";
18
18
  import type { Agent } from "./agent-store.ts";
19
19
  import type { AvatarMode } from "./agent-tools.ts";
20
- import { agentRoomKey, discordKey, keysOf, seatsOf } from "./team-keys.ts";
20
+ import { createHelper } from "./helper-create.ts";
21
+ import { routesOf } from "./routes.ts";
22
+ import { agentRoomKey, keysOf, seatsOf } from "./team-keys.ts";
21
23
  import { groupTopic, teamCategory } from "./team-layout.ts";
22
24
  import type { TeamContext } from "./team-options.ts";
23
25
 
@@ -121,6 +123,39 @@ export class TeamEditing {
121
123
  return made.text;
122
124
  }
123
125
 
126
+ #helperChain: Promise<void> = Promise.resolve();
127
+
128
+ /** Closes the thread of a temp knight whose row was never written, logging a failure. */
129
+ async #closeOrphan(threadId?: string): Promise<void> {
130
+ const { channels, logger } = this.#ctx.options;
131
+ await channels
132
+ ?.closeThread?.(threadId ?? "")
133
+ .catch((err: unknown) =>
134
+ logger.warn({ err }, "orphan thread not closed"),
135
+ );
136
+ }
137
+
138
+ /** The agent tool: a knight makes a temp knight for one task; see `createHelper`. */
139
+ createHelper(
140
+ caller: AgentTurnScope,
141
+ input: Parameters<typeof createHelper>[3],
142
+ ): Promise<string> {
143
+ // One at a time: parallel calls cannot overshoot a cap or pick one name.
144
+ const run = this.#helperChain.then(() =>
145
+ createHelper(
146
+ this.#ctx,
147
+ (who, fields, settings) => this.#create(who, fields, settings),
148
+ caller,
149
+ input,
150
+ ),
151
+ );
152
+ this.#helperChain = run.then(
153
+ () => undefined,
154
+ () => undefined,
155
+ );
156
+ return run;
157
+ }
158
+
124
159
  /**
125
160
  * An owner creates an agent from outside any agent turn (the web app's form; any other tier is
126
161
  * refused, since the opening task runs as `speaker`): the same checks,
@@ -185,6 +220,10 @@ export class TeamEditing {
185
220
  creator: Agent | undefined;
186
221
  /** The surface whose room runs the opening task. */
187
222
  openIn?: string | undefined;
223
+ /** Makes the agent a temp knight of this knight: its place is a thread of the parent's channel, and it settles on the parent's model. */
224
+ parent?: Agent | undefined;
225
+ /** The conversation a temp knight is made from. */
226
+ origin?: string | undefined;
188
227
  },
189
228
  input: {
190
229
  name: string;
@@ -216,14 +255,21 @@ export class TeamEditing {
216
255
  ...fields
217
256
  } = input;
218
257
  const modes = longRunning ? this.#longRunning(who.speaker) : undefined;
219
- const parent = teamCategory(category, "Agents");
258
+ const categoryName = teamCategory(category, "Agents");
220
259
  store.checkNewName(input.name);
221
260
  skills?.checkRegistered(carried);
222
261
  // A model or thinking level the host cannot run fails the create, before anything is made.
223
- const model = await modelSetting(settings.model, () =>
224
- this.#ctx.options.models.usable(),
225
- );
226
- const thinking = thinkingSetting(settings.thinking);
262
+ // A temp knight runs the parent's, as it stands, unless told otherwise.
263
+ const model =
264
+ settings.model === undefined && who.parent
265
+ ? who.parent.model
266
+ : await modelSetting(settings.model, () =>
267
+ this.#ctx.options.models.usable(),
268
+ );
269
+ const thinking =
270
+ settings.thinking === undefined && who.parent
271
+ ? who.parent.thinking
272
+ : thinkingSetting(settings.thinking);
227
273
  // The picture comes before the row and the channel: a render failure fails the create.
228
274
  const taken = store
229
275
  .agents()
@@ -233,25 +279,43 @@ export class TeamEditing {
233
279
  const suggested = suggestCharacter(input, taken);
234
280
  const character = this.#resolve(wanted ?? {}, suggested);
235
281
  const avatarHash = await studio.render(character);
236
- // Without Discord channels the agent lives in its rooms, which exist as soon as it does.
237
- const channelId = await channels?.createChannel(
238
- input.name,
239
- input.displayName,
240
- parent,
241
- );
242
- let agent = await store.createAgent({
243
- ...fields,
244
- character,
245
- avatarHash,
246
- ...(channelId ? { channelId } : {}),
247
- });
248
- if (model || thinking)
249
- agent = await store.updateAgent(agent.name, {
250
- ...(model ? { model } : {}),
251
- ...(thinking ? { thinking } : {}),
282
+ // Without Discord channels the agent lives in its rooms, which exist as soon as it does. A
283
+ // temp knight lives in a thread of its parent's channel, or in rooms alone without one.
284
+ const channelId = who.parent
285
+ ? who.parent.channelId && channels?.createThread
286
+ ? await channels.createThread(who.parent.channelId, input.displayName)
287
+ : undefined
288
+ : await channels?.createChannel(
289
+ input.name,
290
+ input.displayName,
291
+ categoryName,
292
+ );
293
+ let agent: Agent;
294
+ try {
295
+ agent = await store.createAgent({
296
+ ...fields,
297
+ character,
298
+ avatarHash,
299
+ ...(channelId ? { channelId } : {}),
300
+ ...(who.parent ? { parent: who.parent.name } : {}),
301
+ ...(who.parent && who.origin ? { origin: who.origin } : {}),
252
302
  });
253
- if (skills && carried.length > 0)
254
- await skills.attach(agent.name, carried, []);
303
+ } catch (error) {
304
+ await this.#closeOrphan(channelId);
305
+ throw error;
306
+ }
307
+ try {
308
+ if (model || thinking)
309
+ agent = await store.updateAgent(agent.name, {
310
+ ...(model ? { model } : {}),
311
+ ...(thinking ? { thinking } : {}),
312
+ });
313
+ if (skills && carried.length > 0)
314
+ await skills.attach(agent.name, carried, []);
315
+ } catch (error) {
316
+ if (who.parent) await this.#ctx.archiveHelpers([agent.name]);
317
+ throw error;
318
+ }
255
319
  logger.info(
256
320
  { agent: agent.name, by: who.by, class: character.class },
257
321
  "agent created",
@@ -269,14 +333,15 @@ export class TeamEditing {
269
333
  agent,
270
334
  text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}.`,
271
335
  };
272
- // The opening task runs in the room of the surface that asked, else where the agent is first
273
- // reached: its channel, else its first room.
274
- const home =
275
- who.openIn !== undefined && rooms.includes(who.openIn)
276
- ? agentRoomKey(who.openIn, agent.name)
277
- : channelId
278
- ? discordKey(channelId)
279
- : keysOf(store, agent.name, rooms)[0];
336
+ // The opening task runs where the routing table puts it: by default the agent's place on the
337
+ // surface that asked, else its primary place.
338
+ const home = routesOf(this.#ctx.options).of(
339
+ agent.name,
340
+ "opening",
341
+ who.openIn === undefined
342
+ ? undefined
343
+ : agentRoomKey(who.openIn, agent.name),
344
+ );
280
345
  if (!home) {
281
346
  logger.warn(
282
347
  { agent: agent.name },
@@ -0,0 +1,216 @@
1
+ import { helpersOf } from "../config/helpers.ts";
2
+ import { AgentError } from "../domain/errors.ts";
3
+ import type { AgentTurnScope } from "../domain/ports.ts";
4
+ import { isHelper } from "./agent-rules.ts";
5
+ import type { Agent } from "./agent-store.ts";
6
+ import type { HelpersEnded } from "./helper-ops.ts";
7
+ import type { TeamContext } from "./team-options.ts";
8
+
9
+ /** How often the host looks for finished temp knights past their keep. */
10
+ const SWEEP_EVERY_MS = 3_600_000;
11
+
12
+ /**
13
+ * The team's temp knights after they are made: which a knight has, which are finished, and their
14
+ * archive, restore, and sweep. Archiving one closes its Discord thread, since a temp knight's
15
+ * place is a thread of its parent's channel; an archived one keeps its history.
16
+ */
17
+ export class TeamHelpers {
18
+ readonly #ctx: TeamContext;
19
+ /** Archives one agent as the team archives any: schedules, groups, conversations. */
20
+ readonly #archiveAgent: (agent: Agent) => Promise<void>;
21
+ readonly #ended: ((event: HelpersEnded) => void)[] = [];
22
+ #sweeper: ReturnType<typeof setInterval> | undefined;
23
+
24
+ constructor(ctx: TeamContext, archiveAgent: (agent: Agent) => Promise<void>) {
25
+ this.#ctx = ctx;
26
+ this.#archiveAgent = archiveAgent;
27
+ }
28
+
29
+ onEnded(listener: (event: HelpersEnded) => void): void {
30
+ this.#ended.push(listener);
31
+ }
32
+
33
+ /** The active temp knights a knight made. */
34
+ of(parent: string): Agent[] {
35
+ return this.#ctx.options.store.helpersOf(parent, "active");
36
+ }
37
+
38
+ /** The active temp knights that are finished, of the named parents or of every knight. */
39
+ finished(parents?: readonly string[]): Agent[] {
40
+ return this.#ctx.options.store
41
+ .agents()
42
+ .filter(
43
+ (agent) =>
44
+ isHelper(agent) &&
45
+ agent.status === "active" &&
46
+ agent.finishedAt !== undefined &&
47
+ (parents === undefined || parents.includes(agent.parent as string)),
48
+ );
49
+ }
50
+
51
+ /**
52
+ * The agent tool: a knight archives its own temp knights, the named ones or every finished one.
53
+ * Whoever shows the parent's room is told, to offer the undo.
54
+ */
55
+ async end(
56
+ caller: AgentTurnScope,
57
+ names: readonly string[] | "finished",
58
+ ): Promise<string> {
59
+ const mine = this.of(caller.name);
60
+ const wanted =
61
+ names === "finished"
62
+ ? mine.filter((agent) => agent.finishedAt !== undefined)
63
+ : names.map((name) => {
64
+ const own = mine.find((agent) => agent.name === name);
65
+ if (!own)
66
+ throw new AgentError(
67
+ `"${name}" is not one of your temp knights; yours: ${mine.map((agent) => agent.name).join(", ") || "none"}.`,
68
+ );
69
+ return own;
70
+ });
71
+ if (wanted.length === 0)
72
+ return "None of your temp knights is finished yet, so none was archived.";
73
+ const archived = await this.archive(wanted.map((agent) => agent.name));
74
+ for (const listener of this.#ended)
75
+ listener({ parent: caller.name, names: archived });
76
+ return `Archived ${archived.length} temp knight${archived.length === 1 ? "" : "s"}: ${archived.join(", ")}. Their history stays readable, and the owner can restore them.`;
77
+ }
78
+
79
+ /**
80
+ * Archives temp knights: each stops answering, keeps its history, and its Discord thread is
81
+ * closed. Names that are no active temp knight are passed over. Says which were archived.
82
+ */
83
+ async archive(names: readonly string[]): Promise<string[]> {
84
+ const { store } = this.#ctx.options;
85
+ const archived: string[] = [];
86
+ for (const name of new Set(names)) {
87
+ const agent = store.agent(name);
88
+ if (!agent || !isHelper(agent) || agent.status !== "active") continue;
89
+ await this.archiveOne(agent);
90
+ archived.push(name);
91
+ }
92
+ return archived;
93
+ }
94
+
95
+ /** Archives a temp knight and closes its thread, when it has one; a thread that will not close is logged. */
96
+ async archiveOne(agent: Agent): Promise<void> {
97
+ const { channels, logger } = this.#ctx.options;
98
+ await this.#archiveAgent(agent);
99
+ if (!agent.channelId || !channels?.closeThread) return;
100
+ try {
101
+ await channels.closeThread(agent.channelId);
102
+ } catch (error) {
103
+ logger.warn(
104
+ { agent: agent.name, err: error },
105
+ "temp knight's thread not closed; it stays silent where it is",
106
+ );
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Returns archived temp knights to the active ones, each with its thread open again; a name that
112
+ * is no archived temp knight of an active parent is passed over, so an undo after another
113
+ * change restores only what is still there. Says which were restored.
114
+ */
115
+ async restore(names: readonly string[]): Promise<string[]> {
116
+ const { store } = this.#ctx.options;
117
+ const restored: string[] = [];
118
+ for (const name of new Set(names)) {
119
+ const agent = store.agent(name);
120
+ if (!agent || !isHelper(agent) || agent.status !== "archived") continue;
121
+ if (store.agent(agent.parent as string)?.status !== "active") continue;
122
+ await this.restoreOne(agent);
123
+ restored.push(name);
124
+ }
125
+ return restored;
126
+ }
127
+
128
+ /**
129
+ * Restores one archived temp knight: its thread opens again, or a new one is made when the old
130
+ * is gone. It comes back finished, with nothing running.
131
+ */
132
+ async restoreOne(agent: Agent): Promise<void> {
133
+ const { store, channels, logger } = this.#ctx.options;
134
+ const parent = store.agent(agent.parent as string);
135
+ if (parent?.status !== "active")
136
+ throw new AgentError(
137
+ `"${agent.name}" belongs to "${agent.parent}", which is archived; restore it first.`,
138
+ );
139
+ let { channelId } = agent;
140
+ if (channels?.createThread && parent.channelId) {
141
+ if (
142
+ channelId &&
143
+ channels.openThread &&
144
+ (await channels.exists(channelId))
145
+ )
146
+ await channels.openThread(channelId);
147
+ else
148
+ channelId = await channels.createThread(
149
+ parent.channelId,
150
+ agent.displayName,
151
+ );
152
+ }
153
+ await store.restoreAgent(agent.name, channelId);
154
+ logger.info(
155
+ { name: agent.name, parent: parent.name },
156
+ "temp knight restored",
157
+ );
158
+ this.#ctx.changed();
159
+ }
160
+
161
+ /**
162
+ * Stamps every temp knight still marked working as finished. Nothing runs when the team starts,
163
+ * so one marked working was cut off by a stop or a crash and would hold its cap slot for good;
164
+ * a replayed message makes it work again.
165
+ */
166
+ async reconcile(): Promise<string[]> {
167
+ const { store } = this.#ctx.options;
168
+ const stale = store
169
+ .agents()
170
+ .filter(
171
+ (agent) =>
172
+ isHelper(agent) &&
173
+ agent.status === "active" &&
174
+ agent.finishedAt === undefined,
175
+ );
176
+ const stamped: string[] = [];
177
+ for (const agent of stale)
178
+ if (await store.setFinished(agent.name, true)) stamped.push(agent.name);
179
+ return stamped;
180
+ }
181
+
182
+ /** Archives the temp knights that have been finished for longer than the host keeps them. */
183
+ async sweepFinished(now: Date = new Date()): Promise<string[]> {
184
+ const limits = helpersOf(this.#ctx.options.helpers);
185
+ if (!limits || limits.archiveFinishedAfterHours === false) return [];
186
+ const cutoff = now.getTime() - limits.archiveFinishedAfterHours * 3_600_000;
187
+ const stale = this.finished()
188
+ .filter((agent) => (agent.finishedAt as Date).getTime() <= cutoff)
189
+ .map((agent) => agent.name);
190
+ if (stale.length === 0) return [];
191
+ const archived = await this.archive(stale);
192
+ this.#ctx.options.logger.info(
193
+ { archived },
194
+ "finished temp knights archived",
195
+ );
196
+ return archived;
197
+ }
198
+
199
+ /** Sweeps once now and then every hour, until `stop`. */
200
+ startSweeper(): void {
201
+ const limits = helpersOf(this.#ctx.options.helpers);
202
+ if (!limits || limits.archiveFinishedAfterHours === false) return;
203
+ const run = () =>
204
+ this.sweepFinished().catch((error: unknown) =>
205
+ this.#ctx.options.logger.warn({ err: error }, "temp knights not swept"),
206
+ );
207
+ void run();
208
+ this.#sweeper = setInterval(run, SWEEP_EVERY_MS);
209
+ this.#sweeper.unref();
210
+ }
211
+
212
+ stop(): void {
213
+ if (this.#sweeper) clearInterval(this.#sweeper);
214
+ this.#sweeper = undefined;
215
+ }
216
+ }
@@ -8,9 +8,10 @@ import { AgentError } from "../domain/errors.ts";
8
8
  import type { AgentTurnScope } from "../domain/ports.ts";
9
9
  import { messages } from "../i18n/index.ts";
10
10
  import type { AgentChannels } from "./agent-ports.ts";
11
- import { overBase } from "./agent-rules.ts";
11
+ import { isHelper, overBase } from "./agent-rules.ts";
12
12
  import type { Agent, AgentGroup } from "./agent-store.ts";
13
13
  import { refreshAvatars } from "./avatar-refresh.ts";
14
+ import { TeamHelpers } from "./team-helpers.ts";
14
15
  import {
15
16
  agentOfChannel,
16
17
  coordinatorOf,
@@ -30,14 +31,18 @@ import type { TeamContext } from "./team-options.ts";
30
31
  export class TeamLifecycle {
31
32
  readonly #ctx: TeamContext;
32
33
  readonly #stopped = new AbortController();
34
+ /** The temp knights: their archive, restore and sweep, which archiving a parent calls. */
35
+ readonly helpers: TeamHelpers;
33
36
 
34
37
  constructor(ctx: TeamContext) {
35
38
  this.#ctx = ctx;
39
+ this.helpers = new TeamHelpers(ctx, (agent) => this.#archiveAgent(agent));
36
40
  }
37
41
 
38
42
  /** Ends the background avatar refresh: nothing is rendered, written or logged after it. */
39
43
  stop(): void {
40
44
  this.#stopped.abort();
45
+ this.helpers.stop();
41
46
  }
42
47
 
43
48
  // ── Startup ────────────────────────────────────────────────────────────
@@ -55,6 +60,8 @@ export class TeamLifecycle {
55
60
  if (added.length > 0) logger.info({ added }, "seed agents added");
56
61
  if (channels) await this.#startChannels(channels);
57
62
  this.#ctx.changed();
63
+ await this.helpers.reconcile();
64
+ this.helpers.startSweeper();
58
65
  void this.#dressMissing().then(async (dressed) => {
59
66
  if (this.#stopped.signal.aborted) return;
60
67
  if (dressed) this.#ctx.changed();
@@ -81,6 +88,8 @@ export class TeamLifecycle {
81
88
  const { store, logger } = this.#ctx.options;
82
89
  for (const agent of store.agents()) {
83
90
  if (agent.status !== "active") continue;
91
+ // A temp knight of a parent without a channel lives in rooms only.
92
+ if (!agent.channelId && isHelper(agent)) continue;
84
93
  if (!agent.channelId) {
85
94
  const id = await channels.createChannel(
86
95
  agent.name,
@@ -216,6 +225,11 @@ export class TeamLifecycle {
216
225
  if (agent && name === caller?.name)
217
226
  throw new AgentError("You cannot archive yourself; ask another agent.");
218
227
  const { channelId } = target;
228
+ // A temp knight's place is a thread of its parent's channel, closed rather than moved.
229
+ if (agent && isHelper(agent)) {
230
+ await this.helpers.archiveOne(agent);
231
+ return `Archived "${target.displayName}"; its history stays readable, and it no longer answers.`;
232
+ }
219
233
  if (agent) await this.#archiveAgent(agent);
220
234
  else if (group) await this.#archiveGroup(group);
221
235
  if (!channelId || !channels)
@@ -253,6 +267,10 @@ export class TeamLifecycle {
253
267
  );
254
268
  if (target.status === "active")
255
269
  throw new AgentError(`"${name}" is not archived.`);
270
+ if (agent && isHelper(agent)) {
271
+ await this.helpers.restoreOne(agent);
272
+ return `Restored "${target.displayName}"; it answers again.`;
273
+ }
256
274
  if (group) store.checkRestorableGroup(name);
257
275
  // The store checks again when it restores. A member archived, or a second restore of the
258
276
  // same name, between the two checks leaves the channel moved or made with no owner; it
@@ -304,6 +322,10 @@ export class TeamLifecycle {
304
322
  `"${name}" is active; archive it before deleting it.`,
305
323
  );
306
324
  await cleanup?.();
325
+ // A parent's archived temp knights go with it; none is left naming a parent that is gone.
326
+ if (agent)
327
+ for (const helper of store.helpersOf(name))
328
+ await this.remove(helper.name);
307
329
  // What a name keeps in the runtime would be picked up by the next agent or group made under it.
308
330
  const runtime = this.#ctx.options.runtime();
309
331
  const rooms = this.#rooms();
@@ -326,6 +348,9 @@ export class TeamLifecycle {
326
348
  const { store, schedules, logger } = this.#ctx.options;
327
349
  const runtime = this.#ctx.options.runtime();
328
350
  const rooms = this.#rooms();
351
+ // A parent's temp knights are archived first: they have no place of their own once it is gone.
352
+ for (const helper of store.helpersOf(agent.name, "active"))
353
+ await this.helpers.archiveOne(helper);
329
354
  const left = await store.archiveAgent(agent.name);
330
355
  // The agent's Discord channel and each of its rooms are conversations of their own.
331
356
  for (const key of keysOf(store, agent.name, rooms)) {
@@ -1,3 +1,4 @@
1
+ import type { ResolvedHelpers } from "../config/helpers.ts";
1
2
  import type { ChannelKey } from "../domain/conversation.ts";
2
3
  import type { ThinkingSetting } from "../models.ts";
3
4
  import type { ConversationModes } from "../modes/conversation-modes.ts";
@@ -64,6 +65,11 @@ export interface AgentTeamOptions
64
65
  skills?: SkillRegistry;
65
66
  /** The conversation modes, read when an agent is created with long-running on; none, none is set. */
66
67
  modes?: () => ConversationModes | undefined;
68
+ /**
69
+ * The temp knights' limits; `false` leaves `helper_create` and `helper_end` out. Without it the
70
+ * defaults apply.
71
+ */
72
+ helpers?: ResolvedHelpers | false;
67
73
  }
68
74
 
69
75
  /** What the team's collaborators share: its options, its turns, and its change signal. */
@@ -76,4 +82,6 @@ export interface TeamContext {
76
82
  modelOf(name: string): { model: string; thinking: ThinkingSetting };
77
83
  /** Writes a group's members and host into its channel topic, without waiting on Discord. */
78
84
  retitle(group: AgentGroup): void;
85
+ /** Archives temp knights, closing their threads; says which were archived. */
86
+ archiveHelpers(names: readonly string[]): Promise<string[]>;
79
87
  }
@@ -0,0 +1,75 @@
1
+ import { AgentError } from "../domain/errors.ts";
2
+ import type { AgentTurnScope } from "../domain/ports.ts";
3
+ import { agentSystemPrompt, placeOf } from "./agent-prompt.ts";
4
+ import type { Agent } from "./agent-store.ts";
5
+ import { coordinatorOf } from "./team-keys.ts";
6
+ import type { AgentTeamOptions } from "./team-options.ts";
7
+ import type { TeamTurns } from "./team-turns.ts";
8
+
9
+ /**
10
+ * The system prompt addition of an agent session, read before each of its runs: the base every
11
+ * agent turn starts with, then what the plugins' prompt sections add. `images` is whether the
12
+ * agent can generate pictures.
13
+ */
14
+ export function teamSystemPrompt(
15
+ options: AgentTeamOptions,
16
+ turns: Pick<TeamTurns, "speakerOf" | "turnChannel">,
17
+ images: boolean,
18
+ scope: AgentTurnScope,
19
+ ): string {
20
+ const {
21
+ store,
22
+ workDir,
23
+ sharedPrompt,
24
+ guestPrompt,
25
+ owner,
26
+ shellUser,
27
+ scratchDir,
28
+ agentTools,
29
+ surfaces,
30
+ channels,
31
+ } = options;
32
+ const agent = store.agent(scope.name);
33
+ if (!agent) throw new AgentError(`no agent ${scope.name}`);
34
+ const shared =
35
+ typeof sharedPrompt === "function" ? sharedPrompt() : sharedPrompt;
36
+ const guest = typeof guestPrompt === "function" ? guestPrompt() : guestPrompt;
37
+ const group = scope.group ? store.group(scope.group) : undefined;
38
+ const coordinator = coordinatorOf(store, options);
39
+ const speaker = turns.speakerOf(scope);
40
+ const base = agentSystemPrompt({
41
+ agent,
42
+ ...(speaker ? { speaker } : {}),
43
+ ...(coordinator ? { coordinator } : {}),
44
+ shared,
45
+ ...(guest ? { guestShared: guest } : {}),
46
+ workDir,
47
+ owner,
48
+ shellUser,
49
+ ...(scratchDir ? { scratchDir } : {}),
50
+ images,
51
+ ...(agent.parent ? { parent: store.agent(agent.parent) } : {}),
52
+ helpers: options.helpers !== false,
53
+ ...(agentTools ? { agentTools } : {}),
54
+ ...placeOf(turns.turnChannel(scope), surfaces, !!channels),
55
+ ...(group
56
+ ? {
57
+ group: {
58
+ group,
59
+ members: group.members
60
+ .map((m) => store.agent(m))
61
+ .filter((m): m is Agent => m !== undefined),
62
+ },
63
+ }
64
+ : {}),
65
+ });
66
+ const added = (options.promptSections?.() ?? []).flatMap(
67
+ (section) =>
68
+ section.build({
69
+ agent: { name: agent.name, displayName: agent.displayName },
70
+ speaker,
71
+ scope,
72
+ }) ?? [],
73
+ );
74
+ return [base, ...added.filter((text) => text.trim() !== "")].join("\n\n");
75
+ }
@@ -1,31 +1,49 @@
1
1
  import type { AgentTurnScope } from "../domain/ports.ts";
2
2
  import { SHELL_TOOLS } from "../modules/host-shell/shell-policy.ts";
3
3
  import type { TurnSelection } from "../sessions.ts";
4
- import { AGENT_TOOLS, MESSAGE_AGENT_TOOL } from "./agent-tools.ts";
4
+ import { isHelper } from "./agent-rules.ts";
5
+ import {
6
+ AGENT_TOOLS,
7
+ HELPER_TOOLS,
8
+ MESSAGE_AGENT_TOOL,
9
+ WITHHELD_FROM_HELPERS,
10
+ } from "./agent-tools.ts";
5
11
  import type { AgentTeamOptions } from "./team-options.ts";
6
12
 
7
13
  /** The tools every agent turn runs with: the plugins', plus the shell and agent tools. */
8
14
  export function teamSelection(
9
15
  options: Pick<
10
16
  AgentTeamOptions,
11
- "pluginSelection" | "pluginTools" | "agentTools"
12
- >,
17
+ "pluginSelection" | "pluginTools" | "agentTools" | "helpers"
18
+ > & { store?: Pick<AgentTeamOptions["store"], "agent"> },
13
19
  scope?: AgentTurnScope,
14
20
  ): TurnSelection {
15
21
  const plugged = options.pluginSelection();
22
+ // A temp knight cannot make knights, groups or schedules, nor archive anything: the tools are
23
+ // not offered to its turns, which stops a chain of them multiplying.
24
+ const helper =
25
+ scope !== undefined && isHelper(options.store?.agent(scope.name));
26
+ const kept = (tool: string): boolean =>
27
+ !(helper && WITHHELD_FROM_HELPERS.includes(tool)) &&
28
+ !(
29
+ (options.helpers === false || scope?.group) &&
30
+ (HELPER_TOOLS as readonly string[]).includes(tool)
31
+ );
16
32
  return {
17
33
  id: "agent",
18
34
  tools: [
19
- ...new Set([
20
- ...plugged.tools,
21
- // A chat-only host's agents carry no shell and no file tools.
22
- ...(options.agentTools === "chat-only" ? [] : SHELL_TOOLS),
23
- // Group members hand off by mentioning each other, so a group seat has no message_agent.
24
- ...AGENT_TOOLS.filter(
25
- (tool) => !(scope?.group && tool === MESSAGE_AGENT_TOOL),
26
- ),
27
- ...(options.pluginTools?.() ?? []),
28
- ]),
35
+ ...new Set(
36
+ [
37
+ ...plugged.tools,
38
+ // A chat-only host's agents carry no shell and no file tools.
39
+ ...(options.agentTools === "chat-only" ? [] : SHELL_TOOLS),
40
+ // Group members hand off by mentioning each other, so a group seat has no message_agent.
41
+ ...AGENT_TOOLS.filter(
42
+ (tool) => !(scope?.group && tool === MESSAGE_AGENT_TOOL),
43
+ ),
44
+ ...(options.pluginTools?.() ?? []),
45
+ ].filter(kept),
46
+ ),
29
47
  ],
30
48
  groups: plugged.groups,
31
49
  };
@@ -19,6 +19,10 @@ export interface AgentStatus {
19
19
  lastActive?: Date;
20
20
  /** Its schedules, across its Discord channel and its rooms. */
21
21
  schedules: number;
22
+ /** The knight that made it, for a temp knight. */
23
+ parent?: string;
24
+ /** A temp knight with nothing running, queued or waiting for the owner. */
25
+ finished?: true;
22
26
  }
23
27
 
24
28
  /** An active group as the dashboard shows it. */
@@ -74,6 +78,8 @@ export async function teamStatus(ctx: TeamContext): Promise<TeamStatus> {
74
78
  ...(context ? { context } : {}),
75
79
  ...(lastActive ? { lastActive } : {}),
76
80
  schedules: scheduled,
81
+ ...(agent.parent ? { parent: agent.parent } : {}),
82
+ ...(agent.parent && agent.finishedAt ? { finished: true as const } : {}),
77
83
  });
78
84
  }
79
85
  const groups: GroupStatus[] = store.groups().flatMap((group) => {