pi-roundtable 0.26.1 → 0.26.3
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 +45 -0
- package/docs/plugins.md +12 -7
- package/package.json +1 -1
- package/src/core/agents/agent-claim.ts +1 -0
- package/src/core/agents/agent-conversations.ts +94 -0
- package/src/core/agents/agent-messages.ts +247 -56
- package/src/core/agents/agent-outbox.ts +91 -0
- package/src/core/agents/agent-prompt.ts +40 -1
- package/src/core/agents/agent-schema.ts +6 -0
- package/src/core/agents/agent-store.ts +22 -3
- package/src/core/agents/agent-team-fixture.ts +81 -88
- package/src/core/agents/agent-team-seeds.ts +29 -0
- package/src/core/agents/agent-team.ts +17 -14
- package/src/core/agents/agent-tools.ts +34 -5
- package/src/core/agents/group-turns.ts +1 -1
- package/src/core/agents/message-target.ts +79 -0
- package/src/core/agents/team-editing.ts +30 -2
- package/src/core/agents/team-keys.ts +7 -0
- package/src/core/agents/team-options.ts +3 -0
- package/src/core/agents/team-text.ts +4 -0
- package/src/core/agents/team-turn-types.ts +28 -0
- package/src/core/agents/team-turns.ts +36 -25
- package/src/core/assets/prompts/shared-guest.md +1 -3
- package/src/core/assets/prompts/shared.md +1 -3
- package/src/core/builtin/agent-server.ts +5 -0
- package/src/core/builtin/modules.ts +6 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/contract/surface.ts +5 -0
- package/src/core/discord/channel-context.ts +3 -1
- package/src/core/discord/embed-text.ts +45 -0
- package/src/core/discord/inbound-message.ts +7 -2
- package/src/core/domain/ports.ts +17 -0
- package/src/core/domain/turn-timeout.ts +2 -0
- package/src/core/i18n/turn-timeout.ts +8 -2
- package/src/core/modules/background/background-turns.ts +3 -0
- package/src/core/modules/schedules/precheck-catalog.ts +49 -0
- package/src/core/modules/schedules/schedule-store.ts +27 -3
- package/src/core/modules/schedules/schedule-tools.ts +43 -86
- package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
- package/src/core/modules/schedules/schedules.ts +26 -2
- package/src/core/modules/schedules/time-limit.ts +27 -0
- package/src/core/presentation/reply-body.ts +8 -0
- package/src/core/routing/conversation-turns.ts +21 -2
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +31 -27
- package/src/core/runtime/extensions/hold-lookups.ts +21 -0
- package/src/core/runtime/extensions/late-releases.ts +76 -0
- package/src/core/runtime/pi-agent-runtime.ts +21 -19
- package/src/core/runtime/turn-answer.ts +5 -1
- package/src/core/runtime/turn-budget.ts +1 -0
- package/src/core/runtime/turn-limits.ts +12 -2
- package/src/core/runtime/turn-tools.ts +26 -0
- package/src/core/shared/schedule-tools.ts +25 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
2
|
-
import { AgentError } from "../domain/errors.ts";
|
|
2
|
+
import { AgentError, IdentityError } from "../domain/errors.ts";
|
|
3
3
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
4
|
+
import { HostStoppingError } from "../errors.ts";
|
|
4
5
|
import { messages } from "../i18n/index.ts";
|
|
6
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
7
|
+
import { isQuiet } from "../presentation/reply-body.ts";
|
|
5
8
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
6
|
-
import { addressee } from "../speakers.ts";
|
|
9
|
+
import { addressee, type Speaker } from "../speakers.ts";
|
|
10
|
+
import type { OutboxEntry, StoredOutboxEntry } from "./agent-outbox.ts";
|
|
7
11
|
import {
|
|
8
12
|
agentFailureText,
|
|
9
13
|
agentMessageText,
|
|
@@ -13,13 +17,8 @@ import {
|
|
|
13
17
|
returnedAnswerPost,
|
|
14
18
|
} from "./agent-prompt.ts";
|
|
15
19
|
import type { Agent } from "./agent-store.ts";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
discordKey,
|
|
19
|
-
homeScope,
|
|
20
|
-
roomScope,
|
|
21
|
-
roomSurfaceOf,
|
|
22
|
-
} from "./team-keys.ts";
|
|
20
|
+
import { conversationLabel, messageTarget } from "./message-target.ts";
|
|
21
|
+
import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
|
|
23
22
|
import {
|
|
24
23
|
type Chain,
|
|
25
24
|
type Exchange,
|
|
@@ -30,22 +29,43 @@ import {
|
|
|
30
29
|
/** Messages between agents: each runs the target's turn, then wakes the sender with the answer. */
|
|
31
30
|
export class AgentMessages {
|
|
32
31
|
readonly #host: TurnHost;
|
|
32
|
+
/** The writes of entries the host refused while it stopped, which the shutdown waits for. */
|
|
33
|
+
readonly #keeping = new Set<Promise<void>>();
|
|
34
|
+
/** The last write, which the next waits for: ids follow the order the queue refused the entries in. */
|
|
35
|
+
#lastKept: Promise<void> = Promise.resolve();
|
|
33
36
|
|
|
34
37
|
constructor(host: TurnHost) {
|
|
35
38
|
this.#host = host;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
/** What is still being written to the outbox; the shutdown drain waits until it is empty. */
|
|
42
|
+
busy(): string[] {
|
|
43
|
+
return this.#keeping.size > 0 ? ["agent messages being kept"] : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
message(
|
|
47
|
+
caller: AgentTurnScope,
|
|
48
|
+
to: string,
|
|
49
|
+
text: string,
|
|
50
|
+
conversation?: string,
|
|
51
|
+
): string {
|
|
52
|
+
const { store, queue, logger, surfaces } = this.#host.options;
|
|
40
53
|
if (to === caller.name)
|
|
41
54
|
throw new AgentError("message_agent is for other agents, not yourself.");
|
|
42
55
|
const target = store.activeAgent(to);
|
|
43
56
|
const sender = store.activeAgent(caller.name);
|
|
44
|
-
//
|
|
57
|
+
// The sender writes from its turn channel, to which the answer returns.
|
|
45
58
|
const parent = this.#host.turnChannel(caller);
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
const targetChannel = messageTarget(
|
|
60
|
+
{
|
|
61
|
+
store,
|
|
62
|
+
roomSurfaces: surfaces?.roomSurfaces() ?? [],
|
|
63
|
+
latest: store.conversations.latest(target.name),
|
|
64
|
+
from: parent,
|
|
65
|
+
},
|
|
66
|
+
target,
|
|
67
|
+
conversation,
|
|
68
|
+
);
|
|
49
69
|
// Every agent turn runs through #turn, which registers its chain; a message from anywhere
|
|
50
70
|
// else would start an uncounted chain, so it is refused.
|
|
51
71
|
const chain = this.#host.chains.get(caller.session);
|
|
@@ -58,32 +78,52 @@ export class AgentMessages {
|
|
|
58
78
|
chainLimitReason(addressee(chain.speaker, this.#host.options.owner)),
|
|
59
79
|
);
|
|
60
80
|
chain.messages += 1;
|
|
61
|
-
const targetChannel = this.#home(target, via);
|
|
62
81
|
// The thread opens in the sender's turn channel, on its surface when that hosts threads; a
|
|
63
|
-
// surface that hosts none leaves the answer returning to the sender's
|
|
82
|
+
// surface that hosts none leaves the answer returning to the sender's conversation.
|
|
64
83
|
const exchange = this.#openExchange(parent, sender, target, text);
|
|
65
84
|
void queue
|
|
66
85
|
.run(targetChannel, async () =>
|
|
67
|
-
this.#deliver(
|
|
86
|
+
this.#deliver(
|
|
87
|
+
sender,
|
|
88
|
+
target,
|
|
89
|
+
text,
|
|
90
|
+
chain,
|
|
91
|
+
await exchange,
|
|
92
|
+
targetChannel,
|
|
93
|
+
parent,
|
|
94
|
+
),
|
|
68
95
|
)
|
|
69
|
-
.catch((error: unknown) =>
|
|
96
|
+
.catch((error: unknown) => {
|
|
97
|
+
if (error instanceof HostStoppingError) {
|
|
98
|
+
// Refused before it started: the thread it opened has nothing more to hold.
|
|
99
|
+
void exchange.then((opened) => opened?.thread.close());
|
|
100
|
+
this.#keep({
|
|
101
|
+
kind: "message",
|
|
102
|
+
sender: sender.name,
|
|
103
|
+
target: target.name,
|
|
104
|
+
channel: targetChannel,
|
|
105
|
+
text,
|
|
106
|
+
origin: parent,
|
|
107
|
+
speaker: chain.speaker,
|
|
108
|
+
messages: chain.messages,
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
70
112
|
logger.error(
|
|
71
113
|
{ from: sender.name, to: target.name, err: error },
|
|
72
114
|
"agent message not delivered",
|
|
73
|
-
)
|
|
74
|
-
);
|
|
115
|
+
);
|
|
116
|
+
});
|
|
75
117
|
logger.info(
|
|
76
|
-
{
|
|
118
|
+
{
|
|
119
|
+
from: sender.name,
|
|
120
|
+
to: target.name,
|
|
121
|
+
channel: targetChannel,
|
|
122
|
+
chain: chain.messages,
|
|
123
|
+
},
|
|
77
124
|
"agent message queued",
|
|
78
125
|
);
|
|
79
|
-
return `Sent to "${target.displayName}". It works
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** The agent's own channel: its Discord channel, or its room on the surface of a room turn. */
|
|
83
|
-
#home(agent: Agent, via: string | undefined): ChannelKey {
|
|
84
|
-
return via === undefined
|
|
85
|
-
? discordKey(agent.channelId ?? "")
|
|
86
|
-
: agentRoomKey(via, agent.name);
|
|
126
|
+
return `Sent to "${target.displayName}" in its ${conversationLabel(targetChannel)}. It works there; its answer comes back to you as a new turn.`;
|
|
87
127
|
}
|
|
88
128
|
|
|
89
129
|
/**
|
|
@@ -125,10 +165,19 @@ export class AgentMessages {
|
|
|
125
165
|
text: string,
|
|
126
166
|
chain: Chain,
|
|
127
167
|
exchange: Exchange | undefined,
|
|
128
|
-
|
|
168
|
+
targetChannel: ChannelKey,
|
|
169
|
+
senderChannel: ChannelKey,
|
|
129
170
|
): Promise<void> {
|
|
130
171
|
try {
|
|
131
|
-
await this.#exchange(
|
|
172
|
+
await this.#exchange(
|
|
173
|
+
sender,
|
|
174
|
+
target,
|
|
175
|
+
text,
|
|
176
|
+
chain,
|
|
177
|
+
exchange,
|
|
178
|
+
targetChannel,
|
|
179
|
+
senderChannel,
|
|
180
|
+
);
|
|
132
181
|
} finally {
|
|
133
182
|
await exchange?.thread.close();
|
|
134
183
|
}
|
|
@@ -140,10 +189,10 @@ export class AgentMessages {
|
|
|
140
189
|
text: string,
|
|
141
190
|
chain: Chain,
|
|
142
191
|
exchange: Exchange | undefined,
|
|
143
|
-
|
|
192
|
+
targetChannel: ChannelKey,
|
|
193
|
+
senderChannel: ChannelKey,
|
|
144
194
|
): Promise<void> {
|
|
145
195
|
const { queue, logger } = this.#host.options;
|
|
146
|
-
const targetChannel = this.#home(target, via);
|
|
147
196
|
await this.#host.post(targetChannel, this.#host.current(sender), {
|
|
148
197
|
chunks: splitReply(
|
|
149
198
|
deliveredMessagePost(this.#host.current(target), text),
|
|
@@ -151,7 +200,7 @@ export class AgentMessages {
|
|
|
151
200
|
});
|
|
152
201
|
const result = await this.#host.turn(
|
|
153
202
|
target,
|
|
154
|
-
|
|
203
|
+
scopeOf(target, targetChannel),
|
|
155
204
|
targetChannel,
|
|
156
205
|
agentMessageText(
|
|
157
206
|
this.#host.current(sender),
|
|
@@ -159,21 +208,25 @@ export class AgentMessages {
|
|
|
159
208
|
addressee(chain.speaker, this.#host.options.owner),
|
|
160
209
|
),
|
|
161
210
|
chain,
|
|
211
|
+
{ unattended: true },
|
|
162
212
|
);
|
|
163
213
|
const thread = exchange?.thread;
|
|
164
214
|
if (thread && !result.ok)
|
|
165
215
|
await thread.close(messages().agentNoReply(result.error.message));
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
216
|
+
// A target with nothing to answer sends nothing back and wakes nobody.
|
|
217
|
+
if (isQuiet(result)) {
|
|
218
|
+
logger.info(
|
|
219
|
+
{ from: sender.name, to: target.name },
|
|
220
|
+
"agent message answered with nothing; no follow-up",
|
|
221
|
+
);
|
|
171
222
|
return;
|
|
172
|
-
|
|
223
|
+
}
|
|
224
|
+
const current = this.#host.options.store.agent(sender.name);
|
|
225
|
+
if (current?.status !== "active") return;
|
|
173
226
|
// With a thread the answer goes there, leaving the sender's channel its own reply.
|
|
174
227
|
if (result.ok)
|
|
175
228
|
await this.#host
|
|
176
|
-
.post(
|
|
229
|
+
.post(senderChannel, this.#host.current(target), {
|
|
177
230
|
chunks: splitReply(returnedAnswerPost(result.text)),
|
|
178
231
|
...(thread ? { threadId: thread.id } : {}),
|
|
179
232
|
})
|
|
@@ -197,21 +250,159 @@ export class AgentMessages {
|
|
|
197
250
|
// The answer is a report back to the sender, so its turn may ask the owner on cards.
|
|
198
251
|
void queue
|
|
199
252
|
.run(senderChannel, () =>
|
|
200
|
-
this.#
|
|
201
|
-
current,
|
|
202
|
-
via === undefined
|
|
203
|
-
? homeScope(current)
|
|
204
|
-
: roomScope(current, senderChannel),
|
|
205
|
-
senderChannel,
|
|
206
|
-
followUp,
|
|
207
|
-
chain,
|
|
208
|
-
{
|
|
209
|
-
interactive: true,
|
|
210
|
-
},
|
|
211
|
-
),
|
|
253
|
+
this.#followUp(current, senderChannel, followUp, chain),
|
|
212
254
|
)
|
|
213
|
-
.catch((error: unknown) =>
|
|
214
|
-
|
|
255
|
+
.catch((error: unknown) => {
|
|
256
|
+
if (error instanceof HostStoppingError) {
|
|
257
|
+
// The sender would wait for this answer forever, so it waits in the outbox for the next start.
|
|
258
|
+
this.#keep({
|
|
259
|
+
kind: "reply",
|
|
260
|
+
sender: current.name,
|
|
261
|
+
target: target.name,
|
|
262
|
+
channel: senderChannel,
|
|
263
|
+
text: followUp,
|
|
264
|
+
speaker: chain.speaker,
|
|
265
|
+
messages: chain.messages,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
logger.error({ err: error }, "agent follow-up failed");
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The sender's turn that carries the target's answer; it may ask the owner on cards, as a report back does. */
|
|
274
|
+
#followUp(sender: Agent, channel: ChannelKey, text: string, chain: Chain) {
|
|
275
|
+
return this.#host.turn(
|
|
276
|
+
sender,
|
|
277
|
+
scopeOf(sender, channel),
|
|
278
|
+
channel,
|
|
279
|
+
text,
|
|
280
|
+
chain,
|
|
281
|
+
{ interactive: true },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Writes an entry the queue refused to the outbox; the write is waited for by the shutdown. */
|
|
286
|
+
#keep(entry: OutboxEntry): void {
|
|
287
|
+
const { store, logger } = this.#host.options;
|
|
288
|
+
const write = this.#lastKept
|
|
289
|
+
.then(() => store.outbox.keep(entry))
|
|
290
|
+
.then(
|
|
291
|
+
() =>
|
|
292
|
+
logger.info(
|
|
293
|
+
{ kind: entry.kind, from: entry.sender, to: entry.target },
|
|
294
|
+
"agent message kept for the next start",
|
|
295
|
+
),
|
|
296
|
+
(error: unknown) =>
|
|
297
|
+
logger.error(
|
|
298
|
+
{
|
|
299
|
+
kind: entry.kind,
|
|
300
|
+
from: entry.sender,
|
|
301
|
+
to: entry.target,
|
|
302
|
+
err: error,
|
|
303
|
+
},
|
|
304
|
+
"agent message lost: not kept for the next start",
|
|
305
|
+
),
|
|
215
306
|
);
|
|
307
|
+
this.#lastKept = write;
|
|
308
|
+
this.#keeping.add(write);
|
|
309
|
+
void write.finally(() => this.#keeping.delete(write));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Delivers what the last run kept, oldest first, each through the queue of the conversation it
|
|
314
|
+
* addressed; never rejects. An entry is taken out of the outbox when its turn starts, so it runs
|
|
315
|
+
* once even with a second replay or another host reading the same table; one the queue refuses
|
|
316
|
+
* stays for the next start.
|
|
317
|
+
*/
|
|
318
|
+
async replay(): Promise<void> {
|
|
319
|
+
const { store, queue, logger } = this.#host.options;
|
|
320
|
+
// A failed read leaves the entries kept for the next start and does not fail this one.
|
|
321
|
+
const entries = await store.outbox.pending().catch((error: unknown) => {
|
|
322
|
+
logger.error({ err: error }, "kept agent messages not read");
|
|
323
|
+
return [];
|
|
324
|
+
});
|
|
325
|
+
if (entries.length === 0) return;
|
|
326
|
+
logger.info({ count: entries.length }, "replaying agent messages");
|
|
327
|
+
for (const entry of entries)
|
|
328
|
+
void queue
|
|
329
|
+
.run(entry.channel, () => this.#replayOne(entry))
|
|
330
|
+
.catch((error: unknown) => {
|
|
331
|
+
if (error instanceof HostStoppingError)
|
|
332
|
+
logger.info(
|
|
333
|
+
{ id: entry.id },
|
|
334
|
+
"agent message stays kept: the host is stopping",
|
|
335
|
+
);
|
|
336
|
+
else
|
|
337
|
+
logger.error(
|
|
338
|
+
{ id: entry.id, err: error },
|
|
339
|
+
"agent message not replayed",
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The kept speaker as they stand now, like a background turn's author; undefined when gone or disabled. */
|
|
345
|
+
async #stillRunsAs(kept: Speaker): Promise<Speaker | undefined> {
|
|
346
|
+
const principals = this.#host.options.principals?.();
|
|
347
|
+
if (!principals || kept.principalId === SYSTEM_PRINCIPAL) return kept;
|
|
348
|
+
try {
|
|
349
|
+
const now = await principals.speakerFor(kept.principalId, kept.tier);
|
|
350
|
+
return { ...kept, tier: now.tier, principalId: now.principalId };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof IdentityError) return undefined;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async #replayOne(entry: StoredOutboxEntry): Promise<void> {
|
|
358
|
+
const { store, logger } = this.#host.options;
|
|
359
|
+
if (!(await store.outbox.claim(entry.id))) return;
|
|
360
|
+
const sender = store.agent(entry.sender);
|
|
361
|
+
const target = store.agent(entry.target);
|
|
362
|
+
// An agent archived or moved to another channel since has no turn to give the entry.
|
|
363
|
+
const has = (agent: Agent | undefined, channel: ChannelKey) =>
|
|
364
|
+
agent?.status === "active" &&
|
|
365
|
+
(discordIdOf(channel) === undefined ||
|
|
366
|
+
(agent.channelId !== undefined &&
|
|
367
|
+
discordKey(agent.channelId) === channel));
|
|
368
|
+
const origin = entry.origin ?? entry.channel;
|
|
369
|
+
const intact =
|
|
370
|
+
entry.kind === "message"
|
|
371
|
+
? has(target, entry.channel) && has(sender, origin)
|
|
372
|
+
: has(sender, entry.channel);
|
|
373
|
+
if (!sender || !target || !intact) {
|
|
374
|
+
logger.warn(
|
|
375
|
+
{ id: entry.id, from: entry.sender, to: entry.target },
|
|
376
|
+
"kept agent message dropped: its agent or channel is gone",
|
|
377
|
+
);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
logger.info(
|
|
381
|
+
{ id: entry.id, kind: entry.kind, from: entry.sender, to: entry.target },
|
|
382
|
+
"replaying a kept agent message",
|
|
383
|
+
);
|
|
384
|
+
const speaker = await this.#stillRunsAs(entry.speaker);
|
|
385
|
+
if (!speaker) {
|
|
386
|
+
logger.warn(
|
|
387
|
+
{ id: entry.id, from: entry.sender },
|
|
388
|
+
"kept agent message dropped: its speaker may no longer run",
|
|
389
|
+
);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const chain: Chain = { messages: entry.messages, speaker };
|
|
393
|
+
if (entry.kind === "reply") {
|
|
394
|
+
await this.#followUp(sender, entry.channel, entry.text, chain);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Its thread was closed when the host refused it, so the answer returns to the sender's conversation.
|
|
398
|
+
await this.#exchange(
|
|
399
|
+
sender,
|
|
400
|
+
target,
|
|
401
|
+
entry.text,
|
|
402
|
+
chain,
|
|
403
|
+
undefined,
|
|
404
|
+
entry.channel,
|
|
405
|
+
origin,
|
|
406
|
+
);
|
|
216
407
|
}
|
|
217
408
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "../db/migrations.ts";
|
|
3
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
4
|
+
import type { Speaker } from "../speakers.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An agent message the host stopped before it could deliver. `message` is the request to the
|
|
8
|
+
* target; `reply` is the follow-up turn that carries the target's answer back to the sender. Each
|
|
9
|
+
* is kept with the chain it belonged to, so the replayed turn counts against the same limit.
|
|
10
|
+
*/
|
|
11
|
+
export interface OutboxEntry {
|
|
12
|
+
kind: "message" | "reply";
|
|
13
|
+
/** The agent that asked. */
|
|
14
|
+
sender: string;
|
|
15
|
+
/** The agent whose turn the entry starts. */
|
|
16
|
+
target: string;
|
|
17
|
+
/** The target's conversation: where the entry runs and posts. */
|
|
18
|
+
channel: ChannelKey;
|
|
19
|
+
/** What the turn is given: the request, or the sender's follow-up text. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** For a `message`, the sender's conversation, where the answer returns. */
|
|
22
|
+
origin?: ChannelKey;
|
|
23
|
+
speaker: Speaker;
|
|
24
|
+
/** The messages the chain had sent when the entry was stored. */
|
|
25
|
+
messages: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A stored entry and its id, which orders the replay and makes it happen once. */
|
|
29
|
+
export interface StoredOutboxEntry extends OutboxEntry {
|
|
30
|
+
id: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OutboxRow {
|
|
34
|
+
id: string | number;
|
|
35
|
+
payload: OutboxEntry | string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The table of undelivered agent messages, created after the guild scoping so it carries its guild from the start. */
|
|
39
|
+
export const outboxMigration = (): Migration => ({
|
|
40
|
+
name: "agent-outbox",
|
|
41
|
+
up: async (sql) => {
|
|
42
|
+
await sql`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS agent_outbox (
|
|
44
|
+
id bigserial PRIMARY KEY,
|
|
45
|
+
guild_id text NOT NULL,
|
|
46
|
+
channel text NOT NULL,
|
|
47
|
+
payload jsonb NOT NULL,
|
|
48
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
49
|
+
)`;
|
|
50
|
+
await sql`CREATE INDEX IF NOT EXISTS agent_outbox_guild ON agent_outbox (guild_id, id)`;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Agent messages and follow-ups the host refused while it stopped, kept for the next start.
|
|
56
|
+
* A row is removed by whoever claims it, so two hosts replaying the same table deliver each once.
|
|
57
|
+
*/
|
|
58
|
+
export class AgentOutbox {
|
|
59
|
+
readonly #sql: SQL;
|
|
60
|
+
readonly #guild: string;
|
|
61
|
+
|
|
62
|
+
constructor(sql: SQL, guildId: string) {
|
|
63
|
+
this.#sql = sql;
|
|
64
|
+
this.#guild = guildId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async keep(entry: OutboxEntry): Promise<void> {
|
|
68
|
+
await this.#sql`
|
|
69
|
+
INSERT INTO agent_outbox (guild_id, channel, payload)
|
|
70
|
+
VALUES (${this.#guild}, ${entry.channel}, ${JSON.stringify(entry)}::jsonb)`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every kept entry, oldest first. */
|
|
74
|
+
async pending(): Promise<StoredOutboxEntry[]> {
|
|
75
|
+
const rows: OutboxRow[] = await this.#sql`
|
|
76
|
+
SELECT id, payload FROM agent_outbox WHERE guild_id = ${this.#guild} ORDER BY id`;
|
|
77
|
+
return rows.map((row) => ({
|
|
78
|
+
...(typeof row.payload === "string"
|
|
79
|
+
? (JSON.parse(row.payload) as OutboxEntry)
|
|
80
|
+
: row.payload),
|
|
81
|
+
id: Number(row.id),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Takes the entry out; false when another host or an earlier call already did. */
|
|
86
|
+
async claim(id: number): Promise<boolean> {
|
|
87
|
+
const rows: { id: string | number }[] = await this.#sql`
|
|
88
|
+
DELETE FROM agent_outbox WHERE guild_id = ${this.#guild} AND id = ${id} RETURNING id`;
|
|
89
|
+
return rows.length > 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
1
3
|
import { messages } from "../i18n/index.ts";
|
|
2
4
|
import { type OwnerIdentity, ownerWords } from "../identity.ts";
|
|
3
5
|
import type { AgentTools } from "../plugin.ts";
|
|
@@ -28,6 +30,8 @@ export function agentSystemPrompt(input: {
|
|
|
28
30
|
images?: boolean;
|
|
29
31
|
/** "chat-only": the agent has no shell, file tools or workspace, and the prompt says so. Default "full". */
|
|
30
32
|
agentTools?: AgentTools;
|
|
33
|
+
/** Where this conversation is, and the other surfaces the agent has places on. */
|
|
34
|
+
surface?: { label: string; key: string; others: readonly string[] };
|
|
31
35
|
}): string {
|
|
32
36
|
const { agent, coordinator, shared, workDir, owner, shellUser, group } =
|
|
33
37
|
input;
|
|
@@ -37,7 +41,7 @@ export function agentSystemPrompt(input: {
|
|
|
37
41
|
const guest = input.speaker && input.speaker.tier !== "owner";
|
|
38
42
|
const parts = [
|
|
39
43
|
(guest && input.guestShared) || shared,
|
|
40
|
-
`You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his}
|
|
44
|
+
`You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} agent server.${placeText(input.surface)} Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
|
|
41
45
|
roleText(agent, coordinator, who),
|
|
42
46
|
`The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; agent_avatar changes an agent's look (class, colors, parts); schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
|
|
43
47
|
input.agentTools === "chat-only"
|
|
@@ -62,6 +66,19 @@ export function agentSystemPrompt(input: {
|
|
|
62
66
|
return parts.join("\n\n");
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** Names the surface of this conversation, and the others the agent has places on. */
|
|
70
|
+
function placeText(
|
|
71
|
+
surface:
|
|
72
|
+
| NonNullable<Parameters<typeof agentSystemPrompt>[0]["surface"]>
|
|
73
|
+
| undefined,
|
|
74
|
+
): string {
|
|
75
|
+
if (!surface) return "";
|
|
76
|
+
const others = surface.others.length
|
|
77
|
+
? ` You also have places on ${surface.others.join(", ")}.`
|
|
78
|
+
: "";
|
|
79
|
+
return ` This conversation is in ${surface.label} (${surface.key}).${others}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
/**
|
|
66
83
|
* Says whom the turn is for when it is not the owner, after every operator text that names the
|
|
67
84
|
* owner, so those texts read as the owner's and this one decides who is addressed.
|
|
@@ -169,3 +186,25 @@ export function chainLimitReason(who: OwnerIdentity): string {
|
|
|
169
186
|
const o = ownerWords(who);
|
|
170
187
|
return `This chain of agent messages has reached 8 messages without ${o.name} in between, so it is stopped. Tell ${o.him} what was done and ask whether to continue.`;
|
|
171
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Where a turn's conversation is: the surface of the turn's conversation (`home`: the agent's own
|
|
192
|
+
* channel or room, or the group's in a group round), named by its `label`, and the other surfaces the agents have places on, Discord when the team has
|
|
193
|
+
* channels and each surface that hosts rooms.
|
|
194
|
+
*/
|
|
195
|
+
export function placeOf(
|
|
196
|
+
home: ChannelKey,
|
|
197
|
+
surfaces: Pick<SurfacePort, "of" | "roomSurfaces"> | undefined,
|
|
198
|
+
hasDiscord: boolean,
|
|
199
|
+
): { surface: { label: string; key: string; others: readonly string[] } } {
|
|
200
|
+
const prefix = parseChannelKey(home).surface;
|
|
201
|
+
const label = (name: string) =>
|
|
202
|
+
surfaces?.of(`${name}:`)?.label ?? (name === "discord" ? "Discord" : name);
|
|
203
|
+
const names = [
|
|
204
|
+
...(hasDiscord ? ["discord"] : []),
|
|
205
|
+
...(surfaces?.roomSurfaces() ?? []),
|
|
206
|
+
].filter((n, i, all) => n !== prefix && all.indexOf(n) === i);
|
|
207
|
+
return {
|
|
208
|
+
surface: { label: label(prefix), key: prefix, others: names.map(label) },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -3,6 +3,8 @@ import { scopeToGuild } from "../db/guild-scope.ts";
|
|
|
3
3
|
import type { Migration } from "../db/migrations.ts";
|
|
4
4
|
import type { ThinkingLevel } from "../models.ts";
|
|
5
5
|
import { promptHistoryMigration } from "../modules/prompts/prompt-history.ts";
|
|
6
|
+
import { AgentConversations } from "./agent-conversations.ts";
|
|
7
|
+
import { outboxMigration } from "./agent-outbox.ts";
|
|
6
8
|
import type { Agent, AgentGroup, AgentStatus } from "./agent-rules.ts";
|
|
7
9
|
|
|
8
10
|
export interface AgentRow {
|
|
@@ -136,5 +138,9 @@ export function agentMigrations(guildId: string): Migration[] {
|
|
|
136
138
|
},
|
|
137
139
|
// The change history of the agents' prompts; the prompts plugin creates the same table for its documents.
|
|
138
140
|
promptHistoryMigration("agent-prompt-history"),
|
|
141
|
+
// Agent messages the host refused while it stopped, replayed at the next start.
|
|
142
|
+
outboxMigration(),
|
|
143
|
+
// Where each agent last ran a turn, so a message to it finds that conversation after a restart.
|
|
144
|
+
AgentConversations.migration,
|
|
139
145
|
];
|
|
140
146
|
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
} from "../modules/prompts/prompt-history.ts";
|
|
10
10
|
import type { AgentDirectory } from "../services.ts";
|
|
11
11
|
import { AgentBook } from "./agent-book.ts";
|
|
12
|
+
import { AgentConversations } from "./agent-conversations.ts";
|
|
13
|
+
import { AgentOutbox } from "./agent-outbox.ts";
|
|
12
14
|
import {
|
|
13
15
|
type Agent,
|
|
14
16
|
type AgentGroup,
|
|
@@ -68,14 +70,24 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
|
|
|
68
70
|
readonly #statements: AgentStatements;
|
|
69
71
|
readonly #messages: GroupMessages;
|
|
70
72
|
readonly #prompts: PromptHistory;
|
|
73
|
+
/** Agent messages the host stopped before delivering them. */
|
|
74
|
+
readonly outbox: AgentOutbox;
|
|
75
|
+
/** The conversation each agent last ran a turn in. */
|
|
76
|
+
readonly conversations: AgentConversations;
|
|
71
77
|
|
|
72
|
-
private constructor(
|
|
78
|
+
private constructor(
|
|
79
|
+
sql: SQL,
|
|
80
|
+
guildId: string,
|
|
81
|
+
conversations: AgentConversations,
|
|
82
|
+
) {
|
|
73
83
|
super();
|
|
84
|
+
this.conversations = conversations;
|
|
74
85
|
this.#sql = sql;
|
|
75
86
|
this.#guild = guildId;
|
|
76
87
|
this.#statements = new AgentStatements(sql, guildId);
|
|
77
88
|
this.#messages = new GroupMessages(sql, guildId);
|
|
78
89
|
this.#prompts = new PromptHistory(sql, guildId);
|
|
90
|
+
this.outbox = new AgentOutbox(sql, guildId);
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
/** The store's tables; the host runs this before any store attaches. */
|
|
@@ -88,9 +100,16 @@ export class PgAgentStore extends AgentBook implements AgentDirectory {
|
|
|
88
100
|
|
|
89
101
|
/** The store over the host's migrated pool, holding the rows of one guild. */
|
|
90
102
|
static async attach(sql: SQL, guildId: string): Promise<PgAgentStore> {
|
|
91
|
-
const store = new PgAgentStore(sql, guildId);
|
|
92
103
|
// `Roundtable.check()` has no database to read the rows from: the store starts empty.
|
|
93
|
-
|
|
104
|
+
const checking = isCheckDatabase(sql);
|
|
105
|
+
const store = new PgAgentStore(
|
|
106
|
+
sql,
|
|
107
|
+
guildId,
|
|
108
|
+
checking
|
|
109
|
+
? AgentConversations.memory()
|
|
110
|
+
: await AgentConversations.load(sql, guildId),
|
|
111
|
+
);
|
|
112
|
+
if (!checking) await store.#reload();
|
|
94
113
|
return store;
|
|
95
114
|
}
|
|
96
115
|
|