pi-roundtable 0.16.0 → 0.17.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 +18 -0
- package/README.md +5 -0
- package/docs/plugins.md +68 -0
- package/package.json +1 -1
- package/src/cli/checks/configuration.ts +21 -1
- package/src/cli/project.ts +59 -8
- package/src/cli/runtime.ts +3 -0
- package/src/core/agents/agent-store.ts +4 -1
- package/src/core/builtin/discord.ts +5 -0
- package/src/core/config/adapters.ts +12 -0
- package/src/core/contract/runtime.ts +7 -0
- package/src/core/contract/surface.ts +7 -0
- package/src/core/db/check-database.ts +39 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/discord/discord-surface.ts +5 -0
- package/src/core/discord/formatting.ts +20 -0
- package/src/core/host-registration.ts +292 -0
- package/src/core/host.ts +45 -227
- package/src/core/identity/config-owner.ts +34 -0
- package/src/core/identity/identity-plugin.ts +7 -2
- package/src/core/identity/identity-service.ts +8 -1
- package/src/core/identity/plugin-identities.ts +19 -4
- package/src/core/modules/skills/skill-store.ts +3 -1
- package/src/core/runtime/extensions/surface-formatting.ts +22 -0
- package/src/core/runtime/pi-agent-runtime.ts +6 -8
- package/src/core/runtime/running-turn.ts +19 -0
- package/src/core/runtime/runtime-plugin.ts +4 -0
- package/src/core/runtime/runtime-types.ts +12 -1
- package/src/core/runtime/session-factory.ts +7 -0
- package/src/core/testing/database.ts +20 -2
- package/src/core/testing/postgres.ts +160 -0
- package/src/core/testing/run-tool.ts +110 -0
- package/src/core/testing/test-host.ts +78 -30
- package/src/index.ts +1 -0
- package/src/testing.ts +25 -56
- package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
- package/templates/official/codex-images/plugin.ts +19 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isCheckDatabase } from "../db/check-database.ts";
|
|
1
2
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
2
3
|
import { IDENTITY, IDENTITY_LINKS } from "../services.ts";
|
|
3
4
|
import type { AccessRules } from "./access-policy.ts";
|
|
@@ -45,12 +46,16 @@ export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
|
|
|
45
46
|
provides: rules.selfLink ? [IDENTITY, IDENTITY_LINKS] : [IDENTITY],
|
|
46
47
|
setup: async ({ database, services, logger }) => {
|
|
47
48
|
if (summary) logger.info(backfillLine(summary));
|
|
49
|
+
const sql = database();
|
|
48
50
|
const identity = new PgIdentityService(
|
|
49
|
-
await PgPrincipalStore.attach(
|
|
51
|
+
await PgPrincipalStore.attach(sql),
|
|
50
52
|
rules,
|
|
51
53
|
{ logger, identities },
|
|
52
54
|
);
|
|
53
|
-
|
|
55
|
+
// `Roundtable.check()` has no database to write the configured principals to: it asks
|
|
56
|
+
// only what the configuration and the declared identities settle without one.
|
|
57
|
+
if (isCheckDatabase(sql)) identity.checkConfig();
|
|
58
|
+
else await identity.syncConfig();
|
|
54
59
|
services.provide(IDENTITY, identityView(identity));
|
|
55
60
|
if (rules.selfLink)
|
|
56
61
|
services.provide(
|
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
surfaceOf,
|
|
16
16
|
} from "./actor-facts.ts";
|
|
17
17
|
import { CachingPrincipalStore } from "./caching-principal-store.ts";
|
|
18
|
-
import { configuredOwnerId } from "./config-owner.ts";
|
|
18
|
+
import { configuredOwnerId, refuseOwnerDeclarations } from "./config-owner.ts";
|
|
19
19
|
import type { Contact, ContactAssessor } from "./contact.ts";
|
|
20
20
|
import {
|
|
21
21
|
type DeclaredIdentity,
|
|
22
|
+
refuseUnboundIdentities,
|
|
22
23
|
syncDeclaredIdentities,
|
|
23
24
|
} from "./plugin-identities.ts";
|
|
24
25
|
import { highest, lowest, publicOf, speakerOf } from "./principal-reads.ts";
|
|
@@ -141,6 +142,12 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
|
|
|
141
142
|
this.#logger = options.logger;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** The refusals `syncConfig` makes from the configuration and the declarations alone, for `Roundtable.check()`. */
|
|
146
|
+
checkConfig(): void {
|
|
147
|
+
refuseUnboundIdentities(this.#declared, this.#rules.owners.length > 0);
|
|
148
|
+
refuseOwnerDeclarations(this.#rules.owners, this.#declared);
|
|
149
|
+
}
|
|
150
|
+
|
|
144
151
|
/**
|
|
145
152
|
* Makes the stored owners match the configuration: each configured owner's principal made or
|
|
146
153
|
* renamed, their identities linked, and the owner role granted, all as the configuration's;
|
|
@@ -57,6 +57,21 @@ export function declaredIdentities(
|
|
|
57
57
|
return declared;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The refusal `syncDeclaredIdentities` makes that needs no stored principal: an identity that names
|
|
62
|
+
* no principal when the configuration lists no primary owner to bind it to.
|
|
63
|
+
*/
|
|
64
|
+
export function refuseUnboundIdentities(
|
|
65
|
+
identities: readonly DeclaredIdentity[],
|
|
66
|
+
hasPrimaryOwner: boolean,
|
|
67
|
+
): void {
|
|
68
|
+
for (const { plugin, identity, principal } of identities)
|
|
69
|
+
if (principal === undefined && !hasPrimaryOwner)
|
|
70
|
+
throw new ConfigError(
|
|
71
|
+
`plugin ${plugin}: ${identity} names no principal, and access.owners lists no primary owner to bind it to. Give the plugin the principal it stands for.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
/**
|
|
61
76
|
* Links each identity a plugin declares to its principal, `primaryOwner` by default, as the
|
|
62
77
|
* plugin's: one the plugins bound to another principal at an earlier boot moves, one the
|
|
@@ -76,10 +91,10 @@ export async function syncDeclaredIdentities(
|
|
|
76
91
|
const ref = parseIdentity(identity);
|
|
77
92
|
if (!ref) throw new ConfigError(`plugin ${plugin}: ${identity}`);
|
|
78
93
|
const id = principal ?? primaryOwner;
|
|
79
|
-
if (id === undefined)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
if (id === undefined) {
|
|
95
|
+
refuseUnboundIdentities([{ plugin, identity, principal }], false);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
83
98
|
if (id === SYSTEM_PRINCIPAL || !(await store.get(id)))
|
|
84
99
|
throw new ConfigError(
|
|
85
100
|
`plugin ${plugin}: ${identity} is bound to principal ${id}, and there is no principal ${id}. roundtable principal list shows the principals, and roundtable principal create makes one.`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
+
import { isCheckDatabase } from "../../db/check-database.ts";
|
|
2
3
|
import { scopeToGuild } from "../../db/guild-scope.ts";
|
|
3
4
|
import type { Migration } from "../../db/migrations.ts";
|
|
4
5
|
import { AgentError } from "../../domain/errors.ts";
|
|
@@ -128,7 +129,8 @@ export class SkillStore {
|
|
|
128
129
|
/** The store over the host's migrated pool; the skills agents carry are those of one guild. */
|
|
129
130
|
static async attach(sql: SQL, guildId: string): Promise<SkillStore> {
|
|
130
131
|
const store = new SkillStore(sql, guildId);
|
|
131
|
-
|
|
132
|
+
// `Roundtable.check()` has no database to read the rows from: the store starts empty.
|
|
133
|
+
if (!isCheckDatabase(sql)) await store.#reload();
|
|
132
134
|
return store;
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Appends the formatting guidance of the surface the running turn replies on to the system
|
|
5
|
+
* prompt's appended part before each run, after the persona and what earlier handlers appended.
|
|
6
|
+
* Nothing is added when `guidance` returns undefined or only blank text: a turn whose channel has
|
|
7
|
+
* no surface, a surface without guidance, or no turn running.
|
|
8
|
+
*/
|
|
9
|
+
export function surfaceFormattingExtension(
|
|
10
|
+
guidance: () => string | undefined,
|
|
11
|
+
): ExtensionFactory {
|
|
12
|
+
return (pi) => {
|
|
13
|
+
pi.on("before_agent_start", (event) => {
|
|
14
|
+
const text = guidance();
|
|
15
|
+
if (text === undefined || text.trim() === "") return;
|
|
16
|
+
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
17
|
+
event.systemPromptOptions.appendSystemPrompt = appended
|
|
18
|
+
? `${appended}\n\n${text}`
|
|
19
|
+
: text;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -35,7 +35,7 @@ import { interimPoster } from "./interim-text.ts";
|
|
|
35
35
|
import { preflightTools } from "./preflight-tools.ts";
|
|
36
36
|
import { PromptSlot, workTimeout } from "./prompt-slot.ts";
|
|
37
37
|
import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
|
|
38
|
-
import type
|
|
38
|
+
import { type RunningTurn, runningTurn } from "./running-turn.ts";
|
|
39
39
|
import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
|
|
40
40
|
import { archiveSessions } from "./session-archive.ts";
|
|
41
41
|
import {
|
|
@@ -86,6 +86,10 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
86
86
|
speaker: (channel) => this.#turns.get(channel)?.speaker,
|
|
87
87
|
origin: (channel) => this.#turns.get(channel)?.origin,
|
|
88
88
|
client: (channel) => this.#turns.get(channel)?.client,
|
|
89
|
+
formatting: (channel) => {
|
|
90
|
+
const reply = this.#turns.get(channel)?.channel;
|
|
91
|
+
return reply === undefined ? undefined : options.formatting?.(reply);
|
|
92
|
+
},
|
|
89
93
|
withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
|
|
90
94
|
runTask: (scope, task) => this.#runTask(scope, task),
|
|
91
95
|
});
|
|
@@ -195,13 +199,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
195
199
|
loadsMemory(registered),
|
|
196
200
|
);
|
|
197
201
|
this.#memoryTurns.set(key, markPrivateMemory);
|
|
198
|
-
this.#turns.set(key,
|
|
199
|
-
tier,
|
|
200
|
-
speaker: request.speaker,
|
|
201
|
-
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
202
|
-
...(request.client === undefined ? {} : { client: request.client }),
|
|
203
|
-
withheld,
|
|
204
|
-
});
|
|
202
|
+
this.#turns.set(key, runningTurn(request, tier, withheld));
|
|
205
203
|
const missing = wanted.filter((name) => !registered.has(name));
|
|
206
204
|
if (missing.length > 0) {
|
|
207
205
|
logger.warn(
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ClientPort } from "../domain/client.ts";
|
|
2
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
2
3
|
import type { Speaker, Tier } from "../speakers.ts";
|
|
3
4
|
|
|
4
5
|
/** What the runtime keeps about a conversation's running turn, for its tools to read. */
|
|
5
6
|
export interface RunningTurn {
|
|
7
|
+
/** The channel the turn replies in, as `TurnRequest.channel` says; its surface formats the reply. */
|
|
8
|
+
channel: TurnRequest["channel"];
|
|
6
9
|
tier: Tier;
|
|
7
10
|
speaker: Speaker;
|
|
8
11
|
/** Where the turn's request came from, as `TurnRequest.origin` says. */
|
|
@@ -12,3 +15,19 @@ export interface RunningTurn {
|
|
|
12
15
|
/** Whether the turn loads no memory. */
|
|
13
16
|
withheld: boolean;
|
|
14
17
|
}
|
|
18
|
+
|
|
19
|
+
/** The record of a turn that starts: what its tools read while it runs. */
|
|
20
|
+
export function runningTurn(
|
|
21
|
+
request: TurnRequest,
|
|
22
|
+
tier: Tier,
|
|
23
|
+
withheld: boolean,
|
|
24
|
+
): RunningTurn {
|
|
25
|
+
return {
|
|
26
|
+
channel: request.channel,
|
|
27
|
+
tier,
|
|
28
|
+
speaker: request.speaker,
|
|
29
|
+
...(request.origin === undefined ? {} : { origin: request.origin }),
|
|
30
|
+
...(request.client === undefined ? {} : { client: request.client }),
|
|
31
|
+
withheld,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -129,6 +129,8 @@ export function runtimePlugin(
|
|
|
129
129
|
const prompts = (channel: ChannelKey, scope?: PromptScope | Speaker) =>
|
|
130
130
|
context.surfaces.prompts(channel, scope);
|
|
131
131
|
const agents = () => options.agents?.current();
|
|
132
|
+
const formatting = (channel: ChannelKey) =>
|
|
133
|
+
context.surfaces.of(channel)?.formatting;
|
|
132
134
|
const running: AgentRuntime = providers.filled.has("runtime")
|
|
133
135
|
? providers.runtime(
|
|
134
136
|
runtimeDeps({
|
|
@@ -138,6 +140,7 @@ export function runtimePlugin(
|
|
|
138
140
|
sessions: context.sessions,
|
|
139
141
|
toolTiers: context.toolTiers,
|
|
140
142
|
prompts,
|
|
143
|
+
formatting,
|
|
141
144
|
agents,
|
|
142
145
|
confirmations: heldActions,
|
|
143
146
|
judge: providers.judge,
|
|
@@ -182,6 +185,7 @@ export function runtimePlugin(
|
|
|
182
185
|
return agents();
|
|
183
186
|
},
|
|
184
187
|
prompts,
|
|
188
|
+
formatting,
|
|
185
189
|
logger,
|
|
186
190
|
...(options.interimText
|
|
187
191
|
? { interimText: options.interimText }
|
|
@@ -79,6 +79,13 @@ export interface PiAgentRuntimeOptions {
|
|
|
79
79
|
* held action waits for a message that approves it.
|
|
80
80
|
*/
|
|
81
81
|
prompts?: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* The formatting guidance of the chat surface that serves a channel, such as
|
|
84
|
+
* `ChatSurface.formatting`; undefined for a channel with no surface or no guidance. Read before
|
|
85
|
+
* each run for the channel the turn replies in, and appended to the system prompt after the
|
|
86
|
+
* persona.
|
|
87
|
+
*/
|
|
88
|
+
formatting?: (channel: ChannelKey) => string | undefined;
|
|
82
89
|
/** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
|
|
83
90
|
toolTiers?: ToolTiers;
|
|
84
91
|
/** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
|
|
@@ -124,13 +131,16 @@ export interface CoreExtensions {
|
|
|
124
131
|
askUser: ExtensionFactory;
|
|
125
132
|
selfCompactGuard: ExtensionFactory;
|
|
126
133
|
privateMemory: ExtensionFactory;
|
|
134
|
+
/** The formatting guidance of the surface the turn replies on, appended after the persona. */
|
|
135
|
+
surfaceFormatting: ExtensionFactory;
|
|
127
136
|
activeTools: ExtensionFactory;
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
/**
|
|
131
140
|
* A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
|
|
132
141
|
* and compact guard, the compactor, the MCP phase, the memory projection, so each request is
|
|
133
|
-
* projected after every other extension changed it,
|
|
142
|
+
* projected after every other extension changed it, the surface's formatting guidance, so it follows
|
|
143
|
+
* the persona and everything appended before it, and active-tools last, so its
|
|
134
144
|
* before_agent_start handler runs after every other extension's.
|
|
135
145
|
*/
|
|
136
146
|
export function sessionExtensions(
|
|
@@ -160,6 +170,7 @@ export function sessionExtensions(
|
|
|
160
170
|
...contributed(plan.compaction ? [plan.compaction] : []),
|
|
161
171
|
...contributed(plan.mcp),
|
|
162
172
|
{ name: "private-memory", factory: core.privateMemory },
|
|
173
|
+
{ name: "surface-formatting", factory: core.surfaceFormatting },
|
|
163
174
|
{ name: "active-tools", factory: core.activeTools },
|
|
164
175
|
];
|
|
165
176
|
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
COMPACT_TOOL,
|
|
59
59
|
selfCompactGuardExtension,
|
|
60
60
|
} from "./extensions/self-compact-guard.ts";
|
|
61
|
+
import { surfaceFormattingExtension } from "./extensions/surface-formatting.ts";
|
|
61
62
|
import type { PromptSlot } from "./prompt-slot.ts";
|
|
62
63
|
import type {
|
|
63
64
|
ChannelSession,
|
|
@@ -85,6 +86,8 @@ export interface SessionFactoryDeps {
|
|
|
85
86
|
origin(channel: ChannelKey): string | undefined;
|
|
86
87
|
/** What the client that started the conversation's running turn can do for its tools. */
|
|
87
88
|
client(channel: ChannelKey): ClientPort | undefined;
|
|
89
|
+
/** The formatting guidance of the surface the conversation's running turn replies on. */
|
|
90
|
+
formatting(channel: ChannelKey): string | undefined;
|
|
88
91
|
/** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
|
|
89
92
|
withholdsMemory(channel: ChannelKey): boolean;
|
|
90
93
|
/**
|
|
@@ -416,6 +419,10 @@ export class SessionFactory {
|
|
|
416
419
|
: memoryReader(conversation, deps.speaker(turnKey)),
|
|
417
420
|
state.draws,
|
|
418
421
|
),
|
|
422
|
+
surfaceFormatting: surfaceFormattingExtension(() =>
|
|
423
|
+
// A worker writes a report for the turn, not a reply on its surface.
|
|
424
|
+
worker ? undefined : deps.formatting(turnKey),
|
|
425
|
+
),
|
|
419
426
|
activeTools: activeToolsExtension(() => state.tools),
|
|
420
427
|
}),
|
|
421
428
|
// An agent's prompt is set before each run by the agent-prompt extension.
|
|
@@ -9,9 +9,27 @@ const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
|
|
|
9
9
|
/** The guild the stores of the tests serve. */
|
|
10
10
|
export const TEST_GUILD = "900000000000000001";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** The test database's URL, or "" when the run has none; `usePostgresForTests` sets it for the rest of the run. */
|
|
13
|
+
export let testDatabaseUrl = url ?? "";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/** `describe` when the run has a test database, `describe.skip` otherwise; `usePostgresForTests` switches it on. */
|
|
16
|
+
export let describeDb = url ? describe : describe.skip;
|
|
17
|
+
|
|
18
|
+
/** Makes `url` the run's test database, for the tests and suites that read the two exports above afterwards. */
|
|
19
|
+
export function adoptTestDatabase(url: string): void {
|
|
20
|
+
process.env.ROUNDTABLE_TEST_DATABASE_URL = url;
|
|
21
|
+
testDatabaseUrl = url;
|
|
22
|
+
describeDb = describe;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The test database's URL; throws, naming how to provide one, when the run has none. */
|
|
26
|
+
export function requireDatabase(): string {
|
|
27
|
+
if (!testDatabaseUrl)
|
|
28
|
+
throw new Error(
|
|
29
|
+
"this test needs PostgreSQL: set ROUNDTABLE_TEST_DATABASE_URL to a throwaway database, or start one for the run with usePostgresForTests() in a test preload (it needs initdb and postgres on PATH or in PG_BIN)",
|
|
30
|
+
);
|
|
31
|
+
return testDatabaseUrl;
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
/** A test pool with the migrations run, as the host runs them before any store attaches; the caller closes it. */
|
|
17
35
|
export async function migratedPool(...migrations: Migration[]): Promise<SQL> {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { afterAll } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { SQL } from "bun";
|
|
7
|
+
import { adoptTestDatabase } from "./database.ts";
|
|
8
|
+
|
|
9
|
+
export interface UsePostgresOptions {
|
|
10
|
+
/** Directories searched for initdb and postgres before PATH; default PG_BIN and the usual Homebrew and Debian paths. */
|
|
11
|
+
binDirs?: readonly string[];
|
|
12
|
+
/** Throw when initdb or postgres is not found; default false (the tests that need it skip or call requireDatabase). */
|
|
13
|
+
require?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Where a PostgreSQL install usually keeps initdb and postgres, newest first. */
|
|
17
|
+
const USUAL_DIRS = [
|
|
18
|
+
...[17, 16, 15, 14].map(
|
|
19
|
+
(major) => `/opt/homebrew/opt/postgresql@${major}/bin`,
|
|
20
|
+
),
|
|
21
|
+
...[17, 16, 15, 14].map((major) => `/usr/lib/postgresql/${major}/bin`),
|
|
22
|
+
"/usr/local/bin",
|
|
23
|
+
"/usr/bin",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const READY_TIMEOUT_MS = 20_000;
|
|
27
|
+
const STOP_TIMEOUT_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
function binary(name: string, dirs: readonly string[]): string | undefined {
|
|
30
|
+
for (const dir of dirs) {
|
|
31
|
+
const path = join(dir, name);
|
|
32
|
+
if (existsSync(path)) return path;
|
|
33
|
+
}
|
|
34
|
+
return Bun.which(name) ?? undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A TCP port nothing listens on, found by binding port 0 and letting it go. */
|
|
38
|
+
async function freePort(): Promise<number> {
|
|
39
|
+
const probe = Bun.serve({
|
|
40
|
+
port: 0,
|
|
41
|
+
hostname: "127.0.0.1",
|
|
42
|
+
fetch: () => new Response(),
|
|
43
|
+
});
|
|
44
|
+
const port = probe.port ?? 0;
|
|
45
|
+
await probe.stop(true);
|
|
46
|
+
return port;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Waits until the server at `url` answers a query, or throws when it exited or the wait ran out. */
|
|
50
|
+
async function untilReady(url: string, server: Bun.Subprocess): Promise<void> {
|
|
51
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
52
|
+
for (;;) {
|
|
53
|
+
if (server.exitCode !== null)
|
|
54
|
+
throw new Error(
|
|
55
|
+
`the test PostgreSQL exited with code ${server.exitCode} before it was ready`,
|
|
56
|
+
);
|
|
57
|
+
const sql = new SQL(url, { max: 1 });
|
|
58
|
+
try {
|
|
59
|
+
await sql`SELECT 1`;
|
|
60
|
+
await sql.close();
|
|
61
|
+
return;
|
|
62
|
+
} catch {
|
|
63
|
+
await sql.close().catch(() => undefined);
|
|
64
|
+
if (Date.now() > deadline)
|
|
65
|
+
throw new Error(
|
|
66
|
+
`the test PostgreSQL did not become ready in ${READY_TIMEOUT_MS / 1000} seconds`,
|
|
67
|
+
);
|
|
68
|
+
await Bun.sleep(100);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* For a test preload: unless ROUNDTABLE_TEST_DATABASE_URL is set, starts a throwaway PostgreSQL on
|
|
75
|
+
* 127.0.0.1 (trust auth, fsync off, a temporary data directory), sets the variable and
|
|
76
|
+
* `testDatabaseUrl`, and stops and removes it after the run (bun's global afterAll, exit and signals).
|
|
77
|
+
*/
|
|
78
|
+
export async function usePostgresForTests(
|
|
79
|
+
options: UsePostgresOptions = {},
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const given = process.env.ROUNDTABLE_TEST_DATABASE_URL;
|
|
82
|
+
if (given) {
|
|
83
|
+
adoptTestDatabase(given);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const dirs = options.binDirs ?? [process.env.PG_BIN ?? "", ...USUAL_DIRS];
|
|
87
|
+
const initdb = binary("initdb", dirs.filter(Boolean));
|
|
88
|
+
const postgres = binary("postgres", dirs.filter(Boolean));
|
|
89
|
+
if (!initdb || !postgres) {
|
|
90
|
+
if (options.require)
|
|
91
|
+
throw new Error(
|
|
92
|
+
"this run needs PostgreSQL: install it so initdb and postgres are on PATH, set PG_BIN to its bin directory, or set ROUNDTABLE_TEST_DATABASE_URL to a throwaway database",
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const root = mkdtempSync(join(tmpdir(), "roundtable-test-pg-"));
|
|
97
|
+
const data = join(root, "data");
|
|
98
|
+
const init = spawnSync(
|
|
99
|
+
initdb,
|
|
100
|
+
["-D", data, "-U", "postgres", "--auth=trust", "-E", "UTF8", "--no-sync"],
|
|
101
|
+
{ encoding: "utf8" },
|
|
102
|
+
);
|
|
103
|
+
if (init.status !== 0) {
|
|
104
|
+
rmSync(root, { recursive: true, force: true });
|
|
105
|
+
throw new Error(`initdb failed: ${init.stderr}`);
|
|
106
|
+
}
|
|
107
|
+
const port = await freePort();
|
|
108
|
+
const server = Bun.spawn(
|
|
109
|
+
[
|
|
110
|
+
postgres,
|
|
111
|
+
"-D",
|
|
112
|
+
data,
|
|
113
|
+
"-p",
|
|
114
|
+
String(port),
|
|
115
|
+
"-k",
|
|
116
|
+
root,
|
|
117
|
+
"-c",
|
|
118
|
+
"listen_addresses=127.0.0.1",
|
|
119
|
+
"-c",
|
|
120
|
+
"fsync=off",
|
|
121
|
+
],
|
|
122
|
+
{ stdout: "ignore", stderr: "ignore" },
|
|
123
|
+
);
|
|
124
|
+
let stopped = false;
|
|
125
|
+
const stop = async () => {
|
|
126
|
+
if (stopped) return;
|
|
127
|
+
stopped = true;
|
|
128
|
+
// SIGINT is PostgreSQL's fast shutdown: sessions end and the server exits at once.
|
|
129
|
+
server.kill("SIGINT");
|
|
130
|
+
await Promise.race([server.exited, Bun.sleep(STOP_TIMEOUT_MS)]);
|
|
131
|
+
rmSync(root, { recursive: true, force: true });
|
|
132
|
+
};
|
|
133
|
+
// A global hook of the run: bun runs preload hooks once, after every test file.
|
|
134
|
+
afterAll(stop);
|
|
135
|
+
process.on("exit", () => {
|
|
136
|
+
if (stopped) return;
|
|
137
|
+
server.kill("SIGINT");
|
|
138
|
+
// A process that exits without the run's hooks leaves no data directory behind. The server
|
|
139
|
+
// writes while it shuts down, so removing it takes a few tries.
|
|
140
|
+
for (let attempt = 0; attempt < 100 && existsSync(root); attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
rmSync(root, { recursive: true, force: true });
|
|
143
|
+
} catch {
|
|
144
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const)
|
|
149
|
+
process.on(signal, () => {
|
|
150
|
+
void stop().then(() => process.exit(1));
|
|
151
|
+
});
|
|
152
|
+
const url = `postgres://postgres@127.0.0.1:${port}/postgres`;
|
|
153
|
+
try {
|
|
154
|
+
await untilReady(url, server);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await stop();
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
adoptTestDatabase(url);
|
|
160
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionFactory,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ClientPart } from "../domain/client.ts";
|
|
6
|
+
import type { ReplyFile } from "../domain/conversation.ts";
|
|
7
|
+
import { PluginError } from "../errors.ts";
|
|
8
|
+
import {
|
|
9
|
+
attachReplyFile,
|
|
10
|
+
hasReplyFileScope,
|
|
11
|
+
withReplyFiles,
|
|
12
|
+
} from "../reply-files.ts";
|
|
13
|
+
|
|
14
|
+
/** What a session registers of a tool: the call the model's tool call reaches. */
|
|
15
|
+
export interface RegisteredTool {
|
|
16
|
+
name: string;
|
|
17
|
+
execute(
|
|
18
|
+
id: string,
|
|
19
|
+
params: Record<string, unknown>,
|
|
20
|
+
signal?: AbortSignal,
|
|
21
|
+
): Promise<{
|
|
22
|
+
content: readonly {
|
|
23
|
+
type: string;
|
|
24
|
+
text?: string;
|
|
25
|
+
data?: string;
|
|
26
|
+
mimeType?: string;
|
|
27
|
+
}[];
|
|
28
|
+
isError?: boolean;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the model would get back from one tool call, and what the call attached beside the text. */
|
|
33
|
+
export interface ToolCallResult {
|
|
34
|
+
text: string;
|
|
35
|
+
files: ReplyFile[];
|
|
36
|
+
images: ClientPart[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Anything an extension may call or read on the Pi API besides registering a tool: accepted, does nothing. */
|
|
40
|
+
type Ignored = (...args: readonly never[]) => Ignored;
|
|
41
|
+
|
|
42
|
+
function ignored(): Ignored {
|
|
43
|
+
return new Proxy((() => ignored()) as Ignored, {
|
|
44
|
+
get: (target, key) => (key === "then" ? undefined : target),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The tools a factory registers, in order; every other registration (handlers, commands, events) is accepted and ignored. */
|
|
49
|
+
export async function registeredTools(
|
|
50
|
+
factory: ExtensionFactory | null | undefined,
|
|
51
|
+
): Promise<RegisteredTool[]> {
|
|
52
|
+
const tools: RegisteredTool[] = [];
|
|
53
|
+
if (!factory) return tools;
|
|
54
|
+
// SAFETY: a session factory only registers tools, handlers and commands; the fake records the tools and ignores the rest.
|
|
55
|
+
const api = new Proxy(
|
|
56
|
+
{},
|
|
57
|
+
{
|
|
58
|
+
get: (_target, key) =>
|
|
59
|
+
key === "registerTool"
|
|
60
|
+
? (definition: RegisteredTool) => tools.push(definition)
|
|
61
|
+
: ignored(),
|
|
62
|
+
},
|
|
63
|
+
) as unknown as ExtensionAPI;
|
|
64
|
+
await factory(api);
|
|
65
|
+
return tools;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Runs the call as a turn on a surface that takes files or not runs it: the text, the files it
|
|
70
|
+
* attached and the images it returned. A file is also queued for the enclosing real turn when a
|
|
71
|
+
* fake runtime runs the call inside one. A tool that throws throws here; a refusal (an `isError` result) comes back as its text.
|
|
72
|
+
*/
|
|
73
|
+
export async function callRegisteredTool(
|
|
74
|
+
tool: RegisteredTool,
|
|
75
|
+
args: Record<string, unknown>,
|
|
76
|
+
supportsFiles: boolean,
|
|
77
|
+
): Promise<ToolCallResult> {
|
|
78
|
+
const images: ClientPart[] = [];
|
|
79
|
+
const result = await withReplyFiles(supportsFiles, async () => {
|
|
80
|
+
const output = await tool.execute("test-call", args);
|
|
81
|
+
for (const item of output.content)
|
|
82
|
+
if (item.type === "image" && item.data && item.mimeType)
|
|
83
|
+
images.push({
|
|
84
|
+
mimeType: item.mimeType,
|
|
85
|
+
data: new Uint8Array(Buffer.from(item.data, "base64")),
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
text: output.content
|
|
90
|
+
.filter((item) => item.type === "text")
|
|
91
|
+
.map((item) => item.text ?? "")
|
|
92
|
+
.join("\n"),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
if (!result.ok) throw result.error;
|
|
96
|
+
const files = result.files ?? [];
|
|
97
|
+
// When a tool is run by a fake runtime, forward into the enclosing real turn.
|
|
98
|
+
if (hasReplyFileScope()) for (const file of files) attachReplyFile(file);
|
|
99
|
+
return { text: result.text, files, images };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The registered tool called `name`, or the PluginError a test reads. */
|
|
103
|
+
export function toolNamed(
|
|
104
|
+
tools: readonly RegisteredTool[],
|
|
105
|
+
name: string,
|
|
106
|
+
): RegisteredTool {
|
|
107
|
+
const found = tools.find((tool) => tool.name === name);
|
|
108
|
+
if (!found) throw new PluginError(`tool ${name} is not registered`);
|
|
109
|
+
return found;
|
|
110
|
+
}
|