pi-roundtable 0.9.11 → 0.9.13

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 (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +151 -23
  4. package/examples/fake-surface.test.ts +137 -0
  5. package/examples/fake-surface.ts +71 -14
  6. package/package.json +1 -1
  7. package/src/cli/checks/identity.ts +73 -5
  8. package/src/core/agents/agent-claim.ts +12 -0
  9. package/src/core/agents/agent-prompt.ts +3 -1
  10. package/src/core/agents/agent-team.ts +1 -0
  11. package/src/core/agents/team-turns.ts +11 -4
  12. package/src/core/builtin/agent-server.ts +6 -0
  13. package/src/core/config/access.ts +23 -3
  14. package/src/core/config/adapters.ts +11 -0
  15. package/src/core/config/schema.ts +14 -0
  16. package/src/core/contract/providers.ts +5 -1
  17. package/src/core/define-roundtable.ts +1 -0
  18. package/src/core/define.ts +44 -6
  19. package/src/core/discord/card-audience.ts +104 -7
  20. package/src/core/discord/discord-owners.ts +19 -13
  21. package/src/core/discord/owner-card-types.ts +73 -0
  22. package/src/core/discord/owner-cards.ts +56 -77
  23. package/src/core/domain/conversation.ts +7 -0
  24. package/src/core/domain/ports.ts +4 -1
  25. package/src/core/holds.ts +60 -0
  26. package/src/core/i18n/cards.ts +3 -0
  27. package/src/core/identity/access-policy.ts +65 -2
  28. package/src/core/identity/caching-principal-store.ts +2 -2
  29. package/src/core/identity/identity-links.ts +96 -0
  30. package/src/core/identity/identity-plugin.ts +10 -3
  31. package/src/core/identity/identity-schema.ts +3 -1
  32. package/src/core/identity/identity-service.ts +5 -3
  33. package/src/core/identity/principal-store.ts +59 -142
  34. package/src/core/identity/principal-tables.ts +10 -2
  35. package/src/core/identity/principal-types.ts +146 -0
  36. package/src/core/interactions/prompts.ts +47 -3
  37. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  38. package/src/core/routing/conversation-turns.ts +4 -1
  39. package/src/core/runtime/conversation-sessions.ts +8 -0
  40. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  41. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  42. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  43. package/src/core/runtime/extensions/held-calls.ts +91 -0
  44. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  45. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  46. package/src/core/runtime/prompt-slot.ts +10 -2
  47. package/src/core/runtime/runtime-plugin.ts +3 -0
  48. package/src/core/runtime/runtime-types.ts +8 -0
  49. package/src/core/runtime/session-factory.ts +22 -0
  50. package/src/core/services.ts +11 -0
  51. package/src/core/sessions.ts +7 -0
  52. package/src/core/shared/generate-image-tool.ts +173 -0
  53. package/src/core/testing/surface-contract-support.ts +194 -0
  54. package/src/core/testing/surface-contract.ts +54 -120
  55. package/src/core/tool-turn.ts +5 -1
  56. package/src/index.ts +7 -0
@@ -1,5 +1,3 @@
1
- import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
- import type { ToolTurn } from "../../define.ts";
3
1
  import type {
4
2
  HeldCall,
5
3
  PendingConfirmation,
@@ -14,67 +12,47 @@ import {
14
12
  } from "../../holds.ts";
15
13
  import { messages } from "../../i18n/index.ts";
16
14
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
17
- import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
18
- import type { Speaker } from "../../speakers.ts";
15
+ import type {
16
+ ApprovalOptions,
17
+ ApprovedBy,
18
+ LateAnswer,
19
+ Prompts,
20
+ } from "../../interactions/prompts.ts";
19
21
  import type { ToolTiers } from "../../tool-tiers.ts";
20
- import type { PromptSlot } from "../prompt-slot.ts";
21
22
  import { approvalCard, approvalDetails } from "./approval-card.ts";
23
+ import {
24
+ askSpeaker,
25
+ type Needing,
26
+ namesOf,
27
+ type Release,
28
+ remembered,
29
+ type SpeakerRef,
30
+ unlessNamed,
31
+ } from "./gate-refusals.ts";
32
+ import {
33
+ canonicalJson,
34
+ mayApproveNamed,
35
+ namedApprover,
36
+ unexpired,
37
+ } from "./held-calls.ts";
38
+ import {
39
+ awaited,
40
+ type Described,
41
+ type HoldTurn,
42
+ joinApprovers,
43
+ STOPPED,
44
+ } from "./hold-lookups.ts";
22
45
 
23
- /** Held actions older than this cannot be approved any more. */
24
- export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
25
-
26
- /** The held actions when they can still be approved at `now`; expired ones count as none. */
27
- export function unexpired(
28
- pending: PendingConfirmation | undefined,
29
- now = new Date(),
30
- ): PendingConfirmation | undefined {
31
- if (!pending) return undefined;
32
- return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
33
- ? undefined
34
- : pending;
35
- }
36
-
37
- /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
38
- export interface HoldTurn {
39
- /** Aborts when the turn stops; a call being described then opens no card. */
40
- signal?: AbortSignal;
41
- /** The turn a hold receives, with `signal` as the signal that ends its wait. */
42
- toolTurn(signal: AbortSignal): ToolTurn;
43
- }
44
-
45
- /** The answer of a description wait the turn's stop cut short. */
46
- const STOPPED = Symbol("stopped");
47
-
48
- /** What waiting for a call's description comes to: its text, or the turn stopped first. */
49
- type Described = string | undefined | typeof STOPPED;
50
-
51
- /** JSON with object keys sorted at every level, so equal inputs compare equal. */
52
- // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
53
- export function canonicalJson(value: unknown): string {
54
- // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
55
- return JSON.stringify(value, (_key, v: unknown) =>
56
- isPlainObject(v) ? withSortedKeys(v) : v,
57
- );
58
- }
59
-
60
- // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
61
- function isPlainObject(value: unknown): value is Record<string, unknown> {
62
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
63
- }
64
-
65
- /** The same object with its keys in code-unit order. */
66
- function withSortedKeys(
67
- value: Record<string, unknown>,
68
- ): Record<string, unknown> {
69
- return Object.fromEntries(
70
- Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b)),
71
- );
72
- }
73
-
74
- function byCodeUnit(a: string, b: string): number {
75
- if (a < b) return -1;
76
- return a > b ? 1 : 0;
77
- }
46
+ export { confirmationGateExtension } from "./confirmation-gate-extension.ts";
47
+ export {
48
+ CONFIRMATION_TTL_MS,
49
+ canonicalJson,
50
+ confirmedTurnText,
51
+ mayApproveNamed,
52
+ namedApprover,
53
+ unexpired,
54
+ } from "./held-calls.ts";
55
+ export type { HoldTurn } from "./hold-lookups.ts";
78
56
 
