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