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,6 @@
1
1
  import {
2
2
  type Approval,
3
+ type ApprovedBy,
3
4
  type ChannelKey,
4
5
  type ChatSurface,
5
6
  definePlugin,
@@ -9,6 +10,7 @@ import {
9
10
  type Prompts,
10
11
  parseChannelKey,
11
12
  TIERS,
13
+ type Tier,
12
14
  } from "pi-roundtable";
13
15
 
14
16
  /** A prompt the surface showed, open until someone it is for answers it. */
@@ -19,6 +21,18 @@ interface Asked {
19
21
  open: boolean;
20
22
  }
21
23
 
24
+ /** Who may answer an open prompt and what happens when they do. */
25
+ interface Answering {
26
+ /** The principals who may answer it: the speaker, or the approvers the call named. */
27
+ principals: readonly string[];
28
+ owners: boolean;
29
+ /** The lowest tier an answer needs, when the principals are the approvers'. */
30
+ minTier?: Tier;
31
+ /** Hears who approved it. */
32
+ approved?(by: ApprovedBy): void;
33
+ settle(answer: Approval): void;
34
+ }
35
+
22
36
  /**
23
37
  * A chat surface connects the host to one chat network. This one is an in-memory chat: its
24
38
  * channels are the keys that start with `fake:`, and it records what the host asks of it. A real
@@ -32,10 +46,7 @@ export class FakeSurface implements ChatSurface {
32
46
  readonly stops: string[] = [];
33
47
  readonly asked: Asked[] = [];
34
48
  /** Who may answer each open prompt, and how it settles. */
35
- readonly #answering = new Map<
36
- string,
37
- { principalId?: string; owners: boolean; settle(answer: Approval): void }
38
- >();
49
+ readonly #answering = new Map<string, Answering>();
39
50
  #deliver: ((message: InboundMessage) => void) | undefined;
40
51
 
41
52
  /** The host hands over its router; every message the network reports goes to it. */
@@ -81,42 +92,88 @@ export class FakeSurface implements ChatSurface {
81
92
  * How a turn asks to approve a held action or answers a question. The scope names who may
82
93
  * answer: its speaker when their tier holds the call, and the owners unless the conversation
83
94
  * is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
95
+ * Where the call names approvers and the conversation is shared, they answer it instead of the
96
+ * speaker, at the tier it needs. A private conversation's person alone answers it, and only if
97
+ * named or an owner; for any other the call comes back `unavailable` at once.
84
98
  */
85
99
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
86
100
  return {
87
- confirm: async (title, _message, signal, minTier = "owner") => {
101
+ confirm: async (
102
+ title,
103
+ _message,
104
+ signal,
105
+ minTier = "owner",
106
+ _wait,
107
+ _details,
108
+ options,
109
+ ) => {
110
+ const owners = scope?.escalate !== "none";
111
+ const named = options?.approvers;
112
+ // Alone with the bot, the person answers for themselves, if the call is theirs to approve:
113
+ // they are named, or an owner.
114
+ if (
115
+ named &&
116
+ !owners &&
117
+ scope?.tier !== "owner" &&
118
+ !named.includes(scope?.principalId ?? "")
119
+ )
120
+ return "unavailable";
121
+ const shared = owners ? named : undefined;
88
122
  const theirs =
89
123
  scope !== undefined &&
90
124
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
91
- const owners = scope?.escalate !== "none";
92
- if (!theirs && !owners) return "unavailable";
125
+ const principals = shared ?? (theirs ? [scope.principalId] : []);
126
+ if (principals.length === 0 && !owners) return "unavailable";
93
127
  return this.#ask(channel, title, signal, {
94
- ...(theirs ? { principalId: scope.principalId } : {}),
128
+ principals,
95
129
  owners,
130
+ ...(shared ? { minTier } : {}),
131
+ ...(options?.approvedBy ? { approved: options.approvedBy } : {}),
96
132
  });
97
133
  },
98
134
  ask: async () => undefined,
99
135
  };
100
136
  }
101
137
 
