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.
Files changed (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +146 -21
  4. package/examples/fake-surface.test.ts +137 -0
  5. package/examples/fake-surface.ts +71 -14
  6. package/package.json +1 -1
  7. package/src/cli/checks/identity.ts +73 -5
  8. package/src/core/agents/agent-claim.ts +12 -0
  9. package/src/core/agents/team-turns.ts +11 -4
  10. package/src/core/builtin/agent-server.ts +6 -0
  11. package/src/core/config/access.ts +23 -3
  12. package/src/core/config/adapters.ts +11 -0
  13. package/src/core/config/schema.ts +14 -0
  14. package/src/core/define-roundtable.ts +1 -0
  15. package/src/core/define.ts +44 -6
  16. package/src/core/discord/card-audience.ts +104 -7
  17. package/src/core/discord/discord-owners.ts +19 -13
  18. package/src/core/discord/owner-card-types.ts +73 -0
  19. package/src/core/discord/owner-cards.ts +56 -77
  20. package/src/core/domain/conversation.ts +7 -0
  21. package/src/core/domain/ports.ts +4 -1
  22. package/src/core/holds.ts +60 -0
  23. package/src/core/i18n/cards.ts +3 -0
  24. package/src/core/identity/access-policy.ts +65 -2
  25. package/src/core/identity/caching-principal-store.ts +2 -2
  26. package/src/core/identity/identity-links.ts +96 -0
  27. package/src/core/identity/identity-plugin.ts +10 -3
  28. package/src/core/identity/identity-schema.ts +3 -1
  29. package/src/core/identity/identity-service.ts +5 -3
  30. package/src/core/identity/principal-store.ts +59 -142
  31. package/src/core/identity/principal-tables.ts +10 -2
  32. package/src/core/identity/principal-types.ts +146 -0
  33. package/src/core/interactions/prompts.ts +47 -3
  34. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  35. package/src/core/routing/conversation-turns.ts +4 -1
  36. package/src/core/runtime/conversation-sessions.ts +8 -0
  37. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  38. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  39. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  40. package/src/core/runtime/extensions/held-calls.ts +91 -0
  41. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  42. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  43. package/src/core/runtime/prompt-slot.ts +10 -2
  44. package/src/core/runtime/session-factory.ts +1 -0
  45. package/src/core/services.ts +11 -0
  46. package/src/core/sessions.ts +7 -0
  47. package/src/core/testing/surface-contract-support.ts +194 -0
  48. package/src/core/testing/surface-contract.ts +54 -120
  49. package/src/core/tool-turn.ts +5 -1
  50. package/src/index.ts +7 -0
@@ -1,5 +1,5 @@
1
1
  import { messages } from "../i18n/index.ts";
2
- import type { PromptScope } from "../interactions/prompts.ts";
2
+ import type { ApprovedBy, PromptScope } from "../interactions/prompts.ts";
3
3
  import { type Tier, tierAtLeast } from "../speakers.ts";
4
4
  import type {
5
5
  DiscordOwners,
@@ -17,6 +17,11 @@ export interface Audience {
17
17
  note: string;
18
18
  /** What someone else who presses it is told. */
19
19
  refusal: string;
20
+ /**
21
+ * Who answered, as the approval reports them, for a user `allows` admitted; undefined when it
22
+ * cannot be read. Only an approval's audience has it.
23
+ */
24
+ approver?(user: DiscordUser): Promise<ApprovedBy | undefined>;
20
25
  }
21
26
 
22
27
  /** The primary owner, who answers every card the owners may, and who else holds a tier. */
@@ -31,6 +36,13 @@ export type CardAudienceOptions = DiscordOwnersOptions;
31
36
  * escalates to the owners, every owner with a Discord identity answers it too, and a card the
32
37
  * speaker cannot answer, or whose principal has no Discord identity, is theirs alone; where it
33
38
  * escalates to no one, that card goes to no one. Without a scope, a card is the owners'.
39
+ *
40
+ * An approval that names `approvers`, where it escalates, is theirs instead of the speaker's:
41
+ * each named principal answers on any of their Discord identities that is still theirs when they
42
+ * press, at the tier it needs then, and the card mentions all of those identities. The speaker
43
+ * does not answer it unless named. The owners still do, and are the whole audience when none of
44
+ * the named has a Discord identity. Where the scope escalates to no one, the person is the only
45
+ * one who answers, and only if named or an owner: any other person's card is not posted.
34
46
  */
35
47
  export class CardAudiences {
36
48
  readonly #options: CardAudienceOptions;
@@ -42,14 +54,28 @@ export class CardAudiences {
42
54
  }
43
55
 
44
56
  /** Who may approve a call held at `minTier`; undefined when no one may. */
45
- approval(
57
+ async approval(
46
58
  scope: PromptScope | undefined,
47
59
  minTier: Tier,
60
+ named?: readonly string[],
48
61
  ): Promise<Audience | undefined> {
49
- return this.#audience(scope, minTier, {
50
- note: messages().cardApproversNote,
51
- refusal: messages().cardApproversRefusal,
52
- });
62
+ let approvers = named;
63
+ // A private conversation's person is the only one who answers, so a call naming others is theirs only if named or an owner.
64
+ if (approvers && scope?.escalate === "none") {
65
+ if (!(await this.#mayAnswerPrivately(scope, approvers))) return undefined;
66
+ approvers = undefined;
67
+ }
68
+ return this.#audience(
69
+ scope,
70
+ minTier,
71
+ {
72
+ note: messages().cardApproversNote,
73
+ refusal: approvers
74
+ ? messages().cardNamedApproversRefusal
75
+ : messages().cardApproversRefusal,
76
+ },
77
+ approvers,
78
+ );
53
79
  }
54
80
 
55
81
  /** Who may answer a question; undefined when no one may. */
@@ -64,10 +90,33 @@ export class CardAudiences {
64
90
  scope: PromptScope | undefined,
65
91
  minTier: Tier | undefined,
66
92
  words: { note(...userIds: string[]): string; refusal: string },
93
+ approvers?: readonly string[],
67
94
  ): Promise<Audience | undefined> {
68
95
  const { ownerId } = this.#options;
69
96
  const escalates = scope?.escalate !== "none";
70
- const own = scope && (await this.#ownIdentities(scope, minTier));
97
+ if (approvers && scope) {
98
+ const named = await this.#approverIdentities(approvers);
99
+ if (named.size > 0)
100
+ return {
101
+ allows: async (user) => {
102
+ for (const [principalId, ids] of named)
103
+ if (
104
+ ids.includes(user.id) &&
105
+ // Read the links again: one moved or unlinked since the card was posted is no longer theirs.
106
+ (await this.#stillOwn(principalId, user.id)) &&
107
+ (await this.#owners.holds(user, minTier, principalId))
108
+ )
109
+ return true;
110
+ return this.#owners.isOwner(user);
111
+ },
112
+ mentions: [...new Set([...named.values()].flat())],
113
+ note: words.note(...new Set([...named.values()].flat())),
114
+ refusal: words.refusal,
115
+ approver: (user) => this.#approver(user),
116
+ };
117
+ }
118
+ const own =
119
+ scope && !approvers && (await this.#ownIdentities(scope, minTier));
71
120
  if (scope && own) {
72
121
  const primary = own.includes(ownerId);
73
122
  const mentions = own.includes(scope.speakerId) ? [scope.speakerId] : own;
@@ -87,6 +136,7 @@ export class CardAudiences {
87
136
  mentions,
88
137
  note: primary ? "" : words.note(...mentions),
89
138
  refusal: primary ? messages().cardOwnerOnly : words.refusal,
139
+ approver: (user) => this.#approver(user),
90
140
  };
91
141
  }
92
142
  if (!escalates) return undefined;
@@ -95,9 +145,56 @@ export class CardAudiences {
95
145
  mentions: await this.#owners.ids(),
96
146
  note: "",
97
147
  refusal: messages().cardOwnerOnly,
148
+ approver: (user) => this.#approver(user),
98
149
  };
99
150
  }
100
151
 
152
+ /**
153
+ * The Discord identities of each named principal that has any, by principal id; a principal
154
+ * whose identities cannot be read, or who has none, is left out.
155
+ */
156
+ async #approverIdentities(
157
+ principals: readonly string[],
158
+ ): Promise<Map<string, string[]>> {
159
+ const named = new Map<string, string[]>();
160
+ for (const principalId of new Set(principals)) {
161
+ const ids = await this.#discordIdentities(principalId);
162
+ if (ids && ids.length > 0) named.set(principalId, ids);
163
+ }
164
+ return named;
165
+ }
166
+
167
+ /** Whether a private conversation's person may answer a call that names `approvers`: when named, or an owner. */
168
+ async #mayAnswerPrivately(
169
+ scope: PromptScope,
170
+ approvers: readonly string[],
171
+ ): Promise<boolean> {
172
+ return (
173
+ approvers.includes(scope.principalId) ||
174
+ scope.speakerId === this.#options.ownerId ||
175
+ (await this.#owners.isOwnerPrincipal(scope.principalId))
176
+ );
177
+ }
178
+
179
+ /** Who the user is as an approver: their principal, the name their identity goes by, and whether they are an owner. */
180
+ async #approver(user: DiscordUser): Promise<ApprovedBy | undefined> {
181
+ const owner = (await this.#owners.isOwner(user))
182
+ ? { owner: true as const }
183
+ : {};
184
+ const who = await this.#owners.speakerOf(user);
185
+ if (who)
186
+ return {
187
+ principalId: who.principalId,
188
+ speakerId: user.id,
189
+ name: user.name,
190
+ ...owner,
191
+ };
192
+ // Without an identity service the primary owner is the only one who answers.
193
+ return this.#options.identity
194
+ ? undefined
195
+ : { principalId: user.id, speakerId: user.id, name: user.name, ...owner };
196
+ }
197
+
101
198
  /** Whether the Discord user is still one of the principal's identities; false when that cannot be read. */
102
199
  async #stillOwn(principalId: string, userId: string): Promise<boolean> {
103
200
  return (
@@ -1,6 +1,6 @@
1
1
  import type { IdentityService } from "../identity/identity-service.ts";
2
2
  import type { Logger } from "../log.ts";
3
- import { type Tier, tierAtLeast } from "../speakers.ts";
3
+ import { type Speaker, type Tier, tierAtLeast } from "../speakers.ts";
4
4
 
5
5
  /** A Discord user who pressed or ran something; their roles are unknown where the interaction carries no member. */
6
6
  export interface DiscordUser {
@@ -102,19 +102,11 @@ export class DiscordOwners {
102
102
  }
103
103
  }
104
104
 
105
- /**
106
- * Whether the user holds `minTier` as they act, as the principal named when there is one;
107
- * without `minTier` anyone does. Call it only for someone already known, so it admits no one.
108
- */
109
- async holds(
110
- user: DiscordUser,
111
- minTier: Tier | undefined,
112
- principalId?: string,
113
- ): Promise<boolean> {
105
+ /** The speaker the user is as they act; undefined without an identity service or when none serves them. Call it only for someone already admitted. */
106
+ async speakerOf(user: DiscordUser): Promise<Speaker | undefined> {
114
107
  const { identity } = this.#options;
115
- if (!minTier) return true;
116
- if (!identity) return false;
117
- const who = await identity.resolve({
108
+ if (!identity) return undefined;
109
+ return identity.resolve({
118
110
  provider: "discord",
119
111
  subject: user.id,
120
112
  name: user.name,
@@ -124,6 +116,20 @@ export class DiscordOwners {
124
116
  : {}),
125
117
  legacyId: user.id,
126
118
  });
119
+ }
120
+
121
+ /**
122
+ * Whether the user holds `minTier` as they act, as the principal named when there is one;
123
+ * without `minTier` anyone does. Call it only for someone already known, so it admits no one.
124
+ */
125
+ async holds(
126
+ user: DiscordUser,
127
+ minTier: Tier | undefined,
128
+ principalId?: string,
129
+ ): Promise<boolean> {
130
+ if (!minTier) return true;
131
+ if (!this.#options.identity) return false;
132
+ const who = await this.speakerOf(user);
127
133
  return (
128
134
  who !== undefined &&
129
135
  (principalId === undefined || who.principalId === principalId) &&
@@ -0,0 +1,73 @@
1
+ import type { TextDisplayBuilder } from "discord.js";
2
+ import type { ApprovedBy, OwnerQuestion } from "../interactions/prompts.ts";
3
+ import type { Audience } from "./card-audience.ts";
4
+ import type { DiscordUser } from "./discord-owners.ts";
5
+ import type { Rows } from "./owner-card-rows.ts";
6
+ import type { ownerPanel } from "./owner-panel.ts";
7
+
8
+ /** A card's message: the panel, led in a thread by a mention of those it is for. */
9
+ export type CardPayload = Omit<
10
+ ReturnType<typeof ownerPanel>,
11
+ "components" | "allowedMentions"
12
+ > & {
13
+ components: (
14
+ | ReturnType<typeof ownerPanel>["components"][number]
15
+ | TextDisplayBuilder
16
+ )[];
17
+ allowedMentions: { parse?: never[]; users?: string[] };
18
+ };
19
+
20
+ /** The posted card, which is edited to show how it ended. */
21
+ export interface CardMessage {
22
+ edit(payload: CardPayload): Promise<unknown>;
23
+ }
24
+
25
+ /** Where a channel's cards are posted. */
26
+ export interface CardChannel {
27
+ send(payload: CardPayload): Promise<CardMessage>;
28
+ /** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
29
+ thread?: boolean;
30
+ }
31
+
32
+ /** A turn a card answered after its turn stopped waiting starts, as a message in its channel. */
33
+ export interface LateTurn {
34
+ channelId: string;
35
+ /** The card's message, which the turn's message stands for. */
36
+ messageId: string;
37
+ /** Who answered. */
38
+ user: DiscordUser;
39
+ /** The server the channel belongs to; absent in a direct message. */
40
+ guildId?: string;
41
+ isDirect: boolean;
42
+ text: string;
43
+ }
44
+
45
+ /** Where and by whom a card was answered. */
46
+ export interface Answerer {
47
+ user: DiscordUser;
48
+ /** Who approved, for an approval card they approved. */
49
+ approver?: ApprovedBy;
50
+ messageId?: string;
51
+ guildId?: string;
52
+ isDirect: boolean;
53
+ }
54
+
55
+ /** One open card: how it looks, and how it ends. */
56
+ export interface OpenCard {
57
+ title: string;
58
+ sections: string[];
59
+ /** The card's controls, disabled once it has ended. */
60
+ rows(disabled: boolean): Rows;
61
+ /**
62
+ * Ends the card with its answer; the answering interaction shows the outcome. A card whose
63
+ * turn stopped waiting starts a turn with the answer instead.
64
+ */
65
+ end(value: unknown, by: Answerer): void;
66
+ question?: OwnerQuestion;
67
+ /** Options chosen together with "Other…", kept while its form is open. */
68
+ picked?: string[];
69
+ /** Posted in a thread, so it leads with the mention of who may answer. */
70
+ mention?: boolean;
71
+ /** Who may answer, and what to tell someone else who presses it. */
72
+ audience: Audience;
73
+ }
@@ -24,11 +24,7 @@ import {
24
24
  type CardAudienceOptions,
25
25
  CardAudiences,
26
26
  } from "./card-audience.ts";
27
- import {
28
- DiscordOwners,
29
- type DiscordUser,
30
- discordUser,
31
- } from "./discord-owners.ts";
27
+ import { DiscordOwners, discordUser } from "./discord-owners.ts";
32
28
  import type { InteractionModule } from "./interaction-module.ts";
33
29
  import {
34
30
  answeredLine,
@@ -39,36 +35,25 @@ import {
39
35
  type Rows,
40
36
  TEXT_FIELD,
41
37
  } from "./owner-card-rows.ts";
38
+ import type {
39
+ Answerer,
40
+ CardChannel,
41
+ CardMessage,
42
+ CardPayload,
43
+ LateTurn,
44
+ OpenCard,
45
+ } from "./owner-card-types.ts";
42
46
  import { ownerPanel, type PanelContent } from "./owner-panel.ts";
43
47
 
48
+ export type {
49
+ CardChannel,
50
+ CardPayload,
51
+ LateTurn,
52
+ } from "./owner-card-types.ts";
44
53
  export { CARD_PREFIX };
45
54
  /** How long a turn that can be resumed waits on its card before it goes on without the answer. */
46
55
  export const CARD_GRACE_MS = 2 * 60_000;
47
56
 
48
- /** A card's message: the panel, led in a thread by a mention of those it is for. */
49
- export type CardPayload = Omit<
50
- ReturnType<typeof ownerPanel>,
51
- "components" | "allowedMentions"
52
- > & {
53
- components: (
54
- | ReturnType<typeof ownerPanel>["components"][number]
55
- | TextDisplayBuilder
56
- )[];
57
- allowedMentions: { parse?: never[]; users?: string[] };
58
- };
59
-
60
- /** The posted card, which is edited to show how it ended. */
61
- export interface CardMessage {
62
- edit(payload: CardPayload): Promise<unknown>;
63
- }
64
-
65
- /** Where a channel's cards are posted. */
66
- export interface CardChannel {
67
- send(payload: CardPayload): Promise<CardMessage>;
68
- /** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
69
- thread?: boolean;
70
- }
71
-
72
57
  export interface OwnerCardsOptions {
73
58
  /** The primary owner's Discord user id. */
74
59
  ownerId: string;
@@ -92,47 +77,6 @@ export interface OwnerCardsOptions {
92
77
  resume?(turn: LateTurn): void;
93
78
  }
94
79
 
95
- /** A turn a card answered after its turn stopped waiting starts, as a message in its channel. */
96
- export interface LateTurn {
97
- channelId: string;
98
- /** The card's message, which the turn's message stands for. */
99
- messageId: string;
100
- /** Who answered. */
101
- user: DiscordUser;
102
- /** The server the channel belongs to; absent in a direct message. */
103
- guildId?: string;
104
- isDirect: boolean;
105
- text: string;
106
- }
107
-
108
- /** Where and by whom a card was answered. */
109
- interface Answerer {
110
- user: DiscordUser;
111
- messageId?: string;
112
- guildId?: string;
113
- isDirect: boolean;
114
- }
115
-
116
- /** One open card: how it looks, and how it ends. */
117
- interface OpenCard {
118
- title: string;
119
- sections: string[];
120
- /** The card's controls, disabled once it has ended. */
121
- rows(disabled: boolean): Rows;
122
- /**
123
- * Ends the card with its answer; the answering interaction shows the outcome. A card whose
124
- * turn stopped waiting starts a turn with the answer instead.
125
- */
126
- end(value: unknown, by: Answerer): void;
127
- question?: OwnerQuestion;
128
- /** Options chosen together with "Other…", kept while its form is open. */
129
- picked?: string[];
130
- /** Posted in a thread, so it leads with the mention of who may answer. */
131
- mention?: boolean;
132
- /** Who may answer, and what to tell someone else who presses it. */
133
- audience: Audience;
134
- }
135
-
136
80
  /**
137
81
  * The cards in the assistant's channels: approvals of held actions and ask_user questions,
138
82
  * answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
@@ -164,8 +108,20 @@ export class OwnerCards implements InteractionModule {
164
108
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
165
109
  const channelId = parseChannelKey(channel).id;
166
110
  return {
167
- confirm: async (title, message, signal, minTier = "owner", wait) => {
168
- const audience = await this.#audiences.approval(scope, minTier);
111
+ confirm: async (
112
+ title,
113
+ message,
114
+ signal,
115
+ minTier = "owner",
116
+ wait,
117
+ _details,
118
+ options,
119
+ ) => {
120
+ const audience = await this.#audiences.approval(
121
+ scope,
122
+ minTier,
123
+ options?.approvers,
124
+ );
169
125
  // A private conversation's call above its person's tier is no one's to approve.
170
126
  if (!audience) return "unavailable";
171
127
  return this.#post<Approval>(
@@ -183,11 +139,19 @@ export class OwnerCards implements InteractionModule {
183
139
  wait.late({
184
140
  kind: "approval",
185
141
  approved: value === "approved",
186
- by,
142
+ by: by.user.name,
143
+ ...(by.approver ? { approvedBy: by.approver } : {}),
187
144
  }),
188
145
  },
189
146
  }
190
147
  : {}),
148
+ ...(options?.approvedBy
149
+ ? {
150
+ approved: (by: Answerer) => {
151
+ if (by.approver) options.approvedBy?.(by.approver);
152
+ },
153
+ }
154
+ : {}),
191
155
  title,
192
156
  sections: [message],
193
157
  rows: (id, disabled) => [
@@ -228,7 +192,7 @@ export class OwnerCards implements InteractionModule {
228
192
  wait.late({
229
193
  kind: "question",
230
194
  answer: value as OwnerAnswer,
231
- by,
195
+ by: by.user.name,
232
196
  }),
233
197
  },
234
198
  }
@@ -262,8 +226,10 @@ export class OwnerCards implements InteractionModule {
262
226
  footer: string;
263
227
  wait?: {
264
228
  pending: T;
265
- late(value: unknown, by: string): ReturnType<PromptWait["late"]>;
229
+ late(value: unknown, by: Answerer): ReturnType<PromptWait["late"]>;
266
230
  };
231
+ /** Hears who approved the card, before it resolves approved in its turn. */
232
+ approved?(by: Answerer): void;
267
233
  },
268
234
  ): Promise<T> {
269
235
  const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
@@ -323,10 +289,11 @@ export class OwnerCards implements InteractionModule {
323
289
  audience: card.audience,
324
290
  end: (value, by) => {
325
291
  const answeredLate = late;
292
+ if (value === "approved" && !answeredLate) card.approved?.(by);
326
293
  settle(value as T, undefined);
327
294
  if (!answeredLate || !wait || !resume) return;
328
295
  try {
329
- const text = wait.late(value, by.user.name);
296
+ const text = wait.late(value, by);
330
297
  if (text === undefined) return;
331
298
  resume({
332
299
  channelId,
@@ -429,7 +396,19 @@ export class OwnerCards implements InteractionModule {
429
396
  );
430
397
  if (interaction.isButton() && (action === "yes" || action === "no")) {
431
398
  const approved = action === "yes";
432
- card.end(approved ? "approved" : "declined", by);
399
+ const approver = approved
400
+ ? await card.audience.approver?.(user).catch((error: unknown) => {
401
+ this.#options.logger.warn(
402
+ { err: error },
403
+ "could not tell who approved; the call runs without an approver",
404
+ );
405
+ return undefined;
406
+ })
407
+ : undefined;
408
+ card.end(
409
+ approved ? "approved" : "declined",
410
+ approver ? { ...by, approver } : by,
411
+ );
433
412
  await interaction.update(
434
413
  closed(approved ? messages().cardApproved : messages().cardDeclined),
435
414
  );
@@ -19,6 +19,13 @@ export interface HeldCall {
19
19
  action: string;
20
20
  /** The lowest tier that may approve it, when higher than its tool's own; see `HoldRule.approvalTier`. */
21
21
  minTier?: Tier;
22
+ /**
23
+ * The principal ids that may approve it instead of the speaker whose turn held it, as the tool's
24
+ * `approvers` named them; the owners may still. Absent when the tool names none, and an empty
25
+ * list leaves the owners alone. They are kept with the call, so a message that confirms it after
26
+ * a restart honors them; a private conversation ignores them.
27
+ */
28
+ approverIds?: string[];
22
29
  }
23
30
 
24
31
  export interface PendingConfirmation {
@@ -21,7 +21,10 @@ export interface TurnRequest {
21
21
  text: string;
22
22
  /** Files and images the message carries; none when absent. */
23
23
  attachments?: TurnAttachments;
24
- /** The owner confirmed the channel's pending actions, so they may run in this turn. */
24
+ /**
25
+ * The speaker confirmed the channel's pending actions, so they may run in this turn, except
26
+ * those that named approvers the speaker is neither among nor an owner of, which stay pending.
27
+ */
25
28
  confirmed?: boolean;
26
29
  /** Set for a turn of an agent-server agent. */
27
30
  agent?: AgentTurnScope;
package/src/core/holds.ts CHANGED
@@ -57,6 +57,27 @@ export interface HoldRule {
57
57
  input: Record<string, unknown>,
58
58
  context: HoldContext,
59
59
  ): Tier | undefined;
60
+ /**
61
+ * The principal ids that may approve a call of this rule's tool instead of the speaker whose turn
62
+ * makes it, looked up as that turn; undefined when the rule names none and the speaker approves
63
+ * it, as for any held call. The confirmation gate asks it, with the same wait and `turn.signal`
64
+ * as `describeInTurn`, for each call it holds; a rejection or a timeout leaves the owners alone
65
+ * to approve it. Where several rules name approvers for one call, only principals every rule
66
+ * names may approve. It must change nothing.
67
+ */
68
+ approversInTurn?(
69
+ tool: string,
70
+ input: Record<string, unknown>,
71
+ context: HoldContext,
72
+ turn: ToolTurn,
73
+ ): readonly string[] | undefined | Promise<readonly string[] | undefined>;
74
+ /**
75
+ * Whether `approversInTurn` may name approvers for some call of `tool`, for a rule whose answer
76
+ * depends on the input. Asked when the input is not known yet, as for a precheck script's call
77
+ * whose arguments are computed when it runs; a rule with `approversInTurn` and without this is
78
+ * taken to name approvers for every tool.
79
+ */
80
+ mayName?(tool: string): boolean;
60
81
  }
61
82
 
62
83
  /** The description of a call that must be approved first, or undefined to let it run. */
@@ -84,6 +105,19 @@ export type HoldCheck = ((
84
105
  input: Record<string, unknown>,
85
106
  context: HoldContext,
86
107
  ): Tier | undefined;
108
+ /**
109
+ * Who may approve the call instead of its speaker, from the rules that name approvers; see
110
+ * `HoldRule.approversInTurn`. Absent when no rule has the lookup; it answers at once when every
111
+ * rule asked did, and otherwise as a promise that rejects when a rule's does.
112
+ */
113
+ approversInTurn?(
114
+ tool: string,
115
+ input: Record<string, unknown>,
116
+ context: HoldContext,
117
+ turn: ToolTurn,
118
+ ): readonly string[] | undefined | Promise<readonly string[] | undefined>;
119
+ /** The name of a rule that may name approvers for some call of `tool` whatever its input; see `HoldRule.mayName`. */
120
+ mayName?(tool: string): string | undefined;
87
121
  };
88
122
 
89
123
  /** The higher of two tiers; undefined only when both are. */
@@ -140,9 +174,35 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
140
174
  higherTier(tier, rule.approvalTier?.(tool, input, context)),
141
175
  undefined,
142
176
  );
177
+ if (rules.some((rule) => rule.approversInTurn)) {
178
+ check.mayName = (tool) =>
179
+ rules.find(
180
+ (rule) => rule.approversInTurn && (rule.mayName?.(tool) ?? true),
181
+ )?.name;
182
+ check.approversInTurn = (tool, input, context, turn) => {
183
+ const named = rules.flatMap((rule) => {
184
+ const approvers = rule.approversInTurn?.(tool, input, context, turn);
185
+ return approvers === undefined ? [] : [approvers];
186
+ });
187
+ if (named.length === 0) return undefined;
188
+ if (named.some(isPromise))
189
+ return Promise.all(named).then(commonApprovers);
190
+ return commonApprovers(named as (readonly string[] | undefined)[]);
191
+ };
192
+ }
143
193
  return check;
144
194
  }
145
195
 
196
+ /** The principals every list names, in the first list's order; undefined when there are no lists. */
197
+ function commonApprovers(
198
+ lists: readonly (readonly string[] | undefined)[],
199
+ ): readonly string[] | undefined {
200
+ const named = lists.filter((list): list is readonly string[] => !!list);
201
+ const [first, ...rest] = named;
202
+ if (!first) return undefined;
203
+ return first.filter((id) => rest.every((list) => list.includes(id)));
204
+ }
205
+
146
206
  /** Whether a hold's answer comes later. */
147
207
  export function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
148
208
  return (
@@ -14,6 +14,8 @@ export function cardsEn(ctx: CatalogContext) {
14
14
  `-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
15
15
  cardApproversRefusal:
16
16
  "This approval is for the speaker whose turn asked for it.",
17
+ cardNamedApproversRefusal:
18
+ "This approval is for the people it names, and the owners.",
17
19
  cardAskerNote: (...userIds: readonly string[]) =>
18
20
  `-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
19
21
  cardAskerRefusal: "This question is for another speaker.",
@@ -44,6 +46,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
44
46
  cardApproversNote: (...userIds: readonly string[]) =>
45
47
  `-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
46
48
  cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
49
+ cardNamedApproversRefusal: "這張核准卡是給卡片上點名的人和擁有者的。",
47
50
  cardAskerNote: (...userIds: readonly string[]) =>
48
51
  `-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
49
52
  cardAskerRefusal: "這張問題是問另一位發話者的。",