pi-roundtable 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/README.zh-TW.md +144 -0
  4. package/docs/plugins.md +1448 -66
  5. package/examples/echo-runtime.test.ts +122 -0
  6. package/examples/echo-runtime.ts +107 -0
  7. package/examples/events.test.ts +1 -0
  8. package/examples/events.ts +2 -2
  9. package/examples/fake-surface.test.ts +180 -0
  10. package/examples/fake-surface.ts +109 -0
  11. package/examples/interactions.test.ts +12 -3
  12. package/examples/interactions.ts +19 -19
  13. package/examples/shared-services.test.ts +74 -0
  14. package/examples/shared-services.ts +60 -0
  15. package/examples/study-room.test.ts +69 -0
  16. package/examples/study-room.ts +52 -0
  17. package/examples/support-desk.test.ts +34 -0
  18. package/examples/support-desk.ts +40 -0
  19. package/package.json +11 -3
  20. package/src/cli/checks/database.ts +11 -30
  21. package/src/cli/checks/images.ts +19 -0
  22. package/src/cli/doctor.ts +6 -0
  23. package/src/cli/main.ts +2 -1
  24. package/src/core/agents/agent-claim.ts +42 -16
  25. package/src/core/agents/agent-messages.ts +4 -4
  26. package/src/core/agents/agent-ports.ts +8 -21
  27. package/src/core/agents/agent-prompt.ts +3 -1
  28. package/src/core/agents/agent-store.ts +6 -4
  29. package/src/core/agents/agent-team-fixture.ts +389 -0
  30. package/src/core/agents/agent-team.ts +26 -25
  31. package/src/core/agents/agent-tools.ts +34 -15
  32. package/src/core/agents/avatar-studio.ts +40 -6
  33. package/src/core/agents/fallback-avatar.ts +85 -0
  34. package/src/core/agents/team-editing.ts +49 -10
  35. package/src/core/agents/team-keys.ts +22 -9
  36. package/src/core/agents/team-layout.ts +4 -3
  37. package/src/core/agents/team-lifecycle.ts +28 -2
  38. package/src/core/agents/team-options.ts +9 -5
  39. package/src/core/agents/team-status.ts +4 -4
  40. package/src/core/agents/team-text.ts +8 -6
  41. package/src/core/agents/team-turn-types.ts +4 -4
  42. package/src/core/agents/team-turns.ts +6 -5
  43. package/src/core/builtin/agent-server.ts +187 -121
  44. package/src/core/builtin/discord-admin.ts +41 -0
  45. package/src/core/builtin/discord.ts +65 -40
  46. package/src/core/builtin/modules.ts +67 -58
  47. package/src/core/builtin/session-tool.ts +24 -0
  48. package/src/core/builtin/skills.ts +72 -0
  49. package/src/core/builtin/stores.ts +55 -31
  50. package/src/core/config/config.ts +45 -4
  51. package/src/core/config/schema.ts +6 -0
  52. package/src/core/contract/channels.ts +54 -14
  53. package/src/core/contract/providers.ts +17 -0
  54. package/src/core/contract/runtime.ts +138 -0
  55. package/src/core/contract/services.ts +45 -0
  56. package/src/core/contract/surface.ts +88 -0
  57. package/src/core/db/migrations.ts +118 -3
  58. package/src/core/define-roundtable.ts +70 -46
  59. package/src/core/define.ts +2 -1
  60. package/src/core/discord/agent-commands.ts +39 -14
  61. package/src/core/discord/agent-panel.ts +66 -0
  62. package/src/core/discord/channel-executor.ts +6 -3
  63. package/src/core/discord/channel-operations.ts +14 -11
  64. package/src/core/discord/command-collection.ts +46 -0
  65. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  66. package/src/core/discord/connection.ts +35 -0
  67. package/src/core/discord/discord-surface.ts +32 -94
  68. package/src/core/discord/dispatch-threads.ts +2 -2
  69. package/src/core/discord/inbound-message.ts +99 -0
  70. package/src/core/discord/interaction-module.ts +57 -1
  71. package/src/core/discord/owner-cards.ts +4 -4
  72. package/src/core/discord/owner-command.ts +44 -11
  73. package/src/core/discord/owner-discord.ts +2 -2
  74. package/src/core/discord/schedule-commands.ts +21 -18
  75. package/src/core/discord/stop-button.ts +44 -1
  76. package/src/core/domain/attachment.ts +12 -8
  77. package/src/core/domain/conversation.ts +3 -4
  78. package/src/core/domain/owner-prompts.ts +5 -5
  79. package/src/core/domain/ports.ts +9 -53
  80. package/src/core/freeze.ts +9 -0
  81. package/src/core/host.ts +255 -82
  82. package/src/core/http/listeners.ts +55 -20
  83. package/src/core/i18n/agent-panel.ts +4 -0
  84. package/src/core/i18n/index.ts +12 -2
  85. package/src/core/i18n/owner.ts +7 -1
  86. package/src/core/i18n/schedules.ts +12 -6
  87. package/src/core/identity.ts +1 -1
  88. package/src/core/judging/effort-judge.ts +25 -3
  89. package/src/core/log.ts +21 -3
  90. package/src/core/models.ts +2 -2
  91. package/src/core/modules/background/background-turns.ts +6 -4
  92. package/src/core/modules/delegation/delegate.ts +3 -2
  93. package/src/core/modules/delegation/delegator.ts +15 -13
  94. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  95. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  96. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  97. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  98. package/src/core/modules/memory/owner-memory.ts +9 -13
  99. package/src/core/modules/schedules/recurrence.ts +5 -3
  100. package/src/core/modules/schedules/schedule-store.ts +11 -11
  101. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  102. package/src/core/modules/schedules/scheduler.ts +7 -2
  103. package/src/core/modules/schedules/schedules.ts +9 -3
  104. package/src/core/modules/skills/skill-registry.ts +3 -2
  105. package/src/core/modules/skills/skill-store.ts +2 -15
  106. package/src/core/modules/skills/skill-tools.ts +10 -4
  107. package/src/core/ops/error-reporter.ts +1 -1
  108. package/src/core/plugin.ts +232 -28
  109. package/src/core/registry/contributions.ts +173 -14
  110. package/src/core/registry/providers.ts +35 -5
  111. package/src/core/registry/services.ts +229 -0
  112. package/src/core/routing/channel-queue.ts +5 -0
  113. package/src/core/routing/channel-router.ts +21 -7
  114. package/src/core/routing/conversation-turns.ts +139 -0
  115. package/src/core/routing/message-text.ts +9 -2
  116. package/src/core/routing/surface-port.ts +39 -0
  117. package/src/core/runtime/conversation-sessions.ts +4 -3
  118. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  119. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  120. package/src/core/runtime/mcp.ts +1 -1
  121. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  122. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  123. package/src/core/runtime/prompt-slot.ts +8 -3
  124. package/src/core/runtime/runtime-types.ts +9 -35
  125. package/src/core/runtime/session-factory.ts +21 -5
  126. package/src/core/runtime/text-tools.ts +1 -3
  127. package/src/core/services.ts +238 -90
  128. package/src/core/sessions.ts +3 -2
  129. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  130. package/src/core/shared/delegate-tool.ts +4 -3
  131. package/src/core/shared/schedule-tools.ts +22 -13
  132. package/src/core/shared/session-messages.ts +1 -1
  133. package/src/core/speakers.ts +6 -38
  134. package/src/core/testing/database.ts +1 -7
  135. package/src/core/testing/eager-catalog.ts +30 -0
  136. package/src/core/testing/locale.ts +23 -5
  137. package/src/core/testing/modules.ts +105 -41
  138. package/src/core/testing/test-host.ts +279 -0
  139. package/src/core/testing/tool-set.ts +64 -0
  140. package/src/core/time.ts +6 -1
  141. package/src/core/tool-set.snapshot.json +288 -0
  142. package/src/core/tool-tiers.ts +17 -57
  143. package/src/discord/index.ts +64 -0
  144. package/src/index.ts +181 -10
  145. package/src/kit/channels.ts +14 -0
  146. package/src/kit/domain.ts +18 -0
  147. package/src/kit/holds.ts +4 -0
  148. package/src/kit/index.ts +129 -0
  149. package/src/kit/judging.ts +10 -0
  150. package/src/kit/memory.ts +3 -0
  151. package/src/kit/mirror.ts +19 -0
  152. package/src/kit/presentation.ts +7 -0
  153. package/src/kit/shell.ts +7 -0
  154. package/src/kit/skills.ts +8 -0
  155. package/src/kit/support.ts +21 -0
  156. package/src/kit/threads.ts +7 -0
  157. package/src/kit/tools.ts +14 -0
  158. package/src/kit/worker.ts +16 -0
  159. package/src/testing.ts +394 -34
  160. package/examples/guide.test.ts +0 -52
  161. package/src/cli/add-plugin.test.ts +0 -107
  162. package/src/cli/checks/basic.test.ts +0 -276
  163. package/src/cli/checks/database.test.ts +0 -117
  164. package/src/cli/checks/discord.test.ts +0 -200
  165. package/src/cli/cli.test.ts +0 -122
  166. package/src/cli/config-edit.test.ts +0 -130
  167. package/src/cli/doctor.test.ts +0 -184
  168. package/src/cli/init.test.ts +0 -127
  169. package/src/cli/size.test.ts +0 -9
  170. package/src/cli/templates.test.ts +0 -129
  171. package/src/cli/testing/fixtures.ts +0 -106
  172. package/src/core/agents/agent-claim.test.ts +0 -142
  173. package/src/core/agents/agent-dashboard.test.ts +0 -120
  174. package/src/core/agents/agent-guild.test.ts +0 -124
  175. package/src/core/agents/agent-store.test.ts +0 -205
  176. package/src/core/agents/avatar-studio.test.ts +0 -88
  177. package/src/core/agents/group-round.test.ts +0 -143
  178. package/src/core/agents/owner-identity.test.ts +0 -172
  179. package/src/core/agents/team-turns.test.ts +0 -278
  180. package/src/core/attachments/attachments.test.ts +0 -85
  181. package/src/core/boundary.test.ts +0 -45
  182. package/src/core/builtin/modules.test.ts +0 -127
  183. package/src/core/config/config.test.ts +0 -135
  184. package/src/core/contract/discord.ts +0 -33
  185. package/src/core/db/migrations.test.ts +0 -237
  186. package/src/core/define-roundtable.test.ts +0 -134
  187. package/src/core/define.test.ts +0 -144
  188. package/src/core/discord/agent-commands.test.ts +0 -58
  189. package/src/core/discord/agent-discord.test.ts +0 -70
  190. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  191. package/src/core/discord/dispatch-threads.test.ts +0 -94
  192. package/src/core/discord/owner-cards.test.ts +0 -435
  193. package/src/core/discord/owner-discord.test.ts +0 -325
  194. package/src/core/domain/expression.ts +0 -21
  195. package/src/core/domain/profile.ts +0 -32
  196. package/src/core/drain.test.ts +0 -46
  197. package/src/core/events.test.ts +0 -81
  198. package/src/core/holds.test.ts +0 -54
  199. package/src/core/host.test.ts +0 -536
  200. package/src/core/http/listeners.test.ts +0 -164
  201. package/src/core/i18n/i18n.test.ts +0 -139
  202. package/src/core/identity.test.ts +0 -18
  203. package/src/core/judging/effort-judge.test.ts +0 -112
  204. package/src/core/judging/model-judge.test.ts +0 -126
  205. package/src/core/modules/delegation/delegator.test.ts +0 -170
  206. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  207. package/src/core/modules/schedules/schedule.test.ts +0 -358
  208. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  209. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  210. package/src/core/ops/error-reporter.test.ts +0 -283
  211. package/src/core/presentation/card-cadence.ts +0 -41
  212. package/src/core/presentation/presentation.test.ts +0 -166
  213. package/src/core/public-entry.test.ts +0 -59
  214. package/src/core/registry/contributions.test.ts +0 -215
  215. package/src/core/registry/interactions.test.ts +0 -87
  216. package/src/core/registry/providers.test.ts +0 -103
  217. package/src/core/routing/channel-queue.test.ts +0 -31
  218. package/src/core/routing/channel-router.test.ts +0 -326
  219. package/src/core/routing/conversation-kind.ts +0 -14
  220. package/src/core/routing/settle-turn.test.ts +0 -21
  221. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  222. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  223. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  224. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  225. package/src/core/runtime/session-archive.test.ts +0 -20
  226. package/src/core/runtime/steerable-run.test.ts +0 -264
  227. package/src/core/runtime/text-tools.test.ts +0 -110
  228. package/src/core/runtime/turn-answer.test.ts +0 -72
  229. package/src/core/runtime/worker-task.test.ts +0 -82
  230. package/src/core/services.test.ts +0 -28
  231. package/src/core/sessions.test.ts +0 -85
  232. package/src/core/shared/unix-server.ts +0 -18
  233. package/src/core/size.test.ts +0 -9
  234. package/src/core/speakers.test.ts +0 -78
  235. package/src/core/testing/file-size.ts +0 -34
  236. package/src/core/time.test.ts +0 -166
  237. package/src/core/tool-tiers.test.ts +0 -117
  238. package/src/entries.test.ts +0 -112
  239. package/src/testing.test.ts +0 -156