102
- /** Someone answers an open prompt; it settles only when it is theirs to answer. */
138
+ /**
139
+ * Someone answers an open prompt; it settles only when it is theirs to answer. `name` and
140
+ * `speakerId` say who they are to a call that asks who approved it.
141
+ */
103
142
  answer(
104
143
  id: string,
105
- who: { principalId: string; owner?: boolean },
144
+ who: {
145
+ principalId: string;
146
+ owner?: boolean;
147
+ tier?: Tier;
148
+ name?: string;
149
+ speakerId?: string;
150
+ },
106
151
  approved: boolean,
107
152
  ): void {
108
153
  const open = this.#answering.get(id);
109
154
  if (!open) return;
110
- const theirs = open.principalId === who.principalId;
111
- if (theirs || (who.owner && open.owners))
112
- open.settle(approved ? "approved" : "declined");
155
+ // An answer that does not say its tier is not accepted where a tier is needed.
156
+ const tierOk =
157
+ !open.minTier ||
158
+ (who.tier !== undefined &&
159
+ TIERS.indexOf(who.tier) >= TIERS.indexOf(open.minTier));
160
+ const theirs = open.principals.includes(who.principalId) && tierOk;
161
+ if (!theirs && !(who.owner && open.owners)) return;
162
+ if (approved)
163
+ open.approved?.({
164
+ principalId: who.principalId,
165
+ speakerId: who.speakerId ?? who.principalId,
166
+ name: who.name ?? who.principalId,
167
+ ...(who.owner ? { owner: true as const } : {}),
168
+ });
169
+ open.settle(approved ? "approved" : "declined");
113
170
  }
114
171
 
115
172
  #ask(
116
173
  channel: ChannelKey,
117
174
  title: string,
118
175
  signal: AbortSignal | undefined,
