pi-roundtable 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/docs/plugins.md +39 -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/config/config.ts +10 -2
  28. package/src/core/config/conversations.ts +21 -2
  29. package/src/core/contract/channels.ts +5 -0
  30. package/src/core/contract/surface.ts +5 -0
  31. package/src/core/conversations/conversation-retention.ts +22 -6
  32. package/src/core/conversations/conversations-plugin.ts +7 -1
  33. package/src/core/define-roundtable.ts +5 -1
  34. package/src/core/discord/channel-context.ts +3 -1
  35. package/src/core/discord/embed-text.ts +45 -0
  36. package/src/core/discord/inbound-message.ts +7 -2
  37. package/src/core/domain/ports.ts +17 -0
  38. package/src/core/domain/turn-timeout.ts +2 -0
  39. package/src/core/holds.ts +9 -0
  40. package/src/core/i18n/turn-timeout.ts +8 -2
  41. package/src/core/modules/background/background-turns.ts +3 -0
  42. package/src/core/modules/schedules/precheck-catalog.ts +49 -0
  43. package/src/core/modules/schedules/schedule-store.ts +27 -3
  44. package/src/core/modules/schedules/schedule-tools.ts +43 -86
  45. package/src/core/modules/schedules/scheduled-turn-text.ts +31 -0
  46. package/src/core/modules/schedules/schedules.ts +26 -2
  47. package/src/core/modules/schedules/time-limit.ts +27 -0
  48. package/src/core/presentation/reply-body.ts +8 -0
  49. package/src/core/routing/conversation-turns.ts +21 -2
  50. package/src/core/runtime/extensions/ask-user.ts +1 -1
  51. package/src/core/runtime/extensions/confirmation-gate.ts +36 -36
  52. package/src/core/runtime/extensions/gate-refusals.ts +32 -0
  53. package/src/core/runtime/extensions/hold-lookups.ts +31 -2
  54. package/src/core/runtime/extensions/late-answer.ts +5 -2
  55. package/src/core/runtime/extensions/late-releases.ts +76 -0
  56. package/src/core/runtime/pi-agent-runtime.ts +21 -19
  57. package/src/core/runtime/turn-answer.ts +5 -1
  58. package/src/core/runtime/turn-budget.ts +1 -0
  59. package/src/core/runtime/turn-limits.ts +12 -2
  60. package/src/core/runtime/turn-tools.ts +26 -0
  61. package/src/core/shared/schedule-tools.ts +25 -1
@@ -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;
@@ -8,12 +8,9 @@ import type { ScheduleStore } from "../../services.ts";
8
8
  import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
9
9
  import { type Tier, tierAtLeast } from "../../speakers.ts";
10
10
  import { timeZone, zonedStamp } from "../../time.ts";
11
+ import { precheckCatalog } from "./precheck-catalog.ts";
11
12
  import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
