pi-roundtable 0.26.1 → 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 +33 -0
- package/docs/plugins.md +12 -7
- package/package.json +1 -1
- package/src/core/agents/agent-claim.ts +1 -0
- package/src/core/agents/agent-conversations.ts +94 -0
- package/src/core/agents/agent-messages.ts +247 -56
- package/src/core/agents/agent-outbox.ts +91 -0
- package/src/core/agents/agent-prompt.ts +40 -1
- package/src/core/agents/agent-schema.ts +6 -0
- package/src/core/agents/agent-store.ts +22 -3
- package/src/core/agents/agent-team-fixture.ts +81 -88
- package/src/core/agents/agent-team-seeds.ts +29 -0
- package/src/core/agents/agent-team.ts +17 -14
- package/src/core/agents/agent-tools.ts +34 -5
- package/src/core/agents/group-turns.ts +1 -1
- package/src/core/agents/message-target.ts +79 -0
- package/src/core/agents/team-editing.ts +30 -2
- package/src/core/agents/team-keys.ts +7 -0
- package/src/core/agents/team-options.ts +3 -0
- package/src/core/agents/team-text.ts +4 -0
- package/src/core/agents/team-turn-types.ts +28 -0
- package/src/core/agents/team-turns.ts +36 -25
- package/src/core/assets/prompts/shared-guest.md +1 -3
- package/src/core/assets/prompts/shared.md +1 -3
- package/src/core/builtin/agent-server.ts +5 -0
- package/src/core/builtin/modules.ts +6 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/contract/surface.ts +5 -0
- package/src/core/discord/channel-context.ts +3 -1
- package/src/core/discord/embed-text.ts +45 -0
- package/src/core/discord/inbound-message.ts +7 -2
- package/src/core/domain/ports.ts +17 -0
- package/src/core/domain/turn-timeout.ts +2 -0
- package/src/core/i18n/turn-timeout.ts +8 -2
- package/src/core/modules/background/background-turns.ts +3 -0
- package/src/core/modules/schedules/precheck-catalog.ts +49 -0
- package/src/core/modules/schedules/schedule-store.ts +27 -3
- package/src/core/modules/schedules/schedule-tools.ts +43 -86
- package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
- package/src/core/modules/schedules/schedules.ts +26 -2
- package/src/core/modules/schedules/time-limit.ts +27 -0
- package/src/core/presentation/reply-body.ts +8 -0
- package/src/core/routing/conversation-turns.ts +21 -2
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +31 -27
- package/src/core/runtime/extensions/hold-lookups.ts +21 -0
- package/src/core/runtime/extensions/late-releases.ts +76 -0
- package/src/core/runtime/pi-agent-runtime.ts +21 -19
- package/src/core/runtime/turn-answer.ts +5 -1
- package/src/core/runtime/turn-budget.ts +1 -0
- package/src/core/runtime/turn-limits.ts +12 -2
- package/src/core/runtime/turn-tools.ts +26 -0
- package/src/core/shared/schedule-tools.ts +25 -1
|
@@ -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
|
),
|
|
@@ -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
|
/**
|
|
@@ -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. */
|
|
@@ -75,7 +75,10 @@ export function turnTimeoutEn() {
|
|
|
75
75
|
: t.end === "unanswered"
|
|
76
76
|
? `⏱️ No answer to the timeout card in ${durationEn(t.graceMs)}, so the turn was stopped after ${durationEn(t.workedMs)} ${what}.`
|
|
77
77
|
: `⏱️ The turn reached ${limit(t)} and was stopped after ${durationEn(t.workedMs)} ${what}.`;
|
|
78
|
-
|
|
78
|
+
const unasked = t.unasked
|
|
79
|
+
? "No one was in the conversation to ask for more time. To let turns like this run longer, turn on long-running for this conversation, or give the schedule a longer time limit."
|
|
80
|
+
: "";
|
|
81
|
+
return [head, tools(t), unasked].filter(Boolean).join(" ");
|
|
79
82
|
},
|
|
80
83
|
};
|
|
81
84
|
}
|
|
@@ -125,7 +128,10 @@ export function turnTimeoutZhTW(): ReturnType<typeof turnTimeoutEn> {
|
|
|
125
128
|
: t.end === "unanswered"
|
|
126
129
|
? `⏱️ 逾時卡片在 ${durationZh(t.graceMs)}內沒有回答,這一輪在工作 ${durationZh(t.workedMs)} 後停止,${what}。`
|
|
127
130
|
: `⏱️ 這一輪達到${limit(t)},在工作 ${durationZh(t.workedMs)} 後停止,${what}。`;
|
|
128
|
-
|
|
131
|
+
const unasked = t.unasked
|
|
132
|
+
? "當時對話裡沒有人可以問要不要再給時間。要讓這類回合跑久一點,可以替這個對話開啟長時間執行,或替排程設定較長的時間上限。"
|
|
133
|
+
: "";
|
|
134
|
+
return [head, tools(t), unasked].filter(Boolean).join(" ");
|
|
129
135
|
},
|
|
130
136
|
};
|
|
131
137
|
}
|
|
@@ -76,6 +76,9 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
|
|
|
76
76
|
tier: schedule.createdTier,
|
|
77
77
|
turnId: `schedule-${schedule.id}-${firedAt.getTime()}`,
|
|
78
78
|
text: scheduledTurnText(schedule, firedAt, finding),
|
|
79
|
+
...(schedule.timeLimitMinutes
|
|
80
|
+
? { timeLimitMs: schedule.timeLimitMinutes * 60_000 }
|
|
81
|
+
: {}),
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PRECHECK_SCRIPT_CHARS } from "./prechecks.ts";
|
|
2
|
+
import type { ScheduleToolContext } from "./schedule-tools.ts";
|
|
3
|
+
|
|
4
|
+
/** How long schedule_list waits for the script runner to say what a script may call. */
|
|
5
|
+
const DESCRIBE_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
/** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
|
|
8
|
+
export async function precheckCatalog(
|
|
9
|
+
ctx: ScheduleToolContext,
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
const all = ctx.prechecks?.list() ?? [];
|
|
12
|
+
const runner = ctx.prechecks?.scriptRunner?.();
|
|
13
|
+
const named =
|
|
14
|
+
all.length === 0
|
|
15
|
+
? ""
|
|
16
|
+
: `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
|
|
17
|
+
if (!runner) return named;
|
|
18
|
+
// The list still answers when the host cannot say, or is slow to say, what a script may reach here.
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
const late = new Promise<string>((resolve) => {
|
|
21
|
+
timer = setTimeout(
|
|
22
|
+
() => resolve("it did not answer in time"),
|
|
23
|
+
DESCRIBE_TIMEOUT_MS,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
const described = Promise.resolve()
|
|
27
|
+
.then(() =>
|
|
28
|
+
runner.describe({
|
|
29
|
+
channel: ctx.channel,
|
|
30
|
+
target: ctx.target.name,
|
|
31
|
+
tier: ctx.author.tier,
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
.then(
|
|
35
|
+
(text) => ({ text }),
|
|
36
|
+
(error: unknown) => ({
|
|
37
|
+
error: error instanceof Error ? error.message : String(error),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const answer = await Promise.race([
|
|
41
|
+
described,
|
|
42
|
+
late.then((error) => ({ error })),
|
|
43
|
+
]).finally(() => clearTimeout(timer));
|
|
44
|
+
const guide =
|
|
45
|
+
"text" in answer
|
|
46
|
+
? answer.text
|
|
47
|
+
: `The host could not say what a script may call here (${answer.error}); a script set now may fail when it runs.`;
|
|
48
|
+
return `${named}\n\nYou can write a precheck of your own instead, with precheck_script on schedule_create or schedule_update (null removes it): a JavaScript module, at most ${PRECHECK_SCRIPT_CHARS} characters, that the host runs in a sandbox before each turn. A schedule has a precheck or a precheck_script, not both.\n${guide}`;
|
|
49
|
+
}
|
|
@@ -30,6 +30,8 @@ export interface Schedule {
|
|
|
30
30
|
* absent for a script saved before tools were recorded, which must be saved again if it calls a held tool.
|
|
31
31
|
*/
|
|
32
32
|
precheckTools?: PrecheckTool[];
|
|
33
|
+
/** How long, in minutes, each of its turns may work; absent, the runtime's default time limit. */
|
|
34
|
+
timeLimitMinutes?: number;
|
|
33
35
|
lastRun?: Date;
|
|
34
36
|
lastStatus?: string;
|
|
35
37
|
}
|
|
@@ -50,6 +52,8 @@ export interface NewSchedule {
|
|
|
50
52
|
precheckScript?: string;
|
|
51
53
|
/** The tools the script may call, as approved; required with `precheckScript`. */
|
|
52
54
|
precheckTools?: PrecheckTool[];
|
|
55
|
+
/** How long, in minutes, each of its turns may work. */
|
|
56
|
+
timeLimitMinutes?: number;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export interface ScheduleChange {
|
|
@@ -63,6 +67,8 @@ export interface ScheduleChange {
|
|
|
63
67
|
precheckScript?: string | null;
|
|
64
68
|
/** The tools a new script may call, as approved; required with a new `precheckScript`. */
|
|
65
69
|
precheckTools?: PrecheckTool[];
|
|
70
|
+
/** How long, in minutes, each turn may work; null returns it to the runtime's default. */
|
|
71
|
+
timeLimitMinutes?: number | null;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
interface Row {
|
|
@@ -81,6 +87,7 @@ interface Row {
|
|
|
81
87
|
precheck: string | null;
|
|
82
88
|
precheck_script: string | null;
|
|
83
89
|
precheck_tools: string | null;
|
|
90
|
+
time_limit_minutes: number | null;
|
|
84
91
|
last_run: Date | null;
|
|
85
92
|
last_status: string | null;
|
|
86
93
|
}
|
|
@@ -105,6 +112,9 @@ function toSchedule(row: Row): Schedule {
|
|
|
105
112
|
? // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
|
|
106
113
|
{ precheckTools: JSON.parse(row.precheck_tools) as PrecheckTool[] }
|
|
107
114
|
: {}),
|
|
115
|
+
...(row.time_limit_minutes
|
|
116
|
+
? { timeLimitMinutes: row.time_limit_minutes }
|
|
117
|
+
: {}),
|
|
108
118
|
...(row.last_run ? { lastRun: row.last_run } : {}),
|
|
109
119
|
...(row.last_status ? { lastStatus: row.last_status } : {}),
|
|
110
120
|
};
|
|
@@ -126,12 +136,14 @@ async function insert(sql: SQL, schedule: NewSchedule): Promise<Schedule> {
|
|
|
126
136
|
checked(schedule);
|
|
127
137
|
const rows: Row[] = await sql`
|
|
128
138
|
INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
|
|
129
|
-
created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools
|
|
139
|
+
created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools,
|
|
140
|
+
time_limit_minutes)
|
|
130
141
|
VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
|
|
131
142
|
${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
|
|
132
143
|
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
|
|
133
144
|
${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
|
|
134
|
-
${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null}
|
|
145
|
+
${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null},
|
|
146
|
+
${schedule.timeLimitMinutes ?? null})
|
|
135
147
|
RETURNING *`;
|
|
136
148
|
const [row] = rows;
|
|
137
149
|
if (!row) throw new Error("the schedule insert returned no row");
|
|
@@ -195,6 +207,13 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
195
207
|
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_tools text`;
|
|
196
208
|
},
|
|
197
209
|
},
|
|
210
|
+
{
|
|
211
|
+
// Schedules made before have none: their turns keep the runtime's default time limit.
|
|
212
|
+
name: "schedules-time-limit",
|
|
213
|
+
up: async (sql) => {
|
|
214
|
+
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS time_limit_minutes integer`;
|
|
215
|
+
},
|
|
216
|
+
},
|
|
198
217
|
];
|
|
199
218
|
}
|
|
200
219
|
|
|
@@ -282,6 +301,10 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
282
301
|
: current.precheckTools
|
|
283
302
|
? JSON.stringify(current.precheckTools)
|
|
284
303
|
: null;
|
|
304
|
+
const timeLimit =
|
|
305
|
+
change.timeLimitMinutes === undefined
|
|
306
|
+
? (current.timeLimitMinutes ?? null)
|
|
307
|
+
: change.timeLimitMinutes;
|
|
285
308
|
const rows: Row[] = await this.#sql`
|
|
286
309
|
UPDATE schedules SET
|
|
287
310
|
title = ${change.title ?? current.title},
|
|
@@ -290,7 +313,8 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
290
313
|
next_run = ${change.nextRun ?? current.nextRun},
|
|
291
314
|
precheck = ${precheck},
|
|
292
315
|
precheck_script = ${precheckScript},
|
|
293
|
-
precheck_tools = ${precheckTools}
|
|
316
|
+
precheck_tools = ${precheckTools},
|
|
317
|
+
time_limit_minutes = ${timeLimit}
|
|
294
318
|
WHERE id = ${id}
|
|
295
319
|
RETURNING *`;
|
|
296
320
|
return rows[0] ? toSchedule(rows[0]) : undefined;
|