pi-roundtable 0.8.0 → 0.9.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 +102 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +606 -78
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +140 -85
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +41 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +135 -134
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +50 -5
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +47 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SQL } from "bun";
|
|
3
|
-
import { OWNER_TARGET } from "../agents/agent-claim.ts";
|
|
4
3
|
import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
|
|
5
4
|
import { discordAdminPlugin } from "../builtin/discord-admin.ts";
|
|
6
5
|
import { type ModulesOptions, modulesPlugin } from "../builtin/modules.ts";
|
|
7
6
|
import { skillsPlugin } from "../builtin/skills.ts";
|
|
8
7
|
import { memoryPlugin } from "../builtin/stores.ts";
|
|
8
|
+
import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
|
|
9
9
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
10
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
11
|
+
import type { Principal } from "../identity/principal-store.ts";
|
|
10
12
|
import { silentLogger } from "../log.ts";
|
|
13
|
+
import { PERSONAL_TARGET } from "../modules/background/personal-target.ts";
|
|
11
14
|
import type { SkillStore } from "../modules/skills/skill-store.ts";
|
|
12
15
|
import {
|
|
13
16
|
type Contribution,
|
|
@@ -15,10 +18,22 @@ import {
|
|
|
15
18
|
pluginContext,
|
|
16
19
|
type RoundtablePlugin,
|
|
17
20
|
} from "../plugin.ts";
|
|
21
|
+
import {
|
|
22
|
+
type DirectChannelProvider,
|
|
23
|
+
directChannelsPort,
|
|
24
|
+
} from "../presence/direct-channels.ts";
|
|
18
25
|
import { ServiceRegistry } from "../registry/services.ts";
|
|
19
26
|
import { surfacePort } from "../routing/surface-port.ts";
|
|
20
27
|
import type { AgentServer, MemoryStore, ScheduleStore } from "../services.ts";
|
|
21
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
AGENTS,
|
|
30
|
+
CONVERSATIONS,
|
|
31
|
+
IDENTITY,
|
|
32
|
+
MEMORY,
|
|
33
|
+
SCHEDULES,
|
|
34
|
+
} from "../services.ts";
|
|
35
|
+
import type { Tier } from "../speakers.ts";
|
|
36
|
+
import { toolTiers } from "../tool-tiers.ts";
|
|
22
37
|
|
|
23
38
|
/** What the modules' tools did, for a test to read. */
|
|
24
39
|
export interface ModuleRecord {
|
|
@@ -26,14 +41,28 @@ export interface ModuleRecord {
|
|
|
26
41
|
threadOrigins: (ChannelKey | undefined)[];
|
|
27
42
|
/** The channel each delegated report came back to. */
|
|
28
43
|
reportChannels: ChannelKey[];
|
|
29
|
-
/** How often
|
|
44
|
+
/** How often anything asked the connection for the primary owner's messages, which the modules no longer read. */
|
|
30
45
|
ownerChannelAsked: number;
|
|
46
|
+
/** Whose direct channel was asked for, in order. */
|
|
47
|
+
directAsked: string[];
|
|
48
|
+
/** The notices sent through a direct channel, by principal. */
|
|
49
|
+
notified: { principalId: string; text: string }[];
|
|
31
50
|
/** What was posted on the surface, by channel. */
|
|
32
51
|
posted: { channel: ChannelKey; text: string }[];
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
export const OWNER_CHANNEL: ChannelKey = "discord:owner-dm";
|
|
36
55
|
|
|
56
|
+
/** The primary owner the modules serve: principal "1". */
|
|
57
|
+
export const MODULES_OWNER = {
|
|
58
|
+
id: "1",
|
|
59
|
+
name: "Owner",
|
|
60
|
+
pronouns: { subject: "they", object: "them", possessive: "their" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The direct messages the stand-in Discord provider reaches by default: the owner's, principal "1". */
|
|
64
|
+
const DIRECT: Readonly<Record<string, ChannelKey>> = { "1": OWNER_CHANNEL };
|
|
65
|
+
|
|
37
66
|
/** The modules plugin set up over stand-in stores and surface; returns its contribution and the record. */
|
|
38
67
|
export async function setUpModules(
|
|
39
68
|
options: {
|
|
@@ -46,7 +75,29 @@ export async function setUpModules(
|
|
|
46
75
|
owns?: (channel: ChannelKey) => boolean;
|
|
47
76
|
/** Whether the claim owning a channel takes background turns; by default every owned channel's does. */
|
|
48
77
|
takesBackground?: (channel: ChannelKey) => boolean;
|
|
78
|
+
/** Whether the claim owning a channel takes the host's own turns; by default every owned channel's does. */
|
|
79
|
+
takesSystemReports?: (channel: ChannelKey) => boolean;
|
|
49
80
|
errorReporter?: ModulesOptions["errorReporter"];
|
|
81
|
+
/** The schedules the tools keep; by default a stub that answers nothing. */
|
|
82
|
+
schedules?: ScheduleStore;
|
|
83
|
+
/** The conversations the host records, which say whose a conversation is; by default none is recorded. */
|
|
84
|
+
conversations?: Pick<ConversationRegistry, "get">;
|
|
85
|
+
/**
|
|
86
|
+
* Each principal's direct messages on the stand-in Discord provider, the host's only direct
|
|
87
|
+
* channel; by default the owner's are `OWNER_CHANNEL`, and without Discord there is none.
|
|
88
|
+
*/
|
|
89
|
+
direct?: Readonly<Record<string, ChannelKey>>;
|
|
90
|
+
/**
|
|
91
|
+
* Whether the stand-in provider reaches the network, read at each `reaches`, which throws
|
|
92
|
+
* while it does not; `knows` answers from its records either way. Default always.
|
|
93
|
+
*/
|
|
94
|
+
online?: () => boolean;
|
|
95
|
+
/** The host's owners, as `IDENTITY` lists them; by default there is no identity service. */
|
|
96
|
+
owners?: readonly Principal[];
|
|
97
|
+
/** The operator's tool tiers; by default none, so notify stays the owners'. */
|
|
98
|
+
toolTiers?: Readonly<Record<string, Tier>>;
|
|
99
|
+
/** The modules' options besides the test's own. */
|
|
100
|
+
modules?: Partial<ModulesOptions>;
|
|
50
101
|
} = {},
|
|
51
102
|
): Promise<{
|
|
52
103
|
plugin: RoundtablePlugin;
|
|
@@ -58,14 +109,31 @@ export async function setUpModules(
|
|
|
58
109
|
threadOrigins: [],
|
|
59
110
|
reportChannels: [],
|
|
60
111
|
ownerChannelAsked: 0,
|
|
112
|
+
directAsked: [],
|
|
113
|
+
notified: [],
|
|
61
114
|
posted: [],
|
|
62
115
|
};
|
|
116
|
+
const direct =
|
|
117
|
+
options.direct ?? (options.discord === false ? undefined : DIRECT);
|
|
118
|
+
const providers: DirectChannelProvider[] = direct
|
|
119
|
+
? [
|
|
120
|
+
{
|
|
121
|
+
name: "discord",
|
|
122
|
+
label: "a direct message on Discord",
|
|
123
|
+
knows: async (principalId) => direct[principalId] !== undefined,
|
|
124
|
+
reaches: async (principalId) => {
|
|
125
|
+
record.directAsked.push(principalId);
|
|
126
|
+
if (options.online?.() === false)
|
|
127
|
+
throw new Error("Discord is unreachable");
|
|
128
|
+
return direct[principalId];
|
|
129
|
+
},
|
|
130
|
+
deliver: async (principalId, text) =>
|
|
131
|
+
void record.notified.push({ principalId, text }),
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
: [];
|
|
63
135
|
const plugin = modulesPlugin({
|
|
64
|
-
owner:
|
|
65
|
-
id: "1",
|
|
66
|
-
name: "Owner",
|
|
67
|
-
pronouns: { subject: "they", object: "them", possessive: "their" },
|
|
68
|
-
},
|
|
136
|
+
owner: MODULES_OWNER,
|
|
69
137
|
assistant: "Assistant",
|
|
70
138
|
// SAFETY: a worker of the test's own replaces the one that would read the runtime.
|
|
71
139
|
modelRuntime: {} as ModelRuntime,
|
|
@@ -77,11 +145,26 @@ export async function setUpModules(
|
|
|
77
145
|
worker: { run: async () => "found it" },
|
|
78
146
|
},
|
|
79
147
|
...(options.errorReporter ? { errorReporter: options.errorReporter } : {}),
|
|
148
|
+
...options.modules,
|
|
80
149
|
});
|
|
81
150
|
const services = new ServiceRegistry([plugin]);
|
|
82
151
|
// SAFETY: the tools under test read no store; each stub is asked for nothing else.
|
|
83
152
|
services.preset(MEMORY, {} as MemoryStore);
|
|
84
|
-
services.preset(SCHEDULES, {} as ScheduleStore);
|
|
153
|
+
services.preset(SCHEDULES, options.schedules ?? ({} as ScheduleStore));
|
|
154
|
+
if (options.conversations)
|
|
155
|
+
// SAFETY: the schedule tools only read a conversation's record.
|
|
156
|
+
services.preset(
|
|
157
|
+
CONVERSATIONS,
|
|
158
|
+
options.conversations as unknown as ConversationRegistry,
|
|
159
|
+
);
|
|
160
|
+
if (options.owners) {
|
|
161
|
+
const owners = options.owners;
|
|
162
|
+
// SAFETY: the modules ask the identity service for the owners and a principal by id only.
|
|
163
|
+
services.preset(IDENTITY, {
|
|
164
|
+
owners: async () => owners,
|
|
165
|
+
principal: async (id: string) => owners.find((owner) => owner.id === id),
|
|
166
|
+
} as unknown as IdentityService);
|
|
167
|
+
}
|
|
85
168
|
if (options.agentChannelOf)
|
|
86
169
|
// SAFETY: the schedule tools ask the agent team for a channel and nothing else.
|
|
87
170
|
services.preset(AGENTS, {
|
|
@@ -108,30 +191,34 @@ export async function setUpModules(
|
|
|
108
191
|
} as unknown as DiscordServices);
|
|
109
192
|
const owns =
|
|
110
193
|
options.owns ?? ((channel: ChannelKey) => channel.startsWith("discord:"));
|
|
111
|
-
//
|
|
194
|
+
// Only Discord's channels have a surface; any other conversation has none to report in.
|
|
195
|
+
const surfaces = surfacePort(() => [
|
|
196
|
+
{
|
|
197
|
+
surface: "discord",
|
|
198
|
+
start: async () => undefined,
|
|
199
|
+
sendReply: async (channel, reply) =>
|
|
200
|
+
void record.posted.push({ channel, text: reply.chunks.join("") }),
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
// SAFETY: setup and the preflight read only the logger, the conversations' background, targets and owners, the surfaces, the direct channels, and the services.
|
|
112
204
|
const context = {
|
|
113
205
|
logger: silentLogger(),
|
|
114
206
|
conversations: {
|
|
115
207
|
target: (name: string) =>
|
|
116
|
-
options.ownerTarget !== false && name ===
|
|
117
|
-
?
|
|
208
|
+
options.ownerTarget !== false && name === PERSONAL_TARGET.name
|
|
209
|
+
? PERSONAL_TARGET
|
|
118
210
|
: undefined,
|
|
119
211
|
owns,
|
|
120
212
|
takesBackground: options.takesBackground ?? owns,
|
|
213
|
+
takesSystemReports: options.takesSystemReports ?? owns,
|
|
121
214
|
background: async (turn: { channel: ChannelKey }) => {
|
|
122
215
|
record.reportChannels.push(turn.channel);
|
|
123
216
|
return { status: "ran" };
|
|
124
217
|
},
|
|
125
218
|
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
surface: "discord",
|
|
130
|
-
start: async () => undefined,
|
|
131
|
-
sendReply: async (channel, reply) =>
|
|
132
|
-
void record.posted.push({ channel, text: reply.chunks.join("") }),
|
|
133
|
-
},
|
|
134
|
-
]),
|
|
219
|
+
surfaces,
|
|
220
|
+
directChannels: directChannelsPort(() => providers, surfaces),
|
|
221
|
+
toolTiers: toolTiers(options.toolTiers),
|
|
135
222
|
} as unknown as Omit<PluginContext, "services">;
|
|
136
223
|
const contribution = await services.setUp(plugin, () =>
|
|
137
224
|
plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Principal } from "../identity/principal-store.ts";
|
|
1
2
|
import type { OwnerIdentity } from "../identity.ts";
|
|
2
3
|
import type { Speaker } from "../speakers.ts";
|
|
3
4
|
|
|
@@ -7,9 +8,25 @@ export const TEST_OWNER: OwnerIdentity = {
|
|
|
7
8
|
pronouns: { subject: "he", object: "him", possessive: "his" },
|
|
8
9
|
};
|
|
9
10
|
|
|
11
|
+
/** The owner as a principal of the tests, its id the owner speaker's, as for an owner carried over from 0.8. */
|
|
12
|
+
export const OWNER_PRINCIPAL: Principal = {
|
|
13
|
+
id: "1",
|
|
14
|
+
displayName: TEST_OWNER.name,
|
|
15
|
+
pronouns: "he",
|
|
16
|
+
disabled: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A member as a principal of the tests. */
|
|
20
|
+
export const MEMBER_PRINCIPAL: Principal = {
|
|
21
|
+
id: "2",
|
|
22
|
+
displayName: "Bo",
|
|
23
|
+
disabled: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
10
26
|
/** The owner as a speaker of the tests. */
|
|
11
27
|
export const OWNER_SPEAKER: Speaker = {
|
|
12
28
|
id: "1",
|
|
13
29
|
name: TEST_OWNER.name,
|
|
14
30
|
tier: "owner",
|
|
31
|
+
principalId: OWNER_PRINCIPAL.id,
|
|
15
32
|
};
|
|
@@ -2,8 +2,12 @@ import { describe, test } from "bun:test";
|
|
|
2
2
|
import type { InboundMessage } from "../contract/channels.ts";
|
|
3
3
|
import type { ChatSurface } from "../contract/surface.ts";
|
|
4
4
|
import { parseChannelKey } from "../contract/surface.ts";
|
|
5
|
-
import type { Approval } from "../domain/owner-prompts.ts";
|
|
6
5
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
6
|
+
import {
|
|
7
|
+
type Approval,
|
|
8
|
+
type PromptScope,
|
|
9
|
+
promptScope,
|
|
10
|
+
} from "../interactions/prompts.ts";
|
|
7
11
|
import type { ChannelKey } from "../sessions.ts";
|
|
8
12
|
import type { Speaker } from "../speakers.ts";
|
|
9
13
|
|
|
@@ -33,23 +37,34 @@ export interface SurfaceContractSubject {
|
|
|
33
37
|
/** The person answers an open approval; without it the prompt checks are skipped. */
|
|
34
38
|
answer?(prompt: string, approved: boolean): Promise<void>;
|
|
35
39
|
/**
|
|
36
|
-
* The person as the speaker of the turns that ask them, given to `surface.prompts
|
|
37
|
-
* owner-tier speaker. Every approval the
|
|
38
|
-
* it is theirs to answer
|
|
40
|
+
* The person as the speaker of the turns that ask them, given to `surface.prompts` as the
|
|
41
|
+
* scope `promptScope(speaker, visibility)`; default an owner-tier speaker. Every approval the
|
|
42
|
+
* contract asks for needs exactly the speaker's tier, so it is theirs to answer; one above a
|
|
43
|
+
* speaker below the owner tier checks that a private conversation escalates to no one.
|
|
39
44
|
*/
|
|
40
45
|
speaker?: Speaker;
|
|
41
46
|
/**
|
|
42
|
-
* Someone else on the surface's network, not
|
|
43
|
-
* prompt and answers it; without it the responder-scope check is
|
|
47
|
+
* Someone else on the surface's network, another principal and not an owner, who knows the
|
|
48
|
+
* id of the person's open prompt and answers it; without it the responder-scope check is
|
|
49
|
+
* skipped.
|
|
44
50
|
*/
|
|
45
|
-
stranger?:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
stranger?: SurfaceContractAnswerer;
|
|
52
|
+
/**
|
|
53
|
+
* An owner on the surface's network, another principal than the person, who knows the id of
|
|
54
|
+
* the person's open prompt in their private conversation and answers it; without it that
|
|
55
|
+
* check is skipped.
|
|
56
|
+
*/
|
|
57
|
+
owner?: SurfaceContractAnswerer;
|
|
50
58
|
close?(): Promise<void>;
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
/** Someone on the network other than the person, who answers a prompt by its id. */
|
|
62
|
+
export interface SurfaceContractAnswerer {
|
|
63
|
+
/** They join the surface, once it has started. */
|
|
64
|
+
join?(): Promise<void>;
|
|
65
|
+
answer(prompt: string, approved: boolean): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/** One way the subject broke the contract. */
|
|
54
69
|
export interface SurfaceContractFailure {
|
|
55
70
|
name: string;
|
|
@@ -93,7 +108,18 @@ const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
|
|
|
93
108
|
running.subject.observations().filter((o) => o.kind === kind);
|
|
94
109
|
|
|
95
110
|
const speakerOf = (subject: SurfaceContractSubject): Speaker =>
|
|
96
|
-
subject.speaker ?? {
|
|
111
|
+
subject.speaker ?? {
|
|
112
|
+
id: "contract-speaker",
|
|
113
|
+
name: "Ada",
|
|
114
|
+
tier: "owner",
|
|
115
|
+
principalId: "contract-speaker",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The person's prompt scope in a conversation of this visibility. */
|
|
119
|
+
const scopeOf = (
|
|
120
|
+
subject: SurfaceContractSubject,
|
|
121
|
+
visibility: "private" | "shared" = "shared",
|
|
122
|
+
): PromptScope => promptScope(speakerOf(subject), visibility);
|
|
97
123
|
|
|
98
124
|
/** The newest prompt the person has seen, once one more than `before` is open. */
|
|
99
125
|
async function nextPrompt(running: Running, before: number): Promise<string> {
|
|
@@ -109,7 +135,7 @@ async function nextPrompt(running: Running, before: number): Promise<string> {
|
|
|
109
135
|
/** Asks for one approval and answers it as the person does. */
|
|
110
136
|
async function approval(running: Running, approved: boolean) {
|
|
111
137
|
const { surface, channel, answer } = running.subject;
|
|
112
|
-
const prompts = surface.prompts?.(channel,
|
|
138
|
+
const prompts = surface.prompts?.(channel, scopeOf(running.subject));
|
|
113
139
|
if (!prompts || !answer)
|
|
114
140
|
throw new ContractBroken("the surface shows no prompts");
|
|
115
141
|
const before = seen(running, "prompt").length;
|
|
@@ -264,7 +290,7 @@ const CHECKS: readonly Check[] = [
|
|
|
264
290
|
subject.surface.prompts !== undefined && subject.answer !== undefined,
|
|
265
291
|
run: async (running) => {
|
|
266
292
|
const { surface, channel } = running.subject;
|
|
267
|
-
const prompts = surface.prompts?.(channel,
|
|
293
|
+
const prompts = surface.prompts?.(channel, scopeOf(running.subject));
|
|
268
294
|
if (!prompts) throw new ContractBroken("the surface shows no prompts");
|
|
269
295
|
const stop = new AbortController();
|
|
270
296
|
const before = seen(running, "prompt").length;
|
|
@@ -293,39 +319,89 @@ const CHECKS: readonly Check[] = [
|
|
|
293
319
|
subject.surface.prompts !== undefined &&
|
|
294
320
|
subject.answer !== undefined &&
|
|
295
321
|
subject.stranger !== undefined,
|
|
322
|
+
run: (running) =>
|
|
323
|
+
answeredOnlyByThem(running, running.subject.stranger, "shared"),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "another principal cannot answer for the person in their private conversation, even an owner",
|
|
327
|
+
applies: (subject) =>
|
|
328
|
+
subject.surface.prompts !== undefined &&
|
|
329
|
+
subject.answer !== undefined &&
|
|
330
|
+
subject.owner !== undefined,
|
|
331
|
+
run: (running) =>
|
|
332
|
+
answeredOnlyByThem(running, running.subject.owner, "private"),
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "a private conversation's approval above the person's tier goes to no one: it expires at once",
|
|
336
|
+
applies: (subject) =>
|
|
337
|
+
subject.surface.prompts !== undefined &&
|
|
338
|
+
speakerOf(subject).tier !== "owner",
|
|
296
339
|
run: async (running) => {
|
|
297
|
-
const { surface, channel
|
|
298
|
-
const prompts = surface.prompts?.(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
340
|
+
const { surface, channel } = running.subject;
|
|
341
|
+
const prompts = surface.prompts?.(
|
|
342
|
+
channel,
|
|
343
|
+
scopeOf(running.subject, "private"),
|
|
344
|
+
);
|
|
345
|
+
if (!prompts) throw new ContractBroken("the surface shows no prompts");
|
|
302
346
|
const before = seen(running, "prompt").length;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
.
|
|
306
|
-
|
|
307
|
-
"Send the report.",
|
|
308
|
-
undefined,
|
|
309
|
-
speakerOf(running.subject).tier,
|
|
310
|
-
)
|
|
311
|
-
.then((value) => {
|
|
312
|
-
settled = value;
|
|
313
|
-
return value;
|
|
314
|
-
});
|
|
315
|
-
const id = await nextPrompt(running, before);
|
|
316
|
-
await stranger.answer(id, true);
|
|
317
|
-
await Bun.sleep(50);
|
|
347
|
+
const answer = await Promise.race([
|
|
348
|
+
prompts.confirm("Approve?", "Delete the notes.", undefined, "owner"),
|
|
349
|
+
Bun.sleep(1000).then(() => "still open" as const),
|
|
350
|
+
]);
|
|
318
351
|
expectThat(
|
|
319
|
-
|
|
320
|
-
`
|
|
352
|
+
answer === "expired",
|
|
353
|
+
`an approval no one may answer came back ${answer}`,
|
|
354
|
+
);
|
|
355
|
+
expectThat(
|
|
356
|
+
seen(running, "prompt").length === before,
|
|
357
|
+
"an approval no one may answer was shown",
|
|
321
358
|
);
|
|
322
|
-
await answer(id, false);
|
|
323
|
-
const own = await result;
|
|
324
|
-
expectThat(own === "declined", `the person's answer came back ${own}`);
|
|
325
359
|
},
|
|
326
360
|
},
|
|
327
361
|
];
|
|
328
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Opens the person's approval in a conversation of `visibility`, has `other` answer it by its id,
|
|
365
|
+
* and checks that only the person's own answer settles it.
|
|
366
|
+
*/
|
|
367
|
+
async function answeredOnlyByThem(
|
|
368
|
+
running: Running,
|
|
369
|
+
other: SurfaceContractAnswerer | undefined,
|
|
370
|
+
visibility: "private" | "shared",
|
|
371
|
+
): Promise<void> {
|
|
372
|
+
const { surface, channel, answer } = running.subject;
|
|
373
|
+
const prompts = surface.prompts?.(
|
|
374
|
+
channel,
|
|
375
|
+
scopeOf(running.subject, visibility),
|
|
376
|
+
);
|
|
377
|
+
if (!prompts || !answer || !other)
|
|
378
|
+
throw new ContractBroken("the surface shows no prompts");
|
|
379
|
+
await other.join?.();
|
|
380
|
+
const before = seen(running, "prompt").length;
|
|
381
|
+
let settled: Approval | undefined;
|
|
382
|
+
const result = prompts
|
|
383
|
+
.confirm(
|
|
384
|
+
"Approve?",
|
|
385
|
+
"Send the report.",
|
|
386
|
+
undefined,
|
|
387
|
+
speakerOf(running.subject).tier,
|
|
388
|
+
)
|
|
389
|
+
.then((value) => {
|
|
390
|
+
settled = value;
|
|
391
|
+
return value;
|
|
392
|
+
});
|
|
393
|
+
const id = await nextPrompt(running, before);
|
|
394
|
+
await other.answer(id, true);
|
|
395
|
+
await Bun.sleep(50);
|
|
396
|
+
expectThat(
|
|
397
|
+
settled === undefined,
|
|
398
|
+
`another principal's answer settled the approval as ${settled}`,
|
|
399
|
+
);
|
|
400
|
+
await answer(id, false);
|
|
401
|
+
const own = await result;
|
|
402
|
+
expectThat(own === "declined", `the person's answer came back ${own}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
329
405
|
async function runCheck(
|
|
330
406
|
check: Check,
|
|
331
407
|
make: () => Promise<SurfaceContractSubject>,
|
|
@@ -366,8 +442,9 @@ export async function checkSurfaceContract(
|
|
|
366
442
|
* Registers the chat surface contract as one test per check, under `name`: the surface names a
|
|
367
443
|
* prefix and owns its channel, delivers what its person writes, shows every chunk of a reply, and,
|
|
368
444
|
* where it offers them, delivers files, ends typing and stop controls idempotently, shows
|
|
369
|
-
* progress, resolves approvals as the person answers or as a stopped turn cancels them,
|
|
370
|
-
*
|
|
445
|
+
* progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
|
|
446
|
+
* another person's answer to the person's approval, and, in a private conversation, an owner's
|
|
447
|
+
* too, and lets an approval above the person's tier there expire at once, shown to no one. Each
|
|
371
448
|
* check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
|
|
372
449
|
*/
|
|
373
450
|
export function describeSurfaceContract(
|
|
@@ -11,27 +11,32 @@ import { type RoundtableConfig, resolveConfig } from "../config/config.ts";
|
|
|
11
11
|
import type { ConversationPort } from "../contract/channels.ts";
|
|
12
12
|
import type { AgentRuntime } from "../contract/runtime.ts";
|
|
13
13
|
import type { ChatSurface } from "../contract/surface.ts";
|
|
14
|
-
import { defineRoundtable } from "../define-roundtable.ts";
|
|
14
|
+
import { defineRoundtable, discordOwnerOf } from "../define-roundtable.ts";
|
|
15
15
|
import { CommandCollection } from "../discord/command-collection.ts";
|
|
16
16
|
import type { ComposedCommands } from "../discord/compose-commands.ts";
|
|
17
17
|
import type { DiscordConnection } from "../discord/connection.ts";
|
|
18
|
+
import { discordDirectChannel } from "../discord/direct-channel.ts";
|
|
18
19
|
import type {
|
|
19
20
|
CommandGuard,
|
|
20
21
|
InteractionContribution,
|
|
21
22
|
} from "../discord/interaction-module.ts";
|
|
22
|
-
import {
|
|
23
|
+
import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
|
|
23
24
|
import { Roundtable } from "../host.ts";
|
|
24
25
|
import { silentLogger } from "../log.ts";
|
|
25
26
|
import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
|
|
26
27
|
import { CompactionTiers } from "../runtime/compaction-tiers.ts";
|
|
28
|
+
import { privateCompaction } from "../runtime/extensions/private-memory.ts";
|
|
27
29
|
import { sessionExtensions } from "../runtime/runtime-types.ts";
|
|
30
|
+
import { IDENTITY } from "../services.ts";
|
|
28
31
|
import type { AgentTurnScope, SessionContext } from "../sessions.ts";
|
|
32
|
+
import { THE_SPEAKER } from "../speakers.ts";
|
|
29
33
|
import { testDatabaseUrl } from "./database.ts";
|
|
30
34
|
|
|
31
35
|
export interface TestHostOptions {
|
|
32
36
|
/**
|
|
33
|
-
* Over a test configuration:
|
|
34
|
-
* (`ROUNDTABLE_TEST_DATABASE_URL`).
|
|
37
|
+
* Over a test configuration: the owner Ada in `access`, a guild, a temporary `dataDir`, and
|
|
38
|
+
* the test database (`ROUNDTABLE_TEST_DATABASE_URL`). Giving the 0.8 `owner` or `speakers`
|
|
39
|
+
* replaces the default `access`.
|
|
35
40
|
*/
|
|
36
41
|
config?: Partial<RoundtableConfig>;
|
|
37
42
|
/** The app's plugins, after the built-in ones, as `defineRoundtable` places them. */
|
|
@@ -73,17 +78,28 @@ export interface TestHost {
|
|
|
73
78
|
stop(): Promise<void>;
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
/** The test host's owner, Ada, as `access` writes her: her principal is her Discord user id. */
|
|
82
|
+
const TEST_ACCESS: NonNullable<RoundtableConfig["access"]> = {
|
|
83
|
+
owners: [
|
|
84
|
+
{
|
|
85
|
+
name: "Ada",
|
|
86
|
+
principal: "100000000000000001",
|
|
87
|
+
identities: ["discord:100000000000000001"],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
76
92
|
function defaultConfig(withDiscord: boolean): RoundtableConfig {
|
|
77
93
|
const dataDir = mkdtempSync(join(tmpdir(), "roundtable-test-host-"));
|
|
78
94
|
if (!withDiscord)
|
|
79
95
|
return {
|
|
80
|
-
|
|
96
|
+
access: TEST_ACCESS,
|
|
81
97
|
database: { url: testDatabaseUrl },
|
|
82
98
|
dataDir,
|
|
83
99
|
model: "anthropic/claude-sonnet-5-5",
|
|
84
100
|
};
|
|
85
101
|
return {
|
|
86
|
-
|
|
102
|
+
access: TEST_ACCESS,
|
|
87
103
|
discord: {
|
|
88
104
|
token: "token",
|
|
89
105
|
guild: "900000000000000001",
|
|
@@ -122,10 +138,14 @@ function quietRuntime(): AgentRuntime {
|
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
/** A Discord that connects to nothing, recording the commands added to it, standing in for the built-in plugin that does. */
|
|
125
|
-
function standInDiscord(
|
|
141
|
+
export function standInDiscord(
|
|
126
142
|
commands: CommandCollection,
|
|
127
143
|
guard: CommandGuard,
|
|
128
144
|
given: TestHostOptions["discord"],
|
|
145
|
+
/** The primary owner's Discord user id, whose direct messages are the owner channel. */
|
|
146
|
+
ownerId: string,
|
|
147
|
+
/** What `DISCORD.channelContext` answers; by default no context, as on a host with it off. */
|
|
148
|
+
channelContext: DiscordServices["channelContext"] = async () => undefined,
|
|
129
149
|
): RoundtablePlugin {
|
|
130
150
|
const surface: ChatSurface = {
|
|
131
151
|
surface: "discord",
|
|
@@ -137,6 +157,18 @@ function standInDiscord(
|
|
|
137
157
|
provides: [DISCORD],
|
|
138
158
|
replaces: [DISCORD],
|
|
139
159
|
setup: ({ services }) => {
|
|
160
|
+
const identity = services.find(IDENTITY);
|
|
161
|
+
const ownerChannel =
|
|
162
|
+
(given ? given.ownerChannel : undefined) ??
|
|
163
|
+
(async () => "discord:owner-dm" as const);
|
|
164
|
+
// Each person's direct messages, as the Discord plugin's provider reaches them; the owner's is the owner channel.
|
|
165
|
+
const direct = discordDirectChannel({
|
|
166
|
+
directChannel: async (userId) =>
|
|
167
|
+
userId === ownerId ? ownerChannel() : `discord:dm-${userId}`,
|
|
168
|
+
sendDirect: async () => undefined,
|
|
169
|
+
...(identity ? { identity } : {}),
|
|
170
|
+
ownerId,
|
|
171
|
+
});
|
|
140
172
|
// SAFETY: the plugins after it read only these members while they set up; the rest is read when a tool runs, which a test host does not do.
|
|
141
173
|
services.provide(DISCORD, {
|
|
142
174
|
connection: {
|
|
@@ -150,8 +182,9 @@ function standInDiscord(
|
|
|
150
182
|
commands: commands.registrar,
|
|
151
183
|
guard,
|
|
152
184
|
threads: { open: async () => undefined, sweep: async () => undefined },
|
|
185
|
+
channelContext,
|
|
153
186
|
} as unknown as DiscordServices);
|
|
154
|
-
return { surfaces: [surface] };
|
|
187
|
+
return { surfaces: [surface], directChannels: [direct] };
|
|
155
188
|
},
|
|
156
189
|
};
|
|
157
190
|
}
|
|
@@ -168,8 +201,8 @@ function ignored(): Ignored {
|
|
|
168
201
|
/** The tools a factory registers, by name; every other registration (handlers, commands, events) is accepted and ignored. */
|
|
169
202
|
async function registeredBy(factory: ExtensionFactory): Promise<string[]> {
|
|
170
203
|
const names: string[] = [];
|
|
171
|
-
// SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
|
|
172
204
|
const registerTool = (tool: { name: string }) => names.push(tool.name);
|
|
205
|
+
// SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
|
|
173
206
|
const api = new Proxy(
|
|
174
207
|
{},
|
|
175
208
|
{
|
|
@@ -188,6 +221,7 @@ const CORE_EXTENSIONS = {
|
|
|
188
221
|
confirmationGate: noop,
|
|
189
222
|
askUser: noop,
|
|
190
223
|
selfCompactGuard: noop,
|
|
224
|
+
privateMemory: noop,
|
|
191
225
|
activeTools: noop,
|
|
192
226
|
};
|
|
193
227
|
|
|
@@ -211,17 +245,31 @@ export async function testHost(
|
|
|
211
245
|
};
|
|
212
246
|
const withDiscord = options.discord !== false;
|
|
213
247
|
const given = options.discord === false ? undefined : options.discord;
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
};
|
|
248
|
+
const defaults = defaultConfig(withDiscord);
|
|
249
|
+
// A test that writes the 0.8 owner or speakers gets them in place of the default access.
|
|
250
|
+
if (options.config?.owner || options.config?.speakers) delete defaults.access;
|
|
251
|
+
const base: RoundtableConfig = { ...defaults, ...options.config };
|
|
218
252
|
const resolved = resolveConfig(base);
|
|
219
253
|
const rootCommand = resolved.slug;
|
|
220
|
-
|
|
254
|
+
// Like the Discord plugin's, the guard asks the host's identity service, once the host is up.
|
|
255
|
+
const identity = () => captured?.services.find(IDENTITY);
|
|
256
|
+
const guard = commandGuard({
|
|
257
|
+
ownerId: discordOwnerOf(resolved).id,
|
|
258
|
+
identity: {
|
|
259
|
+
resolve: async (facts, scope) => identity()?.resolve(facts, scope),
|
|
260
|
+
owners: async () => (await identity()?.owners()) ?? [],
|
|
261
|
+
identities: async (principalId) =>
|
|
262
|
+
(await identity()?.identities(principalId)) ?? [],
|
|
263
|
+
},
|
|
264
|
+
root: rootCommand,
|
|
265
|
+
logger: silentLogger(),
|
|
266
|
+
});
|
|
221
267
|
const config: RoundtableConfig = {
|
|
222
268
|
...base,
|
|
223
269
|
plugins: [
|
|
224
|
-
...(withDiscord
|
|
270
|
+
...(withDiscord
|
|
271
|
+
? [standInDiscord(commands, guard, given, discordOwnerOf(resolved).id)]
|
|
272
|
+
: []),
|
|
225
273
|
{
|
|
226
274
|
name: "test-runtime",
|
|
227
275
|
providers: { runtime: () => runtime },
|
|
@@ -261,9 +309,24 @@ export async function testHost(
|
|
|
261
309
|
kind: scope ? "agent" : "owner",
|
|
262
310
|
homeChannel: scope?.home ?? ownerChannel,
|
|
263
311
|
turnChannel: turnChannelOf(scope) ?? ownerChannel,
|
|
312
|
+
// Shared, as the runtime makes it, so its compactor summarizes no one's private memory.
|
|
264
313
|
compaction: {
|
|
265
|
-
wrap: (compactor) =>
|
|
314
|
+
wrap: (compactor) =>
|
|
315
|
+
tiers.wrapCompactor(
|
|
316
|
+
compactor,
|
|
317
|
+
() => undefined,
|
|
318
|
+
(event) =>
|
|
319
|
+
privateCompaction(
|
|
320
|
+
event.preparation,
|
|
321
|
+
event.branchEntries.flatMap((entry) =>
|
|
322
|
+
entry.type === "message" ? [entry.message] : [],
|
|
323
|
+
),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
266
326
|
},
|
|
327
|
+
conversation: { visibility: "shared" },
|
|
328
|
+
addressee: THE_SPEAKER,
|
|
329
|
+
memory: "speaker",
|
|
267
330
|
speaker: () => undefined,
|
|
268
331
|
runTask: async () => {
|
|
269
332
|
throw new Error("a test host session cannot run tasks");
|