pi-roundtable 0.26.0 → 0.26.2
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 +58 -0
- package/docs/plugins.md +39 -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/config/config.ts +10 -2
- package/src/core/config/conversations.ts +21 -2
- package/src/core/contract/channels.ts +5 -0
- package/src/core/contract/surface.ts +5 -0
- package/src/core/conversations/conversation-retention.ts +22 -6
- package/src/core/conversations/conversations-plugin.ts +7 -1
- package/src/core/define-roundtable.ts +5 -1
- 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/holds.ts +9 -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 +36 -36
- package/src/core/runtime/extensions/gate-refusals.ts +32 -0
- package/src/core/runtime/extensions/hold-lookups.ts +31 -2
- package/src/core/runtime/extensions/late-answer.ts +5 -2
- 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
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
TurnResult,
|
|
8
8
|
} from "../domain/conversation.ts";
|
|
9
9
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
10
|
+
import type { IdentityService } from "../identity/identity-port.ts";
|
|
10
11
|
import type { OwnerIdentity } from "../identity.ts";
|
|
11
12
|
import type { Logger } from "../log.ts";
|
|
12
13
|
import type { EventSink } from "../plugin.ts";
|
|
@@ -85,6 +86,12 @@ export interface TeamTurnsOptions {
|
|
|
85
86
|
changed(): void;
|
|
86
87
|
/** Where turns and team changes are reported for the plugins' handlers. */
|
|
87
88
|
events?: EventSink;
|
|
89
|
+
/**
|
|
90
|
+
* The identity service, read when a kept agent message is replayed: its speaker runs again only
|
|
91
|
+
* as a principal that still exists and is enabled, at no more than its tier now. Without one, the
|
|
92
|
+
* stored speaker is kept.
|
|
93
|
+
*/
|
|
94
|
+
principals?: () => Pick<IdentityService, "speakerFor"> | undefined;
|
|
88
95
|
/** A thread for each message between agents; without it both go in the sender's channel. */
|
|
89
96
|
threads?: Pick<DispatchThreads, "open">;
|
|
90
97
|
}
|
|
@@ -94,8 +101,29 @@ export interface TurnExtra {
|
|
|
94
101
|
attachments?: TurnAttachments;
|
|
95
102
|
confirmed?: boolean;
|
|
96
103
|
steerable?: boolean;
|
|
104
|
+
/** A person's message started it. */
|
|
105
|
+
byMessage?: boolean;
|
|
97
106
|
/** The owner started it by writing, or it delivers a report, so it may ask them on cards. */
|
|
98
107
|
interactive?: boolean;
|
|
108
|
+
/** No person waits on the answer, so an empty one is no failure. */
|
|
109
|
+
unattended?: boolean;
|
|
110
|
+
/** How long the turn may work, in ms, in place of the default; a schedule's own limit. */
|
|
111
|
+
timeLimitMs?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The fields of `extra` that a turn request carries, left out where unset. */
|
|
115
|
+
export function turnFlags(extra: TurnExtra) {
|
|
116
|
+
return {
|
|
117
|
+
...(extra.attachments ? { attachments: extra.attachments } : {}),
|
|
118
|
+
...(extra.confirmed ? { confirmed: true } : {}),
|
|
119
|
+
...(extra.steerable ? { steerable: true } : {}),
|
|
120
|
+
...(extra.byMessage ? { byMessage: true } : {}),
|
|
121
|
+
...(extra.interactive ? { interactive: true } : {}),
|
|
122
|
+
...(extra.unattended ? { unattended: true } : {}),
|
|
123
|
+
...(extra.timeLimitMs === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { timeLimitMs: extra.timeLimitMs }),
|
|
126
|
+
};
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
/** What the team's message and group collaborators need of the turns that own their state. */
|
|
@@ -11,7 +11,7 @@ import { AgentError } from "../domain/errors.ts";
|
|
|
11
11
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
12
12
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
13
13
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
14
|
-
import { replyBody } from "../presentation/reply-body.ts";
|
|
14
|
+
import { isQuiet, replyBody } from "../presentation/reply-body.ts";
|
|
15
15
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
16
16
|
import { withReplyFiles } from "../reply-files.ts";
|
|
17
17
|
import { endOf, settleTurn } from "../routing/settle-turn.ts";
|
|
@@ -28,9 +28,9 @@ import {
|
|
|
28
28
|
coordinatorOf,
|
|
29
29
|
discordIdOf,
|
|
30
30
|
discordKey,
|
|
31
|
-
homeScope,
|
|
32
31
|
keysOf,
|
|
33
32
|
roomScope,
|
|
33
|
+
scopeOf,
|
|
34
34
|
} from "./team-keys.ts";
|
|
35
35
|
import {
|
|
36
36
|
type Chain,
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type TeamTurnsOptions,
|
|
39
39
|
type TurnExtra,
|
|
40
40
|
type TurnHost,
|
|
41
|
+
turnFlags,
|
|
41
42
|
} from "./team-turn-types.ts";
|
|
42
43
|
|
|
43
44
|
export { MAX_CHAIN_MESSAGES, type TeamTurnsOptions };
|
|
@@ -95,8 +96,8 @@ export class TeamTurns {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
|
-
* The owner's message in an agent's channel; call inside
|
|
99
|
-
*
|
|
99
|
+
* The owner's message in an agent's channel; call inside its queue. `text` carries the
|
|
100
|
+
* reference, `replyText` is what they typed.
|
|
100
101
|
*/
|
|
101
102
|
async answerOwner(
|
|
102
103
|
channel: ChannelKey,
|
|
@@ -106,7 +107,7 @@ export class TeamTurns {
|
|
|
106
107
|
attachments: TurnAttachments,
|
|
107
108
|
): Promise<TurnResult> {
|
|
108
109
|
const agent = this.#agentOf(channel);
|
|
109
|
-
const scope =
|
|
110
|
+
const scope = scopeOf(agent, channel);
|
|
110
111
|
const pending = await this.#options.runtime().heldActions(scope.session);
|
|
111
112
|
const confirmed =
|
|
112
113
|
pending !== undefined &&
|
|
@@ -122,29 +123,31 @@ export class TeamTurns {
|
|
|
122
123
|
attachments,
|
|
123
124
|
confirmed,
|
|
124
125
|
steerable: true,
|
|
126
|
+
byMessage: true,
|
|
125
127
|
interactive: true,
|
|
126
128
|
},
|
|
127
129
|
);
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
/**
|
|
131
|
-
* A turn in an agent's channel that no owner message started; call inside its queue. A report
|
|
132
|
-
* turn is interactive: it may ask the owner on cards there.
|
|
133
|
-
*/
|
|
132
|
+
/** A turn no owner message started; call inside its queue. A report turn is interactive. */
|
|
134
133
|
answerBackground(
|
|
135
134
|
channel: ChannelKey,
|
|
136
135
|
speaker: Speaker,
|
|
137
136
|
text: string,
|
|
138
137
|
interactive = false,
|
|
138
|
+
timeLimitMs?: number,
|
|
139
139
|
): Promise<TurnResult> {
|
|
140
140
|
const agent = this.#agentOf(channel);
|
|
141
141
|
return this.#turn(
|
|
142
142
|
agent,
|
|
143
|
-
|
|
143
|
+
scopeOf(agent, channel),
|
|
144
144
|
channel,
|
|
145
145
|
text,
|
|
146
146
|
{ messages: 0, speaker },
|
|
147
|
-
|
|
147
|
+
{
|
|
148
|
+
...(interactive ? { interactive: true } : { unattended: true }),
|
|
149
|
+
...(timeLimitMs === undefined ? {} : { timeLimitMs }),
|
|
150
|
+
},
|
|
148
151
|
);
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -190,13 +193,6 @@ export class TeamTurns {
|
|
|
190
193
|
return agent;
|
|
191
194
|
}
|
|
192
195
|
|
|
193
|
-
/** The agent's own conversation in the channel: its Discord channel's, or its room's. */
|
|
194
|
-
#scopeOf(agent: Agent, channel: ChannelKey): AgentTurnScope {
|
|
195
|
-
return discordIdOf(channel) === undefined
|
|
196
|
-
? roomScope(agent, channel)
|
|
197
|
-
: homeScope(agent);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
196
|
/**
|
|
201
197
|
* Runs one agent turn and posts its reply, or a failure notice, in `postTo` under the
|
|
202
198
|
* agent's name. Never rejects.
|
|
@@ -216,6 +212,9 @@ export class TeamTurns {
|
|
|
216
212
|
this.#chains.set(scope.session, chain);
|
|
217
213
|
this.#posting.set(scope.session, postTo);
|
|
218
214
|
this.#working.set(agent.name, postTo);
|
|
215
|
+
// A message to the agent lands where it last ran a turn; a group round is none.
|
|
216
|
+
if (!scope.group)
|
|
217
|
+
this.#options.store.conversations.keep(agent.name, postTo, logger);
|
|
219
218
|
this.#active(agent.name, scope.group);
|
|
220
219
|
const turn = {
|
|
221
220
|
agent: agent.name,
|
|
@@ -236,10 +235,7 @@ export class TeamTurns {
|
|
|
236
235
|
channel: postTo,
|
|
237
236
|
selection: this.#options.selection(scope),
|
|
238
237
|
text,
|
|
239
|
-
...(extra
|
|
240
|
-
...(extra.confirmed ? { confirmed: true } : {}),
|
|
241
|
-
...(extra.steerable ? { steerable: true } : {}),
|
|
242
|
-
...(extra.interactive ? { interactive: true } : {}),
|
|
238
|
+
...turnFlags(extra),
|
|
243
239
|
agent: scope,
|
|
244
240
|
speaker: chain.speaker,
|
|
245
241
|
...(interim ? { interim } : {}),
|
|
@@ -266,7 +262,12 @@ export class TeamTurns {
|
|
|
266
262
|
"agent turn failed",
|
|
267
263
|
);
|
|
268
264
|
}
|
|
269
|
-
|
|
265
|
+
if (isQuiet(result))
|
|
266
|
+
logger.info(
|
|
267
|
+
{ agent: agent.name, channel: postTo },
|
|
268
|
+
"unattended turn had nothing to say; nothing posted",
|
|
269
|
+
);
|
|
270
|
+
else await this.#post(postTo, this.#current(agent), replyBody(result));
|
|
270
271
|
} catch (error) {
|
|
271
272
|
logger.error({ agent: agent.name, err: error }, "agent reply not posted");
|
|
272
273
|
} finally {
|
|
@@ -459,8 +460,18 @@ export class TeamTurns {
|
|
|
459
460
|
await this.#post(home, coordinator, { chunks: splitReply(text) });
|
|
460
461
|
}
|
|
461
462
|
|
|
462
|
-
message(
|
|
463
|
-
return this.#messages.message(
|
|
463
|
+
message(...args: Parameters<AgentMessages["message"]>): string {
|
|
464
|
+
return this.#messages.message(...args);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Delivers the agent messages the last run kept while it stopped; never rejects. */
|
|
468
|
+
replayMessages(): Promise<void> {
|
|
469
|
+
return this.#messages.replay();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** What the team still writes that a shutdown must wait for. */
|
|
473
|
+
busy(): string[] {
|
|
474
|
+
return this.#messages.busy();
|
|
464
475
|
}
|
|
465
476
|
|
|
466
477
|
/**
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
You are an agent in
|
|
1
|
+
You are an agent in an agent server. The person you are talking with is a guest of that server, named in the last section of this prompt. You are not speaking for the server's owner, and the owner's private facts stay theirs.
|
|
2
2
|
|
|
3
3
|
Reply in the language they write in. Be warm, direct, and brief.
|
|
4
4
|
|
|
5
|
-
Write in Discord's Markdown: bold, italics, headings, bullet and numbered lists, block quotes, inline code, fenced code blocks, and `-#` small text all render. Discord shows Markdown tables as raw pipes, so list figures as bullets instead.
|
|
6
|
-
|
|
7
5
|
Your context holds only their own core facts and upcoming events. When a request touches their projects, people, tools, or past, search memory first. When they ask you to remember or forget something, use the memory tools; what you store is theirs alone.
|
|
8
6
|
|
|
9
7
|
When you need their decision to continue, ask with ask_user instead of ending your turn with a question; they answer on buttons in the channel and your turn goes on.
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
You are an agent
|
|
1
|
+
You are an agent working for the owner of this agent server. You talk with them in channels, rooms and threads.
|
|
2
2
|
|
|
3
3
|
Reply in the language they write in. Be warm, direct, and brief.
|
|
4
4
|
|
|
5
|
-
Write in Discord's Markdown: bold, italics, headings, bullet and numbered lists, block quotes, inline code, fenced code blocks, and `-#` small text all render. Discord shows Markdown tables as raw pipes, so list figures as bullets instead.
|
|
6
|
-
|
|
7
5
|
Your context holds only their core facts and upcoming events. When a request touches their projects, people, tools, or past, search memory first. When they ask you to remember or forget something, use the memory tools.
|
|
8
6
|
|
|
9
7
|
When you need their decision to continue, ask with ask_user instead of ending your turn with a question; they answer on buttons in the channel and your turn goes on.
|
|
@@ -26,6 +26,8 @@ import type { AgentSessionsSlot } from "../runtime/runtime-plugin.ts";
|
|
|
26
26
|
import {
|
|
27
27
|
AGENTS,
|
|
28
28
|
BACKGROUND_TURNS,
|
|
29
|
+
IDENTITY,
|
|
30
|
+
MODES,
|
|
29
31
|
PROMPTS,
|
|
30
32
|
RUNTIME,
|
|
31
33
|
SCHEDULES,
|
|
@@ -316,6 +318,8 @@ export function agentServerPlugin(
|
|
|
316
318
|
? { guestPrompt: guestPrompt ?? options.prompts.guest }
|
|
317
319
|
: {}),
|
|
318
320
|
...(skills ? { skills } : {}),
|
|
321
|
+
modes: () => context.services.find(MODES),
|
|
322
|
+
principals: () => context.services.find(IDENTITY),
|
|
319
323
|
...(threads ? { threads } : {}),
|
|
320
324
|
logger,
|
|
321
325
|
});
|
|
@@ -387,6 +391,7 @@ export function agentServerPlugin(
|
|
|
387
391
|
{
|
|
388
392
|
name: AGENT_TEAM_SERVICE,
|
|
389
393
|
stop: () => built.stop(),
|
|
394
|
+
busy: () => built.busy(),
|
|
390
395
|
// The dashboard shows the team, so it starts once the team has; the error
|
|
391
396
|
// reporter connects whether or not the team did, since it reports the failure.
|
|
392
397
|
startInBackground: async () => {
|
|
@@ -125,6 +125,9 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
125
125
|
// Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
|
|
126
126
|
const agentChannelOf = (agent: string, near: ChannelKey) =>
|
|
127
127
|
services.get(AGENTS).team.channelOf(agent, near);
|
|
128
|
+
// All of an agent's conversations, for its own schedules; asked when a tool runs.
|
|
129
|
+
const ownConversations = (agent: string) =>
|
|
130
|
+
services.get(AGENTS).team.roomsOf(agent);
|
|
128
131
|
// A conversation no chat surface carries keeps its runs in the creator's direct messages, where
|
|
129
132
|
// a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
|
|
130
133
|
const channelFor = async (
|
|
@@ -303,6 +306,9 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
303
306
|
session.homeChannel,
|
|
304
307
|
session.agent ? agentChannelOf : undefined,
|
|
305
308
|
session.speaker,
|
|
309
|
+
session.agent
|
|
310
|
+
? () => ownConversations(session.agent?.name ?? "")
|
|
311
|
+
: undefined,
|
|
306
312
|
),
|
|
307
313
|
),
|
|
308
314
|
),
|
|
@@ -271,7 +271,10 @@ export interface ResolvedConfig extends ResolvedLimits {
|
|
|
271
271
|
/** Absent `retention`: used files live as long as their conversation. */
|
|
272
272
|
attachments: AttachmentsConfig;
|
|
273
273
|
/** Days to keep each conversation kind after its last activity; a kind not listed is kept. */
|
|
274
|
-
conversations: {
|
|
274
|
+
conversations: {
|
|
275
|
+
keepDays: Readonly<Record<string, number>>;
|
|
276
|
+
keep?: ConversationsConfig["keep"];
|
|
277
|
+
};
|
|
275
278
|
agentDir: string;
|
|
276
279
|
model: ModelRef;
|
|
277
280
|
thinking: ThinkingLevel;
|
|
@@ -373,7 +376,12 @@ export function resolveConfig(input: unknown): ResolvedConfig {
|
|
|
373
376
|
attachments: config.attachments?.retention
|
|
374
377
|
? { retention: config.attachments.retention }
|
|
375
378
|
: {},
|
|
376
|
-
conversations: {
|
|
379
|
+
conversations: {
|
|
380
|
+
keepDays: { ...config.conversations?.keepDays },
|
|
381
|
+
...(config.conversations?.keep
|
|
382
|
+
? { keep: config.conversations.keep }
|
|
383
|
+
: {}),
|
|
384
|
+
},
|
|
377
385
|
agentDir: config.agentDir ?? `${config.dataDir}/pi`,
|
|
378
386
|
model,
|
|
379
387
|
thinking,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ConversationRecord } from "../conversations/conversation-registry.ts";
|
|
2
|
+
import { guarded, integer, optional, record, shape } from "./schema.ts";
|
|
2
3
|
|
|
3
4
|
/** The configuration's `conversations`: what the host does with the conversations it has recorded. */
|
|
4
5
|
export interface ConversationsConfig {
|
|
@@ -10,6 +11,15 @@ export interface ConversationsConfig {
|
|
|
10
11
|
* which is what a host without this key does.
|
|
11
12
|
*/
|
|
12
13
|
keepDays?: Readonly<Record<string, number>>;
|
|
14
|
+
/**
|
|
15
|
+
* Asked for each conversation the sweep would delete, after its kind's `keepDays` ran out:
|
|
16
|
+
* answer `true` to keep it for now, such as a support conversation whose ticket is still open.
|
|
17
|
+
* It is asked again at the next sweep, so a conversation kept today is deleted once the host
|
|
18
|
+
* stops wanting it. A function that throws or rejects keeps the conversation for this sweep and
|
|
19
|
+
* is logged. Without it
|
|
20
|
+
* every conversation past its days is deleted.
|
|
21
|
+
*/
|
|
22
|
+
keep?: (conversation: ConversationRecord) => boolean | Promise<boolean>;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/** The longest keep the configuration accepts, in days: a hundred years. */
|
|
@@ -17,5 +27,14 @@ const MAX_DAYS = 36_500;
|
|
|
17
27
|
|
|
18
28
|
/** The `conversations` key's shape. */
|
|
19
29
|
export const conversationsShape = optional(
|
|
20
|
-
shape({
|
|
30
|
+
shape({
|
|
31
|
+
keepDays: optional(record(integer(1, MAX_DAYS))),
|
|
32
|
+
keep: optional(
|
|
33
|
+
guarded(
|
|
34
|
+
"a function from a conversation record to true to keep it",
|
|
35
|
+
(value): value is NonNullable<ConversationsConfig["keep"]> =>
|
|
36
|
+
typeof value === "function",
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
}),
|
|
21
40
|
);
|
|
@@ -153,6 +153,11 @@ export interface BackgroundTurn {
|
|
|
153
153
|
text: string;
|
|
154
154
|
/** It delivers a report the owner is waiting for, so it may ask them on cards. */
|
|
155
155
|
report?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* How long the turn may work, in ms, in place of the runtime's default time limit: a
|
|
158
|
+
* schedule's own. The runtime holds it under the long-running cap. Absent = the default.
|
|
159
|
+
*/
|
|
160
|
+
timeLimitMs?: number;
|
|
156
161
|
/**
|
|
157
162
|
* Who the turn runs as, set by the router alone once it checked the author: their principal
|
|
158
163
|
* exists and is not disabled, and the tier is capped at theirs. A claim runs the turn as this
|
|
@@ -79,6 +79,11 @@ export interface SurfaceThreads {
|
|
|
79
79
|
export interface ChatSurface {
|
|
80
80
|
/** The key prefix of this surface's channels, such as "discord"; unique per host. */
|
|
81
81
|
readonly surface: string;
|
|
82
|
+
/**
|
|
83
|
+
* What people call this surface, such as "Discord" or "the web app"; the agent prompt names it
|
|
84
|
+
* as the place a conversation is in. Absent = the key prefix.
|
|
85
|
+
*/
|
|
86
|
+
readonly label?: string;
|
|
82
87
|
/** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
|
|
83
88
|
readonly supportsFiles?: boolean;
|
|
84
89
|
/**
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ConversationPort } from "../contract/channels.ts";
|
|
2
2
|
import type { Logger } from "../log.ts";
|
|
3
3
|
import type { Service } from "../plugin.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ConversationRecord,
|
|
6
|
+
ConversationRegistry,
|
|
7
|
+
} from "./conversation-registry.ts";
|
|
5
8
|
|
|
6
9
|
const DAY_MS = 24 * 60 * 60_000;
|
|
7
10
|
|
|
@@ -12,6 +15,8 @@ export interface ConversationRetentionOptions {
|
|
|
12
15
|
conversations: Pick<ConversationPort, "deleteConversation">;
|
|
13
16
|
/** Days to keep each kind after its last activity; a kind not listed is kept. */
|
|
14
17
|
keepDays: Readonly<Record<string, number>>;
|
|
18
|
+
/** True keeps a conversation past its days for this sweep; a throw keeps it too and is logged. */
|
|
19
|
+
keep?: (record: ConversationRecord) => boolean | Promise<boolean>;
|
|
15
20
|
now: () => Date;
|
|
16
21
|
logger: Logger;
|
|
17
22
|
}
|
|
@@ -21,8 +26,10 @@ export interface ConversationSweep {
|
|
|
21
26
|
deleted: number;
|
|
22
27
|
/** A turn was running or waiting: tried again at the next sweep. */
|
|
23
28
|
busy: number;
|
|
24
|
-
/** Its claim could not delete it: logged, and tried again at the next sweep. */
|
|
29
|
+
/** Its claim could not delete it, or `keep` threw: logged, and tried again at the next sweep. */
|
|
25
30
|
failed: number;
|
|
31
|
+
/** `keep` wanted it: asked again at the next sweep. */
|
|
32
|
+
kept: number;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -33,8 +40,13 @@ export interface ConversationSweep {
|
|
|
33
40
|
export async function sweepConversations(
|
|
34
41
|
options: ConversationRetentionOptions,
|
|
35
42
|
): Promise<ConversationSweep> {
|
|
36
|
-
const { registry, conversations, keepDays, now, logger } = options;
|
|
37
|
-
const result: ConversationSweep = {
|
|
43
|
+
const { registry, conversations, keepDays, keep, now, logger } = options;
|
|
44
|
+
const result: ConversationSweep = {
|
|
45
|
+
deleted: 0,
|
|
46
|
+
busy: 0,
|
|
47
|
+
failed: 0,
|
|
48
|
+
kept: 0,
|
|
49
|
+
};
|
|
38
50
|
const at = now().getTime();
|
|
39
51
|
for (const record of await registry.list()) {
|
|
40
52
|
const days = keepDays[record.kind];
|
|
@@ -44,6 +56,10 @@ export async function sweepConversations(
|
|
|
44
56
|
)
|
|
45
57
|
continue;
|
|
46
58
|
try {
|
|
59
|
+
if (keep && (await keep(record))) {
|
|
60
|
+
result.kept += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
47
63
|
const outcome = await conversations.deleteConversation(record.key);
|
|
48
64
|
if (outcome === "busy") {
|
|
49
65
|
result.busy += 1;
|
|
@@ -55,7 +71,7 @@ export async function sweepConversations(
|
|
|
55
71
|
result.failed += 1;
|
|
56
72
|
logger.warn(
|
|
57
73
|
{ channel: record.key, kind: record.kind, err: error },
|
|
58
|
-
"conversation past its keepDays not deleted",
|
|
74
|
+
"conversation past its keepDays not deleted (keep or its claim failed)",
|
|
59
75
|
);
|
|
60
76
|
}
|
|
61
77
|
}
|
|
@@ -77,7 +93,7 @@ export function conversationRetentionService(
|
|
|
77
93
|
const sweep = async (): Promise<void> => {
|
|
78
94
|
try {
|
|
79
95
|
const result = await sweepConversations(options);
|
|
80
|
-
if (result.deleted + result.busy + result.failed > 0)
|
|
96
|
+
if (result.deleted + result.busy + result.failed + result.kept > 0)
|
|
81
97
|
options.logger.info(result, "conversations past their keepDays swept");
|
|
82
98
|
} catch (error) {
|
|
83
99
|
options.logger.warn(
|
|
@@ -2,7 +2,10 @@ import type { SQL } from "bun";
|
|
|
2
2
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
3
3
|
import { CONVERSATIONS } from "../services.ts";
|
|
4
4
|
import type { ConversationRegistry } from "./conversation-registry.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type ConversationRetentionOptions,
|
|
7
|
+
conversationRetentionService,
|
|
8
|
+
} from "./conversation-retention.ts";
|
|
6
9
|
import { PgConversationRegistry } from "./conversation-store.ts";
|
|
7
10
|
|
|
8
11
|
/** The name of the conversation registry's plugin, as the migration ledger names it. */
|
|
@@ -19,6 +22,8 @@ export function conversationsPlugin(
|
|
|
19
22
|
PgConversationRegistry.attach(sql),
|
|
20
23
|
/** Days to keep each conversation kind after its last activity (`conversations.keepDays`); none = keep them all. */
|
|
21
24
|
keepDays: Readonly<Record<string, number>> = {},
|
|
25
|
+
/** Asked before a conversation past its days is deleted (`conversations.keep`); true keeps it. */
|
|
26
|
+
keep?: ConversationRetentionOptions["keep"],
|
|
22
27
|
): RoundtablePlugin {
|
|
23
28
|
return {
|
|
24
29
|
name: CONVERSATIONS_PLUGIN,
|
|
@@ -35,6 +40,7 @@ export function conversationsPlugin(
|
|
|
35
40
|
registry,
|
|
36
41
|
conversations,
|
|
37
42
|
keepDays,
|
|
43
|
+
...(keep ? { keep } : {}),
|
|
38
44
|
now: () => env.now(),
|
|
39
45
|
logger,
|
|
40
46
|
}),
|
|
@@ -372,7 +372,11 @@ export async function defineRoundtable(
|
|
|
372
372
|
}),
|
|
373
373
|
...(assembly?.admin ?? []),
|
|
374
374
|
...(agentTeam?.skills ?? []),
|
|
375
|
-
conversationsPlugin(
|
|
375
|
+
conversationsPlugin(
|
|
376
|
+
undefined,
|
|
377
|
+
config.conversations.keepDays,
|
|
378
|
+
config.conversations.keep,
|
|
379
|
+
),
|
|
376
380
|
modesPlugin(),
|
|
377
381
|
runtimePlugin({
|
|
378
382
|
owner,
|
|
@@ -14,6 +14,7 @@ import { parseChannelKey } from "../contract/surface.ts";
|
|
|
14
14
|
import { MAX_TURN_IMAGES } from "../domain/attachment.ts";
|
|
15
15
|
import type { Logger } from "../log.ts";
|
|
16
16
|
import { contextImages, type SkippedImage } from "./context-images.ts";
|
|
17
|
+
import { embedText } from "./embed-text.ts";
|
|
17
18
|
import type { BotIdentity } from "./inbound-message.ts";
|
|
18
19
|
|
|
19
20
|
/** The most bytes one image may be: the core's limit for any attachment. */
|
|
@@ -122,7 +123,8 @@ export function contextSourceOf(
|
|
|
122
123
|
if (!user) return mention;
|
|
123
124
|
return `@${members?.get(id)?.displayName ?? user.globalName ?? user.username}`;
|
|
124
125
|
});
|
|
125
|
-
const
|
|
126
|
+
const embeds = embedText(message.embeds);
|
|
127
|
+
const parts = [text.trim(), embeds].filter(Boolean);
|
|
126
128
|
for (const sticker of message.stickers.values())
|
|
127
129
|
parts.push(`[sticker: ${sticker.name}]`);
|
|
128
130
|
for (const file of message.attachments.values())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Embed } from "discord.js";
|
|
2
|
+
|
|
3
|
+
/** The most characters the embeds of one message add to its text; the rest is cut and marked. */
|
|
4
|
+
export const EMBED_TEXT_CHARS = 4_000;
|
|
5
|
+
|
|
6
|
+
/** What of an embed is read: its words and link, never its pictures, colors, or timestamp. */
|
|
7
|
+
type EmbedFacts = Pick<
|
|
8
|
+
Embed,
|
|
9
|
+
"title" | "description" | "url" | "fields" | "footer" | "author"
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
const CUT = "…";
|
|
13
|
+
|
|
14
|
+
function embedLines(embed: EmbedFacts): string[] {
|
|
15
|
+
const lines: string[] = [];
|
|
16
|
+
const head = [embed.author?.name, embed.title].filter(Boolean).join(" — ");
|
|
17
|
+
if (head) lines.push(head);
|
|
18
|
+
if (embed.description) lines.push(embed.description);
|
|
19
|
+
for (const field of embed.fields) lines.push(`${field.name}: ${field.value}`);
|
|
20
|
+
if (embed.footer?.text) lines.push(embed.footer.text);
|
|
21
|
+
if (embed.url) lines.push(embed.url);
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The words of a message's embeds as plain text, one block per embed (title, description, each
|
|
27
|
+
* field as `name: value`, footer, link), so a post that is only an embed, such as an alert a
|
|
28
|
+
* webhook posts, reads as text. Discord renders a Slack-compatible webhook's attachments as
|
|
29
|
+
* embeds, so those are covered too. Text only, nothing is wrapped in markup; at most
|
|
30
|
+
* `EMBED_TEXT_CHARS` characters; empty for a message without embeds.
|
|
31
|
+
*/
|
|
32
|
+
export function embedText(embeds: readonly EmbedFacts[]): string {
|
|
33
|
+
const text = embeds
|
|
34
|
+
.map((embed) => embedLines(embed).join("\n").replace(/\r\n?/g, "\n").trim())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join("\n\n");
|
|
37
|
+
return text.length > EMBED_TEXT_CHARS
|
|
38
|
+
? `${text.slice(0, EMBED_TEXT_CHARS - CUT.length)}${CUT}`
|
|
39
|
+
: text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A message's own text and its embeds' text, apart by a blank line; either may be empty. */
|
|
43
|
+
export function withEmbeds(content: string, embeds: readonly EmbedFacts[]) {
|
|
44
|
+
return [content.trim(), embedText(embeds)].filter(Boolean).join("\n\n");
|
|
45
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type Message, MessageReferenceType } from "discord.js";
|
|
|
2
2
|
import type { InboundMessage } from "../contract/channels.ts";
|
|
3
3
|
import { channelKey } from "../contract/surface.ts";
|
|
4
4
|
import type { AttachmentRef } from "../domain/attachment.ts";
|
|
5
|
+
import { withEmbeds } from "./embed-text.ts";
|
|
5
6
|
import type { LateTurn } from "./owner-cards.ts";
|
|
6
7
|
|
|
7
8
|
/** Who the bot is on Discord, as far as the client knows it. */
|
|
@@ -25,7 +26,9 @@ function attachmentRefs(
|
|
|
25
26
|
|
|
26
27
|
function referenceOf(referenced: Message, mention: RegExp) {
|
|
27
28
|
return {
|
|
28
|
-
text: referenced.content
|
|
29
|
+
text: withEmbeds(referenced.content, referenced.embeds)
|
|
30
|
+
.replace(mention, "")
|
|
31
|
+
.trim(),
|
|
29
32
|
attachments: attachmentRefs(referenced),
|
|
30
33
|
...(referenced.webhookId
|
|
31
34
|
? { webhookName: referenced.author.username }
|
|
@@ -97,7 +100,9 @@ export async function toInbound(
|
|
|
97
100
|
...(message.guildId ? { space: message.guildId } : {}),
|
|
98
101
|
mentionsBot: message.mentions.users.has(botId),
|
|
99
102
|
repliesToBot: referenced?.author.id === botId,
|
|
100
|
-
text: message.content
|
|
103
|
+
text: withEmbeds(message.content, message.embeds)
|
|
104
|
+
.replace(mention, "")
|
|
105
|
+
.trim(),
|
|
101
106
|
attachments: [
|
|
102
107
|
...attachmentRefs(message),
|
|
103
108
|
...(forward ? attachmentRefs(forward) : []),
|
package/src/core/domain/ports.ts
CHANGED
|
@@ -60,12 +60,29 @@ export interface TurnRequest {
|
|
|
60
60
|
kind?: string;
|
|
61
61
|
/** The owner's own chat turn, which their next messages may steer. */
|
|
62
62
|
steerable?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* A person's message started the turn, so the speaker is present: only such a turn spends a late
|
|
65
|
+
* approval's release. Set by message ingest alone, never by a schedule, agent message, report or error turn.
|
|
66
|
+
*/
|
|
67
|
+
byMessage?: boolean;
|
|
63
68
|
/**
|
|
64
69
|
* The owner started the turn by writing in its channel, or it delivers a dispatch's report,
|
|
65
70
|
* so it may ask them there on cards: approvals of held actions and ask_user questions. A
|
|
66
71
|
* schedule's turn is not.
|
|
67
72
|
*/
|
|
68
73
|
interactive?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* No person waits on the turn's answer: a schedule's turn, an agent's message, an agent's
|
|
76
|
+
* opening task. A turn like that may end with nothing to say, which is an empty answer
|
|
77
|
+
* instead of a failure; the reply is then not posted. Absent = someone waits, and an empty
|
|
78
|
+
* final answer fails.
|
|
79
|
+
*/
|
|
80
|
+
unattended?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* How long the turn may work, in ms, in place of the runtime's default time limit; a schedule's
|
|
83
|
+
* own limit. The runtime never lets it pass the long-running cap. Absent = the default.
|
|
84
|
+
*/
|
|
85
|
+
timeLimitMs?: number;
|
|
69
86
|
/**
|
|
70
87
|
* Where the turn posts the text it writes before its final answer, as it goes; the caller
|
|
71
88
|
* hands the channel its reply goes to. Absent = only the final reply is posted.
|
|
@@ -24,6 +24,8 @@ export interface TurnTimeout {
|
|
|
24
24
|
/** How long the timeout card waits for an answer. */
|
|
25
25
|
graceMs: number;
|
|
26
26
|
end: TurnTimeoutEnd;
|
|
27
|
+
/** No timeout card could be shown, as in a schedule's turn, so nobody was asked whether to go on. */
|
|
28
|
+
unasked?: true;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** A turn aborted at a time limit; `timeout` says why, and the message says it in English. */
|
package/src/core/holds.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface HoldContext {
|
|
|
47
47
|
*
|
|
48
48
|
* `classes` are the hold classes of the call, for a rule that describes it in a turn (see
|
|
49
49
|
* `HoldRule.holdClasses`); a rule that leaves them out leaves its call unclassed.
|
|
50
|
+
*
|
|
51
|
+
* `onDeclined` is called once when the call's approval card is declined, whether the turn was
|
|
52
|
+
* still waiting on it or the owner declined after the turn stopped waiting, so a plugin can close
|
|
53
|
+
* what it opened for the call, such as its own notice that asks for the approval. It is not
|
|
54
|
+
* called when the call is withdrawn through `supersede` (the plugin did that itself), approved,
|
|
55
|
+
* left unanswered or expired, or dropped by a message that does not confirm it: only a declined
|
|
56
|
+
* card calls it, in the process that holds the card (a restart loses it with the card). It
|
|
57
|
+
* must not throw; whatever it throws or rejects with is ignored, and the call is refused either way.
|
|
50
58
|
*/
|
|
51
59
|
export type HoldDescription =
|
|
52
60
|
| string
|
|
@@ -56,6 +64,7 @@ export type HoldDescription =
|
|
|
56
64
|
supersede?: AbortSignal;
|
|
57
65
|
approval?: "speaker";
|
|
58
66
|
classes?: readonly string[];
|
|
67
|
+
onDeclined?: () => void | Promise<void>;
|
|
59
68
|
};
|
|
60
69
|
|
|
61
70
|
/**
|