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.
- package/CHANGELOG.md +16 -0
- package/docs/plugins.md +46 -11
- package/examples/fake-surface.ts +9 -5
- package/package.json +4 -4
- 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
|
@@ -11,38 +11,39 @@ import {
|
|
|
11
11
|
isPromise,
|
|
12
12
|
} from "../../holds.ts";
|
|
13
13
|
import { messages } from "../../i18n/index.ts";
|
|
14
|
-
import {
|
|
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
|
-
|
|
40
|
-
|
|
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 #
|
|
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
|
-
/**
|
|
94
|
-
#
|
|
95
|
-
/**
|
|
96
|
-
#
|
|
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.#
|
|
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
|
|
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.#
|
|
195
|
+
this.#grants.clear();
|
|
196
|
+
this.#sweep();
|
|
194
197
|
}
|
|
195
198
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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.#
|
|
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,
|
|
215
|
-
this.#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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.#
|
|
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
|
-
|
|
301
|
-
return {
|
|
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:
|
|
361
|
+
reason: awaitingText(this.#addressee, call),
|
|
307
362
|
refused: "pending",
|
|
308
363
|
};
|
|
309
|
-
}
|
|
310
364
|
if (answer === "declined") {
|
|
311
|
-
|
|
365
|
+
watch?.end();
|
|
312
366
|
return {
|
|
313
|
-
reason:
|
|
367
|
+
reason: declinedText(this.#addressee, call),
|
|
314
368
|
refused: "declined",
|
|
315
369
|
};
|
|
316
370
|
}
|
|
317
|
-
if (answer === "cancelled")
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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)
|
|
440
|
+
if (spent) {
|
|
441
|
+
this.#withdrawals.endCall(spent.call);
|
|
442
|
+
return { released: spent };
|
|
443
|
+
}
|
|
451
444
|
}
|
|
452
|
-
return {
|
|
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
|
-
| {
|
|
31
|
-
|
|
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
|
+
}
|