pi-roundtable 0.26.1 → 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/docs/plugins.md +12 -7
  3. package/package.json +1 -1
  4. package/src/core/agents/agent-claim.ts +1 -0
  5. package/src/core/agents/agent-conversations.ts +94 -0
  6. package/src/core/agents/agent-messages.ts +247 -56
  7. package/src/core/agents/agent-outbox.ts +91 -0
  8. package/src/core/agents/agent-prompt.ts +40 -1
  9. package/src/core/agents/agent-schema.ts +6 -0
  10. package/src/core/agents/agent-store.ts +22 -3
  11. package/src/core/agents/agent-team-fixture.ts +81 -88
  12. package/src/core/agents/agent-team-seeds.ts +29 -0
  13. package/src/core/agents/agent-team.ts +17 -14
  14. package/src/core/agents/agent-tools.ts +34 -5
  15. package/src/core/agents/group-turns.ts +1 -1
  16. package/src/core/agents/message-target.ts +79 -0
  17. package/src/core/agents/team-editing.ts +30 -2
  18. package/src/core/agents/team-keys.ts +7 -0
  19. package/src/core/agents/team-options.ts +3 -0
  20. package/src/core/agents/team-text.ts +4 -0
  21. package/src/core/agents/team-turn-types.ts +28 -0
  22. package/src/core/agents/team-turns.ts +36 -25
  23. package/src/core/assets/prompts/shared-guest.md +1 -3
  24. package/src/core/assets/prompts/shared.md +1 -3
  25. package/src/core/builtin/agent-server.ts +5 -0
  26. package/src/core/builtin/modules.ts +6 -0
  27. package/src/core/contract/channels.ts +5 -0
  28. package/src/core/contract/surface.ts +5 -0
  29. package/src/core/discord/channel-context.ts +3 -1
  30. package/src/core/discord/embed-text.ts +45 -0
  31. package/src/core/discord/inbound-message.ts +7 -2
  32. package/src/core/domain/ports.ts +17 -0
  33. package/src/core/domain/turn-timeout.ts +2 -0
  34. package/src/core/i18n/turn-timeout.ts +8 -2
  35. package/src/core/modules/background/background-turns.ts +3 -0
  36. package/src/core/modules/schedules/precheck-catalog.ts +49 -0
  37. package/src/core/modules/schedules/schedule-store.ts +27 -3
  38. package/src/core/modules/schedules/schedule-tools.ts +43 -86
  39. package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
  40. package/src/core/modules/schedules/schedules.ts +26 -2
  41. package/src/core/modules/schedules/time-limit.ts +27 -0
  42. package/src/core/presentation/reply-body.ts +8 -0
  43. package/src/core/routing/conversation-turns.ts +21 -2
  44. package/src/core/runtime/extensions/ask-user.ts +1 -1
  45. package/src/core/runtime/extensions/confirmation-gate.ts +31 -27
  46. package/src/core/runtime/extensions/hold-lookups.ts +21 -0
  47. package/src/core/runtime/extensions/late-releases.ts +76 -0
  48. package/src/core/runtime/pi-agent-runtime.ts +21 -19
  49. package/src/core/runtime/turn-answer.ts +5 -1
  50. package/src/core/runtime/turn-budget.ts +1 -0
  51. package/src/core/runtime/turn-limits.ts +12 -2
  52. package/src/core/runtime/turn-tools.ts +26 -0
  53. 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 the channel's queue. `text`
99
- * carries the reference, `replyText` is what they typed.
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 = this.#scopeOf(agent, channel);
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
- this.#scopeOf(agent, channel),
143
+ scopeOf(agent, channel),
144
144
  channel,
145
145
  text,
146
146
  { messages: 0, speaker },
147
- interactive ? { interactive: true } : {},
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.attachments ? { attachments: extra.attachments } : {}),
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
- await this.#post(postTo, this.#current(agent), replyBody(result));
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(caller: AgentTurnScope, to: string, text: string): string {
463
- return this.#messages.message(caller, to, text);
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 a Discord 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.
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 in a Discord server, working for the server's owner. You talk with them in channels and threads.
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 parts = text.trim() ? [text.trim()] : [];
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.replace(mention, "").trim(),
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.replace(mention, "").trim(),
103
+ text: withEmbeds(message.content, message.embeds)
104
+ .replace(mention, "")
105
+ .trim(),
101
106
  attachments: [
102
107
  ...attachmentRefs(message),
103
108
  ...(forward ? attachmentRefs(forward) : []),
@@ -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
- return [head, tools(t)].filter(Boolean).join(" ");
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
- return [head, tools(t)].filter(Boolean).join(" ");
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;