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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Pronouns } from "../config/config.ts";
|
|
2
|
+
import type { Tier } from "../speakers.ts";
|
|
3
|
+
|
|
4
|
+
export type { Pronouns };
|
|
5
|
+
|
|
6
|
+
/** A person the host serves, whichever surface they reach it through. */
|
|
7
|
+
export interface Principal {
|
|
8
|
+
/** Opaque: a speaker id carried over from 0.8, or `p_<ulid>` for one made since. */
|
|
9
|
+
id: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
pronouns?: Pronouns;
|
|
12
|
+
disabled: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A principal as stored, with when it was made and when it was last seen. */
|
|
16
|
+
export interface PrincipalRecord extends Principal {
|
|
17
|
+
locale?: string;
|
|
18
|
+
timeZone?: string;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
lastSeenAt?: Date;
|
|
21
|
+
/** The tier it was last seen at; it only ever lowers what a background turn may do, never grants. */
|
|
22
|
+
lastTier?: Tier;
|
|
23
|
+
/** Whether the person of its 0.8 speaker id may still claim it: only one the backfill made, until any identity is linked to it. */
|
|
24
|
+
claimable: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** An identity on one provider: `discord` and a user id, `oidc:<issuer>` and a subject, `token` and a name. */
|
|
28
|
+
export interface IdentityRef {
|
|
29
|
+
provider: string;
|
|
30
|
+
subject: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How an identity came to be linked: by the configuration, a plugin declaring it
|
|
35
|
+
* (`RoundtablePlugin.identities`), the CLI, admission on first contact, a 0.8 speaker id claimed,
|
|
36
|
+
* or the person themself through `IDENTITY_LINKS` (`self`).
|
|
37
|
+
*/
|
|
38
|
+
export type LinkSource =
|
|
39
|
+
| "config"
|
|
40
|
+
| "plugin"
|
|
41
|
+
| "cli"
|
|
42
|
+
| "jit"
|
|
43
|
+
| "legacy"
|
|
44
|
+
| "self";
|
|
45
|
+
|
|
46
|
+
export interface IdentityLink extends IdentityRef {
|
|
47
|
+
principalId: string;
|
|
48
|
+
source: LinkSource;
|
|
49
|
+
linkedAt: Date;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where a role came from. The configuration's are synced at every boot; a CLI grant stays until the CLI revokes it. */
|
|
53
|
+
export type RoleSource = "config" | "cli";
|
|
54
|
+
|
|
55
|
+
export interface RoleGrant {
|
|
56
|
+
role: Tier;
|
|
57
|
+
source: RoleSource;
|
|
58
|
+
grantedAt: Date;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A principal holding a role, and where the role came from. */
|
|
62
|
+
export interface RoleHolder {
|
|
63
|
+
principalId: string;
|
|
64
|
+
source: RoleSource;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface NewPrincipal {
|
|
68
|
+
/** The id to keep, for a principal carried over from 0.8; otherwise a new `p_` id is made. */
|
|
69
|
+
id?: string;
|
|
70
|
+
displayName: string;
|
|
71
|
+
pronouns?: Pronouns;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The principals, their linked identities, and their lasting roles. */
|
|
75
|
+
export interface PrincipalStore {
|
|
76
|
+
get(id: string): Promise<PrincipalRecord | undefined>;
|
|
77
|
+
list(): Promise<PrincipalRecord[]>;
|
|
78
|
+
/** Refuses an id already taken, and the system principal's. */
|
|
79
|
+
create(input: NewPrincipal): Promise<PrincipalRecord>;
|
|
80
|
+
/** Changes the name or the pronouns; `pronouns: null` clears them. */
|
|
81
|
+
update(
|
|
82
|
+
id: string,
|
|
83
|
+
change: { displayName?: string; pronouns?: Pronouns | null },
|
|
84
|
+
): Promise<PrincipalRecord | undefined>;
|
|
85
|
+
/**
|
|
86
|
+
* Links the identity to the principal; linking it again to the same one changes nothing, but
|
|
87
|
+
* that the configuration linking it makes the link the configuration's, and a plugin linking it
|
|
88
|
+
* makes one the CLI, a first contact, or a claim made the plugin's; to another one is refused. A
|
|
89
|
+
* principal with any identity linked is no longer claimable, even after the identity is
|
|
90
|
+
* unlinked, except by a plugin's link: a plugin's credential is not the person's own identity,
|
|
91
|
+
* so it leaves their 0.8 claim to them.
|
|
92
|
+
*/
|
|
93
|
+
link(
|
|
94
|
+
principalId: string,
|
|
95
|
+
identity: IdentityRef,
|
|
96
|
+
source: LinkSource,
|
|
97
|
+
): Promise<IdentityLink>;
|
|
98
|
+
/**
|
|
99
|
+
* The identity's link, claiming the principal for it when the identity is still unlinked and
|
|
100
|
+
* the principal claimable and not an owner; undefined when it may not be claimed. A principal
|
|
101
|
+
* is claimed at most once. A claim names the principal `displayName`, when given, while its
|
|
102
|
+
* name is still its id, as the backfill names someone it knows no name of; a real name stays.
|
|
103
|
+
*/
|
|
104
|
+
claim(
|
|
105
|
+
principalId: string,
|
|
106
|
+
identity: IdentityRef,
|
|
107
|
+
displayName?: string,
|
|
108
|
+
): Promise<IdentityLink | undefined>;
|
|
109
|
+
/**
|
|
110
|
+
* The identity's link, making a new `p_` principal for it, of `id` when given, when it is still
|
|
111
|
+
* unlinked; however many contacts admit it at once, one principal is made.
|
|
112
|
+
*/
|
|
113
|
+
admit(
|
|
114
|
+
identity: IdentityRef,
|
|
115
|
+
displayName: string,
|
|
116
|
+
id?: string,
|
|
117
|
+
): Promise<IdentityLink>;
|
|
118
|
+
/** Whether there was a link to remove; with `source`, only a link made by it is removed. */
|
|
119
|
+
unlink(
|
|
120
|
+
provider: string,
|
|
121
|
+
subject: string,
|
|
122
|
+
source?: LinkSource,
|
|
123
|
+
): Promise<boolean>;
|
|
124
|
+
identity(
|
|
125
|
+
provider: string,
|
|
126
|
+
subject: string,
|
|
127
|
+
): Promise<IdentityLink | undefined>;
|
|
128
|
+
identitiesOf(principalId: string): Promise<IdentityLink[]>;
|
|
129
|
+
/** Every link made by `source`, such as the configuration's. */
|
|
130
|
+
linksFrom(source: LinkSource): Promise<IdentityLink[]>;
|
|
131
|
+
/** Grants a lasting role. A CLI grant is kept as the CLI's even when the configuration grants the same role. */
|
|
132
|
+
grant(principalId: string, role: Tier, source: RoleSource): Promise<void>;
|
|
133
|
+
/** Revokes the role, or only the grant from `source`; whether there was one to revoke. */
|
|
134
|
+
revoke(
|
|
135
|
+
principalId: string,
|
|
136
|
+
role: Tier,
|
|
137
|
+
source?: RoleSource,
|
|
138
|
+
): Promise<boolean>;
|
|
139
|
+
rolesOf(principalId: string): Promise<RoleGrant[]>;
|
|
140
|
+
/** Every principal holding the role. */
|
|
141
|
+
holders(role: Tier): Promise<RoleHolder[]>;
|
|
142
|
+
disable(id: string): Promise<void>;
|
|
143
|
+
enable(id: string): Promise<void>;
|
|
144
|
+
/** Records that the principal was seen at `at` (default now), at this tier, or null when refused. */
|
|
145
|
+
touch(id: string, tier: Tier | null, at?: Date): Promise<void>;
|
|
146
|
+
}
|
|
@@ -37,9 +37,51 @@ export interface ApprovalDetails {
|
|
|
37
37
|
files?: ApprovalFile[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Who approved a held call: the principal, the identity they answered with as `Speaker.id`, and
|
|
42
|
+
* the name that identity goes by. A surface reports it when it knows it; a held call's `run`
|
|
43
|
+
* reads it as `ToolTurn.approvedBy`.
|
|
44
|
+
*/
|
|
45
|
+
export interface ApprovedBy {
|
|
46
|
+
principalId: string;
|
|
47
|
+
speakerId: string;
|
|
48
|
+
name: string;
|
|
49
|
+
/**
|
|
50
|
+
* True when they approved as a core owner. A surface sets it when the approver is one; a call
|
|
51
|
+
* that named approvers is released only by an approver it names or by an `owner`, so an
|
|
52
|
+
* approval without it, from anyone else, is not accepted.
|
|
53
|
+
*/
|
|
54
|
+
owner?: true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a held call's approval is asked with besides its text: who may approve it, and where to
|
|
59
|
+
* hear who did.
|
|
60
|
+
*/
|
|
61
|
+
export interface ApprovalOptions {
|
|
62
|
+
/**
|
|
63
|
+
* The principal ids that may approve this call instead of the scope's speaker. Given in a
|
|
64
|
+
* conversation that escalates to the owners, the speaker may not approve it unless named, and
|
|
65
|
+
* each named principal needs the call's tier when they answer; the owners may still approve it.
|
|
66
|
+
* An empty list leaves the owners alone. In a private conversation, whose person is the only one
|
|
67
|
+
* who answers, the person may approve it only when named or an owner; otherwise it comes back
|
|
68
|
+
* `unavailable` at once. A surface must report who approved through `approvedBy`: in a shared
|
|
69
|
+
* conversation an approval it does not name is not accepted for a call that names approvers.
|
|
70
|
+
*/
|
|
71
|
+
approvers?: readonly string[];
|
|
72
|
+
/**
|
|
73
|
+
* Called with who approved the call, before the approval resolves `approved`; not called for a
|
|
74
|
+
* refusal, or by a surface that does not know who answered.
|
|
75
|
+
*/
|
|
76
|
+
approvedBy?(by: ApprovedBy): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* An answer given on a prompt after its turn stopped waiting; `by` names who gave it. An approval
|
|
81
|
+
* names the approver too, where the surface knows them.
|
|
82
|
+
*/
|
|
41
83
|
export type LateAnswer = { by: string } & (
|
|
42
|
-
| { kind: "approval"; approved: boolean }
|
|
84
|
+
| { kind: "approval"; approved: boolean; approvedBy?: ApprovedBy }
|
|
43
85
|
| { kind: "question"; answer: OwnerAnswer }
|
|
44
86
|
);
|
|
45
87
|
|
|
@@ -119,7 +161,8 @@ export interface Prompts {
|
|
|
119
161
|
* tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
|
|
120
162
|
* prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
|
|
121
163
|
* `pending` comes only with a `wait`. `details` is the approval as data beside `message`, its
|
|
122
|
-
* text; a surface that shows text only ignores it.
|
|
164
|
+
* text; a surface that shows text only ignores it. `options` says who else may approve it and
|
|
165
|
+
* hears who did; see `ApprovalOptions`.
|
|
123
166
|
*/
|
|
124
167
|
confirm(
|
|
125
168
|
title: string,
|
|
@@ -128,6 +171,7 @@ export interface Prompts {
|
|
|
128
171
|
minTier?: Tier,
|
|
129
172
|
wait?: PromptWait,
|
|
130
173
|
details?: ApprovalDetails,
|
|
174
|
+
options?: ApprovalOptions,
|
|
131
175
|
): Promise<Approval>;
|
|
132
176
|
/**
|
|
133
177
|
* Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
|
|
@@ -292,30 +292,61 @@ export function precheckScriptTools(
|
|
|
292
292
|
},
|
|
293
293
|
): PrecheckTool[] {
|
|
294
294
|
const tools = new Map<string, PrecheckTool>();
|
|
295
|
-
for (const call of
|
|
296
|
-
const name = options.toolName(call.server, call.tool);
|
|
297
|
-
const context = options.context ?? {};
|
|
298
|
-
let held: string | undefined;
|
|
299
|
-
if (call.args) held = options.holds(name, call.args, context);
|
|
300
|
-
else {
|
|
301
|
-
held = options.holds(name, {}, context);
|
|
302
|
-
const rule =
|
|
303
|
-
held === undefined ? options.holds.mayHold?.(name) : undefined;
|
|
304
|
-
if (rule !== undefined) held = messages().precheckToolMayHold(name, rule);
|
|
305
|
-
}
|
|
295
|
+
for (const call of judgedScriptCalls(script, options)) {
|
|
306
296
|
const key = `${call.server}\u0000${call.tool}`;
|
|
307
297
|
const known = tools.get(key);
|
|
308
298
|
if (!known)
|
|
309
299
|
tools.set(key, {
|
|
310
300
|
server: call.server,
|
|
311
301
|
tool: call.tool,
|
|
312
|
-
...(held ? { held } : {}),
|
|
302
|
+
...(call.held ? { held: call.held } : {}),
|
|
313
303
|
});
|
|
314
|
-
else if (held && !known.held) known.held = held;
|
|
304
|
+
else if (call.held && !known.held) known.held = call.held;
|
|
315
305
|
}
|
|
316
306
|
return [...tools.values()];
|
|
317
307
|
}
|
|
318
308
|
|
|
309
|
+
/** A call in a precheck script, judged: the name its rules know it by, its input, and what they say it would do. */
|
|
310
|
+
interface JudgedScriptCall {
|
|
311
|
+
server: string;
|
|
312
|
+
tool: string;
|
|
313
|
+
name: string;
|
|
314
|
+
/** The arguments written out in the script; empty when it computes them. */
|
|
315
|
+
input: Record<string, unknown>;
|
|
316
|
+
/** Whether the script writes its arguments out, so `input` is what the call will be made with. */
|
|
317
|
+
literal: boolean;
|
|
318
|
+
held?: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Each call of the script, judged by the host's hold rules, one entry per call. */
|
|
322
|
+
function judgedScriptCalls(
|
|
323
|
+
script: unknown,
|
|
324
|
+
options: {
|
|
325
|
+
toolName: (server: string, tool: string) => string;
|
|
326
|
+
holds: HoldCheck;
|
|
327
|
+
context?: HoldContext;
|
|
328
|
+
},
|
|
329
|
+
): JudgedScriptCall[] {
|
|
330
|
+
const context = options.context ?? {};
|
|
331
|
+
return readPrecheckScript(script).calls.map((call) => {
|
|
332
|
+
const name = options.toolName(call.server, call.tool);
|
|
333
|
+
const input = call.args ?? {};
|
|
334
|
+
let held = options.holds(name, input, context);
|
|
335
|
+
if (!call.args && held === undefined) {
|
|
336
|
+
const rule = options.holds.mayHold?.(name);
|
|
337
|
+
if (rule !== undefined) held = messages().precheckToolMayHold(name, rule);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
server: call.server,
|
|
341
|
+
tool: call.tool,
|
|
342
|
+
name,
|
|
343
|
+
input,
|
|
344
|
+
literal: call.args !== undefined,
|
|
345
|
+
...(held ? { held } : {}),
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
319
350
|
const SCHEDULE_TOOLS = new Set(["schedule_create", "schedule_update"]);
|
|
320
351
|
|
|
321
352
|
/**
|
|
@@ -330,12 +361,17 @@ export function precheckScriptHoldRule(options: {
|
|
|
330
361
|
tiers?: () => ToolTiers;
|
|
331
362
|
}): HoldRule {
|
|
332
363
|
let judging = false;
|
|
333
|
-
/** The held
|
|
334
|
-
const
|
|
364
|
+
/** The held calls a script being saved makes, each with the name the hold rules know its tool by and its input. */
|
|
365
|
+
const scriptCalls = (
|
|
335
366
|
tool: string,
|
|
336
367
|
input: Record<string, unknown>,
|
|
337
368
|
context: HoldContext,
|
|
338
|
-
): {
|
|
369
|
+
): {
|
|
370
|
+
name: string;
|
|
371
|
+
input: Record<string, unknown>;
|
|
372
|
+
literal: boolean;
|
|
373
|
+
held: string;
|
|
374
|
+
}[] => {
|
|
339
375
|
const script = input.precheck_script;
|
|
340
376
|
if (!SCHEDULE_TOOLS.has(tool) || typeof script !== "string" || judging)
|
|
341
377
|
return [];
|
|
@@ -343,13 +379,20 @@ export function precheckScriptHoldRule(options: {
|
|
|
343
379
|
if (!runner) return [];
|
|
344
380
|
judging = true;
|
|
345
381
|
try {
|
|
346
|
-
return
|
|
382
|
+
return judgedScriptCalls(script, {
|
|
347
383
|
toolName: (server, name) => runner.toolName(server, name),
|
|
348
384
|
holds: options.holds(),
|
|
349
385
|
context,
|
|
350
|
-
}).flatMap((
|
|
351
|
-
|
|
352
|
-
? [
|
|
386
|
+
}).flatMap((call) =>
|
|
387
|
+
call.held
|
|
388
|
+
? [
|
|
389
|
+
{
|
|
390
|
+
name: call.name,
|
|
391
|
+
input: call.input,
|
|
392
|
+
literal: call.literal,
|
|
393
|
+
held: call.held,
|
|
394
|
+
},
|
|
395
|
+
]
|
|
353
396
|
: [],
|
|
354
397
|
);
|
|
355
398
|
} catch {
|
|
@@ -359,6 +402,19 @@ export function precheckScriptHoldRule(options: {
|
|
|
359
402
|
judging = false;
|
|
360
403
|
}
|
|
361
404
|
};
|
|
405
|
+
/** The held tools a script being saved calls, once each, by the names the hold rules know them by. */
|
|
406
|
+
const heldCalls = (
|
|
407
|
+
tool: string,
|
|
408
|
+
input: Record<string, unknown>,
|
|
409
|
+
context: HoldContext,
|
|
410
|
+
): { name: string; held: string }[] => {
|
|
411
|
+
const seen = new Set<string>();
|
|
412
|
+
return scriptCalls(tool, input, context).filter((call) => {
|
|
413
|
+
if (seen.has(call.name)) return false;
|
|
414
|
+
seen.add(call.name);
|
|
415
|
+
return true;
|
|
416
|
+
});
|
|
417
|
+
};
|
|
362
418
|
return {
|
|
363
419
|
name: "precheck-scripts",
|
|
364
420
|
describe(tool, input, context) {
|
|
@@ -383,5 +439,44 @@ export function precheckScriptHoldRule(options: {
|
|
|
383
439
|
undefined,
|
|
384
440
|
);
|
|
385
441
|
},
|
|
442
|
+
mayName: (tool) => SCHEDULE_TOOLS.has(tool),
|
|
443
|
+
// Whoever approves must also be named for each held call the script makes: the principals
|
|
444
|
+
// every call names, and, where a call's approvers cannot be read, the owners alone.
|
|
445
|
+
approversInTurn(tool, input, context, turn) {
|
|
446
|
+
const calls = scriptCalls(tool, input, context);
|
|
447
|
+
const holds = options.holds();
|
|
448
|
+
const lookup = holds.approversInTurn;
|
|
449
|
+
if (calls.length === 0 || !lookup) return undefined;
|
|
450
|
+
return Promise.all(
|
|
451
|
+
calls.map(async (call) => {
|
|
452
|
+
try {
|
|
453
|
+
// A script's computed arguments are not known now, so its call is judged by none.
|
|
454
|
+
const named = await lookup.call(
|
|
455
|
+
holds,
|
|
456
|
+
call.name,
|
|
457
|
+
call.input,
|
|
458
|
+
context,
|
|
459
|
+
turn,
|
|
460
|
+
);
|
|
461
|
+
// A rule that answers by the arguments may name approvers once they are known:
|
|
462
|
+
// until then only the owners can stand for them.
|
|
463
|
+
return named === undefined &&
|
|
464
|
+
!call.literal &&
|
|
465
|
+
holds.mayName?.(call.name) !== undefined
|
|
466
|
+
? []
|
|
467
|
+
: named;
|
|
468
|
+
} catch {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
}),
|
|
472
|
+
).then((lists) => {
|
|
473
|
+
const named = lists.filter(
|
|
474
|
+
(list): list is readonly string[] => list !== undefined,
|
|
475
|
+
);
|
|
476
|
+
const [first, ...rest] = named;
|
|
477
|
+
if (!first) return undefined;
|
|
478
|
+
return first.filter((id) => rest.every((list) => list.includes(id)));
|
|
479
|
+
});
|
|
480
|
+
},
|
|
386
481
|
};
|
|
387
482
|
}
|
|
@@ -49,7 +49,10 @@ export interface ConversationTurnInput {
|
|
|
49
49
|
steerable?: boolean;
|
|
50
50
|
/** The turn may ask the owner on the surface's prompts, such as approvals of held actions. */
|
|
51
51
|
interactive?: boolean;
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* The speaker confirmed the channel's held actions, so they may run in this turn, except those
|
|
54
|
+
* that named approvers the speaker is neither among nor an owner of, which stay held.
|
|
55
|
+
*/
|
|
53
56
|
confirmed?: boolean;
|
|
54
57
|
/**
|
|
55
58
|
* Posts the turn's outcome in place of the default reply (the text, or a failure or stopped
|
|
@@ -109,6 +109,14 @@ export class ConversationSessions {
|
|
|
109
109
|
this.#tiers,
|
|
110
110
|
// Owner sessions have no shell workspace but still send files from their own directory.
|
|
111
111
|
agents ? agents.workDir : this.#factory.cwd(undefined),
|
|
112
|
+
undefined,
|
|
113
|
+
// A disabled principal cannot approve, so it is not named as one who can.
|
|
114
|
+
async (id) => {
|
|
115
|
+
const principal = await this.#options.principalOf?.(id);
|
|
116
|
+
return principal && !principal.disabled
|
|
117
|
+
? principal.displayName
|
|
118
|
+
: undefined;
|
|
119
|
+
},
|
|
112
120
|
);
|
|
113
121
|
this.#gates.set(key, gate);
|
|
114
122
|
this.#restored.delete(key);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ToolTurn } from "../../define.ts";
|
|
3
|
+
import type { PromptSlot } from "../prompt-slot.ts";
|
|
4
|
+
import type { ConfirmationGate, HoldTurn } from "./confirmation-gate.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Without a slot, or with an empty one, every call that needs confirmation is held. `turnOf` builds
|
|
8
|
+
* the turn a hold that looks things up receives; without it, such a hold is not asked in a turn.
|
|
9
|
+
*/
|
|
10
|
+
export function confirmationGateExtension(
|
|
11
|
+
gate: ConfirmationGate,
|
|
12
|
+
slot?: PromptSlot,
|
|
13
|
+
turnOf?: (signal: AbortSignal) => ToolTurn,
|
|
14
|
+
): ExtensionFactory {
|
|
15
|
+
return (pi) => {
|
|
16
|
+
/** Calls a card's approval released, until their results are back. */
|
|
17
|
+
const approved = new Set<string>();
|
|
18
|
+
pi.on("tool_result", (event) => {
|
|
19
|
+
gate.finish(event.toolCallId);
|
|
20
|
+
if (!approved.delete(event.toolCallId)) return undefined;
|
|
21
|
+
const outcome = event.isError
|
|
22
|
+
? "It was approved on its card and ran, but it failed:"
|
|
23
|
+
: "It was approved on its card and ran; its result follows.";
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: outcome }, ...event.content],
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
29
|
+
const prompts = slot?.prompts;
|
|
30
|
+
let ask: Parameters<ConfirmationGate["review"]>[2];
|
|
31
|
+
if (prompts && slot) {
|
|
32
|
+
ask = { prompts, asker: slot.asker };
|
|
33
|
+
if (ctx.signal) ask.signal = ctx.signal;
|
|
34
|
+
}
|
|
35
|
+
const turn: HoldTurn | undefined = turnOf
|
|
36
|
+
? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
|
|
37
|
+
: undefined;
|
|
38
|
+
const { reason, approvedOnCard, approvedBy, refused } = await gate.decide(
|
|
39
|
+
event.toolName,
|
|
40
|
+
event.input as Record<string, unknown>,
|
|
41
|
+
ask,
|
|
42
|
+
turn,
|
|
43
|
+
);
|
|
44
|
+
if (approvedOnCard) approved.add(event.toolCallId);
|
|
45
|
+
if (approvedBy && !reason) gate.grant(event.toolCallId, approvedBy);
|
|
46
|
+
if (refused) slot?.refuse(event.toolCallId, refused);
|
|
47
|
+
return reason ? { block: true, reason } : undefined;
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
}
|