pi-roundtable 0.17.1 → 0.17.3
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/docs/plugins.md +7 -2
- package/package.json +1 -1
- package/src/core/contract/runtime.ts +1 -1
- package/src/core/host.ts +3 -0
- package/src/core/runtime/prompt-slot.ts +4 -12
- package/src/core/runtime/runtime-plugin.ts +0 -3
- package/src/core/runtime/turn-budget.ts +10 -1
- package/src/testing.ts +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.17.3] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The 0.17.1 entry and the plugin guide called `turnTimeoutMs` and `turnMaxMs` options of the built-in runtime that a host can set. Nothing public sets them: the runtime plugin passes neither, and the runtime class is not exported. Every host runs a turn with 10 minutes of work, renewed by each steer up to 30 minutes in all. The guide now says the two limits are fixed, and its startup order lists the runtime's own `preflight`, which 0.17.2 moved after every plugin's.
|
|
13
|
+
- `testPlugin` runs the runtime's `preflight` where the host does, right after the plugin's own and before the plugin's services and surfaces start, not after they have. A runtime whose `preflight` throws fails `testPlugin` with that error, with nothing started and the runtime disposed.
|
|
14
|
+
- A time limit on a turn, or on a coding worker (`workTimeout`), that comes due while an owner's card is open looks at the turn again no oftener than once a second, not once a millisecond for as long as the card waits. The limit still fires once; a limit that waiting on a card pushed back can fire up to a second late.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- 0.17.2 was tagged but never published: its release run stopped on a LINE plugin test that raced its own frozen clock. 0.17.3 carries 0.17.2's fix, the runtime's startup probe after every plugin's `preflight`; the LINE tests that raced now step their clock, and no shipped LINE code changed.
|
|
19
|
+
|
|
20
|
+
## [0.17.2] - 2026-10-16
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- The runtime's startup probe of the model and the required tools runs after every plugin's `preflight`, not in the runtime plugin's own preflight, which ran first. Since 0.17.0 a plugin that resolves its MCP servers does so in its preflight, so the probe no longer saw the tools they yield, and a required tool that was missing failed only the first session instead of the start. The host now runs the `RUNTIME` service's `preflight` once every plugin's preflight has finished, still before any service starts or any listener opens; a failure stops the start, and the same host may try again. A custom runtime provider's `preflight` moves to the same place. The runtime plugin's claude-bridge memory warning stays in its own preflight. `Roundtable.check()` still runs no preflight.
|
|
25
|
+
|
|
8
26
|
## [0.17.1] - 2026-10-16
|
|
9
27
|
|
|
10
28
|
### Changed
|
package/docs/plugins.md
CHANGED
|
@@ -1443,7 +1443,7 @@ An `AgentRuntime` has these methods:
|
|
|
1443
1443
|
| `pendingConfirmation(conversation)`, `heldActions(conversation)` | The held actions known in memory, and those restored from the store after a restart: the agent server reads `heldActions` to show an approval card again after a restart. After a restart `pendingConfirmation` knows a conversation's held actions only once `heldActions` or `recentTranscript` has restored them, so a host that decides an approval asks one of them first. A `PendingConfirmation` carries the `selectionId` of the `TurnSelection` whose turn held the calls, an opaque string that the caller resolves again when the owner confirms; the core stores it and never reads it |
|
|
1444
1444
|
| `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation; the built-in runtime gives none of a history recorded, or a session opened, for someone else than the conversation serves now |
|
|
1445
1445
|
| `contextUsage?(conversation)` | How full the conversation's context is, `{ tokens, contextWindow }`; leave it out and the team status shows no context bar |
|
|
1446
|
-
| `preflight?()` | Runs
|
|
1446
|
+
| `preflight?()` | Runs after every plugin's preflight, before anything starts; a throw stops the boot |
|
|
1447
1447
|
| `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
|
|
1448
1448
|
|
|
1449
1449
|
A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed`: the speaker's message approved the conversation's held calls, which the runtime releases for the speaker, except those that [name approvers](#a-hold-that-names-its-approvers) the speaker is neither among nor an owner of, which stay held), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
|
|
@@ -1698,6 +1698,10 @@ test("only one plugin may fill the runtime slot", async () => {
|
|
|
1698
1698
|
`preflight` also sits on the plugin.
|
|
1699
1699
|
It runs once every plugin is set up and linked, before any command is registered or any service starts.
|
|
1700
1700
|
A throw stops the boot, so a missing setting is caught before Discord connects.
|
|
1701
|
+
Plugins' preflights run in plugin order.
|
|
1702
|
+
Once every one has finished, the host runs the runtime's own `preflight` (the `RUNTIME` service's), which for Pi's runtime builds throwaway sessions and checks the model and the required tools.
|
|
1703
|
+
So tools a plugin fills in during its preflight, such as MCP servers it resolves there, are in those sessions, and a required tool still missing stops the start before any service starts or any listener opens.
|
|
1704
|
+
`Roundtable.check()` runs no preflight.
|
|
1701
1705
|
|
|
1702
1706
|
<!-- example: examples/preflight.ts -->
|
|
1703
1707
|
```ts
|
|
@@ -3367,6 +3371,7 @@ The host's `run()` handles startup:
|
|
|
3367
3371
|
From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
|
|
3368
3372
|
5. Every plugin's `preflight` runs, in plugin order.
|
|
3369
3373
|
The Discord plugin's preflight is where it composes the slash commands every plugin added in `setup`; a clash stops the start here.
|
|
3374
|
+
Once every one has finished, the runtime's own `preflight` (the `RUNTIME` service's) runs, so its check of the model and the required tools sees the tools that other plugins filled in during theirs.
|
|
3370
3375
|
6. Every service starts: the plugins' in plugin order, and each plugin's own in the order it listed them, after the plugin's chat surfaces (the `surface:<prefix>` services).
|
|
3371
3376
|
7. The HTTP listeners open.
|
|
3372
3377
|
8. Every service's `startInBackground` runs, at once and without holding up the boot, and every plugin hears `serviceStarted` as each ends; the agent server's `team` service starts the agents' channels and the dashboard there.
|
|
@@ -3395,7 +3400,7 @@ On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs,
|
|
|
3395
3400
|
`shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
|
|
3396
3401
|
Every call shares the same shutdown.
|
|
3397
3402
|
Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
|
|
3398
|
-
The built-in runtime aborts a turn that works 10 minutes
|
|
3403
|
+
The built-in runtime aborts a turn that works 10 minutes, not counting time spent waiting on the owner's cards. Each message steered into the running turn gives it the full 10 minutes again from that moment, up to a cap of 30 minutes of work for the whole turn; once the cap is reached the turn takes no more steering, so a message waits for the next turn, and the turn is aborted as on its timeout. Both limits are fixed: the runtime plugin passes no option for them, so a host cannot change them, and a limit that waiting on an owner's card pushed back can fire up to a second late. The log says `steer renewed the turn's time budget` with `deadlineMs`, the work time left, and the timeout warning names the limit that fired (`limit: "turn"` or `"turnMax"`). Task workers (`task.timeoutMs`), coding workers (`workTimeout`) and sandbox guests (their own `turnTimeoutMs`) keep their own limits, which steering does not renew.
|
|
3399
3404
|
A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
|
|
3400
3405
|
`listen()`, called by the command line and a host's own entry point, exits the process with that code.
|
|
3401
3406
|
|
package/package.json
CHANGED
|
@@ -72,7 +72,7 @@ export interface AgentRuntime {
|
|
|
72
72
|
): Promise<TranscriptEntry[]>;
|
|
73
73
|
/** Undefined when unknown; the team status shows no context bar for the conversation then. */
|
|
74
74
|
contextUsage?(conversation: ChannelKey): ContextUse | undefined;
|
|
75
|
-
/** Runs
|
|
75
|
+
/** Runs after every plugin's preflight, before anything starts; a throw stops the boot. */
|
|
76
76
|
preflight?(): Promise<void>;
|
|
77
77
|
/** Runs when the host stops the agent server's runtime service. */
|
|
78
78
|
dispose?(): Promise<void> | void;
|
package/src/core/host.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Locale } from "./i18n/index.ts";
|
|
|
11
11
|
import type { JudgeModel } from "./judging/model-judge.ts";
|
|
12
12
|
import type { Logger } from "./log.ts";
|
|
13
13
|
import type { RoundtablePlugin, Service } from "./plugin.ts";
|
|
14
|
+
import { RUNTIME } from "./services.ts";
|
|
14
15
|
import type { ChannelKey } from "./sessions.ts";
|
|
15
16
|
import type { ToolTierTable } from "./tool-tiers.ts";
|
|
16
17
|
|
|
@@ -149,6 +150,8 @@ export class Roundtable {
|
|
|
149
150
|
return this.#pool;
|
|
150
151
|
});
|
|
151
152
|
for (const plugin of link.active) await plugin.preflight?.();
|
|
153
|
+
// Last, so the probe sees the tools other plugins fill in during their preflight.
|
|
154
|
+
await link.services?.find(RUNTIME)?.preflight?.();
|
|
152
155
|
for (const service of [...link.registry.services, ...retention]) {
|
|
153
156
|
await service.start?.();
|
|
154
157
|
this.#started.push(service);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HoldRefusal } from "../domain/progress.ts";
|
|
2
2
|
import { assistantName } from "../i18n/index.ts";
|
|
3
3
|
import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
|
|
4
|
+
import { turnBudget } from "./turn-budget.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* A run's way to ask the owner while it works. A turn they started or a report turn binds the
|
|
@@ -101,22 +102,13 @@ export function promptSlot(): PromptSlot {
|
|
|
101
102
|
|
|
102
103
|
/**
|
|
103
104
|
* Calls `expire` once the run has worked `ms`, not counting time the slot spent waiting on the
|
|
104
|
-
* owner's cards; returns a function that cancels it.
|
|
105
|
+
* owner's cards; returns a function that cancels it. It is a turn budget with nothing to renew,
|
|
106
|
+
* so it looks again at a run that waits on a card no oftener than `turnBudget` does.
|
|
105
107
|
*/
|
|
106
108
|
export function workTimeout(
|
|
107
109
|
ms: number,
|
|
108
110
|
slot: Pick<PromptSlot, "waitedMs">,
|
|
109
111
|
expire: () => void,
|
|
110
112
|
): () => void {
|
|
111
|
-
|
|
112
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
-
const arm = (delay: number) => {
|
|
114
|
-
timer = setTimeout(() => {
|
|
115
|
-
const left = ms - (Date.now() - started - slot.waitedMs());
|
|
116
|
-
if (left > 0) return arm(left);
|
|
117
|
-
expire();
|
|
118
|
-
}, delay);
|
|
119
|
-
};
|
|
120
|
-
arm(ms);
|
|
121
|
-
return () => clearTimeout(timer);
|
|
113
|
+
return turnBudget(ms, ms, slot, () => expire()).cancel;
|
|
122
114
|
}
|
|
@@ -97,7 +97,6 @@ export function runtimePlugin(
|
|
|
97
97
|
) => Promise<HeldActionStore> = (context) =>
|
|
98
98
|
PendingConfirmationStore.attach(context.database()),
|
|
99
99
|
): RoundtablePlugin {
|
|
100
|
-
let runtime: AgentRuntime | undefined;
|
|
101
100
|
let identity: IdentityService | undefined;
|
|
102
101
|
let warn: ((message: string) => void) | undefined;
|
|
103
102
|
// Admission is a startup risk warning, not proof that this conversation holds private history.
|
|
@@ -119,7 +118,6 @@ export function runtimePlugin(
|
|
|
119
118
|
`${bridgeMemoryProblem(formatModelRef(options.model), why)} ${BRIDGE_MEMORY_FIX}`,
|
|
120
119
|
);
|
|
121
120
|
}
|
|
122
|
-
await runtime?.preflight?.();
|
|
123
121
|
},
|
|
124
122
|
setup: async (context) => {
|
|
125
123
|
const { logger, providers, services } = context;
|
|
@@ -194,7 +192,6 @@ export function runtimePlugin(
|
|
|
194
192
|
? { interimPrimaryChars: options.interimPrimaryChars }
|
|
195
193
|
: {}),
|
|
196
194
|
});
|
|
197
|
-
runtime = running;
|
|
198
195
|
context.services.provide(RUNTIME, running);
|
|
199
196
|
return {
|
|
200
197
|
services: [{ name: "runtime", stop: () => running.dispose?.() }],
|
|
@@ -31,6 +31,15 @@ const REAL_CLOCK: BudgetClock = {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The shortest wait before a budget looks again at a turn that still has time. While a card is
|
|
36
|
+
* open the turn's work time stands still, so a timer that fires with a few milliseconds left would
|
|
37
|
+
* otherwise re-arm for those milliseconds, again and again, for as long as the card waits. The
|
|
38
|
+
* floor bounds that to one look a second; the cost is that a limit that waiting on a card pushed
|
|
39
|
+
* back, by less than the floor, may fire up to this much late.
|
|
40
|
+
*/
|
|
41
|
+
const REARM_FLOOR_MS = 1_000;
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
|
|
36
45
|
* `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
|
|
@@ -54,7 +63,7 @@ export function turnBudget(
|
|
|
54
63
|
const arm = (delay: number) => {
|
|
55
64
|
cancelTimer = clock.schedule(() => {
|
|
56
65
|
const left = deadline - worked();
|
|
57
|
-
if (left > 0) return arm(left);
|
|
66
|
+
if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
|
|
58
67
|
expired = true;
|
|
59
68
|
expire(deadline >= maxMs ? "turnMax" : "turn");
|
|
60
69
|
}, delay);
|
package/src/testing.ts
CHANGED
|
@@ -608,17 +608,18 @@ export async function testPlugin(
|
|
|
608
608
|
// The plugin's own surfaces start as services; the injected ones start beside them, after.
|
|
609
609
|
const deliver = (message: InboundMessage) =>
|
|
610
610
|
void conversations.handle(message);
|
|
611
|
-
// As the host does, the plugin's preflight runs once everything is set up and before any service
|
|
611
|
+
// As the host does, the plugin's preflight runs once everything is set up and before any service
|
|
612
|
+
// starts, and the runtime's own after it, so it sees what the preflight filled in.
|
|
612
613
|
try {
|
|
613
614
|
await plugin.preflight?.();
|
|
615
|
+
await runtime?.preflight?.();
|
|
614
616
|
} catch (error) {
|
|
615
|
-
// The runtime the provider built is not left running when the plugin refuses.
|
|
617
|
+
// The runtime the provider built is not left running when the plugin or the runtime refuses.
|
|
616
618
|
await runtime?.dispose?.();
|
|
617
619
|
throw error;
|
|
618
620
|
}
|
|
619
621
|
for (const service of registry.services) await service.start?.();
|
|
620
622
|
for (const surface of options.surfaces ?? []) await surface.start(deliver);
|
|
621
|
-
await runtime?.preflight?.();
|
|
622
623
|
let stopped = false;
|
|
623
624
|
const files: { channel: ChannelKey; file: ReplyFile }[] = [];
|
|
624
625
|
const images: { channel: ChannelKey; image: ClientPart }[] = [];
|