pi-roundtable 0.27.1 → 0.29.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 +39 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/docs/plugins.md +54 -22
- 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 +37 -92
- package/src/core/agents/agent-ports.ts +3 -0
- package/src/core/agents/agent-prompt.ts +2 -16
- package/src/core/agents/agent-rules.ts +27 -0
- package/src/core/agents/agent-team.ts +5 -0
- package/src/core/agents/agent-tools.ts +18 -10
- package/src/core/agents/helper-create.ts +3 -24
- package/src/core/agents/helper-modes.ts +64 -0
- package/src/core/agents/message-target.ts +20 -18
- package/src/core/agents/routes.ts +42 -24
- package/src/core/agents/team-editing.ts +5 -5
- package/src/core/agents/team-helpers.ts +76 -5
- package/src/core/agents/team-keys.ts +14 -0
- package/src/core/agents/team-turn-types.ts +7 -13
- package/src/core/agents/team-turns.ts +9 -6
- package/src/core/builtin/agent-server.ts +3 -4
- package/src/core/builtin/discord.ts +35 -8
- package/src/core/builtin/modules.ts +15 -15
- package/src/core/config/adapters.ts +2 -1
- package/src/core/config/limits.ts +35 -2
- package/src/core/config/routing.ts +5 -4
- package/src/core/discord/agent-discord.ts +12 -1
- package/src/core/discord/card-copies.ts +7 -2
- package/src/core/discord/direct-channel.ts +1 -0
- package/src/core/discord/discord-surface.ts +13 -30
- 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/notify/notify.ts +3 -2
- package/src/core/modules/notify/session-notify.ts +21 -4
- package/src/core/modules/skills/skill-contract.ts +13 -0
- package/src/core/modules/skills/skill-registry.ts +3 -1
- package/src/core/modules/skills/skill-store.ts +4 -3
- package/src/core/presence/delivery-router.ts +165 -0
- package/src/core/presence/direct-channels.ts +39 -68
- 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 +7 -1
|
@@ -37,7 +37,7 @@ export interface RoutingTable {
|
|
|
37
37
|
* has none when the agent has no place at all.
|
|
38
38
|
*/
|
|
39
39
|
of(agent: string, kind: "integration", origin: ChannelKey): ChannelKey;
|
|
40
|
-
of(agent: string, kind: "agentMessage", origin
|
|
40
|
+
of(agent: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
|
|
41
41
|
of(
|
|
42
42
|
agent: string,
|
|
43
43
|
kind: RouteKind,
|
|
@@ -154,7 +154,7 @@ export class Routes implements RoutingTable {
|
|
|
154
154
|
* when an `agentMessage` finds no place; undefined when an `opening` finds none.
|
|
155
155
|
*/
|
|
156
156
|
of(name: string, kind: "integration", origin: ChannelKey): ChannelKey;
|
|
157
|
-
of(name: string, kind: "agentMessage", origin
|
|
157
|
+
of(name: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
|
|
158
158
|
of(
|
|
159
159
|
name: string,
|
|
160
160
|
kind: RouteKind,
|
|
@@ -201,39 +201,57 @@ export class Routes implements RoutingTable {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
* The conversation a message to the agent lands in when the sender names none
|
|
205
|
-
*
|
|
206
|
-
* Discord,
|
|
207
|
-
*
|
|
208
|
-
*
|
|
204
|
+
* The conversation a message to the agent lands in when the sender names none. By default
|
|
205
|
+
* (`"origin-surface"`) its place on the sender's own surface: its Discord channel for a message
|
|
206
|
+
* from Discord, its room for one from a room. `"latest"` is the conversation it last ran a turn
|
|
207
|
+
* in, but only on the sender's surface. A message never crosses surfaces by recency: when the
|
|
208
|
+
* agent has no place on the sender's surface, or no sender is known, it lands in the agent's
|
|
209
|
+
* primary place, and a crossing is logged with both surfaces.
|
|
209
210
|
*/
|
|
210
211
|
#agentMessage(name: string, from: ChannelKey | undefined): ChannelKey {
|
|
211
|
-
const {
|
|
212
|
-
const to = this.rules(name).agentMessage ?? "
|
|
213
|
-
if (to !== "latest") {
|
|
212
|
+
const { latest } = this.#options;
|
|
213
|
+
const to = this.rules(name).agentMessage ?? "origin-surface";
|
|
214
|
+
if (to !== "latest" && to !== "origin-surface") {
|
|
214
215
|
const key =
|
|
215
216
|
to === "primary" ? this.primary(name) : this.placeOn(name, to);
|
|
216
217
|
if (key) return key;
|
|
217
218
|
this.#warn(
|
|
218
219
|
`message:${name}`,
|
|
219
220
|
{ agent: name, to },
|
|
220
|
-
"the agent has no place for messages to it;
|
|
221
|
+
"the agent has no place for messages to it; its place on the sender's surface is used",
|
|
221
222
|
);
|
|
222
223
|
}
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
224
|
+
const surface =
|
|
225
|
+
from === undefined ? undefined : parseChannelKey(from).surface;
|
|
226
|
+
if (to === "latest" && surface !== undefined) {
|
|
227
|
+
const last = latest?.(name);
|
|
228
|
+
const keys = keysOf(
|
|
229
|
+
this.#options.store,
|
|
230
|
+
name,
|
|
231
|
+
this.#options.roomSurfaces(),
|
|
232
|
+
);
|
|
233
|
+
if (
|
|
234
|
+
last !== undefined &&
|
|
235
|
+
keys.includes(last) &&
|
|
236
|
+
parseChannelKey(last).surface === surface
|
|
237
|
+
)
|
|
238
|
+
return last;
|
|
235
239
|
}
|
|
236
|
-
|
|
240
|
+
const own = surface === undefined ? undefined : this.placeOn(name, surface);
|
|
241
|
+
if (own) return own;
|
|
242
|
+
const primary = this.primary(name);
|
|
243
|
+
if (!primary) throw new AgentError(`${name} has no channel yet.`);
|
|
244
|
+
if (surface !== undefined)
|
|
245
|
+
this.#warn(
|
|
246
|
+
`message-cross:${name}:${surface}`,
|
|
247
|
+
{
|
|
248
|
+
agent: name,
|
|
249
|
+
from: surface,
|
|
250
|
+
to: parseChannelKey(primary).surface,
|
|
251
|
+
},
|
|
252
|
+
"the agent has no place on the sender's surface; the message lands in its primary place",
|
|
253
|
+
);
|
|
254
|
+
return primary;
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
/**
|
|
@@ -220,9 +220,9 @@ export class TeamEditing {
|
|
|
220
220
|
creator: Agent | undefined;
|
|
221
221
|
/** The surface whose room runs the opening task. */
|
|
222
222
|
openIn?: string | undefined;
|
|
223
|
-
/** Makes the agent a temp knight of this knight
|
|
223
|
+
/** Makes the agent a temp knight of this knight, which settles on the parent's model. */
|
|
224
224
|
parent?: Agent | undefined;
|
|
225
|
-
/** The conversation a temp knight is made from. */
|
|
225
|
+
/** The conversation a temp knight is made from: made on Discord, it lives in a thread of the parent's channel, made elsewhere in its rooms alone. */
|
|
226
226
|
origin?: string | undefined;
|
|
227
227
|
},
|
|
228
228
|
input: {
|
|
@@ -279,10 +279,10 @@ export class TeamEditing {
|
|
|
279
279
|
const suggested = suggestCharacter(input, taken);
|
|
280
280
|
const character = this.#resolve(wanted ?? {}, suggested);
|
|
281
281
|
const avatarHash = await studio.render(character);
|
|
282
|
-
// Without Discord channels the agent lives in its rooms, which exist as soon as it does.
|
|
283
|
-
|
|
282
|
+
// Without Discord channels the agent lives in its rooms, which exist as soon as it does.
|
|
283
|
+
const onDiscord = who.origin?.startsWith("discord:") !== false;
|
|
284
284
|
const channelId = who.parent
|
|
285
|
-
? who.parent.channelId && channels?.createThread
|
|
285
|
+
? onDiscord && who.parent.channelId && channels?.createThread
|
|
286
286
|
? await channels.createThread(who.parent.channelId, input.displayName)
|
|
287
287
|
: undefined
|
|
288
288
|
: await channels?.createChannel(
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { helpersOf } from "../config/helpers.ts";
|
|
2
2
|
import { AgentError } from "../domain/errors.ts";
|
|
3
3
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { surfaceOf } from "../presence/delivery-router.ts";
|
|
5
|
+
import { answersTo, helperSurface, isHelper } from "./agent-rules.ts";
|
|
5
6
|
import type { Agent } from "./agent-store.ts";
|
|
6
7
|
import type { HelpersEnded } from "./helper-ops.ts";
|
|
7
8
|
import type { TeamContext } from "./team-options.ts";
|
|
@@ -11,8 +12,9 @@ const SWEEP_EVERY_MS = 3_600_000;
|
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The team's temp knights after they are made: which a knight has, which are finished, and their
|
|
14
|
-
* archive, restore, and sweep.
|
|
15
|
-
*
|
|
15
|
+
* archive, restore, and sweep. A temp knight made on Discord lives in a thread of its parent's
|
|
16
|
+
* channel; one made in the web app gets that thread only once someone reaches for it on Discord.
|
|
17
|
+
* Archiving one closes its thread, when it has one; an archived one keeps its history.
|
|
16
18
|
*/
|
|
17
19
|
export class TeamHelpers {
|
|
18
20
|
readonly #ctx: TeamContext;
|
|
@@ -20,6 +22,8 @@ export class TeamHelpers {
|
|
|
20
22
|
readonly #archiveAgent: (agent: Agent) => Promise<void>;
|
|
21
23
|
readonly #ended: ((event: HelpersEnded) => void)[] = [];
|
|
22
24
|
#sweeper: ReturnType<typeof setInterval> | undefined;
|
|
25
|
+
/** The threads being made for web-born temp knights, by name, so two reaches make one. */
|
|
26
|
+
readonly #placing = new Map<string, Promise<void>>();
|
|
23
27
|
|
|
24
28
|
constructor(ctx: TeamContext, archiveAgent: (agent: Agent) => Promise<void>) {
|
|
25
29
|
this.#ctx = ctx;
|
|
@@ -125,9 +129,74 @@ export class TeamHelpers {
|
|
|
125
129
|
return restored;
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A `message_agent` call that names "discord", or names nothing from a Discord conversation, gives
|
|
134
|
+
* a web-born temp knight its thread first.
|
|
135
|
+
*/
|
|
136
|
+
async reach(
|
|
137
|
+
caller: AgentTurnScope,
|
|
138
|
+
to: string,
|
|
139
|
+
conversation?: string,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const { store } = this.#ctx.options;
|
|
142
|
+
const { turns } = this.#ctx;
|
|
143
|
+
const target = store.agent(to);
|
|
144
|
+
const speaker = turns.speakerOf(caller);
|
|
145
|
+
const from = turns.turnChannel(caller);
|
|
146
|
+
// Unnamed, a message stays on the sender's surface, so one sent from Discord goes there too.
|
|
147
|
+
const wanted = conversation?.trim() || surfaceOf(from);
|
|
148
|
+
if (wanted !== "discord" || !target || !speaker) return;
|
|
149
|
+
if (answersTo(target, caller.name, from, speaker))
|
|
150
|
+
await this.placeOnDiscord(to);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Gives an active temp knight made in the web app its thread under the parent's channel, the
|
|
155
|
+
* first time someone reaches for it on Discord. One made on Discord, or before the origin was
|
|
156
|
+
* kept, already has its place, and a host without Discord threads makes none; never rejects.
|
|
157
|
+
*/
|
|
158
|
+
placeOnDiscord(name: string): Promise<void> {
|
|
159
|
+
const pending = this.#placing.get(name);
|
|
160
|
+
if (pending) return pending;
|
|
161
|
+
const placing = this.#placeOnDiscord(name).finally(() =>
|
|
162
|
+
this.#placing.delete(name),
|
|
163
|
+
);
|
|
164
|
+
this.#placing.set(name, placing);
|
|
165
|
+
return placing;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #placeOnDiscord(name: string): Promise<void> {
|
|
169
|
+
const { store, channels, logger } = this.#ctx.options;
|
|
170
|
+
const agent = store.agent(name);
|
|
171
|
+
const surface = helperSurface(agent);
|
|
172
|
+
if (agent?.status !== "active" || agent.channelId) return;
|
|
173
|
+
if (surface === undefined || surface === "discord") return;
|
|
174
|
+
const parent = store.agent(agent.parent as string);
|
|
175
|
+
if (!parent?.channelId || !channels?.createThread) return;
|
|
176
|
+
try {
|
|
177
|
+
const channelId = await channels.createThread(
|
|
178
|
+
parent.channelId,
|
|
179
|
+
agent.displayName,
|
|
180
|
+
);
|
|
181
|
+
await store.updateAgent(name, { channelId });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
{ agent: name, err: error },
|
|
185
|
+
"temp knight's thread not made; it stays in its rooms",
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
logger.info(
|
|
190
|
+
{ agent: name, parent: parent.name },
|
|
191
|
+
"temp knight's thread made",
|
|
192
|
+
);
|
|
193
|
+
this.#ctx.changed();
|
|
194
|
+
}
|
|
195
|
+
|
|
128
196
|
/**
|
|
129
197
|
* Restores one archived temp knight: its thread opens again, or a new one is made when the old
|
|
130
|
-
* is gone.
|
|
198
|
+
* is gone. One made in the web app never gets a new thread: its old one reopens, if it had one
|
|
199
|
+
* that is still there. It comes back finished, with nothing running.
|
|
131
200
|
*/
|
|
132
201
|
async restoreOne(agent: Agent): Promise<void> {
|
|
133
202
|
const { store, channels, logger } = this.#ctx.options;
|
|
@@ -137,6 +206,8 @@ export class TeamHelpers {
|
|
|
137
206
|
`"${agent.name}" belongs to "${agent.parent}", which is archived; restore it first.`,
|
|
138
207
|
);
|
|
139
208
|
let { channelId } = agent;
|
|
209
|
+
const surface = helperSurface(agent);
|
|
210
|
+
const bornOnDiscord = surface === undefined || surface === "discord";
|
|
140
211
|
if (channels?.createThread && parent.channelId) {
|
|
141
212
|
if (
|
|
142
213
|
channelId &&
|
|
@@ -144,7 +215,7 @@ export class TeamHelpers {
|
|
|
144
215
|
(await channels.exists(channelId))
|
|
145
216
|
)
|
|
146
217
|
await channels.openThread(channelId);
|
|
147
|
-
else
|
|
218
|
+
else if (bornOnDiscord)
|
|
148
219
|
channelId = await channels.createThread(
|
|
149
220
|
parent.channelId,
|
|
150
221
|
agent.displayName,
|
|
@@ -87,6 +87,20 @@ export const roomSurfaceOf = (room: ChannelKey): string | undefined => {
|
|
|
87
87
|
return surface === DISCORD_SURFACE ? undefined : surface;
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The channel a group seat runs in outside a running turn: the group's room on the surface of the
|
|
92
|
+
* seat's home for a room seat, so it never falls out to the group's Discord channel; otherwise the
|
|
93
|
+
* group's Discord channel, or the seat's home.
|
|
94
|
+
*/
|
|
95
|
+
export function groupSeatChannel(
|
|
96
|
+
group: Pick<AgentGroup, "name" | "channelId">,
|
|
97
|
+
scope: Pick<AgentTurnScope, "home">,
|
|
98
|
+
): ChannelKey {
|
|
99
|
+
const surface = roomSurfaceOf(scope.home);
|
|
100
|
+
if (surface !== undefined) return groupRoomKey(surface, group.name);
|
|
101
|
+
return group.channelId ? discordKey(group.channelId) : scope.home;
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
/**
|
|
91
105
|
* An agent's conversation inside a group, apart from its own. In a Discord group channel, or
|
|
92
106
|
* without a room, it is keyed by the channel; in a room of another surface, by the surface and the
|
|
@@ -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,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
coordinatorOf,
|
|
31
32
|
discordIdOf,
|
|
32
33
|
discordKey,
|
|
34
|
+
groupSeatChannel,
|
|
33
35
|
scopeOf,
|
|
34
36
|
} from "./team-keys.ts";
|
|
35
37
|
import {
|
|
@@ -321,8 +323,10 @@ export class TeamTurns {
|
|
|
321
323
|
const { store, channels, studio, surfaces } = this.#options;
|
|
322
324
|
if (discordIdOf(channel) === undefined) {
|
|
323
325
|
// A room's interim posts are its surface's, under the agent as author.
|
|
324
|
-
|
|
326
|
+
const first = surfaces?.interim(channel, this.#author(agent));
|
|
327
|
+
if (!surfaces || !first) return undefined;
|
|
325
328
|
return {
|
|
329
|
+
...(first.tools === false ? { tools: false } : {}),
|
|
326
330
|
post: async (text) => {
|
|
327
331
|
if (!channelOwner(store, channel, this.#rooms(channel)))
|
|
328
332
|
throw new AgentError(`${channel} is archived`);
|
|
@@ -363,6 +367,7 @@ export class TeamTurns {
|
|
|
363
367
|
files?: ReplyFile[];
|
|
364
368
|
threadId?: string;
|
|
365
369
|
failed?: true;
|
|
370
|
+
intercom?: IntercomCard;
|
|
366
371
|
},
|
|
367
372
|
): Promise<void> {
|
|
368
373
|
const { store, channels, studio, surfaces, logger } = this.#options;
|
|
@@ -378,7 +383,7 @@ export class TeamTurns {
|
|
|
378
383
|
if (discordIdOf(channel) === undefined) {
|
|
379
384
|
if (!surfaces)
|
|
380
385
|
throw new AgentError(`no surface serves the room ${channel}`);
|
|
381
|
-
const { thinking, chunks, files, threadId } = rest;
|
|
386
|
+
const { thinking, chunks, files, threadId, intercom } = rest;
|
|
382
387
|
// A thread of the room's surface is a channel of that surface, and speaks there.
|
|
383
388
|
return surfaces.sendReply(
|
|
384
389
|
threadId === undefined
|
|
@@ -390,6 +395,7 @@ export class TeamTurns {
|
|
|
390
395
|
...(files?.length ? { files } : {}),
|
|
391
396
|
author: this.#author(as),
|
|
392
397
|
...(failed ? { failed } : {}),
|
|
398
|
+
...(intercom ? { intercom } : {}),
|
|
393
399
|
},
|
|
394
400
|
);
|
|
395
401
|
}
|
|
@@ -424,10 +430,7 @@ export class TeamTurns {
|
|
|
424
430
|
: undefined;
|
|
425
431
|
if (!group) return scope.home;
|
|
426
432
|
// A seat of a room's group runs in that room; its running turn knows which.
|
|
427
|
-
return (
|
|
428
|
-
this.#posting.get(scope.session) ??
|
|
429
|
-
(group.channelId ? discordKey(group.channelId) : scope.home)
|
|
430
|
-
);
|
|
433
|
+
return this.#posting.get(scope.session) ?? groupSeatChannel(group, scope);
|
|
431
434
|
}
|
|
432
435
|
|
|
433
436
|
/**
|
|
@@ -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
|
};
|
|
@@ -31,6 +31,7 @@ import { Scheduler } from "../modules/schedules/scheduler.ts";
|
|
|
31
31
|
import { schedulesExtension } from "../modules/schedules/schedules.ts";
|
|
32
32
|
import type { ErrorReporter } from "../ops/error-reporter.ts";
|
|
33
33
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
34
|
+
import { runChannelFor } from "../presence/delivery-router.ts";
|
|
34
35
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
35
36
|
import {
|
|
36
37
|
AGENTS,
|
|
@@ -130,23 +131,22 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
130
131
|
services.get(AGENTS).team.roomsOf(agent);
|
|
131
132
|
// A conversation no chat surface carries keeps its runs in the creator's direct messages, where
|
|
132
133
|
// a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
|
|
133
|
-
|
|
134
|
+
// The delivery router picks those messages with the conversation as the origin.
|
|
135
|
+
const channelFor = (
|
|
134
136
|
channel: ChannelKey,
|
|
135
137
|
principalId: string,
|
|
136
138
|
refused: (message: string) => Error,
|
|
137
|
-
) =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return reached.channel;
|
|
149
|
-
};
|
|
139
|
+
) =>
|
|
140
|
+
runChannelFor(
|
|
141
|
+
{
|
|
142
|
+
surfaces,
|
|
143
|
+
directChannels,
|
|
144
|
+
takesBackground: (key) => conversations.takesBackground(key),
|
|
145
|
+
},
|
|
146
|
+
channel,
|
|
147
|
+
principalId,
|
|
148
|
+
refused,
|
|
149
|
+
);
|
|
150
150
|
const scheduleChannelFor = (channel: ChannelKey, principalId: string) =>
|
|
151
151
|
channelFor(channel, principalId, (m) => new ScheduleError(m));
|
|
152
152
|
const delegateChannelFor = (channel: ChannelKey, principalId: string) =>
|
|
@@ -198,7 +198,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
198
198
|
// Only whether a direct channel knows them, without the network; the conversation there,
|
|
199
199
|
// and whether a claim takes its turns, are checked when a tool runs.
|
|
200
200
|
try {
|
|
201
|
-
if (!(await directChannels.known(own))) return;
|
|
201
|
+
if (!(await directChannels.known(home, own))) return;
|
|
202
202
|
} catch (error) {
|
|
203
203
|
logger.warn(
|
|
204
204
|
{ channel: home, err: error },
|
|
@@ -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
|
+
}
|