pi-roundtable 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { defineTool } from "../define.ts";
|
|
4
|
+
import { NotLinkedError, PluginError } from "../errors.ts";
|
|
5
|
+
import { silentLogger } from "../log.ts";
|
|
6
|
+
import type {
|
|
7
|
+
Contribution,
|
|
8
|
+
PluginContext,
|
|
9
|
+
RoundtablePlugin,
|
|
10
|
+
} from "../plugin.ts";
|
|
11
|
+
import { toolTiers } from "../tool-tiers.ts";
|
|
12
|
+
import {
|
|
13
|
+
collectContributions,
|
|
14
|
+
linkSessions,
|
|
15
|
+
nearest,
|
|
16
|
+
} from "./contributions.ts";
|
|
17
|
+
|
|
18
|
+
/** A context the plugins under test never read. */
|
|
19
|
+
const context = { logger: silentLogger() } as unknown as PluginContext;
|
|
20
|
+
|
|
21
|
+
const plugin = (
|
|
22
|
+
name: string,
|
|
23
|
+
contribution: Contribution | (() => Contribution),
|
|
24
|
+
): RoundtablePlugin => ({
|
|
25
|
+
name,
|
|
26
|
+
setup: () =>
|
|
27
|
+
typeof contribution === "function" ? contribution() : contribution,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const tool = (name: string, minTier: "member" | "admin" | "owner" = "member") =>
|
|
31
|
+
defineTool({
|
|
32
|
+
name,
|
|
33
|
+
description: "A tool.",
|
|
34
|
+
parameters: Type.Object({}),
|
|
35
|
+
minTier,
|
|
36
|
+
run: () => "ok",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const collect = (plugins: RoundtablePlugin[], tiers = toolTiers()) =>
|
|
40
|
+
collectContributions(plugins, context, tiers);
|
|
41
|
+
|
|
42
|
+
describe("collectContributions", () => {
|
|
43
|
+
test("names the part a typo meant, or lists the parts", async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
collect([plugin("p", { serivces: [] } as unknown as Contribution)]),
|
|
46
|
+
).rejects.toThrow(
|
|
47
|
+
'plugin p: setup returned an unknown part "serivces". Did you mean "services"? The parts are services,',
|
|
48
|
+
);
|
|
49
|
+
await expect(
|
|
50
|
+
collect([plugin("p", { zzzzzz: [] } as unknown as Contribution)]),
|
|
51
|
+
).rejects.toThrow(
|
|
52
|
+
'plugin p: setup returned an unknown part "zzzzzz". The parts are services,',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("refuses a plugin that adds nothing, and says what to give it", async () => {
|
|
57
|
+
await expect(collect([plugin("empty", {})])).rejects.toThrow(
|
|
58
|
+
"plugin empty adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a setup that throws or returns no object is named and keeps its cause", async () => {
|
|
63
|
+
const boom = new Error("boom");
|
|
64
|
+
const failing = collect([
|
|
65
|
+
plugin("p", () => {
|
|
66
|
+
throw boom;
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
await expect(failing).rejects.toThrow(
|
|
70
|
+
"plugin p: setup failed: boom. Fix the error, or remove the plugin.",
|
|
71
|
+
);
|
|
72
|
+
await expect(failing).rejects.toHaveProperty("cause", boom);
|
|
73
|
+
await expect(
|
|
74
|
+
collect([
|
|
75
|
+
plugin("early", () => {
|
|
76
|
+
throw new NotLinkedError("parts are linked later.");
|
|
77
|
+
}),
|
|
78
|
+
]),
|
|
79
|
+
).rejects.toThrow(
|
|
80
|
+
"plugin early: setup failed: parts are linked later. Fix the error, or remove the plugin.",
|
|
81
|
+
);
|
|
82
|
+
await expect(
|
|
83
|
+
collect([
|
|
84
|
+
{ name: "q", setup: () => undefined as unknown as Contribution },
|
|
85
|
+
]),
|
|
86
|
+
).rejects.toThrow(
|
|
87
|
+
"plugin q: setup must return an object of the parts it adds; return {} to add none.",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a service, a session tool, or a hold rule two plugins register names both plugins", async () => {
|
|
92
|
+
const service = { name: "sweeper" };
|
|
93
|
+
await expect(
|
|
94
|
+
collect([
|
|
95
|
+
plugin("a", { services: [service] }),
|
|
96
|
+
plugin("b", { services: [service] }),
|
|
97
|
+
]),
|
|
98
|
+
).rejects.toThrow(
|
|
99
|
+
"plugin b: service sweeper is already registered by plugin a. Rename one of the two.",
|
|
100
|
+
);
|
|
101
|
+
const rule = { name: "shell", describe: () => undefined };
|
|
102
|
+
await expect(
|
|
103
|
+
collect([
|
|
104
|
+
plugin("a", { holdRules: [rule] }),
|
|
105
|
+
plugin("b", { holdRules: [rule] }),
|
|
106
|
+
]),
|
|
107
|
+
).rejects.toThrow(
|
|
108
|
+
"plugin b: hold rule shell is already registered by plugin a. Rename one of the two.",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a tool two plugins define names both plugins", async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
collect([
|
|
115
|
+
plugin("a", { tools: [tool("note_add")] }),
|
|
116
|
+
plugin("b", { tools: [tool("note_add")] }),
|
|
117
|
+
]),
|
|
118
|
+
).rejects.toThrow(
|
|
119
|
+
"plugin b: tool note_add is already defined by plugin a. Rename one of the two tools.",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a plugin's tools carry their tier, their hold rule, and their place among the agent's tools", async () => {
|
|
124
|
+
const tiers = toolTiers({ note_list: "admin" });
|
|
125
|
+
const held = defineTool({
|
|
126
|
+
name: "note_clear",
|
|
127
|
+
description: "Clear the notes.",
|
|
128
|
+
parameters: Type.Object({}),
|
|
129
|
+
minTier: "admin",
|
|
130
|
+
agent: false,
|
|
131
|
+
hold: () => "clear every note",
|
|
132
|
+
run: () => "cleared",
|
|
133
|
+
});
|
|
134
|
+
const registry = await collect(
|
|
135
|
+
[
|
|
136
|
+
plugin("notes", {
|
|
137
|
+
tools: [tool("note_add"), tool("note_list", "member"), held],
|
|
138
|
+
}),
|
|
139
|
+
],
|
|
140
|
+
tiers,
|
|
141
|
+
);
|
|
142
|
+
expect(tiers.minTier("note_add")).toBe("member");
|
|
143
|
+
// The operator's setting wins over the plugin's.
|
|
144
|
+
expect(tiers.minTier("note_list")).toBe("admin");
|
|
145
|
+
expect(tiers.minTier("note_clear")).toBe("admin");
|
|
146
|
+
const linked = linkSessions(registry);
|
|
147
|
+
expect(linked.agentTools).toEqual(["note_add", "note_list"]);
|
|
148
|
+
expect(linked.holds("note_clear", {}, {})).toBe("clear every note");
|
|
149
|
+
expect(linked.holds("note_add", {}, {})).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("seeds and prompt sections are collected in contribution order", async () => {
|
|
153
|
+
const seed = (name: string) => ({
|
|
154
|
+
name,
|
|
155
|
+
displayName: name,
|
|
156
|
+
prompt: "p",
|
|
157
|
+
avatarPrompt: "a",
|
|
158
|
+
});
|
|
159
|
+
const section = (name: string) => ({ name, build: () => name });
|
|
160
|
+
const registry = await collect([
|
|
161
|
+
plugin("a", { seeds: [seed("one")], prompt: [section("x")] }),
|
|
162
|
+
plugin("b", { seeds: [seed("two")], prompt: [section("y")] }),
|
|
163
|
+
]);
|
|
164
|
+
const linked = linkSessions(registry);
|
|
165
|
+
expect(linked.seeds.map((s) => s.name)).toEqual(["one", "two"]);
|
|
166
|
+
expect(linked.prompt.map((s) => s.name)).toEqual(["x", "y"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("every plugin's agent selection is merged when read, each name once", async () => {
|
|
170
|
+
let groups = ["mail"];
|
|
171
|
+
const registry = await collect([
|
|
172
|
+
plugin("a", {
|
|
173
|
+
agentSelection: () => ({ tools: ["web_search"], groups }),
|
|
174
|
+
}),
|
|
175
|
+
plugin("b", {
|
|
176
|
+
agentSelection: () => ({
|
|
177
|
+
tools: ["web_search", "notes"],
|
|
178
|
+
groups: ["mail", "calendar"],
|
|
179
|
+
}),
|
|
180
|
+
}),
|
|
181
|
+
]);
|
|
182
|
+
const linked = linkSessions(registry);
|
|
183
|
+
expect(linked.agentSelection()).toEqual({
|
|
184
|
+
tools: ["web_search", "notes"],
|
|
185
|
+
groups: ["mail", "calendar"],
|
|
186
|
+
});
|
|
187
|
+
// Read again at each turn, so a set that changes while the process runs stays current.
|
|
188
|
+
groups = ["docs"];
|
|
189
|
+
expect(linked.agentSelection().groups).toEqual([
|
|
190
|
+
"docs",
|
|
191
|
+
"mail",
|
|
192
|
+
"calendar",
|
|
193
|
+
]);
|
|
194
|
+
expect(
|
|
195
|
+
linkSessions(
|
|
196
|
+
await collect([plugin("c", { dashboard: ["a line"] })]),
|
|
197
|
+
).agentSelection(),
|
|
198
|
+
).toEqual({ tools: [], groups: [] });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("every refusal is a PluginError", async () => {
|
|
202
|
+
await expect(collect([plugin("empty", {})])).rejects.toBeInstanceOf(
|
|
203
|
+
PluginError,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("nearest", () => {
|
|
209
|
+
test("finds a close word and nothing for a far one", () => {
|
|
210
|
+
const known = ["services", "events", "interactions"];
|
|
211
|
+
expect(nearest("serivces", known)).toBe("services");
|
|
212
|
+
expect(nearest("event", known)).toBe("events");
|
|
213
|
+
expect(nearest("banana", known)).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { AgentSeed } from "../agents/agent-store.ts";
|
|
2
|
+
import type { ChannelClaim } from "../contract/channels.ts";
|
|
3
|
+
import type { InteractionContribution } from "../contract/discord.ts";
|
|
4
|
+
import type { ToolContribution } from "../define.ts";
|
|
5
|
+
import { PluginError } from "../errors.ts";
|
|
6
|
+
import { type HoldRule, holdChain } from "../holds.ts";
|
|
7
|
+
import type { HttpRoute } from "../http/listeners.ts";
|
|
8
|
+
import {
|
|
9
|
+
CONTRIBUTION_KEYS,
|
|
10
|
+
type Contribution,
|
|
11
|
+
type EventHandlers,
|
|
12
|
+
type LinkedSessions,
|
|
13
|
+
type PluginContext,
|
|
14
|
+
type PromptSection,
|
|
15
|
+
type RoundtablePlugin,
|
|
16
|
+
type Service,
|
|
17
|
+
} from "../plugin.ts";
|
|
18
|
+
import {
|
|
19
|
+
compileSessionPlan,
|
|
20
|
+
type SessionTool,
|
|
21
|
+
type ToolSelection,
|
|
22
|
+
} from "../sessions.ts";
|
|
23
|
+
import type { ToolTierTable } from "../tool-tiers.ts";
|
|
24
|
+
|
|
25
|
+
/** Everything the plugins contributed, in contribution order. */
|
|
26
|
+
export interface Registry {
|
|
27
|
+
services: Service[];
|
|
28
|
+
handlers: { plugin: string; events: EventHandlers }[];
|
|
29
|
+
interactions: InteractionContribution[];
|
|
30
|
+
routes: HttpRoute[];
|
|
31
|
+
holdRules: HoldRule[];
|
|
32
|
+
piPackages: string[];
|
|
33
|
+
sessionTools: SessionTool[];
|
|
34
|
+
channels: ChannelClaim[];
|
|
35
|
+
dashboard: string[];
|
|
36
|
+
tools: ToolContribution[];
|
|
37
|
+
seeds: AgentSeed[];
|
|
38
|
+
prompt: PromptSection[];
|
|
39
|
+
agentSelections: (() => ToolSelection)[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const emptyRegistry = (): Registry => ({
|
|
43
|
+
services: [],
|
|
44
|
+
handlers: [],
|
|
45
|
+
interactions: [],
|
|
46
|
+
routes: [],
|
|
47
|
+
holdRules: [],
|
|
48
|
+
piPackages: [],
|
|
49
|
+
sessionTools: [],
|
|
50
|
+
channels: [],
|
|
51
|
+
dashboard: [],
|
|
52
|
+
tools: [],
|
|
53
|
+
seeds: [],
|
|
54
|
+
prompt: [],
|
|
55
|
+
agentSelections: [],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The edit distance between two words, for naming the key a typo meant. */
|
|
59
|
+
function distance(a: string, b: string): number {
|
|
60
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
61
|
+
for (let i = 1; i <= a.length; i++) {
|
|
62
|
+
let previous = row[0] ?? 0;
|
|
63
|
+
row[0] = i;
|
|
64
|
+
for (let j = 1; j <= b.length; j++) {
|
|
65
|
+
const above = row[j] ?? 0;
|
|
66
|
+
row[j] = Math.min(
|
|
67
|
+
above + 1,
|
|
68
|
+
(row[j - 1] ?? 0) + 1,
|
|
69
|
+
previous + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
70
|
+
);
|
|
71
|
+
previous = above;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return row[b.length] ?? 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The known name nearest to `word`, when one is close enough to be what was meant. */
|
|
78
|
+
export function nearest(
|
|
79
|
+
word: string,
|
|
80
|
+
known: readonly string[],
|
|
81
|
+
): string | undefined {
|
|
82
|
+
let best: { name: string; distance: number } | undefined;
|
|
83
|
+
for (const name of known) {
|
|
84
|
+
const d = distance(word.toLowerCase(), name.toLowerCase());
|
|
85
|
+
if (!best || d < best.distance) best = { name, distance: d };
|
|
86
|
+
}
|
|
87
|
+
return best && best.distance <= Math.max(2, Math.floor(word.length / 3))
|
|
88
|
+
? best.name
|
|
89
|
+
: undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Refuses a contribution with a key the contract does not define, naming the nearest one. */
|
|
93
|
+
function checkKeys(plugin: string, contribution: Contribution): void {
|
|
94
|
+
for (const key of Object.keys(contribution)) {
|
|
95
|
+
if ((CONTRIBUTION_KEYS as readonly string[]).includes(key)) continue;
|
|
96
|
+
const meant = nearest(key, CONTRIBUTION_KEYS);
|
|
97
|
+
throw new PluginError(
|
|
98
|
+
`plugin ${plugin}: setup returned an unknown part "${key}".${
|
|
99
|
+
meant ? ` Did you mean "${meant}"?` : ""
|
|
100
|
+
} The parts are ${CONTRIBUTION_KEYS.join(", ")}.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The selections' tools and groups, each once, in the order the plugins gave them. */
|
|
106
|
+
function mergeSelections(
|
|
107
|
+
selections: readonly (() => ToolSelection)[],
|
|
108
|
+
): ToolSelection {
|
|
109
|
+
const read = selections.map((selection) => selection());
|
|
110
|
+
return {
|
|
111
|
+
tools: [...new Set(read.flatMap((s) => s.tools))],
|
|
112
|
+
groups: [...new Set(read.flatMap((s) => s.groups))],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Which plugin registered each name of one kind, so a clash names both. */
|
|
117
|
+
class Names {
|
|
118
|
+
readonly #owners = new Map<string, string>();
|
|
119
|
+
|
|
120
|
+
constructor(private readonly kind: string) {}
|
|
121
|
+
|
|
122
|
+
claim(plugin: string, name: string): void {
|
|
123
|
+
const other = this.#owners.get(name);
|
|
124
|
+
if (other !== undefined)
|
|
125
|
+
throw new PluginError(
|
|
126
|
+
`plugin ${plugin}: ${this.kind} ${name} is already registered by plugin ${other}. Rename one of the two.`,
|
|
127
|
+
);
|
|
128
|
+
this.#owners.set(name, plugin);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Sets up every plugin in order and collects what each adds. A plugin that adds nothing, that
|
|
134
|
+
* reuses a name, or whose setup throws or returns a part the contract does not have is refused
|
|
135
|
+
* with its name and what to fix.
|
|
136
|
+
*/
|
|
137
|
+
export async function collectContributions(
|
|
138
|
+
plugins: readonly RoundtablePlugin[],
|
|
139
|
+
context: PluginContext,
|
|
140
|
+
tiers: ToolTierTable,
|
|
141
|
+
): Promise<Registry> {
|
|
142
|
+
const registry = emptyRegistry();
|
|
143
|
+
const services = new Names("service");
|
|
144
|
+
const sessionTools = new Names("session tool");
|
|
145
|
+
const holds = new Names("hold rule");
|
|
146
|
+
for (const plugin of plugins) {
|
|
147
|
+
let contribution: Contribution;
|
|
148
|
+
try {
|
|
149
|
+
contribution = await plugin.setup(context);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof PluginError) throw error;
|
|
152
|
+
// Some messages end in a period already, such as a NotLinkedError's.
|
|
153
|
+
const reason = (
|
|
154
|
+
error instanceof Error ? error.message : String(error)
|
|
155
|
+
).replace(/\.+$/, "");
|
|
156
|
+
throw new PluginError(
|
|
157
|
+
`plugin ${plugin.name}: setup failed: ${reason}. Fix the error, or remove the plugin.`,
|
|
158
|
+
{ cause: error },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (typeof contribution !== "object" || contribution === null)
|
|
162
|
+
throw new PluginError(
|
|
163
|
+
`plugin ${plugin.name}: setup must return an object of the parts it adds; return {} to add none.`,
|
|
164
|
+
);
|
|
165
|
+
checkKeys(plugin.name, contribution);
|
|
166
|
+
const {
|
|
167
|
+
events,
|
|
168
|
+
interactions = [],
|
|
169
|
+
http = [],
|
|
170
|
+
holdRules = [],
|
|
171
|
+
piPackages = [],
|
|
172
|
+
channels = [],
|
|
173
|
+
dashboard = [],
|
|
174
|
+
tools = [],
|
|
175
|
+
seeds = [],
|
|
176
|
+
prompt = [],
|
|
177
|
+
} = contribution;
|
|
178
|
+
const parts = [
|
|
179
|
+
contribution.services ?? [],
|
|
180
|
+
interactions,
|
|
181
|
+
http,
|
|
182
|
+
holdRules,
|
|
183
|
+
piPackages,
|
|
184
|
+
contribution.sessionTools ?? [],
|
|
185
|
+
channels,
|
|
186
|
+
dashboard,
|
|
187
|
+
tools,
|
|
188
|
+
seeds,
|
|
189
|
+
prompt,
|
|
190
|
+
contribution.agentSelection ? [contribution.agentSelection] : [],
|
|
191
|
+
];
|
|
192
|
+
const declares =
|
|
193
|
+
(plugin.migrations?.length ?? 0) > 0 ||
|
|
194
|
+
Object.keys(plugin.providers ?? {}).length > 0 ||
|
|
195
|
+
plugin.preflight !== undefined ||
|
|
196
|
+
plugin.useCommands !== undefined ||
|
|
197
|
+
plugin.agentServer !== undefined ||
|
|
198
|
+
plugin.stopTurn !== undefined;
|
|
199
|
+
if (!declares && !events && parts.every((part) => part.length === 0))
|
|
200
|
+
throw new PluginError(
|
|
201
|
+
`plugin ${plugin.name} adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.`,
|
|
202
|
+
);
|
|
203
|
+
for (const service of contribution.services ?? []) {
|
|
204
|
+
services.claim(plugin.name, service.name);
|
|
205
|
+
registry.services.push(service);
|
|
206
|
+
}
|
|
207
|
+
for (const rule of holdRules) holds.claim(plugin.name, rule.name);
|
|
208
|
+
for (const tool of contribution.sessionTools ?? []) {
|
|
209
|
+
sessionTools.claim(plugin.name, tool.name);
|
|
210
|
+
registry.sessionTools.push(tool);
|
|
211
|
+
}
|
|
212
|
+
for (const tool of tools) {
|
|
213
|
+
tiers.declare(plugin.name, { [tool.name]: tool.minTier });
|
|
214
|
+
sessionTools.claim(plugin.name, tool.session.name);
|
|
215
|
+
registry.sessionTools.push(tool.session);
|
|
216
|
+
registry.tools.push(tool);
|
|
217
|
+
if (tool.hold) {
|
|
218
|
+
holds.claim(plugin.name, tool.hold.name);
|
|
219
|
+
registry.holdRules.push(tool.hold);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (contribution.agentSelection)
|
|
223
|
+
registry.agentSelections.push(contribution.agentSelection);
|
|
224
|
+
if (events) registry.handlers.push({ plugin: plugin.name, events });
|
|
225
|
+
registry.interactions.push(...interactions);
|
|
226
|
+
registry.routes.push(...http);
|
|
227
|
+
registry.holdRules.push(...holdRules);
|
|
228
|
+
registry.piPackages.push(...piPackages);
|
|
229
|
+
registry.channels.push(...channels);
|
|
230
|
+
registry.dashboard.push(...dashboard);
|
|
231
|
+
registry.seeds.push(...seeds);
|
|
232
|
+
registry.prompt.push(...prompt);
|
|
233
|
+
}
|
|
234
|
+
return registry;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Links the collected session parts; throws PluginError on a clash. */
|
|
238
|
+
export function linkSessions(registry: Registry): LinkedSessions {
|
|
239
|
+
return {
|
|
240
|
+
holds: holdChain(registry.holdRules),
|
|
241
|
+
// A package two plugins ask for loads once, where it was first asked for.
|
|
242
|
+
piPackages: [...new Set(registry.piPackages)],
|
|
243
|
+
plan: compileSessionPlan(registry.sessionTools),
|
|
244
|
+
seeds: registry.seeds,
|
|
245
|
+
prompt: registry.prompt,
|
|
246
|
+
agentTools: registry.tools.filter((t) => t.agent).map((t) => t.name),
|
|
247
|
+
agentSelection: () => mergeSelections(registry.agentSelections),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { InteractionContextType } from "discord.js";
|
|
3
|
+
import type { InteractionModule, RootOption } from "../contract/discord.ts";
|
|
4
|
+
import { PluginError } from "../errors.ts";
|
|
5
|
+
import { composeInteractions } from "./interactions.ts";
|
|
6
|
+
|
|
7
|
+
const ROOT = {
|
|
8
|
+
name: "roundtable",
|
|
9
|
+
description: "control",
|
|
10
|
+
contexts: [InteractionContextType.Guild, InteractionContextType.BotDM],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function module(...names: string[]): InteractionModule {
|
|
14
|
+
return {
|
|
15
|
+
commands: () =>
|
|
16
|
+
names.map((name) => ({ name, description: name, type: 1 as const })),
|
|
17
|
+
handle: async () => false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sub = (name: string): RootOption => ({
|
|
22
|
+
type: 1,
|
|
23
|
+
name,
|
|
24
|
+
description: name,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("composeInteractions", () => {
|
|
28
|
+
test("puts every contribution's subcommands under one root, after the modules' own commands", () => {
|
|
29
|
+
const a = module("roll");
|
|
30
|
+
const b = module();
|
|
31
|
+
const composed = composeInteractions(ROOT, [
|
|
32
|
+
{ module: b, rootOptions: [sub("help"), sub("status")] },
|
|
33
|
+
{ module: a },
|
|
34
|
+
{ module: module(), rootOptions: [sub("party")] },
|
|
35
|
+
]);
|
|
36
|
+
expect(composed.commands.map((c) => c.name)).toEqual([
|
|
37
|
+
"roll",
|
|
38
|
+
"roundtable",
|
|
39
|
+
]);
|
|
40
|
+
const root = composed.commands[1];
|
|
41
|
+
expect(root?.options?.map((o) => o.name)).toEqual([
|
|
42
|
+
"help",
|
|
43
|
+
"status",
|
|
44
|
+
"party",
|
|
45
|
+
]);
|
|
46
|
+
expect(root?.contexts).toEqual(ROOT.contexts);
|
|
47
|
+
expect(composed.modules[0]).toBe(b);
|
|
48
|
+
expect(composed.modules[1]).toBe(a);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("registers no root without subcommands", () => {
|
|
52
|
+
const composed = composeInteractions(ROOT, [{ module: module("dice") }]);
|
|
53
|
+
expect(composed.commands.map((c) => c.name)).toEqual(["dice"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("refuses a subcommand added twice, a command registered twice, and a module registering the root", () => {
|
|
57
|
+
const refused = (build: () => unknown) => {
|
|
58
|
+
try {
|
|
59
|
+
build();
|
|
60
|
+
return undefined;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
expect(
|
|
66
|
+
refused(() =>
|
|
67
|
+
composeInteractions(ROOT, [
|
|
68
|
+
{ module: module(), rootOptions: [sub("mcp")] },
|
|
69
|
+
{ module: module(), rootOptions: [sub("mcp")] },
|
|
70
|
+
]),
|
|
71
|
+
),
|
|
72
|
+
).toBeInstanceOf(PluginError);
|
|
73
|
+
expect(
|
|
74
|
+
refused(() =>
|
|
75
|
+
composeInteractions(ROOT, [
|
|
76
|
+
{ module: module("roll") },
|
|
77
|
+
{ module: module("roll") },
|
|
78
|
+
]),
|
|
79
|
+
),
|
|
80
|
+
).toBeInstanceOf(PluginError);
|
|
81
|
+
expect(
|
|
82
|
+
refused(() =>
|
|
83
|
+
composeInteractions(ROOT, [{ module: module("roundtable") }]),
|
|
84
|
+
),
|
|
85
|
+
).toBeInstanceOf(PluginError);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type RESTPostAPIChatInputApplicationCommandsJSONBody,
|
|
3
|
+
SlashCommandBuilder,
|
|
4
|
+
} from "discord.js";
|
|
5
|
+
import type {
|
|
6
|
+
CommandRoot,
|
|
7
|
+
InteractionContribution,
|
|
8
|
+
InteractionModule,
|
|
9
|
+
} from "../contract/discord.ts";
|
|
10
|
+
import { PluginError } from "../errors.ts";
|
|
11
|
+
|
|
12
|
+
/** Every command to register with Discord, and the modules that answer them in order. */
|
|
13
|
+
export interface ComposedInteractions {
|
|
14
|
+
commands: RESTPostAPIChatInputApplicationCommandsJSONBody[];
|
|
15
|
+
modules: InteractionModule[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Composes the root command from every contribution's subcommands, in contribution order, after
|
|
20
|
+
* the modules' own commands. Refuses a module that registers the root itself, and any command or
|
|
21
|
+
* subcommand name used twice.
|
|
22
|
+
*/
|
|
23
|
+
export function composeInteractions(
|
|
24
|
+
root: CommandRoot,
|
|
25
|
+
contributions: readonly InteractionContribution[],
|
|
26
|
+
): ComposedInteractions {
|
|
27
|
+
const commands = contributions.flatMap(({ module }) => module.commands());
|
|
28
|
+
const names = new Set<string>();
|
|
29
|
+
for (const command of commands) {
|
|
30
|
+
if (command.name === root.name)
|
|
31
|
+
throw new PluginError(
|
|
32
|
+
`/${root.name} is composed from subcommands; a module may not register it`,
|
|
33
|
+
);
|
|
34
|
+
if (names.has(command.name))
|
|
35
|
+
throw new PluginError(`/${command.name} is registered twice`);
|
|
36
|
+
names.add(command.name);
|
|
37
|
+
}
|
|
38
|
+
const options = contributions.flatMap((c) => c.rootOptions ?? []);
|
|
39
|
+
const subcommands = new Set<string>();
|
|
40
|
+
for (const option of options) {
|
|
41
|
+
if (subcommands.has(option.name))
|
|
42
|
+
throw new PluginError(`/${root.name} ${option.name} is added twice`);
|
|
43
|
+
subcommands.add(option.name);
|
|
44
|
+
}
|
|
45
|
+
if (options.length > 0)
|
|
46
|
+
commands.push({
|
|
47
|
+
...new SlashCommandBuilder()
|
|
48
|
+
.setName(root.name)
|
|
49
|
+
.setDescription(root.description)
|
|
50
|
+
.setContexts(...root.contexts)
|
|
51
|
+
.toJSON(),
|
|
52
|
+
options: [...options],
|
|
53
|
+
});
|
|
54
|
+
return { commands, modules: contributions.map((c) => c.module) };
|
|
55
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Judge, Providers } from "../contract/providers.ts";
|
|
3
|
+
import { PluginError, ProviderError } from "../errors.ts";
|
|
4
|
+
import { Roundtable } from "../host.ts";
|
|
5
|
+
import { ModelJudge } from "../judging/model-judge.ts";
|
|
6
|
+
import { silentLogger } from "../log.ts";
|
|
7
|
+
import type { RoundtablePlugin } from "../plugin.ts";
|
|
8
|
+
import { resolveProviders } from "./providers.ts";
|
|
9
|
+
|
|
10
|
+
/** What the promise rejected with; undefined when it resolved. */
|
|
11
|
+
async function rejection(promise: Promise<unknown>): Promise<unknown> {
|
|
12
|
+
try {
|
|
13
|
+
await promise;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fixedJudge: Judge = {
|
|
21
|
+
askYesNo: async () => ({ approves: 1 }),
|
|
22
|
+
askChoice: async () => ({ choice: "a", confidence: 1 }),
|
|
23
|
+
askScore: async () => [1],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const providing = (
|
|
27
|
+
name: string,
|
|
28
|
+
providers: Partial<Providers>,
|
|
29
|
+
): RoundtablePlugin => ({ name, providers, setup: () => ({}) });
|
|
30
|
+
|
|
31
|
+
describe("resolveProviders", () => {
|
|
32
|
+
test("without plugins, the judge asks the given model and images refuse", async () => {
|
|
33
|
+
const resolved = resolveProviders([], async () => '{"approves": 0.7}');
|
|
34
|
+
expect(resolved.judge).toBeInstanceOf(ModelJudge);
|
|
35
|
+
expect(
|
|
36
|
+
await resolved.judge.askYesNo(
|
|
37
|
+
{},
|
|
38
|
+
{
|
|
39
|
+
approves: { type: "yesno", instructions: "Approve?" },
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
).toEqual({ approves: 0.7 });
|
|
43
|
+
expect(await rejection(resolved.images("a cat", []))).toBeInstanceOf(
|
|
44
|
+
ProviderError,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("without a judge model, every judgment refuses so callers keep their defaults", async () => {
|
|
49
|
+
const { judge } = resolveProviders([]);
|
|
50
|
+
expect(
|
|
51
|
+
await rejection(
|
|
52
|
+
judge.askScore({}, "effort", {
|
|
53
|
+
type: "score",
|
|
54
|
+
instructions: "",
|
|
55
|
+
criteria: [],
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
).toBeInstanceOf(ProviderError);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a plugin's provider replaces the default for its slot only", () => {
|
|
62
|
+
const images = async () => new Uint8Array([1]);
|
|
63
|
+
const resolved = resolveProviders([
|
|
64
|
+
providing("acme-judge", { judge: fixedJudge }),
|
|
65
|
+
providing("codex", { images }),
|
|
66
|
+
]);
|
|
67
|
+
expect(resolved.judge).toBe(fixedJudge);
|
|
68
|
+
expect(resolved.images).toBe(images);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("two plugins filling one slot clash", () => {
|
|
72
|
+
expect(() =>
|
|
73
|
+
resolveProviders([
|
|
74
|
+
providing("acme-judge", { judge: fixedJudge }),
|
|
75
|
+
providing("other", { judge: fixedJudge }),
|
|
76
|
+
]),
|
|
77
|
+
).toThrow(PluginError);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("Roundtable providers", () => {
|
|
82
|
+
test("a provider-only plugin is valid, and setups read the resolved providers", async () => {
|
|
83
|
+
let seen: Judge | undefined;
|
|
84
|
+
const roundtable = new Roundtable(
|
|
85
|
+
{
|
|
86
|
+
logger: silentLogger(),
|
|
87
|
+
exit: () => {},
|
|
88
|
+
},
|
|
89
|
+
[
|
|
90
|
+
providing("acme-judge", { judge: fixedJudge }),
|
|
91
|
+
{
|
|
92
|
+
name: "reader",
|
|
93
|
+
setup: ({ providers }) => {
|
|
94
|
+
seen = providers.judge;
|
|
95
|
+
return { events: {} };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
);
|
|
100
|
+
await roundtable.run();
|
|
101
|
+
expect(seen).toBe(fixedJudge);
|
|
102
|
+
});
|
|
103
|
+
});
|