79
57
  /**
80
58
  * Per-channel state of held actions. With the owner's prompts bound, a call that needs their
@@ -86,12 +64,16 @@ function byCodeUnit(a: string, b: string): number {
86
64
  // pi-lens-ignore: large-class — one gate per conversation; its state is the held calls and their approvals
87
65
  export class ConfirmationGate {
88
66
  #selectionId: string | undefined;
89
- /** The running turn's speaker, whom the actions it holds belong to. */
90
- #speaker: Pick<Speaker, "id" | "principalId"> | undefined;
67
+ /** The running turn's speaker, whom the actions it holds belong to, and who approves those a message confirmed. */
68
+ #speaker: SpeakerRef | undefined;
69
+ /** Whether the running turn's conversation is private to its speaker, who then alone answers its cards. */
70
+ #private = false;
91
71
  #pending: PendingConfirmation | undefined;
92
- #approved: HeldCall[] = [];
72
+ #approved: Release[] = [];
93
73
  /** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
94
- #lateApproved: HeldCall[] = [];
74
+ #lateApproved: Release[] = [];
75
+ /** Who approved each call that is running, by tool call id, until its result is back. */
76
+ readonly #approvers = new Map<string, ApprovedBy>();
95
77
  readonly #holds: HoldCheck;
96
78
  readonly #owner: OwnerIdentity;