@@ -2,6 +2,7 @@ import type { SQL } from "bun";
2
2
  import type { Migration } from "../db/migrations.ts";
3
3
  import { AgentError } from "../domain/errors.ts";
4
4
  import type { ThinkingLevel } from "../models.ts";
5
+ import type { AgentDirectory } from "../services.ts";
5
6
  import {
6
7
  type Agent,
7
8
  type AgentGroup,
@@ -51,7 +52,7 @@ export {
51
52
  * Agents, groups, and group messages, in the database. Agents and groups are also held
52
53
  * in memory, because every inbound message asks and only this process changes them.
53
54
  */
54
- export class AgentStore {
55
+ export class PgAgentStore implements AgentDirectory {
55
56
  readonly #sql: SQL;
56
57
  readonly #agents = new Map<string, Agent>();
57
58
  readonly #groups = new Map<string, AgentGroup>();
@@ -74,8 +75,8 @@ export class AgentStore {
74
75
  }
75
76
 
76
77
  /** The store over the host's migrated pool, holding the rows of one guild. */
77
- static async attach(sql: SQL, guildId: string): Promise<AgentStore> {
78
- const store = new AgentStore(sql, guildId);
78
+ static async attach(sql: SQL, guildId: string): Promise<PgAgentStore> {
79
+ const store = new PgAgentStore(sql, guildId);
79
80
  await store.#reload();
80
81
  return store;
81
82
  }
@@ -178,7 +179,8 @@ export class AgentStore {
178
179
  checkAgentName(agent.name);
179
180
  checkDisplayName(agent.displayName);
180
181
  checkPrompt(agent.prompt);
181
- checkAvatarPrompt(agent.avatarPrompt);
182
+ // An agent of a host without an image provider has no avatar prompt.
183
+ if (agent.avatarPrompt !== "") checkAvatarPrompt(agent.avatarPrompt);
182
184
  this.#checkFree(agent.name);
183
185
  const rows: AgentRow[] = await this.#sql`
184
186
  INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
@@ -0,0 +1,389 @@
1
+ import { beforeEach } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SQL } from "bun";
6
+ import type { ContextUse } from "../contract/runtime.ts";
7
+ import { NO_ATTACHMENTS } from "../domain/attachment.ts";
8
+ import type {
9
+ ChannelKey,
10
+ PendingConfirmation,
11
+ TurnResult,
12
+ } from "../domain/conversation.ts";
13
+ import { AgentRunError } from "../domain/errors.ts";
14
+ import type { TurnRequest } from "../domain/ports.ts";
15
+ import { silentLogger } from "../log.ts";
16
+ import { StoredSkillRegistry as SkillRegistry } from "../modules/skills/skill-registry.ts";
17
+ import { SkillStore } from "../modules/skills/skill-store.ts";
18
+ import type { PromptSection } from "../plugin.ts";
19
+ import { type ChannelQueue, channelQueue } from "../routing/channel-queue.ts";
20
+ import {
21
+ openTestStore,
22
+ TEST_GUILD,
23
+ type TestStore,
24
+ testDatabaseUrl,
25
+ } from "../testing/database.ts";
26
+ import { useTestLocale } from "../testing/locale.ts";
27
+ import { OWNER_SPEAKER, TEST_OWNER } from "../testing/owner.ts";
28
+ import type {
29
+ AgentCategory,
30
+ AgentChannels,
31
+ AgentPost,
32
+ AgentTurnRunner,
33
+ CategoryLayout,
34
+ ChannelMessage,
35
+ } from "./agent-ports.ts";
36
+ import type { AgentSeed } from "./agent-rules.ts";
37
+ import { PgAgentStore as AgentStore } from "./agent-store.ts";
38
+ import { DiscordAgentTeam as AgentTeam } from "./agent-team.ts";
39
+ import { discordKey } from "./team-keys.ts";
40
+
41
+ export const ENTRY = "1000";
42
+ export const USABLE = [
43
+ "claude-bridge/claude-opus-5-5",
44
+ "openai-codex/gpt-6-sol",
45
+ ];
46
+
47
+ export class FakeChannels implements AgentChannels {
48
+ posts: { channelId: string; post: AgentPost }[] = [];
49
+ created: string[] = [];
50
+ deleted = new Set<string>();
51
+ #next = 2000;
52
+ async post(channelId: string, post: AgentPost) {
53
+ this.posts.push({ channelId, post });
54
+ }
55
+ /** The server's categories in order, each with its channels in order. */
56
+ categories: CategoryLayout[] = [];
57
+ #nextCategory = 9000;
58
+ arranged: { layout: CategoryLayout[]; remove: string[] }[] = [];
59
+ categoryOf(channelId: string) {
60
+ return this.categories.find((c) => c.channelIds.includes(channelId))?.name;
61
+ }
62
+ /** Moves a channel to the end of the category, made when missing. */
63
+ put(channelId: string, category: AgentCategory) {
64
+ for (const c of this.categories)
65
+ c.channelIds = c.channelIds.filter((id) => id !== channelId);
66
+ let found = this.categories.find((c) => c.name === category);
67
+ if (!found) {
68
+ found = {
69
+ id: String(this.#nextCategory++),
70
+ name: category,
71
+ channelIds: [],
72
+ };
73
+ this.categories.push(found);
74
+ }
75
+ found.channelIds.push(channelId);
76
+ }
77
+ async createChannel(name: string, topic: string, category: AgentCategory) {
78
+ this.created.push(name);
79
+ const id = String(this.#next++);
80
+ this.topics.set(id, topic);
81
+ this.put(id, category);
82
+ return id;
83
+ }
84
+ async placeIn(channelId: string, category: AgentCategory) {
85
+ if (this.categoryOf(channelId) === category) return false;
86
+ this.put(channelId, category);
87
+ return true;
88
+ }
89
+ async layout() {
90
+ return structuredClone(this.categories);
91
+ }
92
+ async arrange(layout: CategoryLayout[], remove: string[]) {
93
+ this.arranged.push({ layout: structuredClone(layout), remove });
94
+ this.categories = layout.map((c) => ({
95
+ id: c.id ?? String(this.#nextCategory++),
96
+ name: c.name,
97
+ channelIds: [...c.channelIds],
98
+ }));
99
+ }
100
+ history: ChannelMessage[] = [];
101
+ reads: { channelId: string; limit: number; around?: string }[] = [];
102
+ async read(channelId: string, options: { limit: number; around?: string }) {
103
+ this.reads.push({ channelId, ...options });
104
+ return this.history;
105
+ }
106
+ topics = new Map<string, string>();
107
+ async setTopic(channelId: string, topic: string) {
108
+ this.topics.set(channelId, topic);
109
+ }
110
+ async exists(channelId: string) {
111
+ return !this.deleted.has(channelId);
112
+ }
113
+ webhooksRemoved: string[] = [];
114
+ async removeWebhook(channelId: string) {
115
+ this.webhooksRemoved.push(channelId);
116
+ }
117
+ texts(channelId: string) {
118
+ return this.posts
119
+ .filter((p) => p.channelId === channelId)
120
+ .map((p) => `${p.post.name}: ${p.post.chunks.join("")}`);
121
+ }
122
+ }
123
+
124
+ /** Answers every turn with a scripted reply; records what each turn received. */
125
+ export class FakeRuntime implements AgentTurnRunner {
126
+ turns: TurnRequest[] = [];
127
+ reply: (request: TurnRequest) => string = (r) => `${r.agent?.name} ok`;
128
+ held = new Map<ChannelKey, PendingConfirmation>();
129
+ fresh: ChannelKey[] = [];
130
+ /** Runs inside a turn, like a tool call the model makes. */
131
+ during?: (request: TurnRequest) => unknown;
132
+ /** The next turn ends as if the owner pressed stop. */
133
+ stopNext = false;
134
+ async runTurn(request: TurnRequest): Promise<TurnResult> {
135
+ this.turns.push(request);
136
+ await this.during?.(request);
137
+ if (this.stopNext) {
138
+ this.stopNext = false;
139
+ return {
140
+ ok: false,
141
+ error: new AgentRunError("stopped by the owner"),
142
+ stopped: true,
143
+ };
144
+ }
145
+ return { ok: true, text: this.reply(request) };
146
+ }
147
+ async heldActions(session: ChannelKey) {
148
+ return this.held.get(session);
149
+ }
150
+ async startFresh(session: ChannelKey) {
151
+ this.fresh.push(session);
152
+ }
153
+ usage = new Map<ChannelKey, ContextUse>();
154
+ contextUsage(session: ChannelKey) {
155
+ return this.usage.get(session);
156
+ }
157
+ }
158
+
159
+ // CI runs the setup below about ten times slower than a laptop, and bun's 5 s hook limit
160
+ // left a timed-out hook running while the next test dropped its tables.
161
+ export const HOOK_TIMEOUT_MS = 30_000;
162
+
163
+ // Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
164
+ let store: TestStore<AgentStore>;
165
+ let channels: FakeChannels;
166
+ let runtime: FakeRuntime;
167
+ export interface TeamFixture {
168
+ store: TestStore<AgentStore>;
169
+ channels: FakeChannels;
170
+ runtime: FakeRuntime;
171
+ queue: ChannelQueue;
172
+ team: AgentTeam;
173
+ skillStore: TestStore<SkillStore>;
174
+ skills: SkillRegistry;
175
+ }
176
+
177
+ /** The fixture of the running test; set by `useTeamFixture` before each test. */
178
+ export const fx = {} as TeamFixture;
179
+
180
+ /** What the team reads from its plugins and its scorer; tests set it. */
181
+ export const script: {
182
+ /** The relevance scores the scorer answers with. */
183
+ scores: Record<string, number> | undefined;
184
+ /** The prompt sections plugins add. */
185
+ prompt: PromptSection[];
186
+ /** The tools plugins defined for agents. */
187
+ tools: string[];
188
+ } = { scores: undefined, prompt: [], tools: [] };
189
+ let queue: ChannelQueue;
190
+ let team: AgentTeam;
191
+ let skillStore: TestStore<SkillStore>;
192
+ let skills: SkillRegistry;
193
+ export const removedSchedules: number[] = [];
194
+ export const stops: string[] = [];
195
+ /** The events the team reported, in order, as `turnStarted infra`, `turnEnded infra ok`, or `changed`. */
196
+ export const eventLog: string[] = [];
197
+
198
+ /** What a plugin adds to every agent's tools: a tool of its own and a group of another server's. */
199
+ const PLUGIN_SELECTION = { tools: ["memory_add"], groups: ["workspace"] };
200
+
201
+ /** The team a fresh database starts with: a coordinator in the entry channel, then two specialists. */
202
+ function seeds(entryChannelId: string): AgentSeed[] {
203
+ return [
204
+ {
205
+ name: "coordinator",
206
+ displayName: "Coordinator",
207
+ channelId: entryChannelId,
208
+ prompt:
209
+ "You are the chief of staff of the owner's agent team. You hand work that belongs to a specialist to that agent with message_agent, one owner per task, and propose a new agent when a lasting responsibility has no owner.",
210
+ avatarPrompt: "A chief of staff mid-command, pointing the way.",
211
+ },
212
+ {
213
+ name: "doctor",
214
+ displayName: "Doctor",
215
+ prompt:
216
+ "You keep the owner's agents healthy: read an agent's prompt with agent_get, find the instruction that caused a misbehavior, and fix it with agent_update.",
217
+ avatarPrompt: "A bot doctor holding up a giant stethoscope.",
218
+ },
219
+ {
220
+ name: "infra",
221
+ displayName: "Infra",
222
+ prompt:
223
+ "You manage the owner's servers. Check before you change: read state first, state what you will change and why, then act.",
224
+ avatarPrompt: "An infra engineer battling a server rack.",
225
+ },
226
+ ];
227
+ }
228
+
229
+ /**
230
+ * Waits until every queued channel task has settled and every active agent has its avatar,
231
+ * which startup draws in the background; a fixed sleep was too short on a slow runner.
232
+ */
233
+ export async function settle(): Promise<void> {
234
+ const deadline = Date.now() + 10_000;
235
+ const quiet = () =>
236
+ queue.busy().length === 0 &&
237
+ store.agents().every((a) => a.status !== "active" || a.avatarHash);
238
+ while (!quiet() && Date.now() < deadline) await Bun.sleep(5);
239
+ // Work the last task queued is still finishing.
240
+ await Bun.sleep(20);
241
+ }
242
+
243
+ /** Registers the fixture's per-test setup in the describe it is called from. */
244
+ export function useTeamFixture(): void {
245
+ beforeEach(async () => {
246
+ // The expected texts are the neutral English catalog's.
247
+ useTestLocale();
248
+ await store?.close();
249
+ await skillStore?.close();
250
+ const admin = new SQL(testDatabaseUrl);
251
+ for (const table of [
252
+ "agents",
253
+ "agent_groups",
254
+ "agent_group_messages",
255
+ "agent_group_cursors",
256
+ "skills",
257
+ "skill_groups",
258
+ "agent_skills",
259
+ ])
260
+ await admin.unsafe(`DROP TABLE IF EXISTS ${table}`);
261
+ await admin.close();
262
+ store = await openTestStore(AgentStore, TEST_GUILD);
263
+ const skillDir = mkdtempSync(join(tmpdir(), "roundtable-team-skills-"));
264
+ mkdirSync(join(skillDir, "repos/acme/kit/.git"), { recursive: true });
265
+ mkdirSync(join(skillDir, "repos/acme/kit/skills/kit-do"), {
266
+ recursive: true,
267
+ });
268
+ writeFileSync(
269
+ join(skillDir, "repos/acme/kit/skills/kit-do/SKILL.md"),
270
+ "---\nname: kit-do\ndescription: Use when changing source.\n---\n",
271
+ );
272
+ mkdirSync(join(skillDir, "builtin/writing-skills"), { recursive: true });
273
+ writeFileSync(
274
+ join(skillDir, "builtin/writing-skills/SKILL.md"),
275
+ "---\nname: writing-skills\ndescription: Use when creating, editing, or reviewing a skill.\n---\n",
276
+ );
277
+ skillStore = await openTestStore(SkillStore, TEST_GUILD);
278
+ skills = new SkillRegistry({
279
+ store: skillStore,
280
+ reposDir: join(skillDir, "repos"),
281
+ writtenDir: join(skillDir, "skills"),
282
+ builtinDir: join(skillDir, "builtin"),
283
+ logger: silentLogger(),
284
+ });
285
+ skills.init();
286
+ await skills.link("acme/kit", "skills");
287
+ channels = new FakeChannels();
288
+ runtime = new FakeRuntime();
289
+ script.scores = undefined;
290
+ script.prompt = [];
291
+ script.tools = [];
292
+ queue = channelQueue();
293
+ removedSchedules.length = 0;
294
+ stops.length = 0;
295
+ eventLog.length = 0;
296
+ team = new AgentTeam({
297
+ owner: TEST_OWNER,
298
+ shellUser: "mcops",
299
+ guildId: "1",
300
+ entryChannelId: ENTRY,
301
+ seeds: () => seeds(ENTRY),
302
+ store,
303
+ channels,
304
+ studio: {
305
+ url: (hash) => `https://x/avatars/${hash ?? "default"}.png`,
306
+ draw: async () => "a".repeat(64),
307
+ edit: async () => "b".repeat(64),
308
+ },
309
+ runtime: () => runtime,
310
+ pluginSelection: () => PLUGIN_SELECTION,
311
+ confirmations: { approves: async (_p, reply) => reply.includes("yes") },
312
+ scorer: { score: async () => script.scores },
313
+ events: {
314
+ turnStarted: (turn) => eventLog.push(`turnStarted ${turn.agent}`),
315
+ turnEnded: (turn) =>
316
+ eventLog.push(`turnEnded ${turn.agent} ${turn.result}`),
317
+ changed: () => eventLog.push("changed"),
318
+ },
319
+ promptSections: () => script.prompt,
320
+ pluginTools: () => script.tools,
321
+ models: {
322
+ defaults: {
323
+ model: "claude-bridge/claude-opus-5-5",
324
+ thinking: "medium",
325
+ },
326
+ usable: async () => USABLE,
327
+ },
328
+ schedules: {
329
+ forChannel: async (channel) =>
330
+ channel === discordKey("2000") ? [{ id: 7 }] : [],
331
+ remove: async (id) => removedSchedules.push(id),
332
+ },
333
+ queue,
334
+ startTyping: () => () => undefined,
335
+ showStop: (channel) => {
336
+ stops.push(`show ${channel}`);
337
+ return () => stops.push(`hide ${channel}`);
338
+ },
339
+ workDir: "/tmp/agents/work",
340
+ sharedPrompt: "Shared rules.",
341
+ skills,
342
+ logger: silentLogger(),
343
+ });
344
+ await team.start();
345
+ Object.assign(fx, {
346
+ store,
347
+ channels,
348
+ runtime,
349
+ queue,
350
+ team,
351
+ skillStore,
352
+ skills,
353
+ });
354
+ await settle();
355
+ }, HOOK_TIMEOUT_MS);
356
+ }
357
+
358
+ export const opsGroup = () => {
359
+ const group = store.group("ops");
360
+ if (!group) throw new Error("no ops group");
361
+ return group;
362
+ };
363
+
364
+ export const agentChannel = (name: string) =>
365
+ discordKey(store.agent(name)?.channelId ?? "");
366
+
367
+ /** Runs `action` during the coordinator's turn, as a tool the model calls does. */
368
+ export async function duringTurn<T>(action: () => Promise<T>): Promise<T> {
369
+ let outcome: { value: T } | { error: unknown } | undefined;
370
+ runtime.during = async () => {
371
+ // Only the first turn is the model's; the agent it creates starts one of its own.
372
+ runtime.during = undefined;
373
+ outcome = await action().then(
374
+ (value) => ({ value }),
375
+ (error: unknown) => ({ error }),
376
+ );
377
+ };
378
+ await team.answerOwner(
379
+ agentChannel("coordinator"),
380
+ OWNER_SPEAKER,
381
+ "go",
382
+ "go",
383
+ NO_ATTACHMENTS,
384
+ );
385
+ runtime.during = undefined;
386
+ if (!outcome || "error" in outcome)
387
+ throw (outcome as { error: unknown })?.error;
388
+ return outcome.value;
389
+ }
@@ -5,11 +5,8 @@ import { AgentError, ScheduleError } from "../domain/errors.ts";
5
5
  import type { AgentTurnScope } from "../domain/ports.ts";
6
6
  import type { ThinkingSetting } from "../models.ts";
7
7
  import { SHELL_TOOLS } from "../modules/host-shell/shell-policy.ts";
8
- import type { ResolvedSkill } from "../modules/skills/skill-registry.ts";
9
- import {
10
- SKILL_TOOLS,
11
- skillToolsExtension,
12
- } from "../modules/skills/skill-tools.ts";
8
+ import type { ResolvedSkill } from "../modules/skills/skill-rules.ts";
9
+ import type { AgentTeam } from "../services.ts";
13
10
  import type { TurnSelection } from "../sessions.ts";
14
11
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
15
12
  import { agentSystemPrompt } from "./agent-prompt.ts";
@@ -17,12 +14,13 @@ import type { Agent } from "./agent-store.ts";
17
14
  import {
18
15
  AGENT_TOOLS,
19
16
  type AgentOps,
17
+ AVATAR_TOOL,
20
18
  type AvatarMode,
21
19
  agentToolsExtension,
22
20
  MESSAGE_AGENT_TOOL,
23
21
  } from "./agent-tools.ts";
24
22
  import { TeamEditing } from "./team-editing.ts";
25
- import { channelKey, channelOwner } from "./team-keys.ts";
23
+ import { channelOwner, discordKey } from "./team-keys.ts";
26
24
  import {
27
25
  layoutText,
28
26
  planArrangement,
@@ -49,7 +47,7 @@ const READ_LIMIT = 50;
49
47
  * This team creates, changes, arranges, and archives agents and groups; its TeamTurns runs
50
48
  * their turns, group rounds, and messages between agents.
51
49
  */
52
- export class AgentTeam implements AgentOps {
50
+ export class DiscordAgentTeam implements AgentOps, AgentTeam {
53
51
  readonly #options: AgentTeamOptions;
54
52
  readonly #turns: TeamTurns;
55
53
  readonly #listeners: (() => void)[] = [];
@@ -101,7 +99,7 @@ export class AgentTeam implements AgentOps {
101
99
  return channelOwner(this.#options.store, channel);
102
100
  }
103
101
 
104
- /** The tools every agent turn runs with: the plugins', plus the shell, agent, and skill tools. */
102
+ /** The tools every agent turn runs with: the plugins', plus the shell and agent tools. */
105
103
  selection(scope?: AgentTurnScope): TurnSelection {
106
104
  const plugged = this.#options.pluginSelection();
107
105
  return {
@@ -112,9 +110,10 @@ export class AgentTeam implements AgentOps {
112
110
  ...SHELL_TOOLS,
113
111
  // Group members hand off by mentioning each other, so a group seat has no message_agent.
114
112
  ...AGENT_TOOLS.filter(
115
- (tool) => !(scope?.group && tool === MESSAGE_AGENT_TOOL),
113
+ (tool) =>
114
+ !(scope?.group && tool === MESSAGE_AGENT_TOOL) &&
115
+ !(tool === AVATAR_TOOL && !this.#avatars()),
116
116
  ),
117
- ...SKILL_TOOLS,
118
117
  ...(this.#options.pluginTools?.() ?? []),
119
118
  ]),
120
119
  ],
@@ -147,6 +146,7 @@ export class AgentTeam implements AgentOps {
147
146
  workDir,
148
147
  owner,
149
148
  shellUser,
149
+ avatars: this.#avatars(),
150
150
  ...(group
151
151
  ? {
152
152
  group: {
@@ -190,22 +190,22 @@ export class AgentTeam implements AgentOps {
190
190
  return current ? [current, ...usable.filter((m) => m !== current)] : usable;
191
191
  }
192
192
 
193
- /** The agent and skill tools of one agent session. */
193
+ /** Whether agents can be offered drawing: an image provider is configured. */
194
+ #avatars(): boolean {
195
+ return this.#options.studio.canDraw !== false;
196
+ }
197
+
198
+ /** The agent tools of one agent session. */
194
199
  extension(scope: AgentTurnScope): ExtensionFactory {
195
- const { skills, store } = this.#options;
196
- const agentTools = agentToolsExtension(this, scope, THE_SPEAKER);
197
- const skillTools = skillToolsExtension(skills, (name) => {
198
- store.activeAgent(name);
200
+ return agentToolsExtension(this, scope, THE_SPEAKER, {
201
+ avatars: this.#avatars(),
202
+ skills: this.#options.skills !== undefined,
199
203
  });
200
- return async (pi) => {
201
- await agentTools(pi);
202
- await skillTools(pi);
203
- };
204
204
  }
205
205
 
206
- /** The skill files an agent's sessions load, built-in ones included. */
206
+ /** The skill files an agent's sessions load, built-in ones included; none while the skills addon is off. */
207
207
  skillsOf(name: string): ResolvedSkill[] {
208
- return this.#options.skills.carried(name).skills;
208
+ return this.#options.skills?.carried(name).skills ?? [];
209
209
  }
210
210
 
211
211
  /** Another active agent's channel, for schedule_list. */
@@ -213,7 +213,7 @@ export class AgentTeam implements AgentOps {
213
213
  try {
214
214
  const agent = this.#options.store.activeAgent(name);
215
215
  if (!agent.channelId) throw new AgentError(`${name} has no channel yet`);
216
- return channelKey(agent.channelId);
216
+ return discordKey(agent.channelId);
217
217
  } catch (error) {
218
218
  throw new ScheduleError(
219
219
  error instanceof Error ? error.message : String(error),
@@ -254,7 +254,7 @@ export class AgentTeam implements AgentOps {
254
254
 
255
255
  /**
256
256
  * The owner's message in an agent's channel; call inside the channel's queue. `text`
257
- * carries the reference, `replyText` is what he typed.
257
+ * carries the reference, `replyText` is what they typed.
258
258
  */
259
259
  answerOwner(
260
260
  channel: ChannelKey,
@@ -380,7 +380,8 @@ export class AgentTeam implements AgentOps {
380
380
  return agentDetails(
381
381
  agent,
382
382
  models.defaults,
383
- skills.describeCarried(agent.name),
383
+ skills?.describeCarried(agent.name),
384
+ this.#avatars(),
384
385
  );
385
386
  }
386
387
 
@@ -408,7 +409,7 @@ export class AgentTeam implements AgentOps {
408
409
  name: string;
409
410
  displayName: string;
410
411
  prompt: string;
411
- avatarPrompt: string;
412
+ avatarPrompt?: string;
412
413
  task: string;
413
414
  category?: string;
414
415
  skills?: string[];
@@ -23,12 +23,15 @@ import {
23
23
  /** The tool that hands a request to another agent; a group seat has none. */
24
24
  export const MESSAGE_AGENT_TOOL = "message_agent";
25
25
 
26
+ /** The tool that redraws an avatar; a host without an image provider does not offer it. */
27
+ export const AVATAR_TOOL = "agent_avatar";
28
+
26
29
  export const AGENT_TOOLS = [
27
30
  "agent_list",
28
31
  "agent_get",
29
32
  "agent_update",
30
33
  "agent_create",
31
- "agent_avatar",
34
+ AVATAR_TOOL,
32
35
  MESSAGE_AGENT_TOOL,
33
36
  "group_create",
34
37
  "group_update",
@@ -58,7 +61,8 @@ export interface AgentOps {
58
61
  name: string;
59
62
  displayName: string;
60
63
  prompt: string;
61
- avatarPrompt: string;
64
+ /** Absent on a host without an image provider. */
65
+ avatarPrompt?: string;
62
66
  task: string;
63
67
  category?: string;
64
68
  skills?: string[];
@@ -120,10 +124,19 @@ const MEMBERS = Type.Array(Type.String(), {
120
124
 
121
125
  type AgentToolDef = TextToolDef<(typeof AGENT_TOOLS)[number]>;
122
126
 
127
+ /** What the agent tools offer, by what the host can do. */
128
+ export interface AgentToolOptions {
129
+ /** Whether an image provider is configured; without one there is no avatar prompt and no agent_avatar. */
130
+ avatars: boolean;
131
+ /** Whether the skills addon is on; without it agent_create takes no skills. */
132
+ skills: boolean;
133
+ }
134
+
123
135
  function toolDefs(
124
136
  ops: AgentOps,
125
137
  scope: AgentTurnScope,
126
138
  owner: OwnerIdentity,
139
+ { avatars, skills: skillsOn }: AgentToolOptions,
127
140
  ): AgentToolDef[] {
128
141
  const o = ownerWords(owner);
129
142
  const defs: AgentToolDef[] = [
@@ -182,8 +195,7 @@ function toolDefs(
182
195
  {
183
196
  name: "agent_create",
184
197
  label: "Create agent",
185
- description:
186
- "Create a new agent with its own channel and a drawn avatar; it starts at once on the opening task in its channel. Its name is permanent.",
198
+ description: `Create a new agent with its own channel and ${avatars ? "a drawn avatar" : "an avatar generated from its display name"}; it starts at once on the opening task in its channel. Its name is permanent.`,
187
199
  parameters: Type.Object({
188
200
  name: Type.String({
189
201
  pattern: "^[a-z0-9-]{1,32}$",
@@ -192,19 +204,23 @@ function toolDefs(
192
204
  }),
193
205
  display_name: DISPLAY,
194
206
  prompt: PROMPT,
195
- avatar_prompt: avatarPrompt(),
207
+ ...(avatars ? { avatar_prompt: avatarPrompt() } : {}),
196
208
  task: Type.String({
197
209
  minLength: 1,
198
210
  maxLength: 4000,
199
211
  description: "Its opening task, self-contained.",
200
212
  }),
201
213
  category: Type.Optional(category("Agents")),
202
- skills: Type.Optional(
203
- Type.Array(Type.String(), {
204
- description:
205
- "Skills it carries, from skill_list; pick those its role needs. Change them later with agent_skills.",
206
- }),
207
- ),
214
+ ...(skillsOn
215
+ ? {
216
+ skills: Type.Optional(
217
+ Type.Array(Type.String(), {
218
+ description:
219
+ "Skills it carries, from skill_list; pick those its role needs. Change them later with agent_skills.",
220
+ }),
221
+ ),
222
+ }
223
+ : {}),
208
224
  }),
209
225
  run: (i) => {
210
226
  const category = optionalString(i, "category");
@@ -213,7 +229,9 @@ function toolDefs(
213
229
  name: requiredString(i, "name"),
214
230
  displayName: requiredString(i, "display_name"),
215
231
  prompt: requiredString(i, "prompt"),
216
- avatarPrompt: requiredString(i, "avatar_prompt"),
232
+ ...(avatars
233
+ ? { avatarPrompt: requiredString(i, "avatar_prompt") }
234
+ : {}),
217
235
  task: requiredString(i, "task"),
218
236
  ...(category ? { category } : {}),
219
237
  ...(skills && skills.length > 0 ? { skills } : {}),
@@ -221,7 +239,7 @@ function toolDefs(
221
239
  },
222
240
  },
223
241
  {
224
- name: "agent_avatar",
242
+ name: AVATAR_TOOL,
225
243
  label: "Redraw avatar",
226
244
  description:
227
245
  "Redraw an agent's avatar: redraw from its stored avatar prompt; new_prompt with a new avatar prompt, which replaces the stored one; or edit the current picture by an instruction.",
@@ -373,7 +391,7 @@ function toolDefs(
373
391
  run: (i) =>
374
392
  ops.message(scope, requiredString(i, "to"), requiredString(i, "text")),
375
393
  });
376
- return defs;
394
+ return avatars ? defs : defs.filter((def) => def.name !== AVATAR_TOOL);
377
395
  }
378
396
 
379
397
  /** Registers the agent tools for one agent session. */
@@ -381,6 +399,7 @@ export function agentToolsExtension(
381
399
  ops: AgentOps,
382
400
  scope: AgentTurnScope,
383
401
  owner: OwnerIdentity,
402
+ options: AgentToolOptions,
384
403
  ): ExtensionFactory {
385
- return textToolsExtension(toolDefs(ops, scope, owner), AgentError);
404
+ return textToolsExtension(toolDefs(ops, scope, owner, options), AgentError);
386
405
  }