pi-roundtable 0.16.0 → 0.17.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 (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +5 -0
  3. package/docs/plugins.md +69 -0
  4. package/package.json +1 -1
  5. package/src/cli/checks/configuration.ts +21 -1
  6. package/src/cli/project.ts +59 -8
  7. package/src/cli/runtime.ts +3 -0
  8. package/src/core/agents/agent-store.ts +4 -1
  9. package/src/core/builtin/discord.ts +5 -0
  10. package/src/core/config/adapters.ts +12 -0
  11. package/src/core/contract/runtime.ts +7 -0
  12. package/src/core/contract/surface.ts +7 -0
  13. package/src/core/db/check-database.ts +39 -0
  14. package/src/core/define-roundtable.ts +3 -0
  15. package/src/core/discord/discord-surface.ts +5 -0
  16. package/src/core/discord/formatting.ts +20 -0
  17. package/src/core/host-registration.ts +292 -0
  18. package/src/core/host.ts +45 -227
  19. package/src/core/identity/config-owner.ts +34 -0
  20. package/src/core/identity/identity-plugin.ts +7 -2
  21. package/src/core/identity/identity-service.ts +8 -1
  22. package/src/core/identity/plugin-identities.ts +19 -4
  23. package/src/core/modules/skills/skill-store.ts +3 -1
  24. package/src/core/runtime/extensions/surface-formatting.ts +22 -0
  25. package/src/core/runtime/pi-agent-runtime.ts +22 -21
  26. package/src/core/runtime/running-turn.ts +19 -0
  27. package/src/core/runtime/runtime-plugin.ts +4 -0
  28. package/src/core/runtime/runtime-types.ts +23 -2
  29. package/src/core/runtime/session-factory.ts +7 -0
  30. package/src/core/runtime/turn-budget.ts +112 -0
  31. package/src/core/testing/database.ts +20 -2
  32. package/src/core/testing/postgres.ts +160 -0
  33. package/src/core/testing/run-tool.ts +110 -0
  34. package/src/core/testing/test-host.ts +78 -30
  35. package/src/index.ts +1 -0
  36. package/src/testing.ts +25 -56
  37. package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
  38. 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(database()),
51
+ await PgPrincipalStore.attach(sql),
50
52
  rules,
51
53
  { logger, identities },
52
54
  );
53
- await identity.syncConfig();
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
- throw new ConfigError(
81
- `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.`,
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
- await store.#reload();
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
+ }
@@ -33,9 +33,9 @@ import { loadsMemory } from "./extensions/private-memory.ts";
33
33
  import { missingToolsError } from "./extensions/self-compact-guard.ts";
34
34
  import { interimPoster } from "./interim-text.ts";
35
35
  import { preflightTools } from "./preflight-tools.ts";
36
- import { PromptSlot, workTimeout } from "./prompt-slot.ts";
36
+ import { PromptSlot } from "./prompt-slot.ts";
37
37
  import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
