pi-roundtable 0.14.1 → 0.15.0

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 (32) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/plugins.md +46 -11
  3. package/examples/fake-surface.ts +9 -5
  4. package/package.json +4 -4
  5. package/src/core/define.ts +15 -0
  6. package/src/core/discord/owner-card-rows.ts +44 -1
  7. package/src/core/discord/owner-card-types.ts +3 -2
  8. package/src/core/discord/owner-cards.ts +57 -58
  9. package/src/core/domain/client.ts +7 -0
  10. package/src/core/domain/progress.ts +8 -2
  11. package/src/core/holds.ts +18 -2
  12. package/src/core/i18n/cards.ts +4 -2
  13. package/src/core/interactions/prompts.ts +43 -1
  14. package/src/core/runtime/conversation-sessions.ts +16 -0
  15. package/src/core/runtime/extensions/approval-card.ts +24 -36
  16. package/src/core/runtime/extensions/approval-fields.ts +212 -0
  17. package/src/core/runtime/extensions/card-ask.ts +74 -0
  18. package/src/core/runtime/extensions/card-text.ts +27 -0
  19. package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
  20. package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
  21. package/src/core/runtime/extensions/gate-refusals.ts +45 -2
  22. package/src/core/runtime/extensions/grants.ts +30 -0
  23. package/src/core/runtime/extensions/held-calls.ts +8 -0
  24. package/src/core/runtime/extensions/hold-describer.ts +99 -0
  25. package/src/core/runtime/extensions/hold-lookups.ts +24 -7
  26. package/src/core/runtime/extensions/late-answer.ts +36 -0
  27. package/src/core/runtime/extensions/withdrawals.ts +95 -0
  28. package/src/core/runtime/session-factory.ts +1 -0
  29. package/src/core/sessions.ts +6 -0
  30. package/src/core/testing/surface-contract.ts +71 -1
  31. package/src/core/tool-turn.ts +3 -0
  32. package/src/index.ts +1 -1
@@ -11,38 +11,39 @@ import {
11
11
  isPromise,
12
12
  } from "../../holds.ts";
13
13
  import { messages } from "../../i18n/index.ts";
14
- import { type OwnerIdentity, ownerWords } from "../../identity.ts";
14
+ import type { OwnerIdentity } from "../../identity.ts";
15
15
  import type {
16
- ApprovalOptions,
17
16
  ApprovedBy,
18
17
  LateAnswer,
19
18
  Prompts,
20
19
  } from "../../interactions/prompts.ts";
21
20
  import type { ToolTiers } from "../../tool-tiers.ts";
22
21
  import { approvalCard, approvalDetails } from "./approval-card.ts";
22
+ import { askOnCard } from "./card-ask.ts";
23
23
  import {
24
+ approverOf,
24
25
  askSpeaker,
26
+ awaitingText,
27
+ declinedText,
25
28
  type Needing,
26
29
  namesOf,
27
30
  type Release,
28
31
  remembered,
29
32
  type SpeakerRef,
30
33
  unlessNamed,
34
+ withdrawn,
31
35
  } from "./gate-refusals.ts";
36
+ import { type Grant, Grants } from "./grants.ts";
32
37
  import {
33
38
  canonicalJson,
34
39
  mayApproveNamed,
35
40
  namedApprover,
41
+ sameCall,
36
42
  unexpired,
37
43
  } from "./held-calls.ts";
38
- import {
39
- awaited,
40
- type Described,
41
- type HoldTurn,
42
- type Judgment,
43
- joinApprovers,
44
- STOPPED,
45
- } from "./hold-lookups.ts";
44
+ import { HoldDescriber } from "./hold-describer.ts";
45
+ import { type HoldTurn, type Judgment, STOPPED } from "./hold-lookups.ts";
46
+ import { lateOutcome } from "./late-answer.ts";
46
47
 
47
48
  export { confirmationGateExtension } from "./confirmation-gate-extension.ts";
