pi-roundtable 0.22.5 → 0.23.1

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 (129) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +351 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -2,6 +2,7 @@ import type {
2
2
  HeldCall,
3
3
  PendingConfirmation,
4
4
  } from "../../domain/conversation.ts";
5
+ import type { HoldRefusal } from "../../domain/progress.ts";
5
6
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
6
7
  import type {
7
8
  ApprovalDetails,
@@ -181,3 +182,25 @@ function orList(items: readonly string[]): string {
181
182
  if (items.length < 2 || last === undefined) return items.join("");
182
183
  return `${items.slice(0, -1).join(", ")} or ${last}`;
183
184
  }
185
+
186
+ /**
187
+ * Decides for a held call whether the server approves it without a card, and says who approved
188
+ * it when it does; see `yoloApprover`. `card.speakerOnly` is true when the call's card is the
189
+ * speaker's alone. It must change nothing but its own record of the approval.
190
+ */
191
+ export type AutoApprover = (
192
+ call: HeldCall,
193
+ card: { speakerOnly: boolean },
194
+ ) => ApprovedBy | undefined;
195
+
196
+ /** What `ConfirmationGate.decide` answers for a call; an empty answer lets the call run. */
197
+ export interface GateDecision {
198
+ reason?: string;
199
+ approvedOnCard?: true;
200
+ /** Who approved the call, when it was held and a surface or a confirming message said who. */
201
+ approvedBy?: ApprovedBy;
202
+ /** The id of the card whose approval released the call, in this turn or answered late; absent for a call a message confirmed. */
203
+ approvedPrompt?: string;
204
+ /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
205
+ refused?: HoldRefusal;
206
+ }
@@ -28,6 +28,19 @@ export function sameCall(
28
28
  return a.tool === b.tool && a.input === b.input;
29
29
  }
30
30
 
31
+ /** Takes the first approval of `call` out of the lists, in order; undefined when none approves it. */
32
+ export function spendApproval<R extends { call: HeldCall }>(
33
+ lists: readonly R[][],
34
+ call: Pick<HeldCall, "tool" | "input">,
35
+ ): R | undefined {
36
+ for (const list of lists) {
37
+ const at = list.findIndex((r) => sameCall(r.call, call));
38
+ const [spent] = at === -1 ? [] : list.splice(at, 1);
39
+ if (spent) return spent;
40
+ }
41
+ return undefined;
42
+ }
43
+
31
44
  /** JSON with object keys sorted at every level, so equal inputs compare equal. */
32
45
  // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
33
46
  export function canonicalJson(value: unknown): string {
@@ -38,7 +51,9 @@ export function canonicalJson(value: unknown): string {
38
51
  }
39
52
 
40
53
  // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
41
- function isPlainObject(value: unknown): value is Record<string, unknown> {
54
+ export function isPlainObject(
55
+ value: unknown,
56
+ ): value is Record<string, unknown> {
42
57
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
43
58
  }
44
59
 
@@ -1,4 +1,11 @@
1
- import { type HoldCheck, type HoldContext, isPromise } from "../../holds.ts";
1
+ import type { ToolTurn } from "../../define.ts";
2
+ import {
3
+ type ClassedHold,
4
+ type HoldCheck,
5
+ type HoldContext,
6
+ type HoldDescription,
7
+ isPromise,
8
+ } from "../../holds.ts";
2
9
  import { messages } from "../../i18n/index.ts";
3
10
  import {
4
11
  awaited,
@@ -8,6 +15,27 @@ import {
8
15
  joinApprovers,
9
16
  } from "./hold-lookups.ts";
10
17
 
18
+ /** A held call's description with its classes inside, for the gate to record with the call. */
19
+ function withClasses(
20
+ held: ClassedHold | undefined,
21
+ ): HoldDescription | undefined {
22
+ if (!held) return undefined;
23
+ const { description, classes } = held;
24
+ return typeof description === "string"
25
+ ? { text: description, classes }
26
+ : { ...description, classes };
27
+ }
28
+
29
+ /** A description without the classes it names. */
30
+ function unclassed(
31
+ description: HoldDescription | undefined,
32
+ ): HoldDescription | undefined {
33
+ if (typeof description !== "object" || description === null)
34
+ return description;
35
+ const { classes: _classes, ...rest } = description;
36
+ return rest;
37
+ }
38
+
11
39
  /**
12
40
  * Asks a conversation's holds to describe a call a turn makes, and hands each description on in
13
41
  * the order the calls were made, whichever comes back first, so calls made together are held,
@@ -44,13 +72,7 @@ export class HoldDescriber {
44
72
  if (turn.signal?.aborted) controller.abort();
45
73
  let answer: ReturnType<NonNullable<HoldCheck["inTurn"]>>;
46
74
  try {
47
- answer = this.#holds.inTurn?.call(
48
- this.#holds,
49
- tool,
50
- input,
51
- this.#context,
52
- turn.toolTurn(controller.signal),
53
- );
75
+ answer = this.#ask(tool, input, turn.toolTurn(controller.signal));
54
76
  } catch {
55
77
  answer = messages().holdGeneric(tool);
56
78
  }
@@ -79,6 +101,34 @@ export class HoldDescriber {
79
101
  );
80
102
  }
81
103
 
104
+ /** The holds' answer for the call, with its classes in the description when the holds report them. */
105
+ #ask(
106
+ tool: string,
107
+ input: Record<string, unknown>,
108
+ toolTurn: ToolTurn,
109
+ ): HoldDescription | undefined | Promise<HoldDescription | undefined> {
110
+ const classed = this.#holds.inTurnWithClasses;
111
+ if (!classed) {
112
+ // A check that does not vouch for its classes has none: yolo mode cards its calls.
113
+ const answer = this.#holds.inTurn?.call(
114
+ this.#holds,
115
+ tool,
116
+ input,
117
+ this.#context,
118
+ toolTurn,
119
+ );
120
+ return isPromise(answer) ? answer.then(unclassed) : unclassed(answer);
121
+ }
122
+ const answer = classed.call(
123
+ this.#holds,
124
+ tool,
125
+ input,
126
+ this.#context,
127
+ toolTurn,
128
+ );
129
+ return isPromise(answer) ? answer.then(withClasses) : withClasses(answer);
130
+ }
131
+
82
132
  #inOrder<T, R>(
83
133
  described: T | Promise<T>,
84
134
  use: (described: T) => R,
@@ -34,6 +34,8 @@ export interface Judgment {
34
34
  approvers?: readonly string[];
35
35
  /** The card is the speaker's alone; see `HoldDescription`. */
36
36
  speakerOnly?: true;
37
+ /** The hold classes of the call; see `HoldRule.holdClasses`. Absent when the holds gave none. */
38
+ classes?: readonly string[] | undefined;
37
39
  }
38
40
 
39
41
  export type Judged = Judgment | undefined | typeof STOPPED;
@@ -57,15 +59,19 @@ function isSignal(value: unknown): value is AbortSignal {
57
59
  export function described(
58
60
  answer: HoldDescription,
59
61
  tool: string,
60
- ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly"> {
62
+ ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly" | "classes"> {
61
63
  if (typeof answer === "string") return { action: answer };
62
64
  if (typeof answer !== "object" || answer === null)
63
65
  return { action: messages().holdGeneric(tool) };
64
- const { text, copy, supersede, approval } = answer;
66
+ const { text, copy, supersede, approval, classes } = answer;
65
67
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
68
+ const named = Array.isArray(classes)
69
+ ? classes.filter((id): id is string => typeof id === "string" && id !== "")
70
+ : [];
66
71
  const withdrawal = {
67
72
  ...(isSignal(supersede) ? { supersede } : {}),
68
73
  ...(approval === "speaker" ? { speakerOnly: true as const } : {}),
74
+ ...(named.length > 0 ? { classes: named } : {}),
69
75
  };
70
76
  if (
71
77
  !copy ||
@@ -135,7 +141,16 @@ export function joinApprovers(
135
141
  copy,
136
142
  supersede,
137
143
  speakerOnly,
144
+ classes,
138
145
  } = described(action, tool);
146
+ const joined = (approvers: readonly string[] | undefined): Judgment => ({
147
+ action: text,
148
+ ...(copy ? { copy } : {}),
149
+ ...(supersede ? { supersede } : {}),
150
+ ...(speakerOnly ? { speakerOnly } : {}),
151
+ ...(classes ? { classes } : {}),
152
+ ...(approvers ? { approvers } : {}),
153
+ });
139
154
  const controller = new AbortController();
140
155
  if (turn.signal?.aborted) controller.abort();
141
156
  let named:
@@ -155,25 +170,10 @@ export function joinApprovers(
155
170
  }
156
171
  if (!isPromise(named)) {
157
172
  controller.abort();
158
- return {
159
- action: text,
160
- ...(copy ? { copy } : {}),
161
- ...(supersede ? { supersede } : {}),
162
- ...(speakerOnly ? { speakerOnly } : {}),
163
- ...(named ? { approvers: named } : {}),
164
- };
173
+ return joined(named);
165
174
  }
166
175
  return awaited(named, [], controller, turn.signal, from.timeoutMs).then(
167
- (approvers) => {
168
- if (approvers === STOPPED) return STOPPED;
169
- return {
170
- action: text,
171
- ...(copy ? { copy } : {}),
172
- ...(supersede ? { supersede } : {}),
173
- ...(speakerOnly ? { speakerOnly } : {}),
174
- ...(approvers ? { approvers } : {}),
175
- };
176
- },
176
+ (approvers) => (approvers === STOPPED ? STOPPED : joined(approvers)),
177
177
  );
178
178
  };
179
179
  return isPromise(answer) ? answer.then(join) : join(answer);
@@ -0,0 +1,105 @@
1
+ import type { HeldCall } from "../../domain/conversation.ts";
2
+ import type { TurnRequest } from "../../domain/ports.ts";
3
+ import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
4
+ import type { ApprovedBy } from "../../interactions/prompts.ts";
5
+ import { type Autonomy, DEFAULT_AUTONOMY } from "../../modes/autonomy.ts";
6
+ import type { ConversationModes } from "../../modes/conversation-modes.ts";
7
+ import { YOLO_CARDED_SHELL_CLASSES } from "../../modules/host-shell/shell-policy.ts";
8
+ import type { ChannelKey } from "../../sessions.ts";
9
+ import type { PiAgentRuntimeOptions } from "../runtime-types.ts";
10
+ import type { AutoApprover } from "./confirmation-gate.ts";
11
+ import type { SpeakerRef } from "./gate-refusals.ts";
12
+
13
+ /** What a turn's yolo approver needs to know. */
14
+ export interface YoloSeams {
15
+ /** The conversations' modes: read when a call is held, and told of each approval. */
16
+ modes: Pick<ConversationModes, "peek" | "autoApproved">;
17
+ /** The classes yolo may approve although it cards them by default, and those it never approves. */
18
+ autonomy: Pick<Autonomy, "yolo">;
19
+ /** The conversation the turn runs in, whose yolo mode decides. */
20
+ channel: ChannelKey;
21
+ /** Who spoke the turn: only an owner other than the host's own principal is acted for. */
22
+ speaker: SpeakerRef;
23
+ /** The clock, for the time of an approval. */
24
+ now?: () => Date;
25
+ }
26
+
27
+ /** The rule a class belongs to: the part before its first dot, or the whole of a rule's own name. */
28
+ function ruleOf(id: string): string {
29
+ const dot = id.indexOf(".");
30
+ return dot === -1 ? id : id.slice(0, dot);
31
+ }
32
+
33
+ /**
34
+ * Whether an id is a rule's bare name, which a call carries when its rule reports no classes for
35
+ * it: the call is unclassed. Rule names have no dot and every class id has one.
36
+ */
37
+ function unclassed(id: string): boolean {
38
+ return !id.includes(".");
39
+ }
40
+
41
+ /** The auto-approver of a turn on a runtime built with `options.modes`; undefined without them. */
42
+ export function turnYolo(
43
+ options: Pick<PiAgentRuntimeOptions, "modes" | "autonomy">,
44
+ request: Pick<TurnRequest, "channel" | "speaker">,
45
+ ): AutoApprover | undefined {
46
+ const { modes } = options;
47
+ return modes
48
+ ? yoloApprover({
49
+ modes,
50
+ autonomy: options.autonomy ?? DEFAULT_AUTONOMY,
51
+ channel: request.channel,
52
+ speaker: request.speaker,
53
+ })
54
+ : undefined;
55
+ }
56
+
57
+ /**
58
+ * The auto-approver of one turn in a yolo conversation: it answers for a held call, so that no
59
+ * card opens, only when all of these hold at the time of the call:
60
+ * - the conversation's yolo mode is on (read now, so a card opened before the toggle stays open);
61
+ * - the turn's speaker is an owner, and not the host's own principal, which the webhook and report
62
+ * turns speak as;
63
+ * - the call names no approvers and its card is not the speaker's alone;
64
+ * - the call has classes (a call without them is unknown, so a person decides), and none of them
65
+ * is carded: not `autonomy.yolo.card`, not one of its rules named there, not a class in
66
+ * `YOLO_CARDED_SHELL_CLASSES` that `autonomy.yolo.allow` leaves out, and not a rule's bare name
67
+ * (its call is unclassed) that `autonomy.yolo.allow` leaves out.
68
+ * It reports who approved as the speaker, marked `auto: "yolo"`, and tells the modes, which log it.
69
+ */
70
+ export function yoloApprover(seams: YoloSeams): AutoApprover {
71
+ const { modes, autonomy, channel, speaker } = seams;
72
+ const allow = new Set(autonomy.yolo.allow);
73
+ const card = new Set(autonomy.yolo.card);
74
+ const carded = new Set(YOLO_CARDED_SHELL_CLASSES);
75
+ const approves = (id: string) => {
76
+ if (card.has(id) || card.has(ruleOf(id))) return false;
77
+ // A rule that reports classes has opted in; one that reports none is approved only by name.
78
+ if (unclassed(id)) return allow.has(id);
79
+ return !carded.has(id) || allow.has(id);
80
+ };
81
+ return (call: HeldCall, { speakerOnly }): ApprovedBy | undefined => {
82
+ if (!modes.peek(channel).yolo) return undefined;
83
+ if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL)
84
+ return undefined;
85
+ if (call.approverIds !== undefined || speakerOnly) return undefined;
86
+ const classes = call.holdClasses;
87
+ if (!classes || classes.length === 0 || !classes.every(approves))
88
+ return undefined;
89
+ modes.autoApproved({
90
+ channel,
91
+ tool: call.tool,
92
+ action: call.action,
93
+ classes: [...classes],
94
+ principalId: speaker.principalId,
95
+ at: (seams.now ?? (() => new Date()))(),
96
+ });
97
+ return {
98
+ principalId: speaker.principalId,
99
+ speakerId: speaker.id,
100
+ name: speaker.name ?? speaker.id,
101
+ owner: true,
102
+ auto: "yolo",
103
+ };
104
+ };
105
+ }
@@ -31,6 +31,7 @@ import { ConversationSessions } from "./conversation-sessions.ts";
31
31
  import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
32
32
  import { loadsMemory } from "./extensions/private-memory.ts";
33
33
  import { missingToolsError } from "./extensions/self-compact-guard.ts";
34
+ import { turnYolo } from "./extensions/yolo.ts";
34
35
  import { interimPoster } from "./interim-text.ts";
35
36
  import { preflightTools } from "./preflight-tools.ts";
36
37
  import { PromptSlot } from "./prompt-slot.ts";
@@ -207,11 +208,7 @@ export class PiAgentRuntime implements AgentRuntime {
207
208
  const missing = wanted.filter((name) => !registered.has(name));
208
209
  if (missing.length > 0) {
209
210
  logger.warn(
210
- {
211
- channel: request.channel,
212
- selection: request.selection.id,
213
- missing,
214
- },
211
+ { channel: request.channel, selection: request.selection.id, missing },
215
212
  "selected tools missing; running without them",
216
213
  );
217
214
  }
@@ -223,6 +220,7 @@ export class PiAgentRuntime implements AgentRuntime {
223
220
  who,
224
221
  request.speaker,
225
222
  conversation.visibility === "private",
223
+ turnYolo(this.#options, request),
226
224
  );
227
225
  if (request.confirmed && pending && pending.calls.length > 0) {
228
226
  logger.info(
@@ -17,8 +17,9 @@ import {
17
17
  type ModelRef,
18
18
  type ThinkingLevel,
19
19
  } from "../models.ts";
20
+ import type { Autonomy } from "../modes/autonomy.ts";
20
21
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
21
- import { CONVERSATIONS, IDENTITY, RUNTIME } from "../services.ts";
22
+ import { CONVERSATIONS, IDENTITY, MODES, RUNTIME } from "../services.ts";
22
23
  import type { Speaker } from "../speakers.ts";
23
24
  import {
24
25
  BRIDGE_MEMORY_FIX,
@@ -28,6 +29,9 @@ import {
28
29
  } from "./bridge-guard.ts";
29
30
  import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
30
31
  import { PiAgentRuntime } from "./pi-agent-runtime.ts";
32
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
33
+
34
+ const NO_MODES = { yolo: false, longRunning: false } as const;
31
35
 
32
36
  /** The name of the runtime plugin, as `serviceStarted` events and the migration ledger name it. */
33
37
  export const RUNTIME_PLUGIN = "runtime";
@@ -82,6 +86,35 @@ export interface RuntimePluginOptions {
82
86
  memory?: boolean;
83
87
  /** Why the configuration lets several people speak in a shared conversation, as `configuredCrowd` says. */
84
88
  crowd?: string;
89
+ /** A turn's budget, in ms; see `PiAgentRuntimeOptions.turnTimeoutMs`. */
90
+ turnTimeoutMs?: number;
91
+ /** The most one turn works in all, in ms; see `PiAgentRuntimeOptions.turnMaxMs`. */
92
+ turnMaxMs?: number;
93
+ /** How long the card at a turn's limit waits, in ms; see `PiAgentRuntimeOptions.turnTimeoutCardMs`. */
94
+ turnTimeoutCardMs?: number;
95
+ /** How far yolo and long-running reach; the modes themselves are the `MODES` service's. */
96
+ autonomy?: Autonomy;
97
+ }
98
+
99
+ /** The turn limits and autonomy the plugin's options set, as the runtime's options name them. */
100
+ export function turnPolicy(
101
+ options: RuntimePluginOptions,
102
+ ): Pick<
103
+ PiAgentRuntimeOptions,
104
+ "turnTimeoutMs" | "turnMaxMs" | "turnTimeoutCardMs" | "autonomy"
105
+ > {
106
+ return {
107
+ ...(options.turnTimeoutMs === undefined
108
+ ? {}
109
+ : { turnTimeoutMs: options.turnTimeoutMs }),
110
+ ...(options.turnMaxMs === undefined
111
+ ? {}
112
+ : { turnMaxMs: options.turnMaxMs }),
113
+ ...(options.turnTimeoutCardMs === undefined
114
+ ? {}
115
+ : { turnTimeoutCardMs: options.turnTimeoutCardMs }),
116
+ ...(options.autonomy ? { autonomy: options.autonomy } : {}),
117
+ };
85
118
  }
86
119
 
87
120
  /**
@@ -157,6 +190,14 @@ export function runtimePlugin(
157
190
  },
158
191
  principalOf: async (id) => services.find(IDENTITY)?.principal(id),
159
192
  memory: options.memory === true,
193
+ // Read at every turn, after every plugin set up; a host without the modes has none on.
194
+ modes: {
195
+ peek: (channel) =>
196
+ services.find(MODES)?.peek(channel) ?? NO_MODES,
197
+ autoApproved: (entry) =>
198
+ services.find(MODES)?.autoApproved(entry),
199
+ },
200
+ ...turnPolicy(options),
160
201
  sessions: context.sessions,
161
202
  agentDir: options.agentDir,
162
203
  modelRuntime: options.modelRuntime,
@@ -14,6 +14,8 @@ import type { OwnerIdentity } from "../identity.ts";
14
14
  import type { PromptScope, Prompts } from "../interactions/prompts.ts";
15
15
  import type { Logger } from "../log.ts";
16
16
  import type { EffortPolicy, ThinkingLevel, ThinkingPicker } from "../models.ts";
17
+ import type { Autonomy } from "../modes/autonomy.ts";
18
+ import type { ConversationModes } from "../modes/conversation-modes.ts";
17
19
  import type { LinkedSessions } from "../plugin.ts";
18
20
  import {
19
21
  planOrder,
@@ -108,6 +110,15 @@ export interface PiAgentRuntimeOptions {
108
110
  * no answer by then the turn is aborted. A turn that cannot show a card is aborted at once.
109
111
  */
110
112
  turnTimeoutCardMs?: number;
113
+ /**
114
+ * The conversations' modes, read at each turn: a turn in a long-running conversation goes on
115
+ * past its time limit without a card, up to `autonomy.longRunningMaxMs` of work, and a turn in
116
+ * a yolo conversation has the held calls `autonomy.yolo` allows approved without a card
117
+ * (`autoApproved` is told of each). Without it no conversation has either mode.
118
+ */
119
+ modes?: Pick<ConversationModes, "peek" | "autoApproved">;
120
+ /** How far the modes reach; default `DEFAULT_AUTONOMY`. */
121
+ autonomy?: Autonomy;
111
122
  /**
112
123
  * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
113
124
  * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
@@ -21,9 +21,11 @@ export interface TurnBudget {
21
21
  renew(): number | undefined;
22
22
  /**
23
23
  * After a limit fired, gives the turn its full budget again from now, as the owner's Continue
24
- * does; with `raiseCap` the cap moves too, to a full budget from now.
24
+ * does; with `raiseCap` the cap moves too, to a full budget from now. With `ceilingMs`
25
+ * the limit never lands past that much work: a turn that reaches it fires there, as at its cap,
26
+ * whatever is left of its budget. A later `extend` without one lifts the ceiling.
25
27
  */
26
- extend(raiseCap: boolean): void;
28
+ extend(raiseCap: boolean, ceilingMs?: number): void;
27
29
  /** Whether the turn has worked up to its cap, or a limit fired and was not extended. */
28
30
  capped(): boolean;
29
31
  /** The turn's work so far, not counting time the slot spent waiting on cards. */
@@ -72,6 +74,8 @@ export function turnBudget(
72
74
  return now - started - slot.waitedMs(now);
73
75
  };
74
76
  let cap = maxMs;
77
+ // The work time an extension was told not to pass; none until one is.
78
+ let ceiling = Number.POSITIVE_INFINITY;
75
79
  let deadline = Math.min(ms, cap);
76
80
  let cancelTimer: (() => void) | undefined;
77
81
  let expired = false;
@@ -81,6 +85,7 @@ export function turnBudget(
81
85
  if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
82
86
  expired = true;
83
87
  if (deadline >= cap) expire("turnMax", cap);
88
+ else if (deadline >= ceiling) expire("turnMax", ceiling);
84
89
  else expire("turn", ms);
85
90
  }, delay);
86
91
  };
@@ -89,15 +94,16 @@ export function turnBudget(
89
94
  renew() {
90
95
  const now = worked();
91
96
  if (expired || now >= cap) return undefined;
92
- deadline = Math.min(now + ms, cap);
97
+ deadline = Math.min(now + ms, cap, ceiling);
93
98
  cancelTimer?.();
94
99
  arm(deadline - now);
95
100
  return deadline - now;
96
101
  },
97
- extend(raiseCap) {
102
+ extend(raiseCap, ceilingMs) {
98
103
  const now = worked();
99
104
  if (raiseCap) cap = Math.max(cap, now + ms);
100
- deadline = Math.min(now + ms, cap);
105
+ ceiling = ceilingMs ?? Number.POSITIVE_INFINITY;
106
+ deadline = Math.min(now + ms, cap, ceiling);
101
107
  expired = false;
102
108
  cancelTimer?.();
103
109
  arm(Math.max(deadline - now, REARM_FLOOR_MS));
@@ -112,6 +118,17 @@ export function turnBudget(
112
118
  export interface TurnBudgetSeams {
113
119
  activity: Pick<ActivityTracker, "now">;
114
120
  clock?: BudgetClock;
121
+ /**
122
+ * Asked when a limit fires, with the turn's work so far: true goes on without a card, as the
123
+ * owner's Continue would (a long-running conversation, up to its own cap); false takes the
124
+ * limit's usual path, a card or an abort. It also answers for a turn that cannot show a card.
125
+ */
126
+ keepGoing?: (workedMs: number) => boolean;
127
+ /**
128
+ * The work time, in ms, a `keepGoing` extension never carries the turn past: the limit then
129
+ * fires at it, as the turn's cap, so a long time limit cannot overshoot the long-running cap.
130
+ */
131
+ ceilingMs?: number;
115
132
  }
116
133
 
117
134
  /**
@@ -121,6 +138,7 @@ export interface TurnBudgetSeams {
121
138
  * to go on: Continue extends the limit that fired by a full budget from then, Cancel aborts, and
122
139
  * no answer within `turnTimeoutCardMs` (default 5 minutes, wall time) aborts. The card closes
123
140
  * when the turn ends first. Without a card the turn is aborted at once. `abort` is told why.
141
+ * When `seams.keepGoing` says so, the limit is extended server-side first and neither happens.
124
142
  */
125
143
  export function runtimeTurnBudget(
126
144
  options: Pick<
@@ -172,6 +190,14 @@ export function runtimeTurnBudget(
172
190
  turnMaxMs,
173
191
  slot,
174
192
  (limit, limitMs) => {
193
+ if (seams.keepGoing?.(budget.workedMs())) {
194
+ budget.extend(limit === "turnMax", seams.ceilingMs);
195
+ logger.info(
196
+ { channel, limit, workedMs: budget.workedMs() },
197
+ "long-running mode continued the turn",
198
+ );
199
+ return;
200
+ }
175
201
  const prompts = slot.cards;
176
202
  if (!prompts) return stop(reason(limit, limitMs, "timeout"));
177
203
  const m = messages();
@@ -230,6 +256,7 @@ export function runtimeTurnBudget(
230
256
  undefined,
231
257
  {
232
258
  supersede: controller.signal,
259
+ purpose: "turn-timeout",
233
260
  wording: {
234
261
  approve: m.turnTimeoutContinue,
235
262
  decline: m.turnTimeoutCancel,
@@ -3,8 +3,10 @@ import type { TurnResult } from "../domain/conversation.ts";
3
3
  import { AgentRunError } from "../domain/errors.ts";
4
4
  import { type TurnTimeout, TurnTimeoutError } from "../domain/turn-timeout.ts";
5
5
  import { catalogFor } from "../i18n/index.ts";
6
+ import { DEFAULT_AUTONOMY } from "../modes/autonomy.ts";
6
7
  import type { ChannelKey } from "../sessions.ts";
7
8
  import type { PromptSlot } from "./prompt-slot.ts";
9
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
8
10
  import { activityTracker } from "./turn-activity.ts";
9
11
  import { runtimeTurnBudget, type TurnBudget } from "./turn-budget.ts";
10
12
 
@@ -30,13 +32,22 @@ export function timeoutMessage(timeout: TurnTimeout): string {
30
32
  return catalogFor("en").turnTimedOutNotice(timeout);
31
33
  }
32
34
 
33
- /** The time limit of a turn of the built-in runtime; see `runtimeTurnBudget`. `abort` aborts the turn. */
35
+ /**
36
+ * The time limit of a turn of the built-in runtime; see `runtimeTurnBudget`. `abort` aborts the
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`.
39
+ */
34
40
  export function turnLimits(
35
- options: Parameters<typeof runtimeTurnBudget>[0],
41
+ options: Parameters<typeof runtimeTurnBudget>[0] &
42
+ Pick<PiAgentRuntimeOptions, "modes" | "autonomy">,
36
43
  slot: Pick<PromptSlot, "waitedMs" | "cards">,
37
44
  channel: ChannelKey,
38
45
  abort: () => void,
39
46
  ): TurnLimits {
47
+ const { longRunningMaxMs } = options.autonomy ?? DEFAULT_AUTONOMY;
48
+ const keepGoing = (workedMs: number) =>
49
+ workedMs < longRunningMaxMs &&
50
+ options.modes?.peek(channel).longRunning === true;
40
51
  const activity = activityTracker();
41
52
  let timedOut: TurnTimeout | undefined;
42
53
  const budget = runtimeTurnBudget(
@@ -47,7 +58,7 @@ export function turnLimits(
47
58
  timedOut = timeout;
48
59
  abort();
49
60
  },
50
- { activity },
61
+ { activity, keepGoing, ceilingMs: longRunningMaxMs },
51
62
  );
52
63
  return {
53
64
  budget,