97
79
  /** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
@@ -101,6 +83,12 @@ export class ConfirmationGate {
101
83
  /** Where the session's relative file paths resolve, for the sizes a card shows. */
102
84
  readonly #fileRoot: string | undefined;
103
85
  readonly #describeTimeoutMs: number;
86
+ /** The name a principal goes by, for telling a speaker who may approve a call; undefined when unknown. */
87
+ readonly #nameOf:
88
+ | ((principalId: string) => Promise<string | undefined>)
89
+ | undefined;
90
+ /** The held calls a partial release kept, until this turn records a hold of its own. */
91
+ #kept: PendingConfirmation | undefined;
104
92
  /** Settles when the descriptions asked for so far have all been used, in the order they were asked. */
105
93
  #describing: Promise<void> = Promise.resolve();
106
94
  /** Descriptions asked for and not used yet. */
@@ -112,6 +100,8 @@ export class ConfirmationGate {
112
100
  * `fileRoot` is the session's working directory, where a card resolves relative file paths;
113
101
  * it defaults to the workspace. A hold that looks things up gets `describeTimeoutMs` to answer
114
102
  * (`HOLD_DESCRIBE_TIMEOUT_MS` by default) before its call is held under a generic description.
103
+ * `nameOf` gives the name of a principal a call names as an approver, which the text a speaker
104
+ * who cannot approve it is given lists.
115
105
  */
116
106
  constructor(
117
107
  holds: HoldCheck,
@@ -121,8 +111,10 @@ export class ConfirmationGate {
121
111
  tiers?: ToolTiers,
122
112
  fileRoot?: string,
123
113
  describeTimeoutMs = HOLD_DESCRIBE_TIMEOUT_MS,
114
+ nameOf?: (principalId: string) => Promise<string | undefined>,
124
115
  ) {
125
116
  this.#describeTimeoutMs = describeTimeoutMs;
117
+ this.#nameOf = nameOf;
126
118
  this.#tiers = tiers;
127
119
  this.#fileRoot = fileRoot ?? context.workspace;
128
120
  this.#holds = holds;
@@ -137,32 +129,102 @@ export class ConfirmationGate {
137
129
  return unexpired(this.#pending, now);
138
130
  }
139
131
 
132
+ /**
133
+ * The held calls a message from `speaker` may release: all of them, except those that named
134
+ * approvers the speaker is neither among nor an owner of, which stay held whoever else asked for
135
+ * them to run. Undefined when there are none.
136
+ */
137
+ releasable(speaker?: SpeakerRef): PendingConfirmation | undefined {
138
+ const pending = this.pending();
139
+ if (!pending) return undefined;
140
+ const calls = pending.calls.filter((call) =>
141
+ mayApproveNamed(speaker, call),
142
+ );
143
+ return calls.length === pending.calls.length
144
+ ? pending
145
+ : { ...pending, calls };
146
+ }
147
+
140
148
  /**
141
149
  * `addressee` is who the turn is for; without one, the owner. `speaker` is who spoke the turn,
142
150
  * recorded with what it holds by their id and principal; without one, only the owners may
143
- * approve them.
151
+ * approve them. When `confirmed`, the speaker's message approved the held calls, and they are
152
+ * the approver the calls' `run` sees; a call that named approvers is released only when the
153
+ * speaker is one of them or an owner, and otherwise stays held. `isPrivate` says the turn's
154
+ * conversation is private to its speaker: they alone then answer its cards.
144
155
  */
145
156
  beginTurn(
146
157
  selectionId: string,
147
158
  confirmed: boolean,
148
159
  addressee?: OwnerIdentity,
149
- speaker?: Pick<Speaker, "id" | "principalId">,
160
+ speaker?: SpeakerRef,
161
+ isPrivate = false,
150
162
  ): void {
151
163
  this.#selectionId = selectionId;
152
164
  this.#speaker = speaker;
165
+ this.#private = isPrivate;
153
166
  this.#addressee = addressee ?? this.#owner;
154
- this.#approved = confirmed ? [...(this.pending()?.calls ?? [])] : [];
155
- this.#pending = undefined;
167
+ const by: ApprovedBy | undefined = speaker
168
+ ? {
169
+ principalId: speaker.principalId,
170
+ speakerId: speaker.id,
171
+ name: speaker.name ?? speaker.id,
172
+ ...(speaker.tier === "owner" ? { owner: true as const } : {}),
173
+ }
174
+ : undefined;
175
+ const held = this.pending();
176
+ const released = confirmed ? this.releasable(speaker) : undefined;
177
+ this.#approved = (released?.calls ?? []).map((call) => ({
178
+ call,
179
+ ...(by ? { by } : {}),
180
+ }));
181
+ // What the speaker could not release stays held for those who may.
182
+ const kept = (held?.calls ?? []).filter(
183
+ (call) => confirmed && !released?.calls.includes(call),
184
+ );
185
+ this.#pending =
186
+ held && kept.length > 0 ? { ...held, calls: kept } : undefined;
187
+ this.#kept = this.#pending;
156
188
  }
157
189
 
158
190
  endTurn(): void {
159
191
  this.#approved = [];
192
+ this.#approvers.clear();
193
+ }
194
+
195
+ /** The running turn's speaker as an approver; undefined without one. */
196
+ #speakerAsApprover(): ApprovedBy | undefined {
197
+ const speaker = this.#speaker;
198
+ if (!speaker) return undefined;
199
+ return {
200
+ principalId: speaker.principalId,
201
+ speakerId: speaker.id,
202
+ name: speaker.name ?? speaker.id,
203
+ ...(speaker.tier === "owner" ? { owner: true as const } : {}),
204
+ };
205
+ }
206
+
207
+ /** Who approved the held call running under this tool call id; undefined when it was not held or they are unknown. */
208
+ approvedBy(toolCallId: string): ApprovedBy | undefined {
209
+ return this.#approvers.get(toolCallId);
210
+ }
211
+
212
+ /** Notes who approved the call running under a tool call id, for its `run` to read. */
213
+ grant(toolCallId: string, by: ApprovedBy): void {
214
+ this.#approvers.set(toolCallId, by);
215
+ }
216
+
217
+ /** Forgets who approved a tool call, once its result is back. */
218
+ finish(toolCallId: string): void {
219
+ this.#approvers.delete(toolCallId);
160
220
  }