48
49
  export {
@@ -53,6 +54,9 @@ export {
53
54
  namedApprover,
54
55
  unexpired,
55
56
  } from "./held-calls.ts";
57
+
58
+ import { type Watch, Withdrawals } from "./withdrawals.ts";
59
+
56
60
  export type { HoldTurn } from "./hold-lookups.ts";
57
61
 
58
62
  /**
@@ -73,8 +77,8 @@ export class ConfirmationGate {
73
77
  #approved: Release[] = [];
74
78
  /** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
75
79
  #lateApproved: Release[] = [];
76
- /** Who approved each call that is running, by tool call id, until its result is back. */
77
- readonly #approvers = new Map<string, ApprovedBy>();
80
+ /** Who approved each call that is running, and on which card, by tool call id, until its result is back. */
81
+ readonly #grants = new Grants();
78
82
  readonly #holds: HoldCheck;
79
83
  readonly #owner: OwnerIdentity;
80
84
  /** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
@@ -83,17 +87,21 @@ export class ConfirmationGate {
83
87
  readonly #tiers: ToolTiers | undefined;
84
88
  /** Where the session's relative file paths resolve, for the sizes a card shows. */
85
89
  readonly #fileRoot: string | undefined;
86
- readonly #describeTimeoutMs: number;
87
90
  /** The name a principal goes by, for telling a speaker who may approve a call; undefined when unknown. */
88
91
  readonly #nameOf:
89
92
  | ((principalId: string) => Promise<string | undefined>)
90
93
  | undefined;
91
94
  /** The held calls a partial release kept, until this turn records a hold of its own. */
92
95
  #kept: PendingConfirmation | undefined;
93
- /** Settles when the descriptions asked for so far have all been used, in the order they were asked. */
94
- #describing: Promise<void> = Promise.resolve();
95
- /** Descriptions asked for and not used yet. */
96
- #undescribed = 0;
96
+ /** Asks the holds to describe a call in a turn, and hands the answers on in the order they were asked. */
97
+ readonly #describer: HoldDescriber;
98
+ /** The held calls their plugins may still withdraw. */
99
+ readonly #withdrawals = new Withdrawals((call) => this.#forget(call));
100
+ /**
101
+ * Called with the held actions that remain when a plugin's withdrawal removed one from them,
102
+ * for whoever keeps them across a restart to keep the smaller set.
103
+ */
104
+ onHeldChange: ((held: PendingConfirmation | undefined) => void) | undefined;
97
105
 
98
106
  /**
99
107
  * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
@@ -114,7 +122,7 @@ export class ConfirmationGate {
114
122
  describeTimeoutMs = HOLD_DESCRIBE_TIMEOUT_MS,
115
123
  nameOf?: (principalId: string) => Promise<string | undefined>,
116
124
  ) {
117
- this.#describeTimeoutMs = describeTimeoutMs;
125
+ this.#describer = new HoldDescriber(holds, context, describeTimeoutMs);
118
126
  this.#nameOf = nameOf;
119
127
  this.#tiers = tiers;
120
128
  this.#fileRoot = fileRoot ?? context.workspace;
@@ -165,14 +173,7 @@ export class ConfirmationGate {
165
173
  this.#speaker = speaker;
166
174
  this.#private = isPrivate;
167
175
  this.#addressee = addressee ?? this.#owner;
168
- const by: ApprovedBy | undefined = speaker
169
- ? {
170
- principalId: speaker.principalId,
171
- speakerId: speaker.id,
172
- name: speaker.name ?? speaker.id,
173
- ...(speaker.tier === "owner" ? { owner: true as const } : {}),
174
- }
175
- : undefined;
176
+ const by = approverOf(speaker);
176
177
  const held = this.pending();
177
178
  const released = confirmed ? this.releasable(speaker) : undefined;
178
179
  this.#approved = (released?.calls ?? []).map((call) => ({
@@ -186,38 +187,69 @@ export class ConfirmationGate {
186
187
  this.#pending =
187
188
  held && kept.length > 0 ? { ...held, calls: kept } : undefined;
188
189
  this.#kept = this.#pending;
190
+ this.#sweep();
189
191
  }
190
192
 
191
193
  endTurn(): void {
192
194
  this.#approved = [];
193
- this.#approvers.clear();
195
+ this.#grants.clear();
196
+ this.#sweep();
194
197
  }
195
198
 
196
- /** The running turn's speaker as an approver; undefined without one. */
197
- #speakerAsApprover(): ApprovedBy | undefined {
198
- const speaker = this.#speaker;
199
- if (!speaker) return undefined;
200
- return {
201
- principalId: speaker.principalId,
202
- speakerId: speaker.id,
203
- name: speaker.name ?? speaker.id,
204
- ...(speaker.tier === "owner" ? { owner: true as const } : {}),
205
- };
199
+ /** How many held calls a plugin may still withdraw. */
200
+ get watching(): number {
201
+ return this.#withdrawals.size;
202
+ }
203
+
204
+ /** Stops watching every call, for a gate that is dropped; a card still open is closed by its own surface. */
205
+ abandon(): void {
206
+ this.#withdrawals.clear();
207
+ }
208
+
209
+ /** Ends the watches of calls that nothing keeps and no card can answer. */
210
+ #sweep(): void {
211
+ const kept = (call: HeldCall) =>
212
+ (this.#pending?.calls.some((c) => sameCall(c, call)) ?? false) ||
213
+ [...this.#approved, ...this.#lateApproved].some((r) =>
214
+ sameCall(r.call, call),
215
+ );
216
+ this.#withdrawals.sweep(kept);
217
+ }
218
+
219
+ /** A plugin withdrew `call`: nothing that could run it stays, and what is held is kept anew. */
220
+ #forget(call: HeldCall): void {
221
+ const other = (r: { call: HeldCall }) => !sameCall(r.call, call);
222
+ this.#approved = this.#approved.filter(other);
223
+ this.#lateApproved = this.#lateApproved.filter(other);
224
+ const pending = this.#pending;
225
+ if (!pending?.calls.some((c) => sameCall(c, call))) return;
226
+ // In place, so a partial release's kept set stays the object `remembered` compares.
227
+ pending.calls = pending.calls.filter((c) => !sameCall(c, call));
228
+ if (pending.calls.length === 0) {
229
+ this.#pending = undefined;
230
+ if (this.#kept === pending) this.#kept = undefined;
231
+ }
232
+ this.onHeldChange?.(this.pending());
206
233
  }
207
234
 
208
235
  /** Who approved the held call running under this tool call id; undefined when it was not held or they are unknown. */
209
236
  approvedBy(toolCallId: string): ApprovedBy | undefined {
210
- return this.#approvers.get(toolCallId);
237
+ return this.#grants.get(toolCallId)?.by;
238
+ }
239
+
240
+ /** The id of the card that approved the call running under this tool call id; undefined when no card did. */
241
+ approvedPrompt(toolCallId: string): string | undefined {
242
+ return this.#grants.get(toolCallId)?.prompt;
211
243
  }
212
244
 
213
- /** Notes who approved the call running under a tool call id, for its `run` to read. */
214
- grant(toolCallId: string, by: ApprovedBy): void {
215
- this.#approvers.set(toolCallId, by);
245
+ /** Notes who approved the call running under a tool call id, and on which card, for its `run` to read. */
246
+ grant(toolCallId: string, grant: Grant): void {
247
+ this.#grants.set(toolCallId, grant);
216
248
  }
217
249
 
218
250
  /** Forgets who approved a tool call, once its result is back. */
219
251
  finish(toolCallId: string): void {
220
- this.#approvers.delete(toolCallId);
252
+ this.#grants.delete(toolCallId);
221
253
  }
222
254
 
223
255
  /** Returns the reason a call is held, or undefined to let it run. */
@@ -246,18 +278,22 @@ export class ConfirmationGate {
246
278
  * the card's own message is shown to the person, never to the model. When a hold refuses the
247
279
  * call, `refused` says how, for the turn's progress to report beside the failed result.
248
280
  * With `turn`, a hold that looks things up as the turn's speaker is waited for before any card
249
- * opens, and a turn that stops meanwhile opens none.
281
+ * opens, and a turn that stops meanwhile opens none. The tool's JSON Schema, as `parameters`,
282
+ * has the card show the call as fields.
250
283
  */
251
284
  async decide(
252
285
  tool: string,
253
286
  input: Record<string, unknown>,
254
287
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
255
288
  turn?: HoldTurn,
289
+ parameters?: unknown,
256
290
  ): Promise<{
257
291
  reason?: string;
258
292
  approvedOnCard?: true;
259
293
  /** Who approved the call, when it was held and a surface or a confirming message said who. */
260
294
  approvedBy?: ApprovedBy;
295
+ /** The id of the card whose approval released the call, in this turn or answered late; absent for a call a message confirmed. */
296
+ approvedPrompt?: string;
261
297
  /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
262
298
  refused?: HoldRefusal;
263
299
  }> {
@@ -265,77 +301,90 @@ export class ConfirmationGate {
265
301
  const needing = isPromise(found) ? await found : found;
266
302
  if (needing === STOPPED) return { reason: "The turn was stopped." };
267
303
  if (!needing) return {};
268
- if ("released" in needing)
269
- return needing.released ? { approvedBy: needing.released } : {};
304
+ if ("released" in needing) {
305
+ const { by, prompt } = needing.released;
306
+ return {
307
+ ...(by ? { approvedBy: by } : {}),
308
+ ...(prompt ? { approvedPrompt: prompt } : {}),
309
+ };
310
+ }
270
311
  const call = needing.held;
271
- if (!ask) return { reason: await this.#held(call), refused: "held" };
312
+ const watch = needing.supersede
313
+ ? this.#withdrawals.watch(call, needing.supersede)
314
+ : undefined;
315
+ if (watch?.withdrawn) return withdrawn(call);
316
+ /** Holds the call for a message, no card being open for it. */
317
+ const held = async (refused: "held" | "expired") => {
318
+ // A withdrawn call is never held again, whatever the card said last.
319
+ if (watch?.withdrawn) return withdrawn(call);
320
+ if (watch) watch.card = false;
321
+ return { reason: await this.#held(call), refused };
322
+ };
323
+ if (!ask) return held("held");
272
324
  // In a private conversation only its person answers: a call that names others is not theirs.
273
325
  if (this.#private && !mayApproveNamed(this.#speaker, call))
274
- return { reason: await this.#held(call), refused: "held" };
275
- let approvedBy: ApprovedBy | undefined;
276
- const options: ApprovalOptions = {
277
- ...(call.approverIds ? { approvers: call.approverIds } : {}),
278
- approvedBy: (by) => {
279
- approvedBy = by;
326
+ return held("held");
327
+ const { answer, approvedBy, approvedPrompt } = await askOnCard(
328
+ ask.prompts,
329
+ {
330
+ title: messages().confirmTitle(ask.asker),
331
+ message: approvalCard(call, this.#fileRoot, {}, parameters),
332
+ signal: ask.signal,
333
+ minTier: higherTier(this.#tiers?.minTier(call.tool), call.minTier),
334
+ wait: { late: (late) => this.#late(call, late, watch) },
335
+ details: approvalDetails(call, this.#fileRoot, needing.copy),
336
+ approvers: call.approverIds,
337
+ watch,
280
338
  },
281
- };
282
- const answer = await ask.prompts.confirm(
283
- messages().confirmTitle(ask.asker),
284
- approvalCard(call, this.#fileRoot),
285
- ask.signal,
286
- higherTier(this.#tiers?.minTier(call.tool), call.minTier),
287
- { late: (late) => this.#late(call, late) },
288
- approvalDetails(call, this.#fileRoot, needing.copy),
289
- options,
290
339
  );
340
+ // Whatever the card then reports, a call the plugin withdrew is refused, never held again.
341
+ if (answer === "withdrawn" || (watch?.withdrawn && answer !== "approved"))
342
+ return withdrawn(call);
291
343
  if (answer === "approved") {
292
344
  // A private conversation's answer to a call that names approvers is its person's, whom a surface need not name.
293
345
  const by =
294
346
  approvedBy ??
295
347
  (this.#private && call.approverIds
296
- ? this.#speakerAsApprover()
348
+ ? approverOf(this.#speaker)
297
349
  : undefined);
298
350
  // A call that names approvers runs only on the word of one of them, or an owner.
299
- if (!namedApprover(call, by))
300
- return { reason: await this.#held(call), refused: "held" };
301
- return { approvedOnCard: true, ...(by ? { approvedBy: by } : {}) };
351
+ if (!namedApprover(call, by)) return held("held");
352
+ watch?.end();
353
+ return {
354
+ approvedOnCard: true,
355
+ ...(by ? { approvedBy: by } : {}),
356
+ ...(approvedPrompt ? { approvedPrompt } : {}),
357
+ };
302
358
  }
303
- if (answer === "pending") {
304
- const o = ownerWords(this.#addressee);
359
+ if (answer === "pending")
305
360
  return {
306
- reason: `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`,
361
+ reason: awaitingText(this.#addressee, call),
307
362
  refused: "pending",
308
363
  };
309
- }
310
364
  if (answer === "declined") {
311
- const o = ownerWords(this.#addressee);
365
+ watch?.end();
312
366
  return {
313
- reason: `${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.`,
367
+ reason: declinedText(this.#addressee, call),
314
368
  refused: "declined",
315
369
  };
316
370
  }
317
- if (answer === "cancelled") return { reason: "The turn was stopped." };
318
- // No card was shown: no one may approve it, or it could not be posted.
319
- if (answer === "unavailable")
320
- return { reason: await this.#held(call), refused: "held" };
321
- return { reason: await this.#held(call), refused: "expired" };
371
+ if (answer === "cancelled") {
372
+ watch?.end();
373
+ return { reason: "The turn was stopped." };
374
+ }
375
+ // No card was shown, or it went unanswered: the call is held for a message, and stays withdrawable.
376
+ return held(answer === "unavailable" ? "held" : "expired");
322
377
  }
323
378
 
324
- /**
325
- * A late answer on a call's card: an approval lets the identical call run once, in whichever
326
- * turn makes it next; returns the text of the turn that tells the model.
327
- */
328
- #late(call: HeldCall, late: LateAnswer): string | undefined {
329
- if (late.kind !== "approval") return undefined;
330
- if (late.approved && !namedApprover(call, late.approvedBy))
331
- return undefined;
332
- if (!late.approved)
333
- 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.)`;
334
- this.#lateApproved.push({
335
- call,
336
- ...(late.approvedBy ? { by: late.approvedBy } : {}),
337
- });
338
- 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}`;
379
+ /** A late answer on a call's card; see `lateOutcome`. Returns the text of the turn that tells the model. */
380
+ #late(
381
+ call: HeldCall,
382
+ late: LateAnswer,
383
+ watch: Watch | undefined,
384
+ ): string | undefined {
385
+ const outcome = lateOutcome(call, late, watch);
386
+ if (outcome?.release) this.#lateApproved.push(outcome.release);
387
+ return outcome?.text;
339
388
  }
340
389
 
341
390
  /** What a call needs when a rule holds it; undefined when none does. */
@@ -358,71 +407,11 @@ export class ConfirmationGate {
358
407
  | undefined
359
408
  | typeof STOPPED
360
409
  | Promise<Needing | undefined | typeof STOPPED> {
361
- const inTurn = this.#holds.inTurn;
362
- if (!turn || !inTurn) return this.#needing(tool, input);
363
- const controller = new AbortController();
364
- if (turn.signal?.aborted) controller.abort();
365
- let answer: ReturnType<NonNullable<HoldCheck["inTurn"]>>;
366
- try {
367
- answer = inTurn.call(
368
- this.#holds,
369
- tool,
370
- input,
371
- this.#context,
372
- turn.toolTurn(controller.signal),
373
- );
374
- } catch {
375
- answer = messages().holdGeneric(tool);
376
- }
377
- const found: Described | Promise<Described> = isPromise(answer)
378
- ? awaited(
379
- answer,
380
- messages().holdGeneric(tool),
381
- controller,
382
- turn.signal,
383
- this.#describeTimeoutMs,
384
- )
385
- : answer;
386
- return this.#inOrder(
387
- joinApprovers(
388
- found,
389
- {
390
- holds: this.#holds,
391
- context: this.#context,
392
- timeoutMs: this.#describeTimeoutMs,
393
- },
394
- tool,
395
- input,
396
- turn,
397
- ),
398
- (judged) => {
399
- if (judged === STOPPED) return STOPPED;
400
- return judged ? this.#unlessApproved(tool, input, judged) : undefined;
401
- },
402
- );
403
- }
404
-
405
- /**
406
- * Hands `described` to `use` after every description asked for before it has been used, so
407
- * calls made together are held, and their cards opened, in the order they were made whichever
408
- * description comes back first.
409
- */
410
- #inOrder<T, R>(
411
- described: T | Promise<T>,
412
- use: (described: T) => R,
413
- ): R | Promise<R> {
414
- if (this.#undescribed === 0 && !isPromise(described)) return use(described);
415
- this.#undescribed++;
416
- const used = this.#describing.then(() => described).then(use);
417
- this.#describing = used.then(
418
- () => {
419
- this.#undescribed--;
420
- },
421
- () => {
422
- this.#undescribed--;
423
- },
424
- );
425
- return used;
410
+ if (!turn || !this.#holds.inTurn) return this.#needing(tool, input);
411
+ return this.#describer.describe(tool, input, turn, (judged) => {
412
+ if (judged === STOPPED) return STOPPED;
413
+ return judged ? this.#unlessApproved(tool, input, judged) : undefined;
414
+ });
426
415
  }
427
416
 
428
417
  /**
@@ -442,14 +431,22 @@ export class ConfirmationGate {
442
431
  ...(minTier ? { minTier } : {}),
443
432
  ...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
444
433
  };
445
- for (const released of [this.#approved, this.#lateApproved]) {
446
- const approved = released.findIndex(
447
- (r) => r.call.tool === call.tool && r.call.input === call.input,
448
- );
434
+ // A plugin that already withdrew the call lets no approval of it release it.
435
+ for (const released of judged.supersede?.aborted
436
+ ? []
437
+ : [this.#approved, this.#lateApproved]) {
438
+ const approved = released.findIndex((r) => sameCall(r.call, call));
449
439
  const [spent] = approved === -1 ? [] : released.splice(approved, 1);
450
- if (spent) return { released: spent.by };
440
+ if (spent) {
441
+ this.#withdrawals.endCall(spent.call);
442
+ return { released: spent };
443
+ }
451
444
  }
452
- return { held: call, ...(judged.copy ? { copy: judged.copy } : {}) };
445
+ return {
446
+ held: call,
447
+ ...(judged.copy ? { copy: judged.copy } : {}),
448
+ ...(judged.supersede ? { supersede: judged.supersede } : {}),
449
+ };
453
450
  }
454
451
 
455
452
  /** Records the call as held and returns what the model is told about it. */
@@ -23,12 +23,55 @@ export type SpeakerRef = Pick<Speaker, "id" | "principalId"> & {
23
23
  export interface Release {
24
24
  call: HeldCall;
25
25
  by?: ApprovedBy;
26
+ /** The id of the card whose approval released the call, when one did. */
27
+ prompt?: string;
26
28
  }
27
29
 
28
30
  /** What a call needs of the gate: nothing, a hold, or a release that spends an approval. */
29
31
  export type Needing =
30
- | { held: HeldCall; copy?: NonNullable<ApprovalDetails["copy"]> }
31
- | { released: ApprovedBy | undefined };
32
+ | {
33
+ held: HeldCall;
34
+ copy?: NonNullable<ApprovalDetails["copy"]>;
35
+ /** Aborts when the plugin withdraws the call; see `HoldDescription`. */
36
+ supersede?: AbortSignal;
37
+ }
38
+ | { released: Release };
39
+
40
+ /** A speaker as an approver; undefined without one. */
41
+ export function approverOf(
42
+ speaker: SpeakerRef | undefined,
43
+ ): ApprovedBy | undefined {
44
+ if (!speaker) return undefined;
45
+ return {
46
+ principalId: speaker.principalId,
47
+ speakerId: speaker.id,
48
+ name: speaker.name ?? speaker.id,
49
+ ...(speaker.tier === "owner" ? { owner: true as const } : {}),
50
+ };
51
+ }
52
+
53
+ /** What a turn waiting on a call its plugin withdrew is told: the call did not run and no approval will run it. */
54
+ export function withdrawn(call: HeldCall): {
55
+ reason: string;
56
+ refused: "withdrawn";
57
+ } {
58
+ return {
59
+ reason: `This action was withdrawn before it was approved: it would ${call.action}. It did not run, and an approval of it will not run it. Do not retry it; go on without it, or tell the owner it is no longer needed.`,
60
+ refused: "withdrawn",
61
+ };
62
+ }
63
+
64
+ /** What a turn is told when its card is left open and the call did not run. */
65
+ export function awaitingText(addressee: OwnerIdentity, call: HeldCall): string {
66
+ const o = ownerWords(addressee);
67
+ return `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`;
68
+ }
69
+
70
+ /** What a turn is told when its call was declined on its card. */
71
+ export function declinedText(addressee: OwnerIdentity, call: HeldCall): string {
72
+ const o = ownerWords(addressee);
73
+ 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.`;
74
+ }
32
75
 
33
76
  /** The text for a held call that the speaker's own message confirms. */
34
77
  export function askSpeaker(addressee: OwnerIdentity, call: HeldCall): string {
@@ -0,0 +1,30 @@
1
+ import type { ApprovedBy } from "../../interactions/prompts.ts";
2
+
3
+ /** What a tool's `run` is told about the approval that released its call. */
4
+ export interface Grant {
5
+ /** Who approved it, when a surface or a confirming message said who. */
6
+ by?: ApprovedBy;
7
+ /** The id of the card that approved it; absent when no card did. */
8
+ prompt?: string;
9
+ }
10
+
11
+ /** The approvals of the calls that are running, by tool call id, until each result is back. */
12
+ export class Grants {
13
+ readonly #grants = new Map<string, Grant>();
14
+
15
+ get(toolCallId: string): Grant | undefined {
16
+ return this.#grants.get(toolCallId);
17
+ }
18
+
19
+ set(toolCallId: string, grant: Grant): void {
20
+ this.#grants.set(toolCallId, grant);
21
+ }
22
+
23
+ delete(toolCallId: string): void {
24
+ this.#grants.delete(toolCallId);
25
+ }
26
+
27
+ clear(): void {
28
+ this.#grants.clear();
29
+ }
30
+ }
@@ -20,6 +20,14 @@ export function unexpired(
20
20
  : pending;
21
21
  }
22
22
 
23
+ /** Whether two held calls are the same call: the same tool with the same canonical input. */
24
+ export function sameCall(
25
+ a: Pick<HeldCall, "tool" | "input">,
26
+ b: Pick<HeldCall, "tool" | "input">,
27
+ ): boolean {
28
+ return a.tool === b.tool && a.input === b.input;
29
+ }
30
+
23
31
  /** JSON with object keys sorted at every level, so equal inputs compare equal. */
24
32
  // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
25
33
  export function canonicalJson(value: unknown): string {
@@ -0,0 +1,99 @@
1
+ import { type HoldCheck, type HoldContext, isPromise } from "../../holds.ts";
2
+ import { messages } from "../../i18n/index.ts";
3
+ import {
4
+ awaited,
5
+ type Described,
6
+ type HoldTurn,
7
+ type Judged,
8
+ joinApprovers,
9
+ } from "./hold-lookups.ts";
10
+
11
+ /**
12
+ * Asks a conversation's holds to describe a call a turn makes, and hands each description on in
13
+ * the order the calls were made, whichever comes back first, so calls made together are held,
14
+ * and their cards opened, in that order.
15
+ */
16
+ export class HoldDescriber {
17
+ readonly #holds: HoldCheck;
18
+ readonly #context: HoldContext;
19
+ readonly #timeoutMs: number;
20
+ /** Settles when the descriptions asked for so far have all been used, in the order they were asked. */
21
+ #describing: Promise<void> = Promise.resolve();
22
+ /** Descriptions asked for and not used yet. */
23
+ #undescribed = 0;
24
+
25
+ /** A hold that looks things up gets `timeoutMs` to answer before its call is held under a generic description. */
26
+ constructor(holds: HoldCheck, context: HoldContext, timeoutMs: number) {
27
+ this.#holds = holds;
28
+ this.#context = context;
29
+ this.#timeoutMs = timeoutMs;
30
+ }
31
+
32
+ /**
33
+ * The call's description with the approvers the holds name, handed to `use` when every
34
+ * description asked for before it has been used; at once when none is outstanding and the
35
+ * holds answered at once. The holds must have an `inTurn` check.
36
+ */
37
+ describe<R>(
38
+ tool: string,
39
+ input: Record<string, unknown>,
40
+ turn: HoldTurn,
41
+ use: (judged: Judged) => R,
42
+ ): R | Promise<R> {
43
+ const controller = new AbortController();
44
+ if (turn.signal?.aborted) controller.abort();
45
+ let answer: ReturnType<NonNullable<HoldCheck["inTurn"]>>;
46
+ try {
47
+ answer = this.#holds.inTurn?.call(
48
+ this.#holds,
49
+ tool,
50
+ input,
51
+ this.#context,
52
+ turn.toolTurn(controller.signal),
53
+ );
54
+ } catch {
55
+ answer = messages().holdGeneric(tool);
56
+ }
57
+ const found: Described | Promise<Described> = isPromise(answer)
58
+ ? awaited(
59
+ answer,
60
+ messages().holdGeneric(tool),
61
+ controller,
62
+ turn.signal,
63
+ this.#timeoutMs,
64
+ )
65
+ : answer;
66
+ return this.#inOrder(
67
+ joinApprovers(
68
+ found,
69
+ {
70
+ holds: this.#holds,
71
+ context: this.#context,
72
+ timeoutMs: this.#timeoutMs,
73
+ },
74
+ tool,
75
+ input,
76
+ turn,
77
+ ),
78
+ use,
79
+ );
80
+ }
81
+
82
+ #inOrder<T, R>(
83
+ described: T | Promise<T>,
84
+ use: (described: T) => R,
85
+ ): R | Promise<R> {
86
+ if (this.#undescribed === 0 && !isPromise(described)) return use(described);
87
+ this.#undescribed++;
88
+ const used = this.#describing.then(() => described).then(use);
89
+ this.#describing = used.then(
90
+ () => {
91
+ this.#undescribed--;
92
+ },
93
+ () => {
94
+ this.#undescribed--;
95
+ },
96
+ );
97
+ return used;
98
+ }
99
+ }