pi-roundtable 0.14.0 → 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.
- package/CHANGELOG.md +22 -0
- package/docs/plugins.md +46 -11
- package/examples/fake-surface.ts +9 -5
- package/package.json +4 -4
- package/src/core/define-roundtable.ts +6 -3
- package/src/core/define.ts +15 -0
- package/src/core/discord/owner-card-rows.ts +44 -1
- package/src/core/discord/owner-card-types.ts +3 -2
- package/src/core/discord/owner-cards.ts +57 -58
- package/src/core/domain/client.ts +7 -0
- package/src/core/domain/progress.ts +8 -2
- package/src/core/holds.ts +18 -2
- package/src/core/i18n/cards.ts +4 -2
- package/src/core/interactions/prompts.ts +43 -1
- package/src/core/runtime/conversation-sessions.ts +16 -0
- package/src/core/runtime/extensions/approval-card.ts +24 -36
- package/src/core/runtime/extensions/approval-fields.ts +212 -0
- package/src/core/runtime/extensions/card-ask.ts +74 -0
- package/src/core/runtime/extensions/card-text.ts +27 -0
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +18 -7
- package/src/core/runtime/extensions/confirmation-gate.ts +159 -162
- package/src/core/runtime/extensions/gate-refusals.ts +45 -2
- package/src/core/runtime/extensions/grants.ts +30 -0
- package/src/core/runtime/extensions/held-calls.ts +8 -0
- package/src/core/runtime/extensions/hold-describer.ts +99 -0
- package/src/core/runtime/extensions/hold-lookups.ts +24 -7
- package/src/core/runtime/extensions/late-answer.ts +36 -0
- package/src/core/runtime/extensions/withdrawals.ts +95 -0
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/sessions.ts +6 -0
- package/src/core/testing/surface-contract.ts +71 -1
- package/src/core/tool-turn.ts +3 -0
- 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
|
|
39
|
-
* nor a text with a copy still holds the call, under the generic
|
|
40
|
-
* a non-empty text
|
|
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
|
-
):
|
|
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),
|
package/src/core/sessions.ts
CHANGED
|
@@ -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,
|
|
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
|
package/src/core/tool-turn.ts
CHANGED
|
@@ -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";
|