161
221
 
162
222
  /** Returns the reason a call is held, or undefined to let it run. */
163
223
  hold(tool: string, input: Record<string, unknown>): string | undefined {
164
- const call = this.#needing(tool, input);
165
- return call ? this.#record(call) : undefined;
224
+ const needing = this.#needing(tool, input);
225
+ return needing && "held" in needing
226
+ ? this.#record(needing.held)
227
+ : undefined;
166
228
  }
167
229
 
168
230
  /**
@@ -193,14 +255,29 @@ export class ConfirmationGate {
193
255
  ): Promise<{
194
256
  reason?: string;
195
257
  approvedOnCard?: true;
258
+ /** Who approved the call, when it was held and a surface or a confirming message said who. */
259
+ approvedBy?: ApprovedBy;
196
260
  /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
197
261
  refused?: HoldRefusal;
198
262
  }> {
199
263
  const found = this.#needingIn(tool, input, turn);
200
- const call = isPromise(found) ? await found : found;
201
- if (call === STOPPED) return { reason: "The turn was stopped." };
202
- if (!call) return {};
203
- if (!ask) return { reason: this.#record(call), refused: "held" };
264
+ const needing = isPromise(found) ? await found : found;
265
+ if (needing === STOPPED) return { reason: "The turn was stopped." };
266
+ if (!needing) return {};
267
+ if ("released" in needing)
268
+ return needing.released ? { approvedBy: needing.released } : {};
269
+ const call = needing.held;
270
+ if (!ask) return { reason: await this.#held(call), refused: "held" };
271
+ // In a private conversation only its person answers: a call that names others is not theirs.
272
+ if (this.#private && !mayApproveNamed(this.#speaker, call))
273
+ return { reason: await this.#held(call), refused: "held" };
274
+ let approvedBy: ApprovedBy | undefined;
275
+ const options: ApprovalOptions = {
276
+ ...(call.approverIds ? { approvers: call.approverIds } : {}),
277
+ approvedBy: (by) => {
278
+ approvedBy = by;
279
+ },
280
+ };
204
281
  const answer = await ask.prompts.confirm(
205
282
  messages().confirmTitle(ask.asker),
206
283
  approvalCard(call, this.#fileRoot),
@@ -208,8 +285,20 @@ export class ConfirmationGate {
208
285
  higherTier(this.#tiers?.minTier(call.tool), call.minTier),
209
286
  { late: (late) => this.#late(call, late) },
210
287
  approvalDetails(call, this.#fileRoot),
288
+ options,
211
289
  );
212
- if (answer === "approved") return { approvedOnCard: true };
290
+ if (answer === "approved") {
291
+ // A private conversation's answer to a call that names approvers is its person's, whom a surface need not name.
292
+ const by =
293
+ approvedBy ??
294
+ (this.#private && call.approverIds
295
+ ? this.#speakerAsApprover()
296
+ : undefined);
297
+ // A call that names approvers runs only on the word of one of them, or an owner.
298
+ if (!namedApprover(call, by))
299
+ return { reason: await this.#held(call), refused: "held" };
300
+ return { approvedOnCard: true, ...(by ? { approvedBy: by } : {}) };
301
+ }
213
302
  if (answer === "pending") {
214
303
  const o = ownerWords(this.#addressee);
215
304
  return {
@@ -227,8 +316,8 @@ export class ConfirmationGate {
227
316
  if (answer === "cancelled") return { reason: "The turn was stopped." };
228
317
  // No card was shown: no one may approve it, or it could not be posted.
229
318
  if (answer === "unavailable")
230
- return { reason: this.#record(call), refused: "held" };
231
- return { reason: this.#record(call), refused: "expired" };
319
+ return { reason: await this.#held(call), refused: "held" };
320
+ return { reason: await this.#held(call), refused: "expired" };
232
321
  }
233
322
 
234
323
  /**
@@ -237,16 +326,21 @@ export class ConfirmationGate {
237
326
  */
238
327
  #late(call: HeldCall, late: LateAnswer): string | undefined {
239
328
  if (late.kind !== "approval") return undefined;
329
+ if (late.approved && !namedApprover(call, late.approvedBy))
330
+ return undefined;
240
331
  if (!late.approved)
241
332
  return `(${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.)`;
242
- this.#lateApproved.push(call);
333
+ this.#lateApproved.push({
334
+ call,
335
+ ...(late.approvedBy ? { by: late.approvedBy } : {}),
336
+ });
243
337
  return `(${late.by} approved on its card the action you were waiting on: it would ${call.action}. Make this call now with exactly this input, and it runs once; then report its result.)\n- ${call.tool} ${call.input}`;
244
338
  }
245
339
 
246
- /** The call when it needs the owner's confirmation and no approval releases it. */
247
- #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
340
+ /** What a call needs when a rule holds it; undefined when none does. */
341
+ #needing(tool: string, input: Record<string, unknown>): Needing | undefined {
248
342
  const action = this.#holds(tool, input, this.#context);
249
- return action ? this.#unlessApproved(tool, input, action) : undefined;
343
+ return action ? this.#unlessApproved(tool, input, { action }) : undefined;
250
344
  }
251
345
 
252
346
  /**
@@ -259,10 +353,10 @@ export class ConfirmationGate {
259
353
  input: Record<string, unknown>,
260
354
  turn: HoldTurn | undefined,
261
355
  ):
262
- | HeldCall
356
+ | Needing
263
357
  | undefined
264
358
  | typeof STOPPED
265
- | Promise<HeldCall | undefined | typeof STOPPED> {
359
+ | Promise<Needing | undefined | typeof STOPPED> {
266
360
  const inTurn = this.#holds.inTurn;
267
361
  if (!turn || !inTurn) return this.#needing(tool, input);
268
362
  const controller = new AbortController();
@@ -280,45 +374,31 @@ export class ConfirmationGate {
280
374
  answer = messages().holdGeneric(tool);
281
375
  }
282
376
  const described: Described | Promise<Described> = isPromise(answer)
283
- ? this.#awaited(answer, tool, controller, turn.signal)
377
+ ? awaited(
378
+ answer,
379
+ messages().holdGeneric(tool),
380
+ controller,
381
+ turn.signal,
382
+ this.#describeTimeoutMs,
383
+ )
284
384
  : answer;
285
- return this.#inOrder(described, (action) => {
286
- if (action === STOPPED) return STOPPED;
287
- return action ? this.#unlessApproved(tool, input, action) : undefined;
288
- });
289
- }
290
-
291
- /**
292
- * What a hold that answers later comes to: its text; the generic description when it rejects or
293
- * outlasts the timeout, so the call is still held; `STOPPED` when the turn stops first. Whatever
294
- * the hold is still doing is aborted when the wait ends.
295
- */
296
- #awaited(
297
- answer: Promise<string | undefined>,
298
- tool: string,
299
- hold: AbortController,
300
- stop: AbortSignal | undefined,
301
- ): Promise<Described> {
302
- return new Promise<Described>((resolve) => {
303
- const onStop = () => end(STOPPED);
304
- const timer = setTimeout(
305
- () => end(messages().holdGeneric(tool)),
306
- this.#describeTimeoutMs,
307
- );
308
- let ended = false;
309
- const end = (described: Described) => {
310
- if (ended) return;
311
- ended = true;
312
- clearTimeout(timer);
313
- stop?.removeEventListener("abort", onStop);
314
- hold.abort();
315
- resolve(described);
316
- };
317
- // Handled first, so a rejection after the wait ended is never left unhandled.
318
- answer.then(end, () => end(messages().holdGeneric(tool)));
319
- if (stop?.aborted) return end(STOPPED);
320
- stop?.addEventListener("abort", onStop, { once: true });
321
- });
385
+ return this.#inOrder(
386
+ joinApprovers(
387
+ described,
388
+ {
389
+ holds: this.#holds,
390
+ context: this.#context,
391
+ timeoutMs: this.#describeTimeoutMs,
392
+ },
393
+ tool,
394
+ input,
395
+ turn,
396
+ ),
397
+ (judged) => {
398
+ if (judged === STOPPED) return STOPPED;
399
+ return judged ? this.#unlessApproved(tool, input, judged) : undefined;
400
+ },
401
+ );
322
402
  }
323
403
 
324
404
  /**
@@ -344,108 +424,76 @@ export class ConfirmationGate {
344
424
  return used;
345
425
  }
346
426
 
347
- /** The call for `action`, unless an approval releases it, which that approval then spends. */
427
+ /**
428
+ * The call for a description, unless an approval releases it, which that approval then spends
429
+ * and whose approver the release names.
430
+ */
348
431
  #unlessApproved(
349
432
  tool: string,
350
433
  input: Record<string, unknown>,
351
- action: string,
352
- ): HeldCall | undefined {
434
+ judged: { action: string; approvers?: readonly string[] },
435
+ ): Needing {
353
436
  const minTier = this.#holds.approvalTier?.(tool, input, this.#context);
354
437
  const call: HeldCall = {
355
438
  tool,
356
439
  input: canonicalJson(input),
357
- action,
440
+ action: judged.action,
358
441
  ...(minTier ? { minTier } : {}),
442
+ ...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
359
443
  };
360
444
  for (const released of [this.#approved, this.#lateApproved]) {
361
445
  const approved = released.findIndex(
362
- (c) => c.tool === call.tool && c.input === call.input,
446
+ (r) => r.call.tool === call.tool && r.call.input === call.input,
363
447
  );
364
- if (approved !== -1) {
365
- released.splice(approved, 1);
366
- return undefined;
367
- }
448
+ const [spent] = approved === -1 ? [] : released.splice(approved, 1);
449
+ if (spent) return { released: spent.by };
368
450
  }
369
- return call;
451
+ return { held: call };
370
452
  }
371
453
 
454
+ /** Records the call as held and returns what the model is told about it. */
372
455
  #record(call: HeldCall): string {
456
+ this.#remember(call);
457
+ const refusal = unlessNamed(
458
+ call,
459
+ this.#speaker,
460
+ this.#addressee,
461
+ this.#private,
462
+ );
463
+ return refusal ? refusal([], false) : askSpeaker(this.#addressee, call);
464
+ }
465
+
466
+ /**
467
+ * `#record` for a call that names approvers the speaker is not among: the text then lists who
468
+ * may approve it, which looks their names up first.
469
+ */
470
+ async #held(call: HeldCall): Promise<string> {
471
+ this.#remember(call);
472
+ const refusal = unlessNamed(
473
+ call,
474
+ this.#speaker,
475
+ this.#addressee,
476
+ this.#private,
477
+ );
478
+ if (!refusal) return askSpeaker(this.#addressee, call);
479
+ const { names, missing } = await namesOf(
480
+ this.#nameOf,
481
+ call.approverIds ?? [],
482
+ );
483
+ return refusal(names, missing);
484
+ }
485
+
486
+ #remember(call: HeldCall): void {
373
487
  const selectionId = this.#selectionId;
374
488
  if (selectionId === undefined)
375
489
  throw new Error("a call was held outside a turn");
376
- const pending: PendingConfirmation = this.#pending ?? {
490
+ this.#pending = remembered(
491
+ this.#pending,
492
+ this.#kept,
377
493
  selectionId,
378
- heldAt: new Date(),
379
- calls: [],
380
- ...(this.#speaker
381
- ? {
382
- speakerId: this.#speaker.id,
383
- principalId: this.#speaker.principalId,
384
- }
385
- : {}),
386
- };
387
- if (
388
- !pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
389
- )
390
- pending.calls.push(call);
391
- this.#pending = pending;
392
- const o = ownerWords(this.#addressee);
393
- return `Held for ${o.name}'s confirmation: this would ${call.action}. Do not retry it in this turn. Tell ${o.him} exactly what will happen and ask ${o.him} to confirm; once ${o.he} agrees in ${o.his} next message, make the same call with the same input and it runs.`;
494
+ this.#speaker,
495
+ call,
496
+ );
497
+ this.#kept = undefined;
394
498
  }
395
499
  }
396
-
397
- /** The owner's approving message, preceded by the held calls it releases, so the model makes them again. */
398
- export function confirmedTurnText(
399
- pending: PendingConfirmation,
400
- text: string,
401
- owner: OwnerIdentity,
402
- ): string {
403
- const calls = pending.calls
404
- .map((call) => `- ${call.action}: ${call.tool} ${call.input}`)
405
- .join("\n");
406
- return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
407
- }
408
-
409
- /**
410
- * Without a slot, or with an empty one, every call that needs confirmation is held. `turnOf` builds
411
- * the turn a hold that looks things up receives; without it, such a hold is not asked in a turn.
412
- */
413
- export function confirmationGateExtension(
414
- gate: ConfirmationGate,
415
- slot?: PromptSlot,
416
- turnOf?: (signal: AbortSignal) => ToolTurn,
417
- ): ExtensionFactory {
418
- return (pi) => {
419
- /** Calls a card's approval released, until their results are back. */
420
- const approved = new Set<string>();
421
- pi.on("tool_result", (event) => {
422
- if (!approved.delete(event.toolCallId)) return undefined;
423
- const outcome = event.isError
424
- ? "It was approved on its card and ran, but it failed:"
425
- : "It was approved on its card and ran; its result follows.";
426
- return {
427
- content: [{ type: "text", text: outcome }, ...event.content],
428
- };
429
- });
430
- pi.on("tool_call", async (event, ctx) => {
431
- const prompts = slot?.prompts;
432
- let ask: Parameters<ConfirmationGate["review"]>[2];
433
- if (prompts && slot) {
434
- ask = { prompts, asker: slot.asker };
435
- if (ctx.signal) ask.signal = ctx.signal;
436
- }
437
- const turn: HoldTurn | undefined = turnOf
438
- ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
439
- : undefined;
440
- const { reason, approvedOnCard, refused } = await gate.decide(
441
- event.toolName,
442
- event.input as Record<string, unknown>,
443
- ask,
444
- turn,
445
- );
446
- if (approvedOnCard) approved.add(event.toolCallId);
447
- if (refused) slot?.refuse(event.toolCallId, refused);
448
- return reason ? { block: true, reason } : undefined;
449
- });
450
- };
451
- }