pi-roundtable 0.26.3 → 0.27.0
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 +41 -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 +14 -16
- 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
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
agent = await store.
|
|
250
|
-
...
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
await
|
|
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
|
|
273
|
-
//
|
|
274
|
-
const home =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
(
|
|
26
|
-
|
|
27
|
-
|
|
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) => {
|