pi-roundtable 0.27.0 → 0.28.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 +27 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/docs/plugins.md +29 -13
- package/package.json +1 -1
- package/src/characters/compose.ts +11 -0
- package/src/characters/parts-face.ts +23 -2
- package/src/core/agents/agent-messages.ts +32 -85
- package/src/core/agents/agent-ports.ts +3 -0
- package/src/core/agents/agent-prompt.ts +2 -16
- package/src/core/agents/agent-tools.ts +1 -1
- package/src/core/agents/helper-create.ts +3 -24
- package/src/core/agents/helper-modes.ts +64 -0
- package/src/core/agents/team-turn-types.ts +7 -13
- package/src/core/agents/team-turns.ts +7 -2
- package/src/core/builtin/agent-server.ts +3 -4
- package/src/core/builtin/discord.ts +35 -8
- package/src/core/config/adapters.ts +2 -1
- package/src/core/config/limits.ts +35 -2
- package/src/core/discord/agent-discord.ts +12 -1
- package/src/core/discord/card-copies.ts +7 -2
- package/src/core/discord/discord-surface.ts +23 -38
- package/src/core/discord/intercom-cards.ts +121 -0
- package/src/core/discord/owner-card-asks.ts +9 -0
- package/src/core/discord/owner-card-types.ts +2 -0
- package/src/core/discord/owner-cards.ts +2 -1
- package/src/core/discord/stop-button.ts +35 -0
- package/src/core/domain/conversation.ts +18 -0
- package/src/core/domain/interim.ts +7 -0
- package/src/core/domain/turn-timeout.ts +5 -0
- package/src/core/i18n/conversation.ts +16 -4
- package/src/core/i18n/turn-timeout.ts +10 -2
- package/src/core/interactions/prompts.ts +5 -0
- package/src/core/modes/autonomy.ts +22 -1
- package/src/core/modes/conversation-modes.ts +135 -23
- package/src/core/modes/mode-inheritance.ts +78 -0
- package/src/core/modes/modes-plugin.ts +1 -1
- package/src/core/modules/skills/skill-contract.ts +2 -0
- package/src/core/presentation/intercom.ts +44 -0
- package/src/core/runtime/ask-questions.ts +21 -4
- package/src/core/runtime/extensions/ask-user-text.ts +36 -3
- package/src/core/runtime/extensions/ask-user.ts +21 -1
- package/src/core/runtime/interim-text.ts +7 -4
- package/src/core/runtime/pi-agent-runtime.ts +2 -2
- package/src/core/runtime/prompt-slot.ts +11 -1
- package/src/core/runtime/session-factory.ts +11 -6
- package/src/core/runtime/turn-budget.ts +34 -3
- package/src/core/runtime/turn-limits.ts +13 -3
- package/src/index.ts +1 -0
|
@@ -2,12 +2,11 @@ import type { CharacterInput } from "../../characters/index.ts";
|
|
|
2
2
|
import { helpersOf } from "../config/helpers.ts";
|
|
3
3
|
import { AgentError } from "../domain/errors.ts";
|
|
4
4
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
5
|
-
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
6
5
|
import type { Speaker } from "../speakers.ts";
|
|
7
6
|
import { toolTiers } from "../tool-tiers.ts";
|
|
8
7
|
import { isHelper } from "./agent-rules.ts";
|
|
9
8
|
import type { Agent } from "./agent-store.ts";
|
|
10
|
-
import {
|
|
9
|
+
import { followOrigin } from "./helper-modes.ts";
|
|
11
10
|
import type { TeamContext } from "./team-options.ts";
|
|
12
11
|
|
|
13
12
|
/** The team's way of making an agent, which `createHelper` uses to make a temp knight. */
|
|
@@ -98,10 +97,10 @@ export async function createHelper(
|
|
|
98
97
|
);
|
|
99
98
|
const helper = made.agent;
|
|
100
99
|
try {
|
|
101
|
-
await
|
|
100
|
+
await followOrigin(ctx, caller, speaker, helper);
|
|
102
101
|
ctx.turns.message(caller, helper.name, task);
|
|
103
102
|
} catch (error) {
|
|
104
|
-
// The
|
|
103
|
+
// The modes or the task could not be handed over (a chain at its cap): the temp
|
|
105
104
|
// knight is not left idle, and its thread is closed.
|
|
106
105
|
await ctx.archiveHelpers([helper.name]);
|
|
107
106
|
throw error;
|
|
@@ -158,23 +157,3 @@ async function makeRoom(
|
|
|
158
157
|
await ctx.archiveHelpers([oldest.name]);
|
|
159
158
|
return ` To make room, "${oldest.displayName}" (${oldest.name}), your oldest finished temp knight, was archived.`;
|
|
160
159
|
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Turns long-running on for the temp knight's conversations when the conversation that asked for
|
|
164
|
-
* it has it on, so it works as long as its parent may. This adds no permission: an owner turned
|
|
165
|
-
* it on there. Only a turn of an owner can carry that over, as for any change of modes.
|
|
166
|
-
*/
|
|
167
|
-
async function inheritLongRunning(
|
|
168
|
-
ctx: TeamContext,
|
|
169
|
-
caller: AgentTurnScope,
|
|
170
|
-
speaker: Speaker,
|
|
171
|
-
helper: Agent,
|
|
172
|
-
): Promise<void> {
|
|
173
|
-
const { store, surfaces } = ctx.options;
|
|
174
|
-
const modes = ctx.options.modes?.();
|
|
175
|
-
if (!modes?.peek(ctx.turns.turnChannel(caller)).longRunning) return;
|
|
176
|
-
if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL)
|
|
177
|
-
return;
|
|
178
|
-
for (const key of keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []))
|
|
179
|
-
await modes.set(key, { longRunning: true }, speaker);
|
|
180
|
-
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
2
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
3
|
+
import type { ModeOrigins } from "../modes/mode-inheritance.ts";
|
|
4
|
+
import type { AgentDirectory } from "../services.ts";
|
|
5
|
+
import type { Speaker } from "../speakers.ts";
|
|
6
|
+
import { isHelper } from "./agent-rules.ts";
|
|
7
|
+
import type { Agent } from "./agent-store.ts";
|
|
8
|
+
import { agentOfChannel, groupOfChannel, keysOf } from "./team-keys.ts";
|
|
9
|
+
import type { TeamContext } from "./team-options.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Makes the temp knight's conversations follow the modes of the conversation that asked for it,
|
|
13
|
+
* live, so turning yolo or long-running on or off there applies to it at once. This adds no
|
|
14
|
+
* permission: an owner turns the modes on there. Only a turn of an owner can carry them over, as
|
|
15
|
+
* for any change of modes; any other turn's temp knight starts with its own, both off, whatever
|
|
16
|
+
* an earlier agent of the same name followed.
|
|
17
|
+
*/
|
|
18
|
+
export async function followOrigin(
|
|
19
|
+
ctx: TeamContext,
|
|
20
|
+
caller: AgentTurnScope,
|
|
21
|
+
speaker: Speaker,
|
|
22
|
+
helper: Agent,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const { store, surfaces } = ctx.options;
|
|
25
|
+
const modes = ctx.options.modes?.();
|
|
26
|
+
if (!modes) return;
|
|
27
|
+
const keys = keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []);
|
|
28
|
+
if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL) {
|
|
29
|
+
for (const key of keys) await modes.forget(key);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await modes.follow(keys, ctx.turns.turnChannel(caller), speaker);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the agent server says of a conversation that follows another's modes: it still does while
|
|
37
|
+
* it is a temp knight's conversation made from that origin, named after the temp knight's parent;
|
|
38
|
+
* the origin is gone once the agent or group whose conversation it is (else the parent) is
|
|
39
|
+
* archived or deleted.
|
|
40
|
+
*/
|
|
41
|
+
export function modeOrigins(
|
|
42
|
+
store: Pick<
|
|
43
|
+
AgentDirectory,
|
|
44
|
+
"agent" | "agentByChannel" | "group" | "groupByChannel"
|
|
45
|
+
>,
|
|
46
|
+
hosts: { roomSurfaces(): readonly string[] },
|
|
47
|
+
): ModeOrigins {
|
|
48
|
+
return {
|
|
49
|
+
originOf: (channel, origin) => {
|
|
50
|
+
const helper = agentOfChannel(store, hosts, channel);
|
|
51
|
+
if (!helper || !isHelper(helper) || helper.origin !== origin)
|
|
52
|
+
return undefined;
|
|
53
|
+
const parent = store.agent(helper.parent as string);
|
|
54
|
+
const owner =
|
|
55
|
+
agentOfChannel(store, hosts, origin) ??
|
|
56
|
+
groupOfChannel(store, hosts, origin) ??
|
|
57
|
+
parent;
|
|
58
|
+
return {
|
|
59
|
+
name: parent?.displayName ?? (helper.parent as string),
|
|
60
|
+
alive: owner?.status === "active",
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { SurfacePort } from "../contract/surface.ts";
|
|
|
3
3
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
4
4
|
import type {
|
|
5
5
|
ChannelKey,
|
|
6
|
+
IntercomCard,
|
|
6
7
|
PendingConfirmation,
|
|
7
8
|
TurnResult,
|
|
8
9
|
} from "../domain/conversation.ts";
|
|
@@ -14,10 +15,6 @@ import type { EventSink } from "../plugin.ts";
|
|
|
14
15
|
import type { AvatarStudio } from "../services.ts";
|
|
15
16
|
import type { TurnSelection } from "../sessions.ts";
|
|
16
17
|
import type { Speaker } from "../speakers.ts";
|
|
17
|
-
import type {
|
|
18
|
-
DispatchThread,
|
|
19
|
-
DispatchThreads,
|
|
20
|
-
} from "../threads/dispatch-threads.ts";
|
|
21
18
|
import type { ToolTiers } from "../tool-tiers.ts";
|
|
22
19
|
import type { AgentChannels, AgentTurnRunner } from "./agent-ports.ts";
|
|
23
20
|
import type { Agent, PgAgentStore } from "./agent-store.ts";
|
|
@@ -36,12 +33,6 @@ export interface Chain {
|
|
|
36
33
|
speaker: Speaker;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
/** A message's thread in the sender's turn channel, which holds the request and the answer. */
|
|
40
|
-
export interface Exchange {
|
|
41
|
-
parent: ChannelKey;
|
|
42
|
-
thread: DispatchThread;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
export interface TeamTurnsOptions {
|
|
46
37
|
/** The agent server's entry channel, where the coordinator lives; absent on a host without Discord. */
|
|
47
38
|
entryChannelId?: string;
|
|
@@ -99,8 +90,6 @@ export interface TeamTurnsOptions {
|
|
|
99
90
|
* stored speaker is kept.
|
|
100
91
|
*/
|
|
101
92
|
principals?: () => Pick<IdentityService, "speakerFor"> | undefined;
|
|
102
|
-
/** A thread for each message between agents; without it both go in the sender's channel. */
|
|
103
|
-
threads?: Pick<DispatchThreads, "open">;
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
/** How a turn runs, beyond its text. */
|
|
@@ -150,7 +139,12 @@ export interface TurnHost {
|
|
|
150
139
|
post(
|
|
151
140
|
channel: ChannelKey,
|
|
152
141
|
as: Agent,
|
|
153
|
-
body: {
|
|
142
|
+
body: {
|
|
143
|
+
thinking?: string;
|
|
144
|
+
chunks: string[];
|
|
145
|
+
threadId?: string;
|
|
146
|
+
intercom?: IntercomCard;
|
|
147
|
+
},
|
|
154
148
|
): Promise<void>;
|
|
155
149
|
/** The agent's latest row. */
|
|
156
150
|
current(agent: Agent): Agent;
|
|
@@ -2,6 +2,7 @@ import { channelKey, parseChannelKey } from "../contract/surface.ts";
|
|
|
2
2
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
3
3
|
import type {
|
|
4
4
|
ChannelKey,
|
|
5
|
+
IntercomCard,
|
|
5
6
|
PendingConfirmation,
|
|
6
7
|
PostAuthor,
|
|
7
8
|
ReplyFile,
|
|
@@ -321,8 +322,10 @@ export class TeamTurns {
|
|
|
321
322
|
const { store, channels, studio, surfaces } = this.#options;
|
|
322
323
|
if (discordIdOf(channel) === undefined) {
|
|
323
324
|
// A room's interim posts are its surface's, under the agent as author.
|
|
324
|
-
|
|
325
|
+
const first = surfaces?.interim(channel, this.#author(agent));
|
|
326
|
+
if (!surfaces || !first) return undefined;
|
|
325
327
|
return {
|
|
328
|
+
...(first.tools === false ? { tools: false } : {}),
|
|
326
329
|
post: async (text) => {
|
|
327
330
|
if (!channelOwner(store, channel, this.#rooms(channel)))
|
|
328
331
|
throw new AgentError(`${channel} is archived`);
|
|
@@ -363,6 +366,7 @@ export class TeamTurns {
|
|
|
363
366
|
files?: ReplyFile[];
|
|
364
367
|
threadId?: string;
|
|
365
368
|
failed?: true;
|
|
369
|
+
intercom?: IntercomCard;
|
|
366
370
|
},
|
|
367
371
|
): Promise<void> {
|
|
368
372
|
const { store, channels, studio, surfaces, logger } = this.#options;
|
|
@@ -378,7 +382,7 @@ export class TeamTurns {
|
|
|
378
382
|
if (discordIdOf(channel) === undefined) {
|
|
379
383
|
if (!surfaces)
|
|
380
384
|
throw new AgentError(`no surface serves the room ${channel}`);
|
|
381
|
-
const { thinking, chunks, files, threadId } = rest;
|
|
385
|
+
const { thinking, chunks, files, threadId, intercom } = rest;
|
|
382
386
|
// A thread of the room's surface is a channel of that surface, and speaks there.
|
|
383
387
|
return surfaces.sendReply(
|
|
384
388
|
threadId === undefined
|
|
@@ -390,6 +394,7 @@ export class TeamTurns {
|
|
|
390
394
|
...(files?.length ? { files } : {}),
|
|
391
395
|
author: this.#author(as),
|
|
392
396
|
...(failed ? { failed } : {}),
|
|
397
|
+
...(intercom ? { intercom } : {}),
|
|
393
398
|
},
|
|
394
399
|
);
|
|
395
400
|
}
|
|
@@ -9,6 +9,7 @@ import { DiscordAgentTeam } from "../agents/agent-team.ts";
|
|
|
9
9
|
import { AGENT_TOOLS } from "../agents/agent-tools.ts";
|
|
10
10
|
import { FileAvatarStudio } from "../agents/avatar-studio.ts";
|
|
11
11
|
import { RelevanceScorer } from "../agents/group-round.ts";
|
|
12
|
+
import { modeOrigins } from "../agents/helper-modes.ts";
|
|
12
13
|
import { Routes } from "../agents/routes.ts";
|
|
13
14
|
import { agentOfChannel, discordKey } from "../agents/team-keys.ts";
|
|
14
15
|
import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
RUNTIME,
|
|
36
37
|
SCHEDULES,
|
|
37
38
|
SKILLS,
|
|
38
|
-
THREADS,
|
|
39
39
|
} from "../services.ts";
|
|
40
40
|
import type { SessionTool } from "../sessions.ts";
|
|
41
41
|
import type { Tier } from "../speakers.ts";
|
|
@@ -217,8 +217,6 @@ export function agentServerPlugin(
|
|
|
217
217
|
setup: async (context) => {
|
|
218
218
|
// Absent on a host without Discord: then the team has no channels or dashboard.
|
|
219
219
|
const discord = context.services.find(DISCORD);
|
|
220
|
-
// Dispatch threads on any surface that hosts them, Discord's or a room surface's.
|
|
221
|
-
const threads = context.services.find(THREADS);
|
|
222
220
|
const schedules = context.services.get(SCHEDULES);
|
|
223
221
|
const connection = discord?.connection;
|
|
224
222
|
const { logger, providers, surfaces } = context;
|
|
@@ -245,6 +243,8 @@ export function agentServerPlugin(
|
|
|
245
243
|
// Absent when the skills addon is off: the agents then carry none.
|
|
246
244
|
const skills = context.services.find(SKILLS);
|
|
247
245
|
const { agents: store } = await openStores(context, teamId);
|
|
246
|
+
// A temp knight's conversations follow the modes of the one that made it.
|
|
247
|
+
context.services.find(MODES)?.trackOrigins(modeOrigins(store, surfaces));
|
|
248
248
|
const running = context.services.get(RUNTIME);
|
|
249
249
|
const judge = providers.judge;
|
|
250
250
|
const confirmations = new ConfirmationJudge({
|
|
@@ -348,7 +348,6 @@ export function agentServerPlugin(
|
|
|
348
348
|
modes: () => context.services.find(MODES),
|
|
349
349
|
...(options.helpers !== undefined ? { helpers: options.helpers } : {}),
|
|
350
350
|
principals: () => context.services.find(IDENTITY),
|
|
351
|
-
...(threads ? { threads } : {}),
|
|
352
351
|
logger,
|
|
353
352
|
});
|
|
354
353
|
connection?.onChannelDeleted((channelId) => {
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ChannelContextOptions,
|
|
6
6
|
} from "../contract/channel-context.ts";
|
|
7
7
|
import { type ServiceKey, serviceKey } from "../contract/services.ts";
|
|
8
|
+
import { CardCopies } from "../discord/card-copies.ts";
|
|
8
9
|
import { ChannelContextReader } from "../discord/channel-context.ts";
|
|
9
10
|
import { CommandCollection } from "../discord/command-collection.ts";
|
|
10
11
|
import type { DiscordConnection } from "../discord/connection.ts";
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
CommandGuard,
|
|
16
17
|
CommandRegistrar,
|
|
17
18
|
} from "../discord/interaction-module.ts";
|
|
19
|
+
import { intercomButtonModule } from "../discord/intercom-cards.ts";
|
|
18
20
|
import { OwnerCards } from "../discord/owner-cards.ts";
|
|
19
21
|
import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
|
|
20
22
|
import type { DiscordPoster } from "../discord/poster/poster.ts";
|
|
@@ -88,11 +90,15 @@ export interface DiscordOptions {
|
|
|
88
90
|
* How the texts behind approval cards' copy buttons are kept: the newest `keep` (default 100)
|
|
89
91
|
* for `keepDays` (default 30), in `card-copies.json` under `dataDir`, so the buttons work after
|
|
90
92
|
* a restart. `false` keeps them in memory only, with the same limits: they are gone when the
|
|
91
|
-
* process restarts and the button then says so.
|
|
93
|
+
* process restarts and the button then says so. The whole texts behind the agents' message
|
|
94
|
+
* cards follow the same choice and `keepDays`, the newest 300, in `intercom-texts.json`.
|
|
92
95
|
*/
|
|
93
96
|
cardCopies?: { keepDays?: number; keep?: number } | false;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/** How many agent message cards keep their whole text for the open button, the newest. */
|
|
100
|
+
const INTERCOM_KEEP = 300;
|
|
101
|
+
|
|
96
102
|
/**
|
|
97
103
|
* The Discord connection and what stands on it: the owner's cards, the command guard, and the
|
|
98
104
|
* threads that carry background reports. It contributes the Discord surface, collects the
|
|
@@ -114,11 +120,24 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
114
120
|
setup: async ({ conversations, services, logger }) => {
|
|
115
121
|
const identity = services.find(IDENTITY);
|
|
116
122
|
// A host that keeps no copy texts on disk drops what an earlier run left.
|
|
117
|
-
if (options.cardCopies === false)
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
if (options.cardCopies === false)
|
|
124
|
+
for (const name of ["card-copies.json", "intercom-texts.json"]) {
|
|
125
|
+
const file = join(options.dataDir, name);
|
|
126
|
+
rmSync(file, { force: true });
|
|
127
|
+
rmSync(`${file}.tmp`, { force: true });
|
|
128
|
+
}
|
|
129
|
+
// The whole texts behind the agents' message cards, kept as the copy texts are.
|
|
130
|
+
const intercomTexts = new CardCopies({
|
|
131
|
+
...(options.cardCopies === false
|
|
132
|
+
? {}
|
|
133
|
+
: { path: join(options.dataDir, "intercom-texts.json") }),
|
|
134
|
+
keep: INTERCOM_KEEP,
|
|
135
|
+
...(options.cardCopies
|
|
136
|
+
? { keepDays: options.cardCopies.keepDays }
|
|
137
|
+
: {}),
|
|
138
|
+
logger,
|
|
139
|
+
what: "agent message texts",
|
|
140
|
+
});
|
|
122
141
|
// The surface exists by the time a turn posts a card.
|
|
123
142
|
const cards: OwnerCards = new OwnerCards({
|
|
124
143
|
ownerId: options.ownerId,
|
|
@@ -142,6 +161,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
142
161
|
ownerId: options.ownerId,
|
|
143
162
|
ownerName: options.ownerName,
|
|
144
163
|
prompts: (channel, scope) => cards.prompts(channel, scope),
|
|
164
|
+
intercomTexts,
|
|
145
165
|
// Built below, once the surface can read messages; called only after setup.
|
|
146
166
|
channelContext: (message, given) => context.of(message, given),
|
|
147
167
|
logger,
|
|
@@ -171,6 +191,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
171
191
|
collection.registrar.add({
|
|
172
192
|
module: stopButtonModule({ guard, conversations }),
|
|
173
193
|
});
|
|
194
|
+
collection.registrar.add({ module: intercomButtonModule(intercomTexts) });
|
|
174
195
|
// Marks the owners in channel context: the primary owner, and every other owner's Discord identities.
|
|
175
196
|
const owners = new DiscordOwners({
|
|
176
197
|
ownerId: options.ownerId,
|
|
@@ -210,8 +231,14 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
210
231
|
// The copy texts of the last cards are on disk before the host exits.
|
|
211
232
|
{
|
|
212
233
|
name: "card-copies",
|
|
213
|
-
start: () =>
|
|
214
|
-
|
|
234
|
+
start: () => {
|
|
235
|
+
cards.startCopies();
|
|
236
|
+
intercomTexts.start();
|
|
237
|
+
},
|
|
238
|
+
stop: async () => {
|
|
239
|
+
await cards.stopCopies();
|
|
240
|
+
await intercomTexts.stop();
|
|
241
|
+
},
|
|
215
242
|
},
|
|
216
243
|
],
|
|
217
244
|
};
|
|
@@ -84,7 +84,8 @@ export interface DiscordConfig {
|
|
|
84
84
|
* for `keepDays` (default 30), in `card-copies.json` under the data directory, so the buttons
|
|
85
85
|
* work after a restart. `false` keeps them in memory only, with the same limits: they are gone
|
|
86
86
|
* when the process restarts, and the button then says so. Discarding a conversation does not
|
|
87
|
-
* remove them; they age out.
|
|
87
|
+
* remove them; they age out. The whole texts behind the agents' message cards follow the same
|
|
88
|
+
* choice and `keepDays`, the newest 300, in `intercom-texts.json`.
|
|
88
89
|
*/
|
|
89
90
|
cardCopies?: { keepDays?: number; keep?: number } | false;
|
|
90
91
|
/**
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { ConfigError } from "../domain/errors.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type Autonomy,
|
|
4
|
+
DEFAULT_LONG_RUNNING_MS,
|
|
5
|
+
DEFAULT_YOLO_QUESTION_GRACE_MS,
|
|
6
|
+
DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS,
|
|
7
|
+
} from "../modes/autonomy.ts";
|
|
3
8
|
import { integer, list, optional, shape, text } from "./schema.ts";
|
|
4
9
|
|
|
5
10
|
/** The configuration's `turnLimits` and `autonomy`: how long a turn works, and what runs without a person. */
|
|
@@ -27,6 +32,16 @@ export interface LimitsConfig {
|
|
|
27
32
|
allow?: readonly string[];
|
|
28
33
|
/** Hold classes or rule names yolo never approves, such as `shell.push` or `tool:repo_push`. */
|
|
29
34
|
card?: readonly string[];
|
|
35
|
+
/**
|
|
36
|
+
* How long an `ask_user` card holds a yolo turn before it goes on with its own best choice
|
|
37
|
+
* (the card stays open, and a later answer starts a new turn); 5 to 600, default 30.
|
|
38
|
+
*/
|
|
39
|
+
questionGraceSeconds?: number;
|
|
40
|
+
/**
|
|
41
|
+
* How long the timeout card waits in a yolo conversation before the turn goes on as if
|
|
42
|
+
* Continue were pressed, up to `longRunning.maxSeconds` of work; 10 to 3600, default 60.
|
|
43
|
+
*/
|
|
44
|
+
timeoutCardSeconds?: number;
|
|
30
45
|
};
|
|
31
46
|
/** Long-running mode: a turn that reaches its time limit goes on by itself, up to this. */
|
|
32
47
|
longRunning?: {
|
|
@@ -48,7 +63,12 @@ export const limitsShape = {
|
|
|
48
63
|
autonomy: optional(
|
|
49
64
|
shape({
|
|
50
65
|
yolo: optional(
|
|
51
|
-
shape({
|
|
66
|
+
shape({
|
|
67
|
+
allow: optional(list(text)),
|
|
68
|
+
card: optional(list(text)),
|
|
69
|
+
questionGraceSeconds: optional(integer(5, 600)),
|
|
70
|
+
timeoutCardSeconds: optional(integer(10, 3_600)),
|
|
71
|
+
}),
|
|
52
72
|
),
|
|
53
73
|
longRunning: optional(
|
|
54
74
|
shape({ maxSeconds: optional(integer(600, 604_800)) }),
|
|
@@ -83,6 +103,14 @@ export function limitsOf(config: LimitsConfig): ResolvedLimits {
|
|
|
83
103
|
yolo: {
|
|
84
104
|
allow: autonomy?.yolo?.allow ?? [],
|
|
85
105
|
card: autonomy?.yolo?.card ?? [],
|
|
106
|
+
questionGraceMs: secondsOr(
|
|
107
|
+
autonomy?.yolo?.questionGraceSeconds,
|
|
108
|
+
DEFAULT_YOLO_QUESTION_GRACE_MS,
|
|
109
|
+
),
|
|
110
|
+
timeoutCardGraceMs: secondsOr(
|
|
111
|
+
autonomy?.yolo?.timeoutCardSeconds,
|
|
112
|
+
DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS,
|
|
113
|
+
),
|
|
86
114
|
},
|
|
87
115
|
longRunningMaxMs:
|
|
88
116
|
longRunning === undefined
|
|
@@ -91,3 +119,8 @@ export function limitsOf(config: LimitsConfig): ResolvedLimits {
|
|
|
91
119
|
},
|
|
92
120
|
};
|
|
93
121
|
}
|
|
122
|
+
|
|
123
|
+
/** Seconds as ms, or the default in ms when unset. */
|
|
124
|
+
function secondsOr(seconds: number | undefined, defaultMs: number): number {
|
|
125
|
+
return seconds === undefined ? defaultMs : seconds * 1_000;
|
|
126
|
+
}
|
|
@@ -28,6 +28,7 @@ import { AgentError } from "../domain/errors.ts";
|
|
|
28
28
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
29
29
|
import { messages } from "../i18n/index.ts";
|
|
30
30
|
import type { Logger } from "../log.ts";
|
|
31
|
+
import { type IntercomTexts, intercomPost } from "./intercom-cards.ts";
|
|
31
32
|
|
|
32
33
|
const DASHBOARD_NAME = "dashboard";
|
|
33
34
|
|
|
@@ -42,10 +43,13 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
42
43
|
readonly #webhooks = new Map<string, Promise<Webhook>>();
|
|
43
44
|
/** The parent channel of each thread the agents live in, and null for a channel that is none. */
|
|
44
45
|
readonly #parents = new Map<string, string | null>();
|
|
46
|
+
/** Where the cards' whole texts are kept for their open buttons; without it a card posts as text. */
|
|
47
|
+
readonly #texts: IntercomTexts | undefined;
|
|
45
48
|
|
|
46
|
-
constructor(client: Client, guildId: string) {
|
|
49
|
+
constructor(client: Client, guildId: string, texts?: IntercomTexts) {
|
|
47
50
|
this.#client = client;
|
|
48
51
|
this.#guildId = guildId;
|
|
52
|
+
this.#texts = texts;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/**
|
|
@@ -77,6 +81,13 @@ export class DiscordAgentChannels implements AgentChannels {
|
|
|
77
81
|
...(route.threadId ? { threadId: route.threadId } : {}),
|
|
78
82
|
};
|
|
79
83
|
try {
|
|
84
|
+
if (post.intercom && this.#texts) {
|
|
85
|
+
await webhook.send({
|
|
86
|
+
...as,
|
|
87
|
+
...intercomPost(post.intercom, this.#texts),
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
80
91
|
if (post.thinking) await webhook.send({ ...as, content: post.thinking });
|
|
81
92
|
for (const chunk of post.chunks)
|
|
82
93
|
await webhook.send({ ...as, content: chunk });
|
|
@@ -35,6 +35,8 @@ export interface CardCopiesOptions {
|
|
|
35
35
|
setInterval?: typeof setInterval;
|
|
36
36
|
/** Replaceable in tests, with `setInterval`. */
|
|
37
37
|
clearInterval?: typeof clearInterval;
|
|
38
|
+
/** What the texts are, as the log names them; default "card copy texts". */
|
|
39
|
+
what?: string;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -69,7 +71,7 @@ export class CardCopies {
|
|
|
69
71
|
} catch (error) {
|
|
70
72
|
logger.warn(
|
|
71
73
|
{ path, err: error },
|
|
72
|
-
"card copy texts not read;
|
|
74
|
+
`${options.what ?? "card copy texts"} not read; buttons of earlier cards will say they are gone`,
|
|
73
75
|
);
|
|
74
76
|
}
|
|
75
77
|
}
|
|
@@ -172,7 +174,10 @@ export class CardCopies {
|
|
|
172
174
|
await chmod(temporary, 0o600);
|
|
173
175
|
await rename(temporary, path);
|
|
174
176
|
} catch (error) {
|
|
175
|
-
logger.warn(
|
|
177
|
+
logger.warn(
|
|
178
|
+
{ path, err: error },
|
|
179
|
+
`${this.#options.what ?? "card copy texts"} not saved`,
|
|
180
|
+
);
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
Events,
|
|
7
7
|
GatewayIntentBits,
|
|
8
8
|
type Interaction,
|
|
9
|
-
type Message,
|
|
10
9
|
MessageFlags,
|
|
11
10
|
Partials,
|
|
12
11
|
type REST,
|
|
@@ -47,18 +46,17 @@ import { DiscordThreadHost } from "./dispatch-thread-host.ts";
|
|
|
47
46
|
import { DISCORD_FORMATTING, pickFormatting } from "./formatting.ts";
|
|
48
47
|
import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
|
|
49
48
|
import { lateAnswerMessage } from "./inbound-message.ts";
|
|
49
|
+
import type { IntercomTexts } from "./intercom-cards.ts";
|
|
50
50
|
import { listenForMessages, reportChanges } from "./message-changes.ts";
|
|
51
51
|
import type { CardChannel, LateTurn } from "./owner-cards.ts";
|
|
52
52
|
import { DiscordOwnerOps } from "./owner-discord.ts";
|
|
53
|
-
import {
|
|
53
|
+
import { showStopPanel } from "./stop-button.ts";
|
|
54
54
|
|
|
55
55
|
const PREFIX = "discord:";
|
|
56
56
|
/** Discord's error code for a channel that does not exist or cannot be seen. */
|
|
57
57
|
const UNKNOWN_CHANNEL = 10003;
|
|
58
58
|
|
|
59
59
|
const TYPING_REFRESH_MS = 8_000;
|
|
60
|
-
/** A turn shorter than this shows no stop button, so quick answers do not flicker one. */
|
|
61
|
-
const STOP_PANEL_DELAY_MS = 5_000;
|
|
62
60
|
|
|
63
61
|
export interface DiscordSurfaceOptions {
|
|
64
62
|
token: string;
|
|
@@ -73,6 +71,8 @@ export interface DiscordSurfaceOptions {
|
|
|
73
71
|
freshMarker?: string | false;
|
|
74
72
|
/** What the model is told about how replies render here; `false` tells it nothing. Default: `DISCORD_FORMATTING`. */
|
|
75
73
|
formatting?: string | false;
|
|
74
|
+
/** Where the agents' message cards keep their whole texts for the open button; without it a card posts as text. */
|
|
75
|
+
intercomTexts?: IntercomTexts;
|
|
76
76
|
/** Reads the channel context of an addressed message; without it the surface has none. */
|
|
77
77
|
channelContext?: (
|
|
78
78
|
message: InboundMessage,
|
|
@@ -157,7 +157,11 @@ export class DiscordSurface
|
|
|
157
157
|
|
|
158
158
|
/** The agent server's channels and webhooks. */
|
|
159
159
|
agentChannels(guildId: string): AgentChannels {
|
|
160
|
-
return new DiscordAgentChannels(
|
|
160
|
+
return new DiscordAgentChannels(
|
|
161
|
+
this.#client,
|
|
162
|
+
guildId,
|
|
163
|
+
this.#options.intercomTexts,
|
|
164
|
+
);
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
/** Threads for background dispatches, in the channel that started each. */
|
|
@@ -297,8 +301,8 @@ export class DiscordSurface
|
|
|
297
301
|
|
|
298
302
|
/**
|
|
299
303
|
* Discord draws attachments below a message's text, so the card goes out as its own message
|
|
300
|
-
* first and produced files last, each on its own. Replies may mention people but never everyone or a role.
|
|
301
|
-
*/
|
|
304
|
+
* first and produced files last, each on its own. Replies may mention people but never everyone or a role. A card
|
|
305
|
+
* Discord refuses (a channel without Attach Files) is logged, and the text still goes out. */
|
|
302
306
|
async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
|
|
303
307
|
const target = await this.#sendable(channel);
|
|
304
308
|
const allowedMentions = { parse: ["users" as const] };
|
|
@@ -306,12 +310,14 @@ export class DiscordSurface
|
|
|
306
310
|
await target.send({ content: reply.thinking, allowedMentions });
|
|
307
311
|
}
|
|
308
312
|
if (reply.card) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
const card = new AttachmentBuilder(Buffer.from(reply.card), {
|
|
314
|
+
name: "card.png",
|
|
315
|
+
});
|
|
316
|
+
await target.send({ files: [card] }).catch((err: unknown) => {
|
|
317
|
+
this.#options.logger.warn(
|
|
318
|
+
{ channel, err },
|
|
319
|
+
"reply card not posted; sending the text alone",
|
|
320
|
+
);
|
|
315
321
|
});
|
|
316
322
|
}
|
|
317
323
|
for (const chunk of reply.chunks) {
|
|
@@ -363,33 +369,12 @@ export class DiscordSurface
|
|
|
363
369
|
}
|
|
364
370
|
|
|
365
371
|
showStop(channel: ChannelKey): () => void {
|
|
366
|
-
const { logger } = this.#options;
|
|
367
372
|
// An outside agent's conversation has no Discord channel to show a button in.
|
|
368
373
|
if (!channel.startsWith(PREFIX)) return () => undefined;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
.delete()
|
|
374
|
-
.catch((error: unknown) =>
|
|
375
|
-
logger.warn({ channel, err: error }, "stop panel not removed"),
|
|
376
|
-
);
|
|
377
|
-
const timer = setTimeout(() => {
|
|
378
|
-
this.#sendable(channel)
|
|
379
|
-
.then((target): Promise<Message> => target.send(stopPanel()))
|
|
380
|
-
.then((message) => {
|
|
381
|
-
if (hidden) void remove(message);
|
|
382
|
-
else panel = message;
|
|
383
|
-
})
|
|
384
|
-
.catch((error: unknown) =>
|
|
385
|
-
logger.warn({ channel, err: error }, "stop panel not shown"),
|
|
386
|
-
);
|
|
387
|
-
}, STOP_PANEL_DELAY_MS);
|
|
388
|
-
return () => {
|
|
389
|
-
hidden = true;
|
|
390
|
-
clearTimeout(timer);
|
|
391
|
-
if (panel) void remove(panel);
|
|
392
|
-
};
|
|
374
|
+
return showStopPanel(
|
|
375
|
+
() => this.#sendable(channel),
|
|
376
|
+
(error, what) => this.#options.logger.warn({ channel, err: error }, what),
|
|
377
|
+
);
|
|
393
378
|
}
|
|
394
379
|
|
|
395
380
|
async react(
|