pi-roundtable 0.14.1 → 0.15.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 (33) hide show
  1. package/CHANGELOG.md +22 -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/agent-discord.ts +26 -6
  7. package/src/core/discord/owner-card-rows.ts +44 -1
  8. package/src/core/discord/owner-card-types.ts +3 -2
  9. package/src/core/discord/owner-cards.ts +57 -58
  10. package/src/core/domain/client.ts +7 -0
  11. package/src/core/domain/progress.ts +8 -2
  12. package/src/core/holds.ts +18 -2
  13. package/src/core/i18n/cards.ts +4 -2
  14. package/src/core/interactions/prompts.ts +43 -1
  15. package/src/core/runtime/conversation-sessions.ts +16 -0
  16. package/src/core/runtime/extensions/approval-card.ts +24 -36
  17. package/src/core/runtime/extensions/approval-fields.ts +212 -0
  18. package/src/core/runtime/extensions/card-ask.ts +74 -0
  19. package/src/core/runtime/extensions/card-text.ts +27 -0
  20. package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
  21. package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
  22. package/src/core/runtime/extensions/gate-refusals.ts +45 -2
  23. package/src/core/runtime/extensions/grants.ts +30 -0
  24. package/src/core/runtime/extensions/held-calls.ts +8 -0
  25. package/src/core/runtime/extensions/hold-describer.ts +99 -0
  26. package/src/core/runtime/extensions/hold-lookups.ts +24 -7
  27. package/src/core/runtime/extensions/late-answer.ts +36 -0
  28. package/src/core/runtime/extensions/withdrawals.ts +95 -0
  29. package/src/core/runtime/session-factory.ts +1 -0
  30. package/src/core/sessions.ts +6 -0
  31. package/src/core/testing/surface-contract.ts +71 -1
  32. package/src/core/tool-turn.ts +3 -0
  33. package/src/index.ts +1 -1
@@ -29,37 +29,52 @@ export type Described = HoldDescription | undefined | typeof STOPPED;
29
29
  export interface Judgment {
30
30
  action: string;
31
31
  copy?: NonNullable<ApprovalDetails["copy"]>;
32
+ /** Aborts when the plugin withdraws the call; see `HoldDescription`. */
33
+ supersede?: AbortSignal;
32
34
  approvers?: readonly string[];
33
35
  }
34
36
 
35
37
  export type Judged = Judgment | undefined | typeof STOPPED;
36
38
 
39
+ /** Whether `value` is an abort signal, however it was made; anything else a hold answers is ignored. */
40
+ function isSignal(value: unknown): value is AbortSignal {
41
+ return (
42
+ typeof value === "object" &&
43
+ value !== null &&
44
+ typeof (value as AbortSignal).aborted === "boolean" &&
45
+ typeof (value as AbortSignal).addEventListener === "function"
46
+ );
47
+ }
48
+
37
49
  /**
38
- * A hold's description as the action and the copy it carries. An answer that is neither a text
39
- * nor a text with a copy still holds the call, under the generic description; a copy that is not
40
- * a non-empty text is left out, so a hold that gets it wrong never lets the call run.
50
+ * A hold's description as the action, the copy and the `supersede` signal it carries. An answer
51
+ * that is neither a text nor a text with a copy still holds the call, under the generic
52
+ * description; a copy that is not a non-empty text and a `supersede` that is not a signal are
53
+ * left out, so a hold that gets them wrong never lets the call run.
41
54
  */
42
55
  export function described(
43
56
  answer: HoldDescription,
44
57
  tool: string,
45
- ): { action: string; copy?: NonNullable<ApprovalDetails["copy"]> } {
58
+ ): Pick<Judgment, "action" | "copy" | "supersede"> {
46
59
  if (typeof answer === "string") return { action: answer };
47
60
  if (typeof answer !== "object" || answer === null)
48
61
  return { action: messages().holdGeneric(tool) };
49
- const { text, copy } = answer;
62
+ const { text, copy, supersede } = answer;
50
63
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
64
+ const withdrawal = isSignal(supersede) ? { supersede } : {};
51
65
  if (
52
66
  !copy ||
53
67
  typeof copy !== "object" ||
54
68
  typeof copy.text !== "string" ||
55
69
  copy.text === ""
56
70
  )
57
- return { action: text };
71
+ return { action: text, ...withdrawal };
58
72
  const label = typeof copy.label === "string" ? copy.label : undefined;
59
73
  return {
60
74
  action: text,
61
75
  copy:
62
76
  label === undefined ? { text: copy.text } : { label, text: copy.text },
77
+ ...withdrawal,
63
78
  };
64
79
  }