119
- audience: { principalId?: string; owners: boolean },
176
+ audience: Omit<Answering, "settle">,
120
177
  ): Promise<Approval> {
121
178
  const asked = {
122
179
  id: `q${this.asked.length + 1}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.12",
3
+ "version": "0.10.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,10 @@
1
1
  import type { SQL } from "bun";
2
2
  import { LEGACY_ACCESS } from "../../core/config/access.ts";
3
3
  import type { ResolvedConfig } from "../../core/config/config.ts";
4
- import type { AccessRules } from "../../core/identity/access-policy.ts";
4
+ import {
5
+ type AccessRules,
6
+ provisioningOf,
7
+ } from "../../core/identity/access-policy.ts";
5
8
  import { identityOf, parseIdentity } from "../../core/identity/actor-facts.ts";
6
9
  import { declaredConflict } from "../../core/identity/config-owner.ts";
7
10
  import { backfillLine } from "../../core/identity/identity-plugin.ts";
@@ -42,9 +45,35 @@ export function surfacesOf(
42
45
  return surfaces;
43
46
  }
44
47
 
48
+ /** Whether a tier admits anyone on Discord: everyone there, or an identity or role of its own. */
49
+ function admitsDiscord(
50
+ tier: AccessRules["members"] | AccessRules["admins"],
51
+ ): boolean {
52
+ if (!tier) return false;
53
+ const { everyone } = tier;
54
+ if (everyone === true) return true;
55
+ if (Array.isArray(everyone) && everyone.includes("discord")) return true;
56
+ return [...(tier.identities ?? []), ...(tier.roles ?? [])].some((entry) =>
57
+ entry.startsWith("discord:"),
58
+ );
59
+ }
60
+
61
+ /** The effective provisioning mode, one word, or one per surface (those the host talks through and those the map names) when it differs by surface. */
62
+ function provisioningSummary(
63
+ rule: AccessRules["provisioning"],
64
+ surfaces: readonly string[],
65
+ ): string {
66
+ if (typeof rule === "string") return rule;
67
+ const named = [...new Set([...surfaces, ...Object.keys(rule)])];
68
+ return named
69
+ .map((surface) => `${surface} ${provisioningOf(rule, surface)}`)
70
+ .join(", ");
71
+ }
72
+
45
73
  /**
46
74
  * Whom the configuration serves: the owners by name, a warning when `everyone: true` admits
47
- * everyone on every surface of a host with more than one, and a note when it is written in the
75
+ * everyone on every surface of a host with more than one, a warning when admins or members are admitted on
76
+ * Discord and `discord.agentSpeakers` is not `"owners"`, and a note when it is written in the
48
77
  * deprecated 0.8 form. It reads no database, so `start` runs it too.
49
78
  */
50
79
  export async function checkAccess(project: Project): Promise<Result> {
@@ -55,17 +84,56 @@ export async function checkAccess(project: Project): Promise<Result> {
55
84
  const broad = (["admins", "members"] as const).filter(
56
85
  (tier) => config.access[tier]?.everyone === true,
57
86
  );
58
- if (broad.length > 0 && surfaces.length > 1)
59
- return warn(
87
+ const summary = provisioningSummary(config.access.provisioning, surfaces);
88
+ const rule = config.access.provisioning;
89
+ const unknown =
90
+ typeof rule === "string"
91
+ ? []
92
+ : Object.keys(rule).filter((key) => !surfaces.includes(key));
93
+ const problems: string[] = [];
94
+ const fixes: string[] = [];
95
+ if (unknown.length > 0) {
96
+ problems.push(
97
+ `access.provisioning names ${list(unknown.map((key) => JSON.stringify(key)))}, which ${unknown.length === 1 ? "is" : "are"} not a surface this host talks through (${list(surfaces)}); a surface the map leaves out is linked.`,
98
+ );
99
+ fixes.push(
100
+ `Fix the spelling of the surface name in access.provisioning, or remove it.`,
101
+ );
102
+ }
103
+ if (broad.length > 0 && surfaces.length > 1) {
104
+ problems.push(
60
105
  `${list(broad.map((tier) => `access.${tier}.everyone`))} ${broad.length === 1 ? "is" : "are"} true, which admits everyone on every surface: ${list(surfaces)}.`,
106
+ );
107
+ fixes.push(
61
108
  `Name the surfaces where everyone may talk, such as everyone: ["discord"], and admit the others by identity or by role, such as roles: ["web:role:<name>"].`,
62
109
  );
110
+ }
111
+ const discordTiers = (["admins", "members"] as const).filter((tier) =>
112
+ admitsDiscord(config.access[tier]),
113
+ );
114
+ if (
115
+ config.discord &&
116
+ config.discord.agentSpeakers !== "owners" &&
117
+ discordTiers.length > 0
118
+ ) {
119
+ problems.push(
120
+ `${list(discordTiers.map((tier) => `access.${tier}`))} admit${discordTiers.length === 1 ? "s" : ""} Discord speakers, and discord.agentSpeakers is not "owners": their turns in the agents' channels run in the agent's home session, shared with the owners, so they can read earlier owner conversation and shell output and leave history that owner turns inherit.`,
121
+ );
122
+ fixes.push(
123
+ `Set discord.agentSpeakers to "owners" (their messages still reach owner turns as channel context), or stop admitting Discord in ${list(discordTiers.map((tier) => `access.${tier}`))}.`,
124
+ );
125
+ }
126
+ if (problems.length > 0)
127
+ return warn(
128
+ `${problems.join(" ")} (provisioning ${summary})`,
129
+ fixes.join(" "),
130
+ );
63
131
  const owners = config.access.owners;
64
132
  const legacy = config.deprecations.includes(LEGACY_ACCESS)
65
133
  ? "; written as the 0.8 owner and speakers, which roundtable upgrade rewrites as access"
66
134
  : "";
67
135
  return ok(
68
- `${owners.length} ${owners.length === 1 ? "owner" : "owners"}, ${list(owners.map((owner) => owner.name))}${legacy}`,
136
+ `${owners.length} ${owners.length === 1 ? "owner" : "owners"}, ${list(owners.map((owner) => owner.name))}${legacy}; provisioning ${summary}`,
69
137
  );
70
138
  }
71
139
 
@@ -41,6 +41,11 @@ export const OWNER_TARGET: BackgroundTarget = PERSONAL_TARGET;
41
41
  export interface AgentClaimOptions {
42
42
  /** The owner, as forwarded messages name them. */
43
43
  owner: OwnerIdentity;
44
+ /**
45
+ * Who is answered in the agents' and groups' channels: `"everyone"` (default) every speaker the
46
+ * router resolved, `"owners"` only the owner tier; a webhook's report is the same either way.
47
+ */
48
+ speakers?: "everyone" | "owners";
44
49
  team: Pick<
45
50
  DiscordAgentTeam,
46
51
  | "guildId"
@@ -134,6 +139,8 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
134
139
  // Who may talk to the agents, and at which tier, the router resolved through the identity service.
135
140
  const { speaker } = message;
136
141
  if (message.authorIsBot || !speaker) return undefined;
142
+ if (options.speakers === "owners" && speaker.tier !== "owner")
143
+ return undefined;
137
144
  const { channel, messageId } = message;
138
145
  const forwarder = addressee(speaker, options.owner);
139
146
  return {
@@ -200,6 +207,11 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
200
207
  reason:
201
208
  "a background turn reaches the agent server through the router, which says whom it runs as",
202
209
  };
210
+ if (options.speakers === "owners" && speaker.tier !== "owner")
211
+ return {
212
+ status: "skipped",
213
+ reason: 'the agent server answers owners only (speakers: "owners")',
214
+ };
203
215
  return outcome(
204
216
  await team.answerBackground(
205
217
  turn.channel,
@@ -13,6 +13,7 @@ import { splitReply } from "../presentation/reply-splitter.ts";
13
13
  import { thinkingLine } from "../presentation/thinking-line.ts";
14
14
  import { withReplyFiles } from "../reply-files.ts";
15
15
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
16
+ import { mayApproveNamed } from "../runtime/extensions/held-calls.ts";
16
17
  import { type Speaker, tierAtLeast } from "../speakers.ts";
17
18
  import { toolTiers } from "../tool-tiers.ts";
18
19
  import { AgentMessages } from "./agent-messages.ts";
@@ -144,19 +145,25 @@ export class TeamTurns {
144
145
  }
145
146
 
146
147
  /**
147
- * Whether the speaker may approve the held actions by a message: an owner, as the agents'
148
- * channels are shared, or the principal whose turn held them (by their id, for actions held
149
- * before 0.9), when their tier now holds every tool. Nobody else in the channel may.
148
+ * Whether the speaker may approve the held actions by a message, call by call: an owner, as the
149
+ * agents' channels are shared, or, for a call whose tool named its approvers, one of those
150
+ * principals, or, for any other call, the principal whose turn held them (by their id, for
151
+ * actions held before 0.9). Their tier must now hold every call. Nobody else in the channel
152
+ * may, so a call that named approvers is not its speaker's to approve unless named. The gate
153
+ * checks the named approvers again when the turn begins, whoever asked for the release.
150
154
  */
151
155
  #mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
152
156
  const theirs =
153
157
  pending.principalId !== undefined
154
158
  ? speaker.principalId === pending.principalId
155
159
  : speaker.id === pending.speakerId;
156
- if (speaker.tier !== "owner" && !theirs) return false;
157
160
  const tiers = this.#options.toolTiers ?? toolTiers();
161
+ // Nothing held names an approver to go by: only the owners and the speaker whose turn held it.
162
+ if (pending.calls.length === 0) return speaker.tier === "owner" || theirs;
158
163
  return pending.calls.every(
159
164
  (call) =>
165
+ (speaker.tier === "owner" ||
166
+ (call.approverIds ? mayApproveNamed(speaker, call) : theirs)) &&
160
167
  tiers.allows(speaker.tier, call.tool) &&
161
168
  (!call.minTier || tierAtLeast(speaker.tier, call.minTier)),
162
169
  );
@@ -64,6 +64,11 @@ export interface AgentServerOptions {
64
64
  * see `AgentSessions.memory`.
65
65
  */
66
66
  memory?: "everyone" | "owners";
67
+ /**
68
+ * Who the agents answer in their channels and groups' channels: `"everyone"` (the default) or
69
+ * `"owners"`, which leaves a speaker below the owner tier unanswered.
70
+ */
71
+ speakers?: "everyone" | "owners";
67
72
  /** The prompt every agent starts with, and the one for speakers other than the owner. */
68
73
  prompts: { shared: string; guest?: string };
69
74
  /** The listener the avatar pictures are served on. */
@@ -283,6 +288,7 @@ export function agentServerPlugin(
283
288
  channels: [
284
289
  agentClaim({
285
290
  owner: options.owner,
291
+ ...(options.speakers ? { speakers: options.speakers } : {}),
286
292
  team: built,
287
293
  runtime: running,
288
294
  surface: context.surfaces,
@@ -3,6 +3,8 @@ import {
3
3
  type AccessRules,
4
4
  type AccessTier,
5
5
  checkAccessRules,
6
+ type Provisioning,
7
+ type ProvisioningRule,
6
8
  rulesOfSpeakerMap,
7
9
  } from "../identity/access-policy.ts";
8
10
  import { parseIdentity } from "../identity/actor-facts.ts";
@@ -11,12 +13,14 @@ import type { Logger } from "../log.ts";
11
13
  import type { TierMembers } from "../speakers.ts";
12
14
  import type { Pronouns, ResolvedConfig, RoundtableConfig } from "./config.ts";
13
15
  import {
16
+ either,
14
17
  type Field,
15
18
  guarded,
16
19
  integer,
17
20
  list,
18
21
  oneOf,
19
22
  optional,
23
+ record,
20
24
  shape,
21
25
  text,
22
26
  } from "./schema.ts";
@@ -48,10 +52,19 @@ export interface AccessConfig {
48
52
  owners: readonly AccessOwnerConfig[];
49
53
  admins?: AccessTierConfig;
50
54
  members?: AccessTierConfig;
51
- /** `admitted` (default): someone the rules admit gets a principal at first contact, and a 0.8 speaker claims theirs; `linked`: only identities linked with the CLI, or the owners' configured ones, are served, and no one claims a 0.8 principal. */
52
- provisioning?: "admitted" | "linked";
55
+ /**
56
+ * `admitted` (default): someone the rules admit gets a principal at first contact, and a 0.8 speaker claims theirs; `linked`: only identities linked with the CLI, or the owners' configured ones, are served, and no one claims a 0.8 principal.
57
+ * A map gives a mode per surface, such as `{ web: "admitted", discord: "linked" }`; a surface it leaves out is `linked`, so a misspelled surface cannot admit everyone.
58
+ */
59
+ provisioning?: ProvisioningRule;
53
60
  /** How many days someone whose tier comes only from the rules may go unseen before their background turns are refused; default 30. */
54
61
  backgroundStaleDays?: number;
62
+ /**
63
+ * Lets people link their own account of these identity providers, such as `["discord"]`, to
64
+ * their principal through the `IDENTITY_LINKS` service a plugin reads; without it no such
65
+ * service exists. A principal that holds the owner role cannot be linked this way.
66
+ */
67
+ selfLink?: { providers: readonly string[] };
55
68
  }
56
69
 
57
70
  const everyone: Field<boolean | string[]> = guarded(
@@ -80,8 +93,14 @@ export const accessShape = shape({
80
93
  ),
81
94
  admins: optional(tierShape),
82
95
  members: optional(tierShape),
83
- provisioning: optional(oneOf<"admitted" | "linked">("admitted", "linked")),
96
+ provisioning: optional(
97
+ either(
98
+ oneOf<Provisioning>("admitted", "linked"),
99
+ record(oneOf<Provisioning>("admitted", "linked")),
100
+ ),
101
+ ),
84
102
  backgroundStaleDays: optional(integer(1, 36_500)),
103
+ selfLink: optional(shape({ providers: list(text) })),
85
104
  });
86
105
 
87
106
  /** The days an unseen principal's background turns keep their last tier, by default. */
@@ -124,6 +143,7 @@ export function rulesOfAccess(config: AccessConfig): AccessRules {
124
143
  ...(members ? { members } : {}),
125
144
  provisioning: config.provisioning ?? "admitted",
126
145
  backgroundStaleDays: config.backgroundStaleDays ?? BACKGROUND_STALE_DAYS,
146
+ ...(config.selfLink ? { selfLink: config.selfLink } : {}),
127
147
  });
128
148
  }
129
149
 
@@ -38,6 +38,12 @@ export interface DiscordConfig {
38
38
  * no memory tools, and never makes claude-bridge refuse the owner afterwards.
39
39
  */
40
40
  agentMemory?: "everyone" | "owners";
41
+ /**
42
+ * Who may talk to the agents in their channels and groups' channels: `"everyone"` (default)
43
+ * lets every speaker the access rules admit, `"owners"` only the owners, so a member's message
44
+ * there starts no turn. An outside webhook's post in an agent's channel is reported as before.
45
+ */
46
+ agentSpeakers?: "everyone" | "owners";
41
47
  /**
42
48
  * What a turn addressed to the assistant in a server channel reads of the messages posted there
43
49
  * since its last post, mentions or not; on with the defaults when left out. `false` turns it off
@@ -67,6 +73,7 @@ export interface ResolvedDiscord {
67
73
  admin: boolean;
68
74
  refusalHint?: string;
69
75
  agentMemory?: "everyone" | "owners";
76
+ agentSpeakers?: "everyone" | "owners";
70
77
  /** As configured: `{}` when left out, so the defaults apply, or `false`. */
71
78
  channelContext: NonNullable<DiscordConfig["channelContext"]>;
72
79
  freshMarker?: string | false;
@@ -90,6 +97,9 @@ export function resolvedDiscord(
90
97
  ...(discord.agentMemory === undefined
91
98
  ? {}
92
99
  : { agentMemory: discord.agentMemory }),
100
+ ...(discord.agentSpeakers === undefined
101
+ ? {}
102
+ : { agentSpeakers: discord.agentSpeakers }),
93
103
  channelContext: discord.channelContext ?? {},
94
104
  ...(discord.freshMarker === undefined
95
105
  ? {}
@@ -116,6 +126,7 @@ export const discordShape = shape({
116
126
  admin: optional(bool),
117
127
  refusalHint: optional(text),
118
128
  agentMemory: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
129
+ agentSpeakers: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
119
130
  channelContext: optional(orOff(channelContextShape)),
120
131
  freshMarker: optional(orOff(text)),
121
132
  cardGraceSeconds: optional(integer(1, 86_400)),
@@ -103,6 +103,20 @@ export const record = <T>(item: Field<T>): Field<Record<string, T>> => ({
103
103
  },
104
104
  });
105
105
 
106
+ /** A string `string` accepts, or an object `object` accepts: one field that is written either way. */
107
+ export const either = <A extends string, B extends object>(
108
+ string: Field<A>,
109
+ object: Field<B>,
110
+ ): Field<A | B> => ({
111
+ describe: `${string.describe}, or ${object.describe}`,
112
+ check: (value, path) => {
113
+ if (typeof value === "string") return string.check(value, path);
114
+ if (typeof value !== "object" || value === null || Array.isArray(value))
115
+ throw wrong(path, `${string.describe}, or ${object.describe}`, value);
116
+ return object.check(value, path);
117
+ },
118
+ });
119
+
106
120
  /** A value a caller-supplied predicate accepts, such as a plugin object. */
107
121
  export const guarded = <T>(
108
122
  describe: string,
@@ -200,6 +200,7 @@ function discordAssembly(
200
200
  shellUser: userInfo().username,
201
201
  prompts: { shared: sharedPrompt, guest },
202
202
  ...(discord.agentMemory ? { memory: discord.agentMemory } : {}),
203
+ ...(discord.agentSpeakers ? { speakers: discord.agentSpeakers } : {}),
203
204
  avatarListener: "public",
204
205
  // resolveConfig refuses Discord without a public address.
205
206
  avatarUrl: config.http?.publicUrl ?? "",
@@ -8,6 +8,7 @@ import type { ReplyFile } from "./domain/conversation.ts";
8
8
  import { PluginError } from "./errors.ts";
9
9
  import { type HoldRule, isPromise } from "./holds.ts";
10
10
  import { messages } from "./i18n/index.ts";
11
+ import type { ApprovedBy } from "./interactions/prompts.ts";
11
12
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
12
13
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
13
14
  import {
@@ -42,6 +43,13 @@ export interface ToolTurn {
42
43
  * when it is undefined.
43
44
  */
44
45
  origin?: string;
46
+ /**
47
+ * Who approved the call, for a call that was held: the person who pressed its card, or whose
48
+ * message confirmed it, including a card answered after the turn stopped waiting. Undefined for
49
+ * a call that ran without a hold, and for one a surface released without saying who; a tool
50
+ * that must know the approver fails closed when it is undefined.
51
+ */
52
+ approvedBy?: ApprovedBy;
45
53
  /**
46
54
  * What the client that started the turn can do for this tool, such as run a function in the
47
55
  * browser the person chats from; undefined for a turn whose transport gives none, such as a
@@ -107,6 +115,20 @@ export interface ToolSpec<Schema extends TObject> {
107
115
  args: Static<Schema>,
108
116
  turn: ToolTurn,
109
117
  ) => string | undefined | Promise<string | undefined>;
118
+ /**
119
+ * The principal ids that may approve this call when it is held, instead of the speaker whose
120
+ * turn makes it, looked up as that turn like `hold` (same wait, same `turn.signal`, and it
121
+ * must change nothing). Each needs the call's tier when they answer, and the owners may still
122
+ * approve it. An empty list leaves the owners alone. A rejection or a timeout leaves the owners
123
+ * alone too. Only these and the owners approve the call, by a card or by a message that
124
+ * confirms it, even after a restart: the speaker whose turn made it may not unless named, and in
125
+ * a private conversation, whose person alone answers, the call is refused unless they are named
126
+ * or an owner. The approver reaches `run` as `turn.approvedBy`.
127
+ */
128
+ approvers?: (
129
+ args: Static<Schema>,
130
+ turn: ToolTurn,
131
+ ) => Promise<readonly string[]>;
110
132
  run(args: Static<Schema>, turn: ToolTurn): Promise<ToolOutput> | ToolOutput;
111
133
  }
112
134
 
@@ -164,12 +186,12 @@ export function defineTool<Schema extends TObject>(
164
186
  label: name,
165
187
  description: spec.description,
166
188
  parameters: spec.parameters,
167
- execute: async (_toolCallId, params, signal) => {
189
+ execute: async (toolCallId, params, signal) => {
168
190
  try {
169
191
  return toolOutput(
170
192
  await spec.run(
171
193
  params as Static<Schema>,
172
- toolTurn(context, signal),
194
+ toolTurn(context, signal, toolCallId),
173
195
  ),
174
196
  );
175
197
  } catch (error) {
@@ -181,22 +203,38 @@ export function defineTool<Schema extends TObject>(
181
203
  },
182
204
  }),
183
205
  };
184
- const hold = spec.hold;
206
+ const { hold, approvers } = spec;
185
207
  const contribution = {
186
208
  name,
187
209
  minTier: spec.minTier,
188
210
  agent: spec.agent ?? true,
189
211
  session,
190
212
  };
191
- if (!hold) return contribution;
213
+ if (!hold && !approvers) return contribution;
192
214
  return {
193
215
  ...contribution,
194
216
  hold: {
195
217
  name: `tool:${name}`,
196
218
  describe: (tool, input) =>
197
- tool === name ? holdWithoutTurn(name, hold, input) : undefined,
219
+ hold && tool === name ? holdWithoutTurn(name, hold, input) : undefined,
198
220
  describeInTurn: (tool, input, _context, turn) =>
199
- tool === name ? hold(input as Static<Schema>, turn) : undefined,
221
+ hold && tool === name ? hold(input as Static<Schema>, turn) : undefined,
222
+ ...(approvers
223
+ ? {
224
+ // The tool names approvers for its own calls only, so another tool's call with
225
+ // computed arguments in a precheck script is not made owner-only by it.
226
+ mayName: (tool: string) => tool === name,
227
+ approversInTurn: (
228
+ tool: string,
229
+ input: Record<string, unknown>,
230
+ _context: unknown,
231
+ turn: ToolTurn,
232
+ ) =>
233
+ tool === name
234
+ ? approvers(input as Static<Schema>, turn)
235
+ : undefined,
236
+ }
237
+ : {}),
200
238
  },
201
239
  };
202
240
  }