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
@@ -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: {
@@ -40,8 +40,14 @@ import {
40
40
  unexpired,
41
41
  } from "./held-calls.ts";
42
42
  import { HoldDescriber } from "./hold-describer.ts";
43
- import { type HoldTurn, type Judgment, STOPPED } from "./hold-lookups.ts";
43
+ import {
44
+ type HoldTurn,
45
+ heldCall,
46
+ type Judgment,
47
+ STOPPED,
48
+ } from "./hold-lookups.ts";
44
49
  import { lateOutcome } from "./late-answer.ts";
50
+ import { LateReleases } from "./late-releases.ts";
45
51
 
46
52
  export { confirmationGateExtension } from "./confirmation-gate-extension.ts";
47
53
  export {
@@ -70,14 +76,13 @@ export class ConfirmationGate {
70
76
  #selectionId: string | undefined;
71
77
  /** The running turn's speaker, whom the actions it holds belong to, and who approves those a message confirmed. */
72
78
  #speaker: SpeakerRef | undefined;
79
+ #lateSpender: SpeakerRef | undefined;
73
80
  /** Whether the running turn's conversation is private to its speaker, who then alone answers its cards. */
74
81
  #private = false;
75
- /** The running turn's auto-approver, asked before any card opens. */
76
82
  #auto: AutoApprover | undefined;
77
83
  #pending: PendingConfirmation | undefined;
78
84
  #approved: Release[] = [];
79
- /** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
80
- #lateApproved: Release[] = [];
85
+ readonly #late = new LateReleases();
81
86
  /** Who approved each call that is running, and on which card, by tool call id, until its result is back. */
82
87
  readonly #grants = new Grants();
83
88
  readonly #holds: HoldCheck;
@@ -162,7 +167,7 @@ export class ConfirmationGate {
162
167
  * the approver the calls' `run` sees; a call that named approvers is released only when the
163
168
  * speaker is one of them or an owner, and otherwise stays held. `isPrivate` says the turn's
164
169
  * conversation is private to its speaker: they alone then answer its cards. `auto` may approve
165
- * a call the turn makes without a card (yolo mode); it does not change a message's approval.
170
+ * a call without a card (yolo mode). `byMessage`: a person's message started the turn.
166
171
  */
167
172
  beginTurn(
168
173
  selectionId: string,
@@ -171,9 +176,11 @@ export class ConfirmationGate {
171
176
  speaker?: SpeakerRef,
172
177
  isPrivate = false,
173
178
  auto?: AutoApprover,
179
+ byMessage = false,
174
180
  ): void {
175
181
  this.#selectionId = selectionId;
176
182
  this.#speaker = speaker;
183
+ this.#lateSpender = byMessage ? speaker : undefined;
177
184
  this.#private = isPrivate;
178
185
  this.#auto = auto;
179
186
  this.#addressee = addressee ?? this.#owner;
@@ -184,7 +191,6 @@ export class ConfirmationGate {
184
191
  call,
185
192
  ...(by ? { by } : {}),
186
193
  }));
187
- // What the speaker could not release stays held for those who may.
188
194
  const kept = (held?.calls ?? []).filter(
189
195
  (call) => confirmed && !released?.calls.includes(call),
190
196
  );
@@ -214,17 +220,15 @@ export class ConfirmationGate {
214
220
  #sweep(): void {
215
221
  const kept = (call: HeldCall) =>
216
222
  (this.#pending?.calls.some((c) => sameCall(c, call)) ?? false) ||
217
- [...this.#approved, ...this.#lateApproved].some((r) =>
218
- sameCall(r.call, call),
219
- );
223
+ this.#approved.some((r) => sameCall(r.call, call)) ||
224
+ this.#late.has(call);
220
225
  this.#withdrawals.sweep(kept);
221
226
  }
222
227
 
223
228
  /** A plugin withdrew `call`: nothing that could run it stays, and what is held is kept anew. */
224
229
  #forget(call: HeldCall): void {
225
- const other = (r: { call: HeldCall }) => !sameCall(r.call, call);
226
- this.#approved = this.#approved.filter(other);
227
- this.#lateApproved = this.#lateApproved.filter(other);
230
+ this.#approved = this.#approved.filter((r) => !sameCall(r.call, call));
231
+ this.#late.drop(call);
228
232
  const pending = this.#pending;
229
233
  if (!pending?.calls.some((c) => sameCall(c, call))) return;
230
234
  // In place, so a partial release's kept set stays the object `remembered` compares.
@@ -338,7 +342,7 @@ export class ConfirmationGate {
338
342
  // A late answer on the card: the text of the turn that tells the model, and the release it earned.
339
343
  late: (late) => {
340
344
  const outcome = lateOutcome(call, late, watch, needing.onDeclined);
341
- if (outcome?.release) this.#lateApproved.push(outcome.release);
345
+ if (outcome?.release) this.#late.add(outcome.release);
342
346
  return outcome?.text;
343
347
  },
344
348
  },
@@ -385,7 +389,7 @@ export class ConfirmationGate {
385
389
  /** What a call needs when a rule holds it; undefined when none does. */
386
390
  #needing(tool: string, input: Record<string, unknown>): Needing | undefined {
387
391
  const action = this.#holds(tool, input, this.#context);
388
- if (!action) return undefined;
392
+ if (!action) return this.#ranUnheld(tool, input);
389
393
  const classes = this.#holds.classesOf?.(tool, input, this.#context);
390
394
  return this.#unlessApproved(tool, input, { action, classes });
391
395
  }
@@ -407,10 +411,18 @@ export class ConfirmationGate {
407
411
  if (!turn || !this.#holds.inTurn) return this.#needing(tool, input);
408
412
  return this.#describer.describe(tool, input, turn, (judged) => {
409
413
  if (judged === STOPPED) return STOPPED;
410
- return judged ? this.#unlessApproved(tool, input, judged) : undefined;
414
+ return judged
415
+ ? this.#unlessApproved(tool, input, judged)
416
+ : this.#ranUnheld(tool, input);
411
417
  });
412
418
  }
413
419
 
420
+ /** A call no hold stops runs as the one a late approval was for, so that approval is spent. */
421
+ #ranUnheld(tool: string, input: Record<string, unknown>): undefined {
422
+ this.#late.drop({ tool, input: canonicalJson(input) });
423
+ return undefined;
424
+ }
425
+
414
426
  /**
415
427
  * The call for a description, unless an approval releases it, which that approval then spends
416
428
  * and whose approver the release names.
@@ -420,21 +432,13 @@ export class ConfirmationGate {
420
432
  input: Record<string, unknown>,
421
433
  judged: Judgment,
422
434
  ): Needing {
423
- const minTier = this.#holds.approvalTier?.(tool, input, this.#context);
424
- const call: HeldCall = {
425
- tool,
426
- input: canonicalJson(input),
427
- action: judged.action,
428
- ...(minTier ? { minTier } : {}),
429
- ...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
430
- ...(judged.classes && judged.classes.length > 0
431
- ? { holdClasses: [...judged.classes] }
432
- : {}),
433
- };
435
+ const tier = this.#holds.approvalTier?.(tool, input, this.#context);
436
+ const call = heldCall(tool, input, judged, tier);
434
437
  // A plugin that already withdrew the call lets no approval of it release it.
435
438
  const spent = judged.supersede?.aborted
436
439
  ? undefined
437
- : spendApproval([this.#approved, this.#lateApproved], call);
440
+ : (spendApproval([this.#approved], call) ??
441
+ this.#late.spend(call, this.#lateSpender));
438
442
  if (spent) {
439
443
  this.#withdrawals.endCall(spent.call);
440
444
  return { released: spent };
@@ -1,4 +1,5 @@
1
1
  import type { ToolTurn } from "../../define.ts";
2
+ import type { HeldCall } from "../../domain/conversation.ts";
2
3
  import {
3
4
  type HoldCheck,
4
5
  type HoldContext,
@@ -7,6 +8,7 @@ import {
7
8
  } from "../../holds.ts";
8
9
  import { messages } from "../../i18n/index.ts";
9
10
  import type { ApprovalDetails } from "../../interactions/prompts.ts";
11
+ import { canonicalJson } from "./held-calls.ts";
10
12
 
11
13
  /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
12
14
  export interface HoldTurn {
@@ -195,3 +197,22 @@ function withoutApprovers(
195
197
  found === STOPPED || found === undefined ? found : described(found, tool);
196
198
  return isPromise(answer) ? answer.then(wrap) : wrap(answer);
197
199
  }
200
+
201
+ /** The held call for a tool call and the description a hold gave it. */
202
+ export function heldCall(
203
+ tool: string,
204
+ input: Record<string, unknown>,
205
+ judged: Judgment,
206
+ minTier: HeldCall["minTier"],
207
+ ): HeldCall {
208
+ return {
209
+ tool,
210
+ input: canonicalJson(input),
211
+ action: judged.action,
212
+ ...(minTier ? { minTier } : {}),
213
+ ...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
214
+ ...(judged.classes && judged.classes.length > 0
215
+ ? { holdClasses: [...judged.classes] }
216
+ : {}),
217
+ };
218
+ }