12
- import {
13
- PRECHECK_SCRIPT_CHARS,
14
- type PrecheckFinding,
15
- type PrecheckRegistry,
16
- } from "./prechecks.ts";
13
+ import { PRECHECK_SCRIPT_CHARS, type PrecheckRegistry } from "./prechecks.ts";
17
14
  import {
18
15
  describeRecurrence,
19
16
  nextRun,
@@ -22,11 +19,10 @@ import {
22
19
  type RecurrenceInput,
23
20
  } from "./recurrence.ts";
24
21
  import type { NewSchedule, Schedule } from "./schedule-store.ts";
22
+ import { timeLimitInput, timeLimitText } from "./time-limit.ts";
25
23
 
26
24
  const TITLE_CHARS = 80;
27
25
  const LIST_PROMPT_PREVIEW = 200;
28
- /** How long schedule_list waits for the script runner to say what a script may call. */
29
- const DESCRIBE_TIMEOUT_MS = 10_000;
30
26
  const DAY_MS = 86_400_000;
31
27
 
32
28
  export interface ScheduleToolContext {
@@ -40,6 +36,12 @@ export interface ScheduleToolContext {
40
36
  > &
41
37
  Partial<Pick<ScheduleStore, "all" | "createWithin">>;
42
38
  channel: ChannelKey;
39
+ /**
40
+ * Every conversation whose schedules the asker lists, changes, and cancels, `channel` among
41
+ * them: an agent's own across all its conversations. Without it, `channel` alone. A new
42
+ * schedule is always made in `channel`.
43
+ */
44
+ channels?: readonly ChannelKey[];
43
45
  /** Whose schedules these are; its `schedules` limits apply, and without them nothing is scheduled. */
44
46
  target: BackgroundTarget;
45
47
  /**
@@ -158,48 +160,6 @@ function toolsText(tools: readonly PrecheckTool[] | undefined): string {
158
160
  .join(", ");
159
161
  }
160
162
 
161
- /** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
162
- async function precheckCatalog(ctx: ScheduleToolContext): Promise<string> {
163
- const all = ctx.prechecks?.list() ?? [];
164
- const runner = ctx.prechecks?.scriptRunner?.();
165
- const named =
166
- all.length === 0
167
- ? ""
168
- : `\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")}`;
169
- if (!runner) return named;
170
- // The list still answers when the host cannot say, or is slow to say, what a script may reach here.
171
- let timer: ReturnType<typeof setTimeout> | undefined;
172
- const late = new Promise<string>((resolve) => {
173
- timer = setTimeout(
174
- () => resolve("it did not answer in time"),
175
- DESCRIBE_TIMEOUT_MS,
176
- );
177
- });
178
- const described = Promise.resolve()
179
- .then(() =>
180
- runner.describe({
181
- channel: ctx.channel,
182
- target: ctx.target.name,
183
- tier: ctx.author.tier,
184
- }),
185
- )
186
- .then(
187
- (text) => ({ text }),
188
- (error: unknown) => ({
189
- error: error instanceof Error ? error.message : String(error),
190
- }),
191
- );
192
- const answer = await Promise.race([
193
- described,
194
- late.then((error) => ({ error })),
195
- ]).finally(() => clearTimeout(timer));
196
- const guide =
197
- "text" in answer
198
- ? answer.text
199
- : `The host could not say what a script may call here (${answer.error}); a script set now may fail when it runs.`;
200
- 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}`;
201
- }
202
-
203
163
  function hasTiming(input: Input): boolean {
204
164
  return [
205
165
  "in_minutes",
@@ -225,7 +185,7 @@ function timing(ctx: ScheduleToolContext, input: Input): [Recurrence, Date] {
225
185
  return [recurrence, next];
226
186
  }
227
187
 
228
- function line(schedule: Schedule): string {
188
+ function line(schedule: Schedule, where?: ChannelKey): string {
229
189
  const prompt =
230
190
  schedule.prompt.length > LIST_PROMPT_PREVIEW
231
191
  ? `${schedule.prompt.slice(0, LIST_PROMPT_PREVIEW)}…`
@@ -235,10 +195,12 @@ function line(schedule: Schedule): string {
235
195
  : schedule.precheckScript
236
196
  ? `; precheck script (${schedule.precheckScript.length} characters; tools: ${toolsText(schedule.precheckTools)})`
237
197
  : "";
198
+ const limit = timeLimitText(schedule.timeLimitMinutes);
238
199
  const last = schedule.lastRun
239
200
  ? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
240
201
  : "";
241
- return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}; set by ${schedule.createdByName}${precheck}${last}\n ${prompt.replace(/\n/g, " ")}`;
202
+ const fires = where ? `; fires in ${where}` : "";
203
+ return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}${fires}; set by ${schedule.createdByName}${precheck}${limit}${last}\n ${prompt.replace(/\n/g, " ")}`;
242
204
  }
243
205
 
244
206
  /** The principal who created a schedule. */
@@ -262,11 +224,15 @@ async function visible(
262
224
  );
263
225
  }
264
226
 
227
+ /** The conversations whose schedules the asker reaches. */
228
+ const scopeOf = (ctx: ScheduleToolContext): readonly ChannelKey[] =>
229
+ ctx.channels ?? [ctx.channel];
230
+
265
231
  async function own(ctx: ScheduleToolContext, input: Input): Promise<Schedule> {
266
232
  const schedule = await ctx.store.get(id(input));
267
233
  if (
268
234
  !schedule ||
269
- schedule.channel !== ctx.channel ||
235
+ !scopeOf(ctx).includes(schedule.channel) ||
270
236
  !(await visible(ctx, schedule))
271
237
  )
272
238
  throw new ScheduleError(
@@ -368,6 +334,8 @@ export async function callScheduleTool(
368
334
  const script = given("precheck_script")
369
335
  ? precheckScript(ctx, input.precheck_script)
370
336
  : undefined;
337
+ const timeLimitMinutes =
338
+ timeLimitInput(input.time_limit_minutes) ?? undefined;
371
339
  const existing = await ctx.store.forChannel(ctx.channel);
372
340
  if (existing.length >= limits.perChannel)
373
341
  throw new ScheduleError(
@@ -384,6 +352,7 @@ export async function callScheduleTool(
384
352
  createdByName: ctx.author.name,
385
353
  createdTier: ctx.author.tier,
386
354
  ...(precheck ? { precheck } : {}),
355
+ ...(timeLimitMinutes ? { timeLimitMinutes } : {}),
387
356
  ...(script
388
357
  ? { precheckScript: script.script, precheckTools: script.tools }
389
358
  : {}),
@@ -393,7 +362,7 @@ export async function callScheduleTool(
393
362
  : script
394
363
  ? "; its precheck script runs first"
395
364
  : "";
396
- return `Scheduled #${made.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
365
+ return `Scheduled #${made.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}${timeLimitText(made.timeLimitMinutes)}.`;
397
366
  }
398
367
  case "schedule_list": {
399
368
  if (input.id !== undefined) {
@@ -404,12 +373,18 @@ export async function callScheduleTool(
404
373
  return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}${script}`;
405
374
  }
406
375
  const all: Schedule[] = [];
407
- for (const schedule of await ctx.store.forChannel(ctx.channel))
408
- if (await visible(ctx, schedule)) all.push(schedule);
376
+ for (const channel of scopeOf(ctx))
377
+ for (const schedule of await ctx.store.forChannel(channel))
378
+ if (await visible(ctx, schedule)) all.push(schedule);
379
+ // Across several conversations each line says where it fires.
380
+ const across = scopeOf(ctx).length > 1;
381
+ if (across) all.sort((a, b) => a.nextRun.getTime() - b.nextRun.getTime());
409
382
  const listed =
410
383
  all.length === 0
411
- ? "This channel has no schedules."
412
- : `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map(line).join("\n")}`;
384
+ ? across
385
+ ? "You have no schedules in any of your conversations."
386
+ : "This channel has no schedules."
387
+ : `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map((s) => line(s, across ? s.channel : undefined)).join("\n")}`;
413
388
  return `${listed}${await precheckCatalog(ctx)}`;
414
389
  }
415
390
  case "schedule_update": {
@@ -442,25 +417,31 @@ export async function callScheduleTool(
442
417
  change.precheckScript = script.script;
443
418
  change.precheckTools = script.tools;
444
419
  }
420
+ const timeLimit = timeLimitInput(input.time_limit_minutes);
421
+ if (timeLimit !== undefined) change.timeLimitMinutes = timeLimit;
445
422
  if (change.precheck) change.precheckScript = null;
446
423
  if (change.precheckScript) change.precheck = null;
447
424
  if (Object.keys(change).length === 0)
448
425
  throw new ScheduleError(
449
- "give a title, prompt, timing, precheck, or precheck_script to change",
426
+ "give a title, prompt, timing, precheck, precheck_script, or time_limit_minutes to change",
450
427
  );
451
- const updated = await ctx.store.update(ctx.channel, schedule.id, change);
428
+ const updated = await ctx.store.update(
429
+ schedule.channel,
430
+ schedule.id,
431
+ change,
432
+ );
452
433
  if (!updated) throw new ScheduleError(`schedule #${schedule.id} is gone`);
453
434
  const checked = updated.precheck
454
435
  ? `; precheck ${updated.precheck} runs first`
455
436
  : updated.precheckScript
456
437
  ? "; its precheck script runs first"
457
438
  : "";
458
- return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
439
+ return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}${timeLimitText(updated.timeLimitMinutes)}.`;
459
440
  }
460
441
  case "schedule_cancel": {
461
442
  notTheHost(ctx, "cancel no schedules; ask the owner to cancel it");
462
443
  const schedule = changeable(ctx, await own(ctx, input));
463
- await ctx.store.remove(schedule.id, ctx.channel);
444
+ await ctx.store.remove(schedule.id, schedule.channel);
464
445
  return `Cancelled #${schedule.id} "${schedule.title}".`;
465
446
  }
466
447
  default:
@@ -468,28 +449,4 @@ export async function callScheduleTool(
468
449
  }
469
450
  }
470
451
 
471
- /** What the schedule's precheck found, or how it failed, under its own heading after the task. */
472
- function findingText(finding: PrecheckFinding): string[] {
473
- return "error" in finding
474
- ? [
475
- "",
476
- `### Precheck failed: ${finding.precheck}`,
477
- `The precheck that decides whether this task needs you failed, so you were woken anyway: ${finding.error}. Check what it watches yourself, and say so if it needs fixing.`,
478
- ]
479
- : ["", `### Precheck found (${finding.precheck}):`, finding.context];
480
- }
481
-
482
- /** What a scheduled run receives as its message, with what its precheck found when it has one. */
483
- export function scheduledTurnText(
484
- schedule: Schedule,
485
- firedAt: Date,
486
- finding?: PrecheckFinding,
487
- ): string {
488
- return [
489
- `## Scheduled task #${schedule.id}: ${schedule.title}`,
490
- `${schedule.createdByName} set this schedule (${describeRecurrence(schedule.recurrence)}). It is due now, ${zonedStamp(firedAt)} ${messages().zoneTime(timeZone())}. Nobody wrote a new message: carry out the task below and write what you would post in this channel. If the task keeps a record for later runs, update it with schedule_update on #${schedule.id}.`,
491
- "",
492
- schedule.prompt,
493
- ...(finding ? findingText(finding) : []),
494
- ].join("\n");
495
- }
452
+ export { scheduledTurnText } from "./scheduled-turn-text.ts";
@@ -0,0 +1,31 @@
1
+ import { messages } from "../../i18n/index.ts";
2
+ import { timeZone, zonedStamp } from "../../time.ts";
3
+ import type { PrecheckFinding } from "./prechecks.ts";
4
+ import { describeRecurrence } from "./recurrence.ts";
5
+ import type { Schedule } from "./schedule-store.ts";
6
+
7
+ /** What the schedule's precheck found, or how it failed, under its own heading after the task. */
8
+ function findingText(finding: PrecheckFinding): string[] {
9
+ return "error" in finding
10
+ ? [
11
+ "",
12
+ `### Precheck failed: ${finding.precheck}`,
13
+ `The precheck that decides whether this task needs you failed, so you were woken anyway: ${finding.error}. Check what it watches yourself, and say so if it needs fixing.`,
14
+ ]
15
+ : ["", `### Precheck found (${finding.precheck}):`, finding.context];
16
+ }
17
+
18
+ /** What a scheduled run receives as its message, with what its precheck found when it has one. */
19
+ export function scheduledTurnText(
20
+ schedule: Schedule,
21
+ firedAt: Date,
22
+ finding?: PrecheckFinding,
23
+ ): string {
24
+ return [
25
+ `## Scheduled task #${schedule.id}: ${schedule.title}`,
26
+ `${schedule.createdByName} set this schedule (${describeRecurrence(schedule.recurrence)}). It is due now, ${zonedStamp(firedAt)} ${messages().zoneTime(timeZone())}. Nobody wrote a new message: carry out the task below and write what you would post in this channel. If the task keeps a record for later runs, update it with schedule_update on #${schedule.id}.`,
27
+ "",
28
+ schedule.prompt,
29
+ ...(finding ? findingText(finding) : []),
30
+ ].join("\n");
31
+ }
@@ -68,6 +68,18 @@ function withAgentOption(spec: ScheduleToolSpec): ScheduleToolSpec {
68
68
  };
69
69
  }
70
70
 
71
+ /** The tools that reach every one of an agent's conversations say so, since they read "this channel's" otherwise. */
72
+ function withAgentScope(spec: ScheduleToolSpec): ScheduleToolSpec {
73
+ if (
74
+ !["schedule_list", "schedule_update", "schedule_cancel"].includes(spec.name)
75
+ )
76
+ return spec;
77
+ return {
78
+ ...spec,
79
+ description: `${spec.description} As an agent you reach all your own schedules, whichever of your conversations (Discord, the web app) they were made in; schedule_list says where each fires. A new schedule belongs to the conversation you are in.`,
80
+ };
81
+ }
82
+
71
83
  /**
72
84
  * Registers the schedule tools for one conversation; a schedule's runs speak for the person whose
73
85
  * turn set it up, and in a turn nobody is named for the tools refuse. With `agents`,
@@ -79,6 +91,11 @@ export function schedulesExtension(
79
91
  agents?: AgentChannelLookup,
80
92
  /** The person the running turn is for; their schedules run at their tier. */
81
93
  speaker: () => Speaker | undefined = () => undefined,
94
+ /**
95
+ * An agent's conversations, asked when a tool runs: its schedules in all of them are listed,
96
+ * changed, and cancelled, `channel`'s among them; without it, `channel`'s alone.
97
+ */
98
+ conversations?: () => readonly ChannelKey[],
82
99
  ): ExtensionFactory {
83
100
  const {
84
101
  store,
@@ -94,7 +111,8 @@ export function schedulesExtension(
94
111
  timeZone: timeZone(),
95
112
  precheckScripts: prechecks?.scriptRunner?.() !== undefined,
96
113
  }).map((base) => {
97
- const spec = agents ? withAgentOption(base) : base;
114
+ const named = agents ? withAgentOption(base) : base;
115
+ const spec = conversations ? withAgentScope(named) : named;
98
116
  return {
99
117
  ...spec,
100
118
  run: async (input: ToolInput) => {
@@ -104,14 +122,20 @@ export function schedulesExtension(
104
122
  "schedules are kept only in a turn someone is named for, whose schedules they are",
105
123
  );
106
124
  const peer =
107
- agents && typeof input.agent === "string" && input.agent
125
+ spec.name === "schedule_list" &&
126
+ agents &&
127
+ typeof input.agent === "string" &&
128
+ input.agent
108
129
  ? agents(input.agent, channel)
109
130
  : undefined;
110
131
  const target = peer ?? (await channelFor(channel, author.principalId));
132
+ // Another agent's schedules are read in its one channel; an agent's own span its conversations.
133
+ const own = peer ? undefined : conversations?.();
111
134
  const answer = await callScheduleTool(
112
135
  {
113
136
  store,
114
137
  channel: target,
138
+ ...(own ? { channels: [...new Set([target, ...own])] } : {}),
115
139
  target: kept,
116
140
  author: {
117
141
  principalId: author.principalId,
@@ -0,0 +1,27 @@
1
+ import { ScheduleError } from "../../domain/errors.ts";
2
+ import { MAX_SCHEDULE_TIME_LIMIT_MINUTES } from "../../shared/schedule-tools.ts";
3
+
4
+ /**
5
+ * A schedule's time limit from a tool's input: undefined when it names none, null when it asks
6
+ * for the runtime's default again, else whole minutes within the schedule limit's maximum.
7
+ */
8
+ export function timeLimitInput(value: unknown): number | null | undefined {
9
+ if (value === undefined || value === null) return value;
10
+ if (
11
+ typeof value !== "number" ||
12
+ !Number.isInteger(value) ||
13
+ value < 1 ||
14
+ value > MAX_SCHEDULE_TIME_LIMIT_MINUTES
15
+ )
16
+ throw new ScheduleError(
17
+ `time_limit_minutes must be a whole number of minutes from 1 to ${MAX_SCHEDULE_TIME_LIMIT_MINUTES}`,
18
+ );
19
+ return value;
20
+ }
21
+
22
+ /** The time limit as the schedule's lines and replies say it; empty for the default. */
23
+ export function timeLimitText(minutes: number | undefined): string {
24
+ return minutes === undefined
25
+ ? ""
26
+ : `; each run may work up to ${minutes} min`;
27
+ }
@@ -23,3 +23,11 @@ export function replyBody(result: TurnResult): {
23
23
  ...(result.files?.length ? { files: result.files } : {}),
24
24
  };
25
25
  }
26
+
27
+ /**
28
+ * Whether a turn ended with nothing to post: an unattended turn, such as a schedule's, that had
29
+ * nothing to say. It is no failure and shows no notice.
30
+ */
31
+ export function isQuiet(result: TurnResult): boolean {
32
+ return result.ok && !result.text.trim() && !result.files?.length;
33
+ }
@@ -13,7 +13,7 @@ import { HostStoppingError, PluginError } from "../errors.ts";
13
13
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
14
14
  import type { Logger } from "../log.ts";
15
15
  import type { EventSink } from "../plugin.ts";
16
- import { replyBody } from "../presentation/reply-body.ts";
16
+ import { isQuiet, replyBody } from "../presentation/reply-body.ts";
17
17
  import { withReplyFiles } from "../reply-files.ts";
18
18
  import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
19
19
  import type { Speaker } from "../speakers.ts";
@@ -45,8 +45,17 @@ export interface ConversationTurnInput {
45
45
  selection?: TurnSelection;
46
46
  /** The owner's own chat turn, which their next messages may steer. */
47
47
  steerable?: boolean;
48
+ /** The speaker's message started the turn (not a schedule or report): it may spend a late approval. */
49
+ byMessage?: boolean;
48
50
  /** The turn may ask the owner on the surface's prompts, such as approvals of held actions. */
49
51
  interactive?: boolean;
52
+ /**
53
+ * No person waits on the answer, as for a schedule's turn: an empty final answer then ends the
54
+ * turn quietly, with nothing posted and `reply` not called, instead of failing it.
55
+ */
56
+ unattended?: boolean;
57
+ /** How long the turn may work, in ms, in place of the runtime's default; a schedule's own limit. */
58
+ timeLimitMs?: number;
50
59
  /**
51
60
  * The speaker confirmed the channel's held actions, so they may run in this turn, except those
52
61
  * that named approvers the speaker is neither among nor an owner of, which stay held.
@@ -205,7 +214,12 @@ export function conversationTurns(
205
214
  : {}),
206
215
  ...(input.confirmed ? { confirmed: true } : {}),
207
216
  ...(input.steerable ? { steerable: true } : {}),
217
+ ...(input.byMessage ? { byMessage: true } : {}),
208
218
  ...(input.interactive ? { interactive: true } : {}),
219
+ ...(input.unattended ? { unattended: true } : {}),
220
+ ...(input.timeLimitMs === undefined
221
+ ? {}
222
+ : { timeLimitMs: input.timeLimitMs }),
209
223
  ...(interim ? { interim } : {}),
210
224
  progress,
211
225
  }),
@@ -223,7 +237,12 @@ export function conversationTurns(
223
237
  try {
224
238
  if (!result.ok && !result.stopped)
225
239
  logger.error({ channel, kind, err: result.error }, "turn failed");
226
- if (input.reply) await input.reply(result);
240
+ if (isQuiet(result))
241
+ logger.info(
242
+ { channel, kind },
243
+ "unattended turn had nothing to say; nothing posted",
244
+ );
245
+ else if (input.reply) await input.reply(result);
227
246
  else await surfaces.sendReply(channel, replyBody(result));
228
247
  } catch (error) {
229
248
  logger.error({ channel, kind, err: error }, "reply not posted");
@@ -87,7 +87,7 @@ export function askUserExtension(
87
87
  const prompts = slot.prompts;
88
88
  if (!prompts)
89
89
  return toolText(
90
- `${o.name} cannot answer buttons in this turn, such as a schedule's. Ask ${o.him} in your reply instead.`,
90
+ `${o.name} cannot answer buttons in this turn: no one wrote to you, a schedule or another agent started it, so nothing waits. Do not call ask_user again. Write the question as your final answer instead; it is posted to this channel for ${o.him} to read, and ${o.his} answer comes in a new turn.`,
91
91
  );
92
92
  const p = params as {
93
93
  questions: {