38
- import type { RunningTurn } from "./running-turn.ts";
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 {
@@ -48,6 +48,7 @@ import { promptImages, SteerableRun } from "./steerable-run.ts";
48
48
  import { thinkingLevel } from "./thinking-level.ts";
49
49
  import { runningCalls } from "./tool-call-scope.ts";
50
50
  import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
51
+ import { runtimeTurnBudget, type TurnBudget } from "./turn-budget.ts";
51
52
  import { progressReporter } from "./turn-progress.ts";
52
53
  import { wrapUpTurn } from "./turn-wrapup.ts";
53
54
  import { workerReport } from "./worker-task.ts";
@@ -75,6 +76,8 @@ export class PiAgentRuntime implements AgentRuntime {
75
76
  readonly #turns = new Map<ChannelKey, RunningTurn>();
76
77
  /** Each conversation's turn in progress, which the owner may steer or stop. */
77
78
  readonly #running = new Map<ChannelKey, SteerableRun>();
79
+ /** Each running turn's time limit, which a steered message renews. */
80
+ readonly #budgets = new Map<ChannelKey, TurnBudget>();
78
81
  readonly #memoryTurns = new Map<ChannelKey, () => void>();
79
82
 
80
83
  /** Builds the runtime without starting a session; the preflight proves it can run. */
@@ -86,6 +89,10 @@ export class PiAgentRuntime implements AgentRuntime {
86
89
  speaker: (channel) => this.#turns.get(channel)?.speaker,
87
90
  origin: (channel) => this.#turns.get(channel)?.origin,
88
91
  client: (channel) => this.#turns.get(channel)?.client,
92
+ formatting: (channel) => {
93
+ const reply = this.#turns.get(channel)?.channel;
94
+ return reply === undefined ? undefined : options.formatting?.(reply);
95
+ },
89
96
  withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
90
97
  runTask: (scope, task) => this.#runTask(scope, task),
91
98
  });
@@ -110,7 +117,7 @@ export class PiAgentRuntime implements AgentRuntime {
110
117
 
111
118
  // pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
112
119
  async runTurn(request: TurnRequest): Promise<TurnResult> {
113
- const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
120
+ const { logger } = this.#options;
114
121
  if (!request.speaker) return unspokenTurn();
115
122
  // A turn that is only asked for now, such as a group's next member, starts nothing.
116
123
  if (this.#options.stopping?.())
@@ -195,13 +202,7 @@ export class PiAgentRuntime implements AgentRuntime {
195
202
  loadsMemory(registered),
196
203
  );
197
204
  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
- });
205
+ this.#turns.set(key, runningTurn(request, tier, withheld));
205
206
  const missing = wanted.filter((name) => !registered.has(name));
206
207
  if (missing.length > 0) {
207
208
  logger.warn(
@@ -274,17 +275,14 @@ export class PiAgentRuntime implements AgentRuntime {
274
275
  this.#sessions.drop(key, channelSession);
275
276
  },
276
277
  });
277
- // Time spent waiting on the owner's cards does not count towards the timeout.
278
- const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
279
- logger.warn(
280
- { channel: request.channel, turnTimeoutMs },
281
- "turn timed out; aborting",
282
- );
283
- guard.abort();
284
- });
278
+ // Time spent waiting on the owner's cards does not count towards the timeout or the cap.
279
+ const budget = runtimeTurnBudget(this.#options, slot, request.channel, () =>
280
+ guard.abort(),
281
+ );
282
+ this.#budgets.set(key, budget);
285
283
  const running = new SteerableRun(
286
284
  session,
287
- () => request.steerable === true && !gate.pending(),
285
+ () => request.steerable === true && !gate.pending() && !budget.capped(),
288
286
  () => guard.abort(),
289
287
  );
290
288
  this.#running.set(key, running);
@@ -315,9 +313,10 @@ export class PiAgentRuntime implements AgentRuntime {
315
313
  };
316
314
  } finally {
317
315
  this.#running.delete(key);
316
+ this.#budgets.delete(key);
318
317
  this.#turns.delete(key);
319
318
  this.#memoryTurns.delete(key);
320
- cancelTimeout();
319
+ budget.cancel();
321
320
  guard.release();
322
321
  slot.unbind();
323
322
  unsubscribe();
@@ -360,8 +359,10 @@ export class PiAgentRuntime implements AgentRuntime {
360
359
  withAttachmentsBlock(text, attachments),
361
360
  promptImages(attachments),
362
361
  );
363
- if (steered)
362
+ if (steered) {
364
363
  this.#options.logger.info({ channel }, "message steered into the turn");
364
+ this.#budgets.get(channel)?.renew();
365
+ }
365
366
  return steered;
366
367
  } catch (error) {
367
368
  this.#options.logger.warn({ channel, err: error }, "steer refused");
@@ -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,10 +79,27 @@ 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
- /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
91
+ /**
92
+ * A run that works longer, not counting time spent waiting on the owner's cards, is aborted and
93
+ * reported as failed; default 10 minutes. Each message steered into the turn gives it this
94
+ * budget again from that moment, up to `turnMaxMs`.
95
+ */
85
96
  turnTimeoutMs?: number;
97
+ /**
98
+ * The most one turn may work in all, however often it is steered, not counting time spent
99
+ * waiting on the owner's cards; default three times `turnTimeoutMs`. Once it is reached the turn
100
+ * takes no more steering (the message waits for the next turn) and is aborted as on its timeout.
101
+ */
102
+ turnMaxMs?: number;
86
103
  /**
87
104
  * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
88
105
  * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
@@ -124,13 +141,16 @@ export interface CoreExtensions {
124
141
  askUser: ExtensionFactory;
125
142
  selfCompactGuard: ExtensionFactory;
126
143
  privateMemory: ExtensionFactory;
144
+ /** The formatting guidance of the surface the turn replies on, appended after the persona. */
145
+ surfaceFormatting: ExtensionFactory;
127
146
  activeTools: ExtensionFactory;
128
147
  }
129
148
 
130
149
  /**
131
150
  * A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
132
151
  * and compact guard, the compactor, the MCP phase, the memory projection, so each request is
133
- * projected after every other extension changed it, and active-tools last, so its
152
+ * projected after every other extension changed it, the surface's formatting guidance, so it follows
153
+ * the persona and everything appended before it, and active-tools last, so its
134
154
  * before_agent_start handler runs after every other extension's.
135
155
  */