65
80
 
@@ -110,7 +125,7 @@ export function joinApprovers(
110
125
  if (!lookup) return withoutApprovers(answer, tool);
111
126
  const join = (action: Described): Judged | Promise<Judged> => {
112
127
  if (action === STOPPED || action === undefined) return action;
113
- const { action: text, copy } = described(action, tool);
128
+ const { action: text, copy, supersede } = described(action, tool);
114
129
  const controller = new AbortController();
115
130
  if (turn.signal?.aborted) controller.abort();
116
131
  let named:
@@ -133,6 +148,7 @@ export function joinApprovers(
133
148
  return {
134
149
  action: text,
135
150
  ...(copy ? { copy } : {}),
151
+ ...(supersede ? { supersede } : {}),
136
152
  ...(named ? { approvers: named } : {}),
137
153
  };
138
154
  }
@@ -142,6 +158,7 @@ export function joinApprovers(
142
158
  return {
143
159
  action: text,
144
160
  ...(copy ? { copy } : {}),
161
+ ...(supersede ? { supersede } : {}),
145
162
  ...(approvers ? { approvers } : {}),
146
163
  };
147
164
  },
@@ -0,0 +1,36 @@
1
+ import type { HeldCall } from "../../domain/conversation.ts";
2
+ import type { LateAnswer } from "../../interactions/prompts.ts";
3
+ import type { Release } from "./gate-refusals.ts";
4
+ import { namedApprover } from "./held-calls.ts";
5
+ import type { Watch } from "./withdrawals.ts";
6
+
7
+ /**
8
+ * What a card answered after its turn stopped waiting comes to: the text of the turn that tells
9
+ * the model, and, for an approval that stands, the release that lets the identical call run once
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.
12
+ */
13
+ export function lateOutcome(
14
+ call: HeldCall,
15
+ late: LateAnswer,
16
+ watch: Watch | undefined,
17
+ ): { text: string; release?: Release } | undefined {
18
+ // A card the plugin withdrew answers nothing, whatever a surface that kept it open reports.
19
+ if (watch?.withdrawn || late.kind !== "approval") return undefined;
20
+ if (watch) watch.card = false;
21
+ if (late.approved && !namedApprover(call, late.approvedBy)) return undefined;
22
+ if (!late.approved) {
23
+ watch?.end();
24
+ return {
25
+ 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
+ };
27
+ }
28
+ return {
29
+ text: `(${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}`,
30
+ release: {
31
+ call,
32
+ ...(late.approvedBy ? { by: late.approvedBy } : {}),
33
+ ...(late.prompt ? { prompt: late.prompt } : {}),
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,95 @@
1
+ import type { HeldCall } from "../../domain/conversation.ts";
2
+
3
+ /**
4
+ * One held call whose plugin may withdraw it (`HoldDescription.supersede`), for as long as the
5
+ * call can still run: while its card is open or answered late, while it waits for a message that
6
+ * confirms it, and while an approval waits for the call to be made.
7
+ */
8
+ export interface Watch {
9
+ readonly call: HeldCall;
10
+ /** The plugin's signal, which the card of the call is given to close by. */
11
+ readonly signal: AbortSignal;
12
+ /** Whether the plugin withdrew the call; the call was forgotten when this turned true. */
13
+ readonly withdrawn: boolean;
14
+ /** Settles once the plugin withdraws the call, never otherwise. */
15
+ readonly whenWithdrawn: Promise<"withdrawn">;
16
+ /** Whether a card of the call can still be answered; a watch without one lasts as long as the call is kept. */
17
+ card: boolean;
18
+ /** Stops watching: the call ran, was refused, or is no longer kept. Does nothing after a withdrawal. */
19
+ end(): void;
20
+ }
21
+
22
+ /**
23
+ * The calls a conversation's gate holds that their plugins may withdraw. Withdrawing is the one
24
+ * transition a call makes from "may run" to "may not", taken by whichever of the plugin's abort
25
+ * and the call's release (an approval spending it) comes first; a watch that ended or withdrew
26
+ * is gone from here, so nothing is kept for a call that can no longer be withdrawn.
27
+ */
28
+ export class Withdrawals {
29
+ readonly #watches = new Set<Watch>();
30
+ readonly #forget: (call: HeldCall) => void;
31
+
32
+ /** `forget` drops a withdrawn call from everything that could still run it. */
33
+ constructor(forget: (call: HeldCall) => void) {
34
+ this.#forget = forget;
35
+ }
36
+
37
+ /** How many calls are watched now. */
38
+ get size(): number {
39
+ return this.#watches.size;
40
+ }
41
+
42
+ /**
43
+ * Watches `call` for `signal`. A signal that is already aborted withdraws it at once: the
44
+ * returned watch is `withdrawn`, and the call is forgotten.
45
+ */
46
+ watch(call: HeldCall, signal: AbortSignal): Watch {
47
+ let withdrawn = false;
48
+ let resolve: (value: "withdrawn") => void = () => {};
49
+ const whenWithdrawn = new Promise<"withdrawn">((done) => {
50
+ resolve = done;
51
+ });
52
+ const watches = this.#watches;
53
+ const forget = this.#forget;
54
+ const withdraw = () => {
55
+ if (withdrawn || !watches.delete(watch)) return;
56
+ withdrawn = true;
57
+ forget(call);
58
+ resolve("withdrawn");
59
+ };
60
+ const watch: Watch = {
61
+ call,
62
+ signal,
63
+ get withdrawn() {
64
+ return withdrawn;
65
+ },
66
+ whenWithdrawn,
67
+ card: true,
68
+ end() {
69
+ if (!watches.delete(watch)) return;
70
+ signal.removeEventListener("abort", withdraw);
71
+ },
72
+ };
73
+ watches.add(watch);
74
+ if (signal.aborted) withdraw();
75
+ else signal.addEventListener("abort", withdraw, { once: true });
76
+ return watch;
77
+ }
78
+
79
+ /** Ends the watches of `call`, which was released or refused: the same object, as the gate holds it. */
80
+ endCall(call: HeldCall): void {
81
+ for (const watch of [...this.#watches])
82
+ if (watch.call === call) watch.end();
83
+ }
84
+
85
+ /** Ends every watch, for a gate that is dropped. */
86
+ clear(): void {
87
+ for (const watch of [...this.#watches]) watch.end();
88
+ }
89
+
90
+ /** Ends the watches whose call is no longer kept (`kept`) and whose card is closed. */
91
+ sweep(kept: (call: HeldCall) => boolean): void {
92
+ for (const watch of [...this.#watches])
93
+ if (!watch.card && !kept(watch.call)) watch.end();
94
+ }
95
+ }
@@ -371,6 +371,7 @@ export class SessionFactory {
371
371
  speaker: () => this.#deps.speaker(turnKey),
372
372
  origin: () => this.#deps.origin(turnKey),
373
373
  approvedBy: (toolCallId) => gate.approvedBy(toolCallId),
374
+ approvedPrompt: (toolCallId) => gate.approvedPrompt(toolCallId),
374
375
  client: () => this.#deps.client(turnKey),
375
376
  runTask: (task) =>
376
377
  this.#deps.runTask({ turn: turnKey, home: channel }, task),
@@ -106,6 +106,12 @@ export interface SessionContext {
106
106
  * this method has none, which reads as undefined.
107
107
  */
108
108
  approvedBy?(toolCallId: string): ApprovedBy | undefined;
109
+ /**
110
+ * The id of the card whose approval released the held call whose tool call has this id, while it
111
+ * runs; undefined for a call no card approved. A context built before this method has none,
112
+ * which reads as undefined.
113
+ */
114
+ approvedPrompt?(toolCallId: string): string | undefined;
109
115
  /**
110
116
  * What the client that started the running turn can do for its tools, as `TurnRequest.client`
111
117
  * says; undefined between turns and for a turn whose transport gives none. A context built
@@ -264,6 +264,75 @@ const CHECKS: readonly Check[] = [
264
264
  );
265
265
  },
266
266
  },
267
+ {
268
+ name: "an approval its plugin withdraws resolves cancelled and closes; one answered first stays answered",
269
+ applies: (subject) =>
270
+ subject.surface.prompts !== undefined && subject.answer !== undefined,
271
+ run: async (running) => {
272
+ const { surface, channel, answer } = running.subject;
273
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
274
+ if (!prompts || !answer)
275
+ throw new ContractBroken("the surface shows no prompts");
276
+ const ask = (supersede: AbortSignal) =>
277
+ prompts.confirm(
278
+ "Approve?",
279
+ "Send it.",
280
+ undefined,
281
+ speakerOf(running.subject).tier,
282
+ undefined,
283
+ undefined,
284
+ { supersede },
285
+ );
286
+ const closes = (id: string) =>
287
+ seen(running, "prompt_closed").filter(
288
+ (o) => o.kind === "prompt_closed" && o.id === id,
289
+ ).length;
290
+ const withdrawn = new AbortController();
291
+ let before = seen(running, "prompt").length;
292
+ const first = ask(withdrawn.signal);
293
+ const firstId = await nextPrompt(running, before);
294
+ withdrawn.abort("replaced by a newer request");
295
+ const result = await Promise.race([
296
+ first,
297
+ Bun.sleep(1000).then(() => "still open" as const),
298
+ ]);
299
+ expectThat(
300
+ result === "cancelled",
301
+ `a withdrawn approval answered ${result}`,
302
+ );
303
+ await until(() => closes(firstId) > 0, "the withdrawn approval closes");
304
+ // An answer to the closed approval changes nothing, as for any ended prompt.
305
+ await answer(firstId, true).catch(() => undefined);
306
+ expectThat(closes(firstId) === 1, "the withdrawn approval closed twice");
307
+ // An approval answered before the withdrawal stays answered.
308
+ const later = new AbortController();
309
+ before = seen(running, "prompt").length;
310
+ const second = ask(later.signal);
311
+ const secondId = await nextPrompt(running, before);
312
+ await answer(secondId, true);
313
+ later.abort();
314
+ const approved: Approval = await second;
315
+ expectThat(
316
+ approved === "approved",
317
+ `an answered approval came back ${approved}`,
318
+ );
319
+ await Bun.sleep(20);
320
+ expectThat(closes(secondId) === 1, "the answered approval closed twice");
321
+ // A signal that is already aborted shows nothing.
322
+ const gone = new AbortController();
323
+ gone.abort();
324
+ before = seen(running, "prompt").length;
325
+ const none: Approval = await ask(gone.signal);
326
+ expectThat(
327
+ none === "cancelled",
328
+ `an approval withdrawn before it opened came back ${none}`,
329
+ );
330
+ expectThat(
331
+ seen(running, "prompt").length === before,
332
+ "a withdrawn approval was shown",
333
+ );
334
+ },
335
+ },
267
336
  {
268
337
  name: "another person cannot answer the person's approval",
269
338
  applies: (subject) =>
@@ -374,7 +443,8 @@ export async function checkSurfaceContract(
374
443
  * Registers the chat surface contract as one test per check, under `name`: the surface names a
375
444
  * prefix and owns its channel, delivers what its person writes, shows every chunk of a reply, and,
376
445
  * where it offers them, delivers files, ends typing and stop controls idempotently, shows
377
- * progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
446
+ * progress, resolves approvals as the person answers or as a stopped turn cancels them, closes
447
+ * one its plugin withdraws (`ApprovalOptions.supersede`) as cancelled, ignores
378
448
  * another person's answer to the person's approval, and, in a private conversation, an owner's
379
449
  * too, and lets an approval above the person's tier there come back at once, shown to no one.
380
450
  * Where it can name approvers, an approval that does is answered by them, reported as the
@@ -41,6 +41,8 @@ export function toolTurn(
41
41
  const origin = context.origin?.();
42
42
  const approvedBy =
43
43
  toolCallId === undefined ? undefined : context.approvedBy?.(toolCallId);
44
+ const approvedPrompt =
45
+ toolCallId === undefined ? undefined : context.approvedPrompt?.(toolCallId);
44
46
  const client = context.client?.();
45
47
  return {
46
48
  speaker: context.speaker(),
@@ -49,6 +51,7 @@ export function toolTurn(
49
51
  signal,
50
52
  ...(origin === undefined ? {} : { origin }),
51
53
  ...(approvedBy === undefined ? {} : { approvedBy }),
54
+ ...(approvedPrompt === undefined ? {} : { approvedPrompt }),
52
55
  ...(client === undefined ? {} : { client: clientIn(client, signal) }),
53
56
  ...(context.workspace ? { workspace: context.workspace } : {}),
54
57
  attachFile: attachReplyFile,
package/src/index.ts CHANGED
@@ -202,7 +202,7 @@ export type {
202
202
  Prompts,
203
203
  PromptWait,
204
204
  } from "./core/interactions/prompts.ts";
205
- export { promptScope } from "./core/interactions/prompts.ts";
205
+ export { promptScope, withdrawalNote } from "./core/interactions/prompts.ts";
206
206
  export type { JudgeModel } from "./core/judging/model-judge.ts";
207
207
  export type { LogEntry, LogFn, Logger } from "./core/log.ts";
208
208
  export type { ThinkingLevel, ThinkingSetting } from "./core/models.ts";