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
@@ -20,7 +20,7 @@ import {
20
20
  approverOf,
21
21
  askSpeaker,
22
22
  awaitingText,
23
- declinedText,
23
+ declinedRefusal,
24
24
  type GateDecision,
25
25
  type Needing,
26
26
  namesOf,
@@ -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.
@@ -337,8 +341,8 @@ export class ConfirmationGate {
337
341
  wait: {
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
- const outcome = lateOutcome(call, late, watch);
341
- if (outcome?.release) this.#lateApproved.push(outcome.release);
344
+ const outcome = lateOutcome(call, late, watch, needing.onDeclined);
345
+ if (outcome?.release) this.#late.add(outcome.release);
342
346
  return outcome?.text;
343
347
  },
344
348
  },
@@ -372,13 +376,8 @@ export class ConfirmationGate {
372
376
  reason: awaitingText(this.#addressee, call),
373
377
  refused: "pending",
374
378
  };
375
- if (answer === "declined") {
376
- watch?.end();
377
- return {
378
- reason: declinedText(this.#addressee, call),
379
- refused: "declined",
380
- };
381
- }
379
+ if (answer === "declined")
380
+ return declinedRefusal(this.#addressee, call, watch, needing.onDeclined);
382
381
  if (answer === "cancelled") {
383
382
  watch?.end();
384
383
  return { reason: "The turn was stopped." };
@@ -390,7 +389,7 @@ export class ConfirmationGate {
390
389
  /** What a call needs when a rule holds it; undefined when none does. */
391
390
  #needing(tool: string, input: Record<string, unknown>): Needing | undefined {
392
391
  const action = this.#holds(tool, input, this.#context);
393
- if (!action) return undefined;
392
+ if (!action) return this.#ranUnheld(tool, input);
394
393
  const classes = this.#holds.classesOf?.(tool, input, this.#context);
395
394
  return this.#unlessApproved(tool, input, { action, classes });
396
395
  }
@@ -412,10 +411,18 @@ export class ConfirmationGate {
412
411
  if (!turn || !this.#holds.inTurn) return this.#needing(tool, input);
413
412
  return this.#describer.describe(tool, input, turn, (judged) => {
414
413
  if (judged === STOPPED) return STOPPED;
415
- return judged ? this.#unlessApproved(tool, input, judged) : undefined;
414
+ return judged
415
+ ? this.#unlessApproved(tool, input, judged)
416
+ : this.#ranUnheld(tool, input);
416
417
  });
417
418
  }
418
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
+
419
426
  /**
420
427
  * The call for a description, unless an approval releases it, which that approval then spends
421
428
  * and whose approver the release names.
@@ -425,21 +432,13 @@ export class ConfirmationGate {
425
432
  input: Record<string, unknown>,
426
433
  judged: Judgment,
427
434
  ): Needing {
428
- const minTier = this.#holds.approvalTier?.(tool, input, this.#context);
429
- const call: HeldCall = {
430
- tool,
431
- input: canonicalJson(input),
432
- action: judged.action,
433
- ...(minTier ? { minTier } : {}),
434
- ...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
435
- ...(judged.classes && judged.classes.length > 0
436
- ? { holdClasses: [...judged.classes] }
437
- : {}),
438
- };
435
+ const tier = this.#holds.approvalTier?.(tool, input, this.#context);
436
+ const call = heldCall(tool, input, judged, tier);
439
437
  // A plugin that already withdrew the call lets no approval of it release it.
440
438
  const spent = judged.supersede?.aborted
441
439
  ? undefined
442
- : spendApproval([this.#approved, this.#lateApproved], call);
440
+ : (spendApproval([this.#approved], call) ??
441
+ this.#late.spend(call, this.#lateSpender));
443
442
  if (spent) {
444
443
  this.#withdrawals.endCall(spent.call);
445
444
  return { released: spent };
@@ -449,6 +448,7 @@ export class ConfirmationGate {
449
448
  ...(judged.copy ? { copy: judged.copy } : {}),
450
449
  ...(judged.supersede ? { supersede: judged.supersede } : {}),
451
450
  ...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
451
+ ...(judged.onDeclined ? { onDeclined: judged.onDeclined } : {}),
452
452
  };
453
453
  }
454
454
 
@@ -37,6 +37,8 @@ export type Needing =
37
37
  supersede?: AbortSignal;
38
38
  /** The card is the speaker's alone; see `HoldDescription`. */
39
39
  speakerOnly?: true;
40
+ /** Called when the call's card is declined; see `HoldDescription`. */
41
+ onDeclined?: () => void | Promise<void>;
40
42
  }
41
43
  | { released: Release };
42
44
 
@@ -76,6 +78,36 @@ export function declinedText(addressee: OwnerIdentity, call: HeldCall): string {
76
78
  return `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`;
77
79
  }
78
80
 
81
+ /**
82
+ * Tells the plugin that held a call that its card was declined (`HoldDescription.onDeclined`).
83
+ * The call is refused whatever the plugin does, so what its hook throws or rejects with is ignored.
84
+ */
85
+ export function notifyDeclined(
86
+ onDeclined: (() => void | Promise<void>) | undefined,
87
+ ): void {
88
+ if (!onDeclined) return;
89
+ try {
90
+ Promise.resolve(onDeclined()).catch(() => undefined);
91
+ } catch {
92
+ // A hook that throws changes nothing: the call is refused already.
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The refusal of a call declined on its card the turn was still waiting on: ends the plugin's
98
+ * watch of the call, tells it through `onDeclined`, and gives the turn the text and the reason.
99
+ */
100
+ export function declinedRefusal(
101
+ addressee: OwnerIdentity,
102
+ call: HeldCall,
103
+ watch: { end(): void } | undefined,
104
+ onDeclined: (() => void | Promise<void>) | undefined,
105
+ ): { reason: string; refused: "declined" } {
106
+ watch?.end();
107
+ notifyDeclined(onDeclined);
108
+ return { reason: declinedText(addressee, call), refused: "declined" };
109
+ }
110
+
79
111
  /** The text for a held call that the speaker's own message confirms. */
80
112
  export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
81
113
  const o = ownerWords(addressee);
@@ -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 {
@@ -36,6 +38,8 @@ export interface Judgment {
36
38
  speakerOnly?: true;
37
39
  /** The hold classes of the call; see `HoldRule.holdClasses`. Absent when the holds gave none. */
38
40
  classes?: readonly string[] | undefined;
41
+ /** Called when the call's card is declined; see `HoldDescription`. */
42
+ onDeclined?: () => void | Promise<void>;
39
43
  }
40
44
 
41
45
  export type Judged = Judgment | undefined | typeof STOPPED;
@@ -59,11 +63,14 @@ function isSignal(value: unknown): value is AbortSignal {
59
63
  export function described(
60
64
  answer: HoldDescription,
61
65
  tool: string,
62
- ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly" | "classes"> {
66
+ ): Pick<
67
+ Judgment,
68
+ "action" | "copy" | "supersede" | "speakerOnly" | "classes" | "onDeclined"
69
+ > {
63
70
  if (typeof answer === "string") return { action: answer };
64
71
  if (typeof answer !== "object" || answer === null)
65
72
  return { action: messages().holdGeneric(tool) };
66
- const { text, copy, supersede, approval, classes } = answer;
73
+ const { text, copy, supersede, approval, classes, onDeclined } = answer;
67
74
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
68
75
  const named = Array.isArray(classes)
69
76
  ? classes.filter((id): id is string => typeof id === "string" && id !== "")
@@ -72,6 +79,7 @@ export function described(
72
79
  ...(isSignal(supersede) ? { supersede } : {}),
73
80
  ...(approval === "speaker" ? { speakerOnly: true as const } : {}),
74
81
  ...(named.length > 0 ? { classes: named } : {}),
82
+ ...(typeof onDeclined === "function" ? { onDeclined } : {}),
75
83
  };
76
84
  if (
77
85
  !copy ||
@@ -142,6 +150,7 @@ export function joinApprovers(
142
150
  supersede,
143
151
  speakerOnly,
144
152
  classes,
153
+ onDeclined,
145
154
  } = described(action, tool);
146
155
  const joined = (approvers: readonly string[] | undefined): Judgment => ({
147
156
  action: text,
@@ -149,6 +158,7 @@ export function joinApprovers(
149
158
  ...(supersede ? { supersede } : {}),
150
159
  ...(speakerOnly ? { speakerOnly } : {}),
151
160
  ...(classes ? { classes } : {}),
161
+ ...(onDeclined ? { onDeclined } : {}),
152
162
  ...(approvers ? { approvers } : {}),
153
163
  });
154
164
  const controller = new AbortController();
@@ -187,3 +197,22 @@ function withoutApprovers(
187
197
  found === STOPPED || found === undefined ? found : described(found, tool);
188
198
  return isPromise(answer) ? answer.then(wrap) : wrap(answer);
189
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
+ }
@@ -1,6 +1,6 @@
1
1
  import type { HeldCall } from "../../domain/conversation.ts";
2
2
  import type { LateAnswer } from "../../interactions/prompts.ts";
3
- import type { Release } from "./gate-refusals.ts";
3
+ import { notifyDeclined, type Release } from "./gate-refusals.ts";
4
4
  import { namedApprover } from "./held-calls.ts";
5
5
  import type { Watch } from "./withdrawals.ts";
6
6
 
@@ -8,12 +8,14 @@ import type { Watch } from "./withdrawals.ts";
8
8
  * What a card answered after its turn stopped waiting comes to: the text of the turn that tells
9
9
  * the model, and, for an approval that stands, the release that lets the identical call run once
10
10
  * in whichever turn makes it next. Undefined when the answer changes nothing: a question's, a
11
- * card its plugin withdrew, or an approval by someone the call does not name.
11
+ * card its plugin withdrew, or an approval by someone the call does not name. A card declined
12
+ * tells the plugin through `onDeclined`.
12
13
  */
13
14
  export function lateOutcome(
14
15
  call: HeldCall,
15
16
  late: LateAnswer,
16
17
  watch: Watch | undefined,
18
+ onDeclined?: () => void | Promise<void>,
17
19
  ): { text: string; release?: Release } | undefined {
18
20
  // A card the plugin withdrew answers nothing, whatever a surface that kept it open reports.
19
21
  if (watch?.withdrawn || late.kind !== "approval") return undefined;
@@ -21,6 +23,7 @@ export function lateOutcome(
21
23
  if (late.approved && !namedApprover(call, late.approvedBy)) return undefined;
22
24
  if (!late.approved) {
23
25
  watch?.end();
26
+ notifyDeclined(onDeclined);
24
27
  return {
25
28
  text: `(${late.by} declined on its card the action you were waiting on: it would ${call.action}. It did not run; do not make the call again. Go on without it, or ask what ${late.by} wants instead.)`,
26
29
  };
@@ -0,0 +1,76 @@
1
+ import type { HeldCall } from "../../domain/conversation.ts";
2
+ import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
3
+ import type { Release, SpeakerRef } from "./gate-refusals.ts";
4
+ import { sameCall } from "./held-calls.ts";
5
+
6
+ /**
7
+ * How long a late approval's release waits for the call it was granted for. The turn that
8
+ * answers a late card re-makes the call at once, so an hour covers a slow model or a queue of
9
+ * turns and no more; a release nobody spent by then belongs to a call that is long gone.
10
+ */
11
+ export const LATE_RELEASE_TTL_MS = 3_600_000;
12
+
13
+ /**
14
+ * The releases that cards answered after their turn stopped waiting earned. A release lets the
15
+ * identical call run once, only while it is fresh and only in a turn spoken by the person who gave it
16
+ * (or by an owner), only in a turn a person's message started, and it is dropped when the identical call
17
+ * runs without a hold, since that run is the call the person approved.
18
+ */
19
+ export class LateReleases {
20
+ #list: { release: Release; at: number }[] = [];
21
+
22
+ add(release: Release, now = Date.now()): void {
23
+ this.#prune(now);
24
+ this.#list.push({ release, at: now });
25
+ }
26
+
27
+ /**
28
+ * Takes the release of `call` that `speaker` may spend out of the list; undefined when none
29
+ * is fresh and theirs. A release whose approver is unknown is anyone's.
30
+ */
31
+ spend(
32
+ call: Pick<HeldCall, "tool" | "input">,
33
+ speaker: SpeakerRef | undefined,
34
+ now = Date.now(),
35
+ ): Release | undefined {
36
+ this.#prune(now);
37
+ const at = this.#list.findIndex(
38
+ ({ release }) =>
39
+ sameCall(release.call, call) && maySpend(release, speaker),
40
+ );
41
+ if (at === -1) return undefined;
42
+ const [spent] = this.#list.splice(at, 1);
43
+ return spent?.release;
44
+ }
45
+
46
+ /** Drops every release of `call`, whoever gave it. */
47
+ drop(call: Pick<HeldCall, "tool" | "input">): void {
48
+ this.#list = this.#list.filter(
49
+ ({ release }) => !sameCall(release.call, call),
50
+ );
51
+ }
52
+
53
+ /** Whether a fresh release of `call` waits, for whoever may spend it. */
54
+ has(call: Pick<HeldCall, "tool" | "input">, now = Date.now()): boolean {
55
+ this.#prune(now);
56
+ return this.#list.some(({ release }) => sameCall(release.call, call));
57
+ }
58
+
59
+ #prune(now: number): void {
60
+ this.#list = this.#list.filter(({ at }) => now - at <= LATE_RELEASE_TTL_MS);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Whether `speaker` may spend `release`: they gave it, or are an owner; any speaker, when its
66
+ * giver is unknown. The gate hands only the speaker of a turn a person's
67
+ * message started; every other turn (schedule, agent message, report, error report) has none and
68
+ * spends nothing, so a card is shown again there. The system principal never spends one.
69
+ */
70
+ function maySpend(release: Release, speaker: SpeakerRef | undefined): boolean {
71
+ if (!speaker || speaker.principalId === SYSTEM_PRINCIPAL) return false;
72
+ if (!release.by) return true;
73
+ return (
74
+ speaker.tier === "owner" || speaker.principalId === release.by.principalId
75
+ );
76
+ }
@@ -21,10 +21,9 @@ import type { TurnRequest } from "../domain/ports.ts";
21
21
  import { HostStoppingError } from "../errors.ts";
22
22
  import { assistantName } from "../i18n/index.ts";
23
23
  import { promptScopeOf } from "../interactions/prompts.ts";
24
- import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
25
24
  import { withoutReplyFiles } from "../reply-files.ts";
26
25
  import type { TransientTask } from "../sessions.ts";
27
- import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
26
+ import { type ToolTiers, toolTiers } from "../tool-tiers.ts";
28
27
  import { AbortGuard } from "./abort-guard.ts";
29
28
  import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
30
29
  import { ConversationSessions } from "./conversation-sessions.ts";
@@ -52,6 +51,7 @@ import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
52
51
  import type { TurnBudget } from "./turn-budget.ts";
53
52
  import { promptFailed, turnLimits } from "./turn-limits.ts";
54
53
  import { progressReporter } from "./turn-progress.ts";
54
+ import { turnTools } from "./turn-tools.ts";
55
55
  import { wrapUpTurn } from "./turn-wrapup.ts";
56
56
  import { workerReport } from "./worker-task.ts";
57
57
 
@@ -62,8 +62,6 @@ export {
62
62
  sessionExtensions,
63
63
  } from "./runtime-types.ts";
64
64
 
65
- const MEMORY = new Set<string>(MEMORY_TOOLS);
66
-
67
65
  /** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
68
66
  const STOPPED = "stopped by the owner";
69
67
 
@@ -183,18 +181,15 @@ export class PiAgentRuntime implements AgentRuntime {
183
181
  request.agent !== undefined &&
184
182
  this.#options.agents?.memory === "owners" &&
185
183
  tier !== "owner";
186
- const wanted = toolsForTier(
187
- [
188
- ...new Set([
189
- ...this.#factory.toolsFor(request.selection),
190
- ...(request.confirmed && pending
191
- ? pending.calls.map((call) => call.tool)
192
- : []),
193
- ]),
194
- ],
195
- tier,
184
+ const wanted = turnTools(
185
+ {
186
+ selected: this.#factory.toolsFor(request.selection),
187
+ held: request.confirmed && pending ? pending.calls : [],
188
+ tier,
189
+ withheld,
190
+ },
196
191
  this.#tiers,
197
- ).filter((name) => !withheld || !MEMORY.has(name));
192
+ );
198
193
  const markPrivateMemory = recordMemoryTurn(
199
194
  session.sessionManager,
200
195
  request.speaker.principalId,
@@ -221,6 +216,7 @@ export class PiAgentRuntime implements AgentRuntime {
221
216
  request.speaker,
222
217
  conversation.visibility === "private",
223
218
  turnYolo(this.#options, request),
219
+ request.byMessage === true,
224
220
  );
225
221
  if (request.confirmed && pending && pending.calls.length > 0) {
226
222
  logger.info(
@@ -231,7 +227,6 @@ export class PiAgentRuntime implements AgentRuntime {
231
227
  "confirmed actions released",
232
228
  );
233
229
  }
234
-
235
230
  // Collected as they end: a compaction during the turn shortens session.messages.
236
231
  const turnMessages: TurnMessage[] = [];
237
232
  const toolCalls: string[] = [];
@@ -240,8 +235,12 @@ export class PiAgentRuntime implements AgentRuntime {
240
235
  // The caller's live view: the text as it streams and each tool, never the thinking.
241
236
  const slot = this.#sessions.slot(key);
242
237
  // Its time limit, not counting time waiting on the owner's cards, follows what it does to say why it fired.
243
- const limits = turnLimits(this.#options, slot, request.channel, () =>
244
- guard.abort(),
238
+ const limits = turnLimits(
239
+ this.#options,
240
+ slot,
241
+ request.channel,
242
+ () => guard.abort(),
243
+ request.timeLimitMs,
245
244
  );
246
245
  const progress = request.progress
247
246
  ? progressReporter(request.progress, {
@@ -333,7 +332,10 @@ export class PiAgentRuntime implements AgentRuntime {
333
332
 
334
333
  if (running.stopped)
335
334
  return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
336
- return limits.failure() ?? turnAnswer(turnMessages, running.steered);
335
+ return (
336
+ limits.failure() ??
337
+ turnAnswer(turnMessages, running.steered, request.unattended !== true)
338
+ );
337
339
  }
338
340
 
339
341
  async steer(
@@ -77,11 +77,14 @@ export function thinkingOf(messages: readonly Message[]): string {
77
77
 
78
78
  /**
79
79
  * A finished turn's result from the messages it added. A steered run may have answered before
80
- * the steer arrived, so every final answer of it is kept.
80
+ * the steer arrived, so every final answer of it is kept. A turn nobody waits on (`attended`
81
+ * false: a schedule's, an agent's message) may end with nothing to say, such as "send nothing if
82
+ * there is no progress"; that is an empty answer, not a failure. For an attended turn it is one.
81
83
  */
82
84
  export function turnAnswer(
83
85
  messages: readonly Message[],
84
86
  steered: boolean,
87
+ attended: boolean,
85
88
  ): TurnResult {
86
89
  const last = lastAssistant(messages);
87
90
  if (!last.ok) return { ok: false, error: new AgentRunError(last.error) };
@@ -97,6 +100,7 @@ export function turnAnswer(
97
100
  .join("\n\n")
98
101
  : textOf(last.message.content)
99
102
  ).trim();
103
+ if (!text && !hasReplyFiles() && !attended) return { ok: true, text: "" };
100
104
  if (!text && !hasReplyFiles())
101
105
  return {
102
106
  ok: false,
@@ -167,6 +167,7 @@ export function runtimeTurnBudget(
167
167
  ...seams.activity.now(clock.now()),
168
168
  graceMs,
169
169
  end,
170
+ ...(slot.cards ? {} : { unasked: true as const }),
170
171
  });
171
172
  const stop = (timeout: TurnTimeout) => {
172
173
  logger.warn(
@@ -35,7 +35,8 @@ export function timeoutMessage(timeout: TurnTimeout): string {
35
35
  /**
36
36
  * The time limit of a turn of the built-in runtime; see `runtimeTurnBudget`. `abort` aborts the
37
37
  * turn. In a long-running conversation (`options.modes`) a limit extends the turn instead of
38
- * asking or aborting, until the turn has worked `options.autonomy.longRunningMaxMs`.
38
+ * asking or aborting, until the turn has worked `options.autonomy.longRunningMaxMs`. A
39
+ * `timeLimitMs` replaces the turn's limit and its cap, both held under that same ceiling.
39
40
  */
40
41
  export function turnLimits(
41
42
  options: Parameters<typeof runtimeTurnBudget>[0] &
@@ -43,15 +44,24 @@ export function turnLimits(
43
44
  slot: Pick<PromptSlot, "waitedMs" | "cards">,
44
45
  channel: ChannelKey,
45
46
  abort: () => void,
47
+ timeLimitMs?: number,
46
48
  ): TurnLimits {
47
49
  const { longRunningMaxMs } = options.autonomy ?? DEFAULT_AUTONOMY;
50
+ const limited =
51
+ timeLimitMs === undefined
52
+ ? options
53
+ : {
54
+ ...options,
55
+ turnTimeoutMs: Math.min(timeLimitMs, longRunningMaxMs),
56
+ turnMaxMs: Math.min(timeLimitMs, longRunningMaxMs),
57
+ };
48
58
  const keepGoing = (workedMs: number) =>
49
59
  workedMs < longRunningMaxMs &&
50
60
  options.modes?.peek(channel).longRunning === true;
51
61
  const activity = activityTracker();
52
62
  let timedOut: TurnTimeout | undefined;
53
63
  const budget = runtimeTurnBudget(
54
- options,
64
+ limited,
55
65
  slot,
56
66
  channel,
57
67
  (timeout) => {
@@ -0,0 +1,26 @@
1
+ import type { HeldCall } from "../domain/conversation.ts";
2
+ import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
3
+ import type { Tier } from "../speakers.ts";
4
+ import { type ToolTiers, toolsForTier } from "../tool-tiers.ts";
5
+
6
+ const MEMORY = new Set<string>(MEMORY_TOOLS);
7
+
8
+ /**
9
+ * The tools a turn is given: its selection, and the tools of the held calls an approving turn
10
+ * releases, limited to the speaker's tier; a speaker kept out of memory gets none of its tools.
11
+ */
12
+ export function turnTools(
13
+ turn: {
14
+ selected: readonly string[];
15
+ held: readonly Pick<HeldCall, "tool">[];
16
+ tier: Tier;
17
+ withheld: boolean;
18
+ },
19
+ tiers: ToolTiers,
20
+ ): string[] {
21
+ return toolsForTier(
22
+ [...new Set([...turn.selected, ...turn.held.map((call) => call.tool)])],
23
+ turn.tier,
24
+ tiers,
25
+ ).filter((name) => !turn.withheld || !MEMORY.has(name));
26
+ }
@@ -13,6 +13,9 @@ export const SCHEDULE_TOOLS = Object.freeze([
13
13
  ] as const);
14
14
  export type ScheduleToolName = (typeof SCHEDULE_TOOLS)[number];
15
15
 
16
+ /** The most minutes a schedule's own time limit may name: the four hours a long-running turn may work. */
17
+ export const MAX_SCHEDULE_TIME_LIMIT_MINUTES = 240;
18
+
16
19
  export function isScheduleTool(value: string): value is ScheduleToolName {
17
20
  return (SCHEDULE_TOOLS as readonly string[]).includes(value);
18
21
  }
@@ -62,6 +65,8 @@ const timing = (zone: string) => ({
62
65
  ),
63
66
  });
64
67
 
68
+ const TIME_LIMIT = `How long, in minutes, each run may work before it is stopped (at most ${MAX_SCHEDULE_TIME_LIMIT_MINUTES}); without it a run is stopped after the host's default of 10 minutes. Set it for a task that works longer.`;
69
+
65
70
  export interface ScheduleToolSpec {
66
71
  name: ScheduleToolName;
67
72
  label: string;
@@ -118,6 +123,13 @@ export function scheduleToolSpecs(
118
123
  "The name of a precheck the host runs first, from schedule_list; you are woken only when it finds something.",
119
124
  }),
120
125
  ),
126
+ time_limit_minutes: Type.Optional(
127
+ Type.Integer({
128
+ minimum: 1,
129
+ maximum: MAX_SCHEDULE_TIME_LIMIT_MINUTES,
130
+ description: TIME_LIMIT,
131
+ }),
132
+ ),
121
133
  ...script(false),
122
134
  }),
123
135
  },
@@ -133,7 +145,7 @@ export function scheduleToolSpecs(
133
145
  name: "schedule_update",
134
146
  label: "Update schedule",
135
147
  description:
136
- "Change one of this channel's schedules: its title, its prompt, its timing (timing fields replace the old timing), or its precheck. A run can use this to keep its own prompt current, such as adding what it already reported.",
148
+ "Change one of this channel's schedules: its title, its prompt, its timing (timing fields replace the old timing), its precheck, or its time limit. A run can use this to keep its own prompt current, such as adding what it already reported.",
137
149
  parameters: Type.Object({
138
150
  id: Type.Integer({ description: "Schedule id." }),
139
151
  title: Type.Optional(Type.String()),
@@ -147,6 +159,18 @@ export function scheduleToolSpecs(
147
159
  "A precheck's name from schedule_list to run first, or null to remove the schedule's precheck.",
148
160
  }),
149
161
  ),
162
+ time_limit_minutes: Type.Optional(
163
+ Type.Union(
164
+ [
165
+ Type.Integer({
166
+ minimum: 1,
167
+ maximum: MAX_SCHEDULE_TIME_LIMIT_MINUTES,
168
+ }),
169
+ Type.Null(),
170
+ ],
171
+ { description: `${TIME_LIMIT} null returns it to the default.` },
172
+ ),
173
+ ),
150
174
  ...script(true),
151
175
  }),
152
176
  },