136
156
  export function sessionExtensions(
@@ -160,6 +180,7 @@ export function sessionExtensions(
160
180
  ...contributed(plan.compaction ? [plan.compaction] : []),
161
181
  ...contributed(plan.mcp),
162
182
  { name: "private-memory", factory: core.privateMemory },
183
+ { name: "surface-formatting", factory: core.surfaceFormatting },
163
184
  { name: "active-tools", factory: core.activeTools },
164
185
  ];
165
186
  }
@@ -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.
@@ -0,0 +1,112 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { PromptSlot } from "./prompt-slot.ts";
3
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
4
+
5
+ /** Which limit ended a turn: its budget since the start or the last steer, or its cap. */
6
+ export type TurnLimit = "turn" | "turnMax";
7
+
8
+ /** A turn's time limit, which a steered message renews up to a cap on the whole turn. */
9
+ export interface TurnBudget {
10
+ /**
11
+ * Gives the turn its full budget again from now, up to the cap; the work time left, in ms,
12
+ * or undefined when the cap is reached and the turn takes no more steering.
13
+ */
14
+ renew(): number | undefined;
15
+ /** Whether the turn has worked up to its cap. */
16
+ capped(): boolean;
17
+ cancel(): void;
18
+ }
19
+
20
+ /** The clock a turn budget reads; the real one by default, a fake one in tests. */
21
+ export interface BudgetClock {
22
+ now(): number;
23
+ schedule(run: () => void, ms: number): () => void;
24
+ }
25
+
26
+ const REAL_CLOCK: BudgetClock = {
27
+ now: () => Date.now(),
28
+ schedule: (run, ms) => {
29
+ const timer = setTimeout(run, ms);
30
+ return () => clearTimeout(timer);
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
36
+ * `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
37
+ * towards neither. `expire` is told which limit fired.
38
+ */
39
+ export function turnBudget(
40
+ ms: number,
41
+ maxMs: number,
42
+ slot: Pick<PromptSlot, "waitedMs">,
43
+ expire: (limit: TurnLimit) => void,
44
+ clock: BudgetClock = REAL_CLOCK,
45
+ ): TurnBudget {
46
+ const started = clock.now();
47
+ const worked = () => {
48
+ const now = clock.now();
49
+ return now - started - slot.waitedMs(now);
50
+ };
51
+ let deadline = Math.min(ms, maxMs);
52
+ let cancelTimer: (() => void) | undefined;
53
+ let expired = false;
54
+ const arm = (delay: number) => {
55
+ cancelTimer = clock.schedule(() => {
56
+ const left = deadline - worked();
57
+ if (left > 0) return arm(left);
58
+ expired = true;
59
+ expire(deadline >= maxMs ? "turnMax" : "turn");
60
+ }, delay);
61
+ };
62
+ arm(deadline);
63
+ return {
64
+ renew() {
65
+ const now = worked();
66
+ if (expired || now >= maxMs) return undefined;
67
+ deadline = Math.min(now + ms, maxMs);
68
+ cancelTimer?.();
69
+ arm(deadline - now);
70
+ return deadline - now;
71
+ },
72
+ capped: () => expired || worked() >= maxMs,
73
+ cancel: () => cancelTimer?.(),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The time limit of one turn of the built-in runtime, from its options: `turnTimeoutMs` (default
79
+ * 10 minutes) renewed by each steer up to `turnMaxMs` (default three times that). It logs each
80
+ * renewal and, when a limit fires, which one before it calls `abort`.
81
+ */
82
+ export function runtimeTurnBudget(
83
+ options: Pick<
84
+ PiAgentRuntimeOptions,
85
+ "turnTimeoutMs" | "turnMaxMs" | "logger"
86
+ >,
87
+ slot: Pick<PromptSlot, "waitedMs">,
88
+ channel: ChannelKey,
89
+ abort: () => void,
90
+ ): TurnBudget {
91
+ const { logger, turnTimeoutMs = 10 * 60_000 } = options;
92
+ const turnMaxMs = options.turnMaxMs ?? 3 * turnTimeoutMs;
93
+ const budget = turnBudget(turnTimeoutMs, turnMaxMs, slot, (limit) => {
94
+ logger.warn(
95
+ { channel, limit, turnTimeoutMs, turnMaxMs },
96
+ "turn timed out; aborting",
97
+ );
98
+ abort();
99
+ });
100
+ return {
101
+ ...budget,
102
+ renew() {
103
+ const deadlineMs = budget.renew();
104
+ if (deadlineMs !== undefined)
105
+ logger.info(
106
+ { channel, deadlineMs },
107
+ "steer renewed the turn's time budget",
108
+ );
109
+ return deadlineMs;
110
+ },
111
+ };
112
+ }
@@ -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
- export const testDatabaseUrl = url ?? "";
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
- export const describeDb = url ? describe : describe.skip;
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> {