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
package/examples/fake-surface.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
92
|
-
if (
|
|
125
|
+
const principals = shared ?? (theirs ? [scope.principalId] : []);
|
|
126
|
+
if (principals.length === 0 && !owners) return "unavailable";
|
|
93
127
|
return this.#ask(channel, title, signal, {
|
|
94
|
-
|
|
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
|
-
/**
|
|
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: {
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
open.
|
|
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:
|
|
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,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
|
|
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,
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
148
|
-
* channels are shared, or
|
|
149
|
-
*
|
|
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
|
-
/**
|
|
52
|
-
|
|
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(
|
|
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 ?? "",
|
package/src/core/define.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
}
|