pi-roundtable 0.9.0 → 0.9.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.
- package/CHANGELOG.md +13 -0
- package/docs/plugins.md +12 -8
- package/package.json +1 -1
- package/src/core/builtin/modules.ts +10 -1
- package/src/core/config/config.ts +9 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +25 -60
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/modules/schedules/scheduler.ts +13 -2
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +59 -7
- package/src/core/routing/conversation-turns.ts +7 -2
- package/src/core/runtime/abort-guard.ts +58 -0
- package/src/core/runtime/conversation-sessions.ts +15 -0
- package/src/core/runtime/pi-agent-runtime.ts +61 -78
- package/src/core/runtime/runtime-plugin.ts +1 -0
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/core/runtime/steerable-run.ts +12 -3
- package/src/core/runtime/thinking-level.ts +47 -0
- package/src/core/runtime/turn-wrapup.ts +63 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.1] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- A SIGTERM no longer lets the host keep taking work until it was idle: in 0.9.0 the shutdown drain only waited, so messages, error and webhook reports, schedules, and plugin turns that arrived meanwhile were served and waited for too, and a host that kept getting them never became idle until the one-hour limit (a deploy waited for systemd to kill it). The drain now starts by closing the channel queue: no new turn starts from any source. A message that arrives is not run and its channel gets one short restarting notice (not for a bot's or an integration's message, which is only logged), including a message that waited behind a running turn, and a message is no longer steered into a running turn; a background turn (a schedule's, a delegated task's report, an error report) is skipped; `context.turns.run`, a task for `context.queue.run` that has not started, and a group's next member's turn are refused (`HostStoppingError`, new in `pi-roundtable`), and so are new worker tasks; a remote MCP run fails instead of starting. The scheduler claims nothing during the drain, so a due schedule stays due and fires after the next start if it is less than twelve hours late. Turns already running finish.
|
|
13
|
+
- The drain is bounded by three minutes by default instead of an hour: `drainSeconds` in the config, or `drain.limitMs` in `RoundtableOptions`. At the limit the host stops each running turn as the owner's stop does, gives it `drain.abortGraceMs` (ten seconds by default) to end, hands what is left to `aborted`, and goes on to stop the services and exit. Give the service manager a stop timeout a minute longer than the drain, such as systemd's `TimeoutStopSec=5min`.
|
|
14
|
+
- A turn or worker task whose model ignores the abort (a claude-bridge subprocess that stays alive after "turn timed out; aborting") no longer holds its channel, and the drain, for ever: a run still going `turnAbortGraceMs` (15 seconds by default, `PiAgentRuntimeOptions`) after its abort has its session disposed of and ends as failed, and the conversation's next turn opens a new session from its files. An owner's stop of such a turn is guarded the same way.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `QueuePort.closed` (`context.queue.closed`): whether the host is shutting down, for a plugin that starts work from its own timer or route.
|
|
19
|
+
- The `drainSeconds` configuration key.
|
|
20
|
+
|
|
8
21
|
## [0.9.0] - 2026-10-09
|
|
9
22
|
|
|
10
23
|
Principal and identity attribution replace implicit owner authority.
|
package/docs/plugins.md
CHANGED
|
@@ -898,7 +898,7 @@ test("the handlers record a turn, a team change, and the shutdown", async () =>
|
|
|
898
898
|
|
|
899
899
|
A service has a name and optional `start`, `stop`, and `busy`.
|
|
900
900
|
Services start once everything is set up and stop in reverse order.
|
|
901
|
-
`busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, for at most
|
|
901
|
+
`busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, for at most the drain limit (three minutes by default, `drainSeconds` in the config), so a deploy rarely cuts work short.
|
|
902
902
|
The host adds its own channel queue to that wait, so a surface that queues its turns through `context.queue` needs no `busy` for them.
|
|
903
903
|
|
|
904
904
|
A service may also have `startInBackground`.
|
|
@@ -2791,7 +2791,7 @@ The copied file begins with this warning.
|
|
|
2791
2791
|
`release-notice` listens to two [events](#events-hear-what-the-core-does).
|
|
2792
2792
|
When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
|
|
2793
2793
|
It records the announced `sha` only after the post succeeds, so a failed post is tried again at the next start, and a `release.json` that is not valid (not JSON, an empty `sha`, or `commits` that is not a list of strings) fails the handler with its path in the log.
|
|
2794
|
-
At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on
|
|
2794
|
+
At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on at its limit, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
|
|
2795
2795
|
A plain restart of the same release with nothing cut short posts nothing.
|
|
2796
2796
|
|
|
2797
2797
|
The deploy writes `release.json` into the release directory, where the bot runs, before it starts the bot.
|
|
@@ -2999,16 +2999,19 @@ If a startup step fails, the host stops its listeners, stops services in reverse
|
|
|
2999
2999
|
The process exits non-zero.
|
|
3000
3000
|
The same host may call `run()` again after that.
|
|
3001
3001
|
|
|
3002
|
-
On `SIGTERM` or `SIGINT` the bot
|
|
3002
|
+
On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs, and stops serving last:
|
|
3003
3003
|
|
|
3004
|
-
1. It
|
|
3005
|
-
2.
|
|
3006
|
-
3.
|
|
3007
|
-
4.
|
|
3008
|
-
5.
|
|
3004
|
+
1. It stops starting work: the channel queue closes (`context.queue.closed` turns true), and from then on no new turn starts, whoever asks. A message that arrives is not run; its channel gets one short "restarting, try again shortly" reply, except a bot's or an integration's message, which is only logged. A background turn (a schedule's, a delegated task's report, an error report) is skipped, and so is a turn a plugin asks of `context.turns.run`, which rejects with `HostStoppingError`; a task for the queue that has not started, whether queued before or after, is refused with the same error. The scheduler claims nothing, so a due schedule stays due and fires after the next start if it is less than twelve hours late. A turn that is already running finishes. A plugin that starts turns from a timer or a route of its own should check `context.queue.closed` first.
|
|
3005
|
+
2. It waits until the channel queue is empty and no service reports `busy()` work, for at most three minutes (`drainSeconds` in the config, `drain.limitMs` in `RoundtableOptions`). If work is left, it stops each running turn as the owner's stop does, gives them ten seconds (`drain.abortGraceMs`) to end, and goes on; whatever is left is logged and given up on.
|
|
3006
|
+
3. Every plugin hears `shutdown(left)`, while every service is still running.
|
|
3007
|
+
4. The HTTP listener closes, so no request reaches a service that has stopped.
|
|
3008
|
+
5. Services stop in the reverse of the order they started.
|
|
3009
|
+
6. The database pool closes.
|
|
3009
3010
|
|
|
3010
3011
|
`shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
|
|
3011
3012
|
Every call shares the same shutdown.
|
|
3013
|
+
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.
|
|
3014
|
+
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.
|
|
3012
3015
|
`listen()`, called by the command line and a host's own entry point, exits the process with that code.
|
|
3013
3016
|
|
|
3014
3017
|
## Errors and their fixes
|
|
@@ -3263,6 +3266,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3263
3266
|
| `HoldRule` | `pi-roundtable` | type |
|
|
3264
3267
|
| `HostEnv` | `pi-roundtable` | type |
|
|
3265
3268
|
| `HostEnvironment` | `pi-roundtable` | type |
|
|
3269
|
+
| `HostStoppingError` | `pi-roundtable` | value |
|
|
3266
3270
|
| `HttpRoute` | `pi-roundtable` | type |
|
|
3267
3271
|
| `IDENTITY` | `pi-roundtable` | value |
|
|
3268
3272
|
| `IdentityError` | `pi-roundtable` | value |
|
package/package.json
CHANGED
|
@@ -331,7 +331,15 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
331
331
|
export function schedulerPlugin(): RoundtablePlugin {
|
|
332
332
|
return {
|
|
333
333
|
name: "schedules",
|
|
334
|
-
setup: ({
|
|
334
|
+
setup: ({
|
|
335
|
+
conversations,
|
|
336
|
+
services,
|
|
337
|
+
env,
|
|
338
|
+
logger,
|
|
339
|
+
surfaces,
|
|
340
|
+
sessions,
|
|
341
|
+
queue,
|
|
342
|
+
}) => {
|
|
335
343
|
const schedules = services.get(SCHEDULES);
|
|
336
344
|
const prechecks = services.find(PRECHECKS);
|
|
337
345
|
const scheduler = new Scheduler({
|
|
@@ -339,6 +347,7 @@ export function schedulerPlugin(): RoundtablePlugin {
|
|
|
339
347
|
runner: services.get(BACKGROUND_TURNS),
|
|
340
348
|
...(prechecks ? { prechecks } : {}),
|
|
341
349
|
holds: () => sessions().holds,
|
|
350
|
+
draining: () => queue.closed,
|
|
342
351
|
// The bot's own message: the surface drops it, so it starts no turn.
|
|
343
352
|
notify: (schedule, note) =>
|
|
344
353
|
surfaces.sendReply(schedule.channel, {
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { AgentSeed } from "../agents/agent-rules.ts";
|
|
4
4
|
import { ConfigError } from "../domain/errors.ts";
|
|
5
5
|
import type { InterimTextMode } from "../domain/interim.ts";
|
|
6
|
+
import { DRAIN_LIMIT_MS } from "../drain.ts";
|
|
6
7
|
import { isLocale, type Locale } from "../i18n/index.ts";
|
|
7
8
|
import type { AccessRules } from "../identity/access-policy.ts";
|
|
8
9
|
import type { OwnerIdentity } from "../identity.ts";
|
|
@@ -164,6 +165,11 @@ export interface RoundtableConfig {
|
|
|
164
165
|
* conversation or no plugin's claim owns it; the web chat takes no error reports.
|
|
165
166
|
*/
|
|
166
167
|
ops?: { agent: string } | { conversation: string };
|
|
168
|
+
/**
|
|
169
|
+
* Seconds a shutdown waits for running turns before stopping them; default 180. Give the
|
|
170
|
+
* service manager a stop timeout a minute longer, such as systemd's `TimeoutStopSec`.
|
|
171
|
+
*/
|
|
172
|
+
drainSeconds?: number;
|
|
167
173
|
/**
|
|
168
174
|
* Whether a turn posts the text it writes before its final answer as it goes: long or
|
|
169
175
|
* structured text as ordinary messages, short narration and the tools called in one small
|
|
@@ -261,6 +267,7 @@ const schema = shape({
|
|
|
261
267
|
),
|
|
262
268
|
memory: optional(bool),
|
|
263
269
|
ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
|
|
270
|
+
drainSeconds: optional(integer(1, 3_600)),
|
|
264
271
|
interimText: optional(oneOf<InterimTextMode>("on", "off")),
|
|
265
272
|
interimPrimaryChars: optional(integer(1, 100_000)),
|
|
266
273
|
plugins: optional(
|
|
@@ -334,6 +341,7 @@ export interface ResolvedConfig {
|
|
|
334
341
|
skills: false | { builtinDir?: string; reposDir?: string };
|
|
335
342
|
memory: boolean;
|
|
336
343
|
ops?: { agent: string } | { conversation: ChannelKey };
|
|
344
|
+
drainMs: number;
|
|
337
345
|
interimText: InterimTextMode;
|
|
338
346
|
interimPrimaryChars: number;
|
|
339
347
|
plugins: RoundtablePlugin[];
|
|
@@ -483,6 +491,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
|
|
|
483
491
|
skills: config.skills ?? (discord ? {} : false),
|
|
484
492
|
memory: config.memory ?? true,
|
|
485
493
|
...(ops ? { ops } : {}),
|
|
494
|
+
drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
|
|
486
495
|
interimText: config.interimText ?? "on",
|
|
487
496
|
interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
|
|
488
497
|
plugins: config.plugins ?? [],
|
|
@@ -259,6 +259,11 @@ export interface QueuePort {
|
|
|
259
259
|
size(channel: ChannelKey): number;
|
|
260
260
|
/** Channels with a task running or waiting. */
|
|
261
261
|
busy(): ChannelKey[];
|
|
262
|
+
/**
|
|
263
|
+
* Whether the host is shutting down: a task that has not started is then refused with
|
|
264
|
+
* HostStoppingError, and a plugin that starts work on its own, such as a timer, starts none.
|
|
265
|
+
*/
|
|
266
|
+
readonly closed: boolean;
|
|
262
267
|
/** Called whenever a channel's count of running and waiting tasks changes. */
|
|
263
268
|
onChange(listener: (channel: ChannelKey) => void): void;
|
|
264
269
|
}
|
|
@@ -283,6 +283,7 @@ export async function defineRoundtable(
|
|
|
283
283
|
],
|
|
284
284
|
judgeModel: judgeThrough(modelRuntime, config.judge.model),
|
|
285
285
|
apiKey: (provider) => registry.getApiKeyForProvider(provider),
|
|
286
|
+
drain: { limitMs: config.drainMs },
|
|
286
287
|
...(overrides.aborted ? { aborted: overrides.aborted } : {}),
|
|
287
288
|
},
|
|
288
289
|
plugins: [
|
package/src/core/drain.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { Logger } from "./log.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long a shutdown waits for running work before aborting it: long enough for a usual turn to
|
|
5
|
+
* answer, short enough that a hung one never holds a restart for an hour.
|
|
6
|
+
*/
|
|
7
|
+
export const DRAIN_LIMIT_MS = 3 * 60_000;
|
|
8
|
+
|
|
9
|
+
/** How long work that was told to stop gets to end before the shutdown goes on without it. */
|
|
10
|
+
const ABORT_GRACE_MS = 10_000;
|
|
3
11
|
|
|
4
12
|
export interface DrainOptions {
|
|
5
13
|
/** What still runs or waits, one entry per piece of work; empty when idle. */
|
|
6
14
|
busy: () => string[];
|
|
15
|
+
/** The most the drain waits for running work; DRAIN_LIMIT_MS by default. */
|
|
7
16
|
limitMs?: number;
|
|
17
|
+
/** How often it looks again; one second by default. */
|
|
8
18
|
intervalMs?: number;
|
|
19
|
+
/** The most work told to stop at the limit gets to end; ABORT_GRACE_MS by default. */
|
|
20
|
+
abortGraceMs?: number;
|
|
9
21
|
sleep?: (ms: number) => Promise<void>;
|
|
10
22
|
now?: () => number;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
|
-
* Waits until nothing runs or waits, checking every interval, for at most the limit.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
26
|
+
* Waits until nothing runs or waits, checking every interval, for at most the limit. Returns what
|
|
27
|
+
* was still busy when the limit ran out, or an empty list once idle. It only waits: the host stops
|
|
28
|
+
* work from starting before it calls this, and a caller that does not must expect work that
|
|
29
|
+
* arrives meanwhile to be waited for too.
|
|
17
30
|
*/
|
|
18
31
|
export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
|
|
19
32
|
const {
|
|
@@ -31,3 +44,41 @@ export async function waitUntilIdle(options: DrainOptions): Promise<string[]> {
|
|
|
31
44
|
await sleep(intervalMs);
|
|
32
45
|
}
|
|
33
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Waits for running work up to the limit; if some is left, tells it to stop (`abort` says whether
|
|
50
|
+
* it told any) and gives it the abort grace to end. Returns what the limit ran out on, even when stopping it worked, so the
|
|
51
|
+
* caller can hand it over as cut short.
|
|
52
|
+
*/
|
|
53
|
+
export async function drainWork(
|
|
54
|
+
options: DrainOptions & { abort: (left: string[]) => boolean },
|
|
55
|
+
): Promise<string[]> {
|
|
56
|
+
const { abort, abortGraceMs = ABORT_GRACE_MS, ...waiting } = options;
|
|
57
|
+
const left = await waitUntilIdle(waiting);
|
|
58
|
+
if (left.length === 0) return left;
|
|
59
|
+
// Work nothing could be told to stop would only be waited for in vain.
|
|
60
|
+
if (abort(left)) await waitUntilIdle({ ...waiting, limitMs: abortGraceMs });
|
|
61
|
+
return left;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tells each channel's running turn to stop, as the owner's stop does; a failure is logged and the
|
|
66
|
+
* rest are still told. Whether any turn was running to be told.
|
|
67
|
+
*/
|
|
68
|
+
export function stopTurns(
|
|
69
|
+
channels: readonly string[],
|
|
70
|
+
stop: (channel: string) => boolean,
|
|
71
|
+
logger: Logger,
|
|
72
|
+
): boolean {
|
|
73
|
+
let stopped = false;
|
|
74
|
+
for (const channel of new Set(channels)) {
|
|
75
|
+
try {
|
|
76
|
+
const told = stop(channel);
|
|
77
|
+
logger.warn({ channel, told }, "stopping the turn");
|
|
78
|
+
stopped ||= told;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
logger.error({ channel, err: error }, "the turn could not be stopped");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return stopped;
|
|
84
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -27,3 +27,16 @@ export class JudgeError extends Error {
|
|
|
27
27
|
export class ProviderError extends Error {
|
|
28
28
|
override name = "ProviderError";
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Work refused because the host is shutting down: once the drain starts, no turn, queue task, or
|
|
33
|
+
* background run starts any more, and what was waiting behind a running one is dropped.
|
|
34
|
+
*/
|
|
35
|
+
export class HostStoppingError extends Error {
|
|
36
|
+
override name = "HostStoppingError";
|
|
37
|
+
constructor() {
|
|
38
|
+
super(
|
|
39
|
+
"the host is shutting down and starts no new work; try again once it is back",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Logger } from "./log.ts";
|
|
2
|
+
import type { ServiceStartedEvent } from "./plugin.ts";
|
|
3
|
+
import type { Registry } from "./registry/contributions.ts";
|
|
4
|
+
|
|
5
|
+
type Attempt = { ok: true } | { ok: false; error: unknown };
|
|
6
|
+
|
|
7
|
+
/** Runs one step whose failure the caller reports and moves past. */
|
|
8
|
+
export async function attempt(
|
|
9
|
+
step: () => Promise<void> | void,
|
|
10
|
+
): Promise<Attempt> {
|
|
11
|
+
try {
|
|
12
|
+
await step();
|
|
13
|
+
return { ok: true };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return { ok: false, error };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs every service's background start at once and tells every plugin, as each ends, how it
|
|
21
|
+
* went; the rest of the process runs either way, and a failing start or handler never stops the
|
|
22
|
+
* others.
|
|
23
|
+
*/
|
|
24
|
+
export async function startInBackground(
|
|
25
|
+
registry: Registry,
|
|
26
|
+
logger: Logger,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const { services, servicePlugins, handlers } = registry;
|
|
29
|
+
await Promise.all(
|
|
30
|
+
// pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
|
|
31
|
+
services.map(async (service) => {
|
|
32
|
+
if (!service.startInBackground) return;
|
|
33
|
+
const plugin = servicePlugins.get(service) ?? "unknown";
|
|
34
|
+
const started = await attempt(() => service.startInBackground?.());
|
|
35
|
+
if (started.ok) logger.info({ plugin, service: service.name }, "ready");
|
|
36
|
+
else
|
|
37
|
+
logger.error(
|
|
38
|
+
{ plugin, service: service.name, err: started.error },
|
|
39
|
+
"service did not start in the background",
|
|
40
|
+
);
|
|
41
|
+
const event: ServiceStartedEvent = {
|
|
42
|
+
plugin,
|
|
43
|
+
service: service.name,
|
|
44
|
+
outcome: started.ok ? "ready" : "failed",
|
|
45
|
+
};
|
|
46
|
+
for (const { plugin: heard, events } of handlers) {
|
|
47
|
+
const handled = await attempt(() => events.serviceStarted?.(event));
|
|
48
|
+
if (!handled.ok)
|
|
49
|
+
logger.error(
|
|
50
|
+
{ plugin: heard, err: handled.error },
|
|
51
|
+
"service started handler failed",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
package/src/core/host.ts
CHANGED
|
@@ -2,9 +2,10 @@ import type { SQL } from "bun";
|
|
|
2
2
|
import type { ConversationPort } from "./contract/channels.ts";
|
|
3
3
|
import type { SurfacePort } from "./contract/surface.ts";
|
|
4
4
|
import { openPool, runMigrations } from "./db/migrations.ts";
|
|
5
|
-
import { type DrainOptions,
|
|
5
|
+
import { type DrainOptions, drainWork, stopTurns } from "./drain.ts";
|
|
6
6
|
import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
|
|
7
7
|
import { EventBus } from "./events.ts";
|
|
8
|
+
import { attempt, startInBackground } from "./host-background.ts";
|
|
8
9
|
import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
|
|
9
10
|
import { type Locale, setLocale } from "./i18n/index.ts";
|
|
10
11
|
import { contactsOf } from "./identity/identity-view.ts";
|
|
@@ -15,7 +16,6 @@ import type {
|
|
|
15
16
|
LinkedSessions,
|
|
16
17
|
RoundtablePlugin,
|
|
17
18
|
Service,
|
|
18
|
-
ServiceStartedEvent,
|
|
19
19
|
} from "./plugin.ts";
|
|
20
20
|
import { refuseRemovedFields } from "./plugin.ts";
|
|
21
21
|
import {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "./routing/conversation-turns.ts";
|
|
35
35
|
import { surfacePort } from "./routing/surface-port.ts";
|
|
36
36
|
import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
|
|
37
|
+
import type { ChannelKey } from "./sessions.ts";
|
|
37
38
|
import { setTimeZone } from "./time.ts";
|
|
38
39
|
import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
|
|
39
40
|
|
|
@@ -78,7 +79,11 @@ export interface RoundtableOptions {
|
|
|
78
79
|
database?: { url: string };
|
|
79
80
|
/** Receives the work a shutdown drain gave up on, before any service stops. */
|
|
80
81
|
aborted?: (left: string[]) => Promise<void>;
|
|
81
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* The shutdown drain: it waits up to `limitMs` (3 minutes by default) for the turns already
|
|
84
|
+
* running, then stops them, gives them `abortGraceMs` (10 seconds) to end, and goes on without
|
|
85
|
+
* them. Nothing new starts once it begins. Tests shorten the limits and the clock.
|
|
86
|
+
*/
|
|
82
87
|
drain?: Omit<DrainOptions, "busy">;
|
|
83
88
|
/** What `listen()` calls with the shutdown's exit code; the process's own exit by default. */
|
|
84
89
|
exit?: (code: number) => void;
|
|
@@ -87,63 +92,13 @@ export interface RoundtableOptions {
|
|
|
87
92
|
/** The host running in this process; the text catalog, time zone and environment are process-wide. */
|
|
88
93
|
let running: Roundtable | undefined;
|
|
89
94
|
|
|
90
|
-
type Attempt = { ok: true } | { ok: false; error: unknown };
|
|
91
|
-
|
|
92
|
-
/** Runs one step whose failure the caller reports and moves past. */
|
|
93
|
-
async function attempt(step: () => Promise<void> | void): Promise<Attempt> {
|
|
94
|
-
try {
|
|
95
|
-
await step();
|
|
96
|
-
return { ok: true };
|
|
97
|
-
} catch (error) {
|
|
98
|
-
return { ok: false, error };
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Runs every service's background start at once and tells every plugin, as each ends, how it
|
|
104
|
-
* went; the rest of the process runs either way, and a failing start or handler never stops the
|
|
105
|
-
* others.
|
|
106
|
-
*/
|
|
107
|
-
async function startInBackground(
|
|
108
|
-
registry: Registry,
|
|
109
|
-
logger: Logger,
|
|
110
|
-
): Promise<void> {
|
|
111
|
-
const { services, servicePlugins, handlers } = registry;
|
|
112
|
-
await Promise.all(
|
|
113
|
-
// pi-lens-ignore: array-callback-return — an async callback returns its promise on every path; map only starts the tasks for Promise.all, and the early return skips a service with no background start
|
|
114
|
-
services.map(async (service) => {
|
|
115
|
-
if (!service.startInBackground) return;
|
|
116
|
-
const plugin = servicePlugins.get(service) ?? "unknown";
|
|
117
|
-
const started = await attempt(() => service.startInBackground?.());
|
|
118
|
-
if (started.ok) logger.info({ plugin, service: service.name }, "ready");
|
|
119
|
-
else
|
|
120
|
-
logger.error(
|
|
121
|
-
{ plugin, service: service.name, err: started.error },
|
|
122
|
-
"service did not start in the background",
|
|
123
|
-
);
|
|
124
|
-
const event: ServiceStartedEvent = {
|
|
125
|
-
plugin,
|
|
126
|
-
service: service.name,
|
|
127
|
-
outcome: started.ok ? "ready" : "failed",
|
|
128
|
-
};
|
|
129
|
-
for (const { plugin: heard, events } of handlers) {
|
|
130
|
-
const handled = await attempt(() => events.serviceStarted?.(event));
|
|
131
|
-
if (!handled.ok)
|
|
132
|
-
logger.error(
|
|
133
|
-
{ plugin: heard, err: handled.error },
|
|
134
|
-
"service started handler failed",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
}),
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
95
|
/**
|
|
142
96
|
* The process around the plugins: it sets them all up, links what they add (session parts,
|
|
143
97
|
* HTTP routes) and runs the preflight, then starts their services in order and the
|
|
144
98
|
* HTTP listeners last, then runs the services' background starts. Nothing reaches Discord or a listener unless
|
|
145
|
-
* every setup, link, and the preflight succeeded. On shutdown it
|
|
146
|
-
*
|
|
99
|
+
* every setup, link, and the preflight succeeded. On shutdown it starts no new work, waits for
|
|
100
|
+
* what runs for at most the drain limit, then closes the listeners and stops the services in
|
|
101
|
+
* reverse.
|
|
147
102
|
*/
|
|
148
103
|
// pi-lens-ignore: large-class
|
|
149
104
|
export class Roundtable {
|
|
@@ -243,6 +198,7 @@ export class Roundtable {
|
|
|
243
198
|
registry: () => this.#services?.find(CONVERSATIONS),
|
|
244
199
|
surfaces: this.#surfaces(),
|
|
245
200
|
events: this.#events.sink,
|
|
201
|
+
stopping: () => this.#queue.closed,
|
|
246
202
|
selection: () => {
|
|
247
203
|
if (!this.#sessions)
|
|
248
204
|
throw new NotLinkedError("session parts are not linked yet.");
|
|
@@ -423,7 +379,8 @@ export class Roundtable {
|
|
|
423
379
|
}
|
|
424
380
|
|
|
425
381
|
/**
|
|
426
|
-
* Stops the host once no work runs or waits
|
|
382
|
+
* Stops the host once no work runs or waits, or the drain limit has passed, and returns the
|
|
383
|
+
* exit code: 0, or 1 when the
|
|
427
384
|
* boot had failed or a listener, service or the pool did not stop. Every call shares the one
|
|
428
385
|
* shutdown.
|
|
429
386
|
*/
|
|
@@ -437,19 +394,27 @@ export class Roundtable {
|
|
|
437
394
|
// A signal during the boot waits for it to settle; a failed boot has already stopped.
|
|
438
395
|
await this.#booting?.catch(() => undefined);
|
|
439
396
|
if (running !== this) return this.#bootFailed ? 1 : 0;
|
|
440
|
-
//
|
|
397
|
+
// Turns already running finish, but nothing new starts from here on, whoever asks.
|
|
441
398
|
logger.info({ signal }, "shutting down once idle");
|
|
442
|
-
|
|
399
|
+
this.#queue.close();
|
|
400
|
+
const left = await drainWork({
|
|
443
401
|
...drain,
|
|
444
402
|
busy: () => [
|
|
445
403
|
...this.#queue.busy(),
|
|
446
404
|
...this.#started.flatMap((service) => service.busy?.() ?? []),
|
|
447
405
|
],
|
|
406
|
+
// A turn the limit finds still running is stopped, as the owner's stop would.
|
|
407
|
+
abort: () =>
|
|
408
|
+
stopTurns(
|
|
409
|
+
this.#queue.busy(),
|
|
410
|
+
(channel) => this.#router?.stop(channel as ChannelKey) ?? false,
|
|
411
|
+
logger,
|
|
412
|
+
),
|
|
448
413
|
});
|
|
449
414
|
if (left.length > 0) {
|
|
450
415
|
logger.warn(
|
|
451
416
|
{ aborted: left },
|
|
452
|
-
"still busy after the drain limit;
|
|
417
|
+
"still busy after the drain limit; aborted",
|
|
453
418
|
);
|
|
454
419
|
const recorded = await attempt(() => aborted?.(left));
|
|
455
420
|
if (!recorded.ok)
|
|
@@ -6,6 +6,7 @@ export function conversationEn(ctx: CatalogContext) {
|
|
|
6
6
|
failureNotice:
|
|
7
7
|
"Sorry, this reply did not go through. Please try again later.",
|
|
8
8
|
stoppedNotice: "-# Stopped.",
|
|
9
|
+
restartingNotice: `-# ${ctx.assistant} is restarting and takes no new messages right now; please send this again in a few minutes.`,
|
|
9
10
|
roundLimitNotice:
|
|
10
11
|
"-# This round already has 8 replies, so it stops here; call us again if you need more.",
|
|
11
12
|
restartNotice: `-# ${ctx.assistant} restarted before this job finished, so nothing more will be reported here.`,
|
|
@@ -30,6 +31,7 @@ export function conversationZhTW(
|
|
|
30
31
|
return {
|
|
31
32
|
failureNotice: "抱歉,這次沒能完成回覆,請稍後再試一次。",
|
|
32
33
|
stoppedNotice: "-# 已停止。",
|
|
34
|
+
restartingNotice: `-# ${ctx.assistant} 正在重新啟動,暫時不接新訊息;請過幾分鐘再傳一次。`,
|
|
33
35
|
roundLimitNotice:
|
|
34
36
|
"-# 這一輪已經有 8 則回覆,先停在這裡;需要的話請再叫我們。",
|
|
35
37
|
restartNotice: `-# ${ctx.assistant} 在這項工作結束前重新啟動了,這裡不會再有回報。`,
|
|
@@ -49,6 +49,12 @@ export interface SchedulerOptions {
|
|
|
49
49
|
holds?: () => HoldCheck;
|
|
50
50
|
/** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
|
|
51
51
|
notify?: (schedule: Schedule, note: string) => Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Whether the host is shutting down. A due schedule is then left unclaimed, so it stays due and
|
|
54
|
+
* fires after the next start, if it is not too late by then (LATE_LIMIT_MS), instead of being
|
|
55
|
+
* moved on to its next run and its turn refused.
|
|
56
|
+
*/
|
|
57
|
+
draining?: () => boolean;
|
|
52
58
|
logger: Logger;
|
|
53
59
|
intervalMs?: number;
|
|
54
60
|
/** Replaceable in tests. */
|
|
@@ -119,15 +125,20 @@ export class Scheduler {
|
|
|
119
125
|
await this.#prechecks.drain(PRECHECK_STOP_MS);
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
#draining(): boolean {
|
|
129
|
+
return this.#options.draining?.() ?? false;
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
/** Claims every due schedule and starts its run; runs continue after tick resolves. */
|
|
123
133
|
async tick(): Promise<void> {
|
|
124
|
-
if (this.#ticking || this.#stopping.signal.aborted)
|
|
134
|
+
if (this.#ticking || this.#stopping.signal.aborted || this.#draining())
|
|
135
|
+
return;
|
|
125
136
|
this.#ticking = true;
|
|
126
137
|
const { store, logger } = this.#options;
|
|
127
138
|
try {
|
|
128
139
|
const now = this.#options.now?.() ?? new Date();
|
|
129
140
|
for (const schedule of await store.due(now)) {
|
|
130
|
-
if (this.#stopping.signal.aborted) break;
|
|
141
|
+
if (this.#stopping.signal.aborted || this.#draining()) break;
|
|
131
142
|
const claimed = await store.claim(
|
|
132
143
|
schedule,
|
|
133
144
|
nextRun(schedule.recurrence, now),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HostStoppingError } from "../errors.ts";
|
|
1
2
|
import type { ChannelKey } from "../sessions.ts";
|
|
2
3
|
|
|
3
4
|
/** Runs tasks one at a time per channel, in arrival order; different channels run independently. */
|
|
@@ -5,6 +6,7 @@ export class ChannelQueue {
|
|
|
5
6
|
readonly #tails = new Map<ChannelKey, Promise<unknown>>();
|
|
6
7
|
readonly #sizes = new Map<ChannelKey, number>();
|
|
7
8
|
readonly #listeners: ((channel: ChannelKey) => void)[] = [];
|
|
9
|
+
#closed = false;
|
|
8
10
|
|
|
9
11
|
/** Called whenever a channel's count of running and waiting tasks changes. */
|
|
10
12
|
onChange(listener: (channel: ChannelKey) => void): void {
|
|
@@ -15,11 +17,26 @@ export class ChannelQueue {
|
|
|
15
17
|
for (const listener of this.#listeners) listener(channel);
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** Whether the host is shutting down, so no task starts any more. */
|
|
21
|
+
get closed(): boolean {
|
|
22
|
+
return this.#closed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Starts the shutdown: from now on a task that has not started, whether queued before or after
|
|
27
|
+
* this call, is refused with HostStoppingError. A running task finishes.
|
|
28
|
+
*/
|
|
29
|
+
close(): void {
|
|
30
|
+
this.#closed = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
|
|
34
|
+
const start = () =>
|
|
35
|
+
this.#closed ? Promise.reject(new HostStoppingError()) : task();
|
|
19
36
|
this.#sizes.set(channel, this.size(channel) + 1);
|
|
20
37
|
this.#changed(channel);
|
|
21
38
|
const previous = this.#tails.get(channel) ?? Promise.resolve();
|
|
22
|
-
const result = previous.then(
|
|
39
|
+
const result = previous.then(start, start);
|
|
23
40
|
const tail = result.catch(() => undefined);
|
|
24
41
|
this.#tails.set(channel, tail);
|
|
25
42
|
void tail.then(() => {
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
} from "../contract/channels.ts";
|
|
13
13
|
import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
|
|
14
14
|
import { IdentityError } from "../domain/errors.ts";
|
|
15
|
-
import { PluginError } from "../errors.ts";
|
|
15
|
+
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
16
|
+
import { messages } from "../i18n/index.ts";
|
|
16
17
|
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
17
18
|
import type { Contact, ContactAssessor } from "../identity/contact.ts";
|
|
18
19
|
import {
|
|
@@ -37,8 +38,11 @@ export interface ChannelRouterOptions {
|
|
|
37
38
|
contacts?: ContactAssessor;
|
|
38
39
|
/** Whom a background turn runs as; without it, only the host's own turns run. */
|
|
39
40
|
principals?: Pick<IdentityService, "speakerFor">;
|
|
40
|
-
/**
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The surfaces, so a conversation started over is marked in a channel that shows one, and a
|
|
43
|
+
* message that arrives while the host shuts down is told so; without it, neither is.
|
|
44
|
+
*/
|
|
45
|
+
surfaces?: Pick<SurfacePort, "of"> & Partial<Pick<SurfacePort, "sendReply">>;
|
|
42
46
|
/** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
|
|
43
47
|
forwardJoinMs?: number;
|
|
44
48
|
}
|
|
@@ -56,6 +60,12 @@ export function orderClaims(
|
|
|
56
60
|
return claims.toSorted((a, b) => b.priority - a.priority);
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/** The outcome of a background turn the host did not start because it is shutting down. */
|
|
64
|
+
const stopping = (): ScheduledOutcome => ({
|
|
65
|
+
status: "skipped",
|
|
66
|
+
reason: new HostStoppingError().message,
|
|
67
|
+
});
|
|
68
|
+
|
|
59
69
|
/**
|
|
60
70
|
* Routes every conversation operation to the claim that owns its channel, each inside the
|
|
61
71
|
* channel's queue: messages after joining a bare forward to its text, background turns,
|
|
@@ -69,6 +79,8 @@ export class ChannelRouter implements ConversationPort {
|
|
|
69
79
|
readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
|
|
70
80
|
/** The surfaces already warned that their messages carry no actor. */
|
|
71
81
|
readonly #warned = new Set<string>();
|
|
82
|
+
/** The channels already told that the host is restarting, so a burst of messages gets one notice. */
|
|
83
|
+
readonly #told = new Set<ChannelKey>();
|
|
72
84
|
|
|
73
85
|
constructor(options: ChannelRouterOptions) {
|
|
74
86
|
this.#options = options;
|
|
@@ -100,7 +112,13 @@ export class ChannelRouter implements ConversationPort {
|
|
|
100
112
|
if (!claim) return;
|
|
101
113
|
const started = await this.#inOrder(message.channel, async () => {
|
|
102
114
|
const admission = await this.#admit(claim, message);
|
|
103
|
-
|
|
115
|
+
if (!admission) return undefined;
|
|
116
|
+
// Once the host shuts down only the turns already running finish: this one starts nothing.
|
|
117
|
+
if (this.#options.queue.closed) {
|
|
118
|
+
await this.#refuse(message, admission);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return this.#start(message, admission);
|
|
104
122
|
});
|
|
105
123
|
await started?.done;
|
|
106
124
|
}
|
|
@@ -209,6 +227,33 @@ export class ChannelRouter implements ConversationPort {
|
|
|
209
227
|
};
|
|
210
228
|
}
|
|
211
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Drops a message the host no longer starts a turn for because it is shutting down, and tells
|
|
232
|
+
* its channel once that it restarts; no one is told for a bot's or an integration's message, such
|
|
233
|
+
* as a report, since a notice there would only start the next one.
|
|
234
|
+
*/
|
|
235
|
+
async #refuse(message: InboundMessage, admission: Admission): Promise<void> {
|
|
236
|
+
const { logger, surfaces } = this.#options;
|
|
237
|
+
const { channel } = message;
|
|
238
|
+
if (admission.kind === "turn") admission.dropped?.();
|
|
239
|
+
else
|
|
240
|
+
admission.unanswered({
|
|
241
|
+
status: "skipped",
|
|
242
|
+
reason: new HostStoppingError().message,
|
|
243
|
+
});
|
|
244
|
+
logger.info({ channel }, "message not run: the host is shutting down");
|
|
245
|
+
if (message.authorIsBot || message.integration || this.#told.has(channel))
|
|
246
|
+
return;
|
|
247
|
+
this.#told.add(channel);
|
|
248
|
+
try {
|
|
249
|
+
await surfaces?.sendReply?.(channel, {
|
|
250
|
+
chunks: [messages().restartingNotice],
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
logger.warn({ channel, err: error }, "restart notice not posted");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
212
257
|
/**
|
|
213
258
|
* Starts what the claim admitted, in the channel's queue, and returns while it runs: a message
|
|
214
259
|
* during a turn is first offered to it, then marked as waiting.
|
|
@@ -238,12 +283,17 @@ export class ChannelRouter implements ConversationPort {
|
|
|
238
283
|
return admission.run();
|
|
239
284
|
})
|
|
240
285
|
// pi-lens-ignore: no-unknown-parameters
|
|
241
|
-
.catch((error: unknown) =>
|
|
286
|
+
.catch(async (error: unknown) => {
|
|
287
|
+
// The host began shutting down while this message waited behind a running turn.
|
|
288
|
+
if (error instanceof HostStoppingError) {
|
|
289
|
+
if (queued) void busy?.unreact(QUEUED_MARK);
|
|
290
|
+
return this.#refuse(message, admission);
|
|
291
|
+
}
|
|
242
292
|
logger.error(
|
|
243
293
|
{ channel: message.channel, err: error },
|
|
244
294
|
admission.failure,
|
|
245
|
-
)
|
|
246
|
-
);
|
|
295
|
+
);
|
|
296
|
+
});
|
|
247
297
|
return { done };
|
|
248
298
|
}
|
|
249
299
|
|
|
@@ -257,6 +307,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
257
307
|
* handed to another target's claim, and so is one whose author may not run it now.
|
|
258
308
|
*/
|
|
259
309
|
async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
|
|
310
|
+
if (this.#options.queue.closed) return stopping();
|
|
260
311
|
if (!this.#options.targets(turn.target))
|
|
261
312
|
return {
|
|
262
313
|
status: "skipped",
|
|
@@ -277,6 +328,7 @@ export class ChannelRouter implements ConversationPort {
|
|
|
277
328
|
return claim.background({ ...turn, speaker: runs.speaker });
|
|
278
329
|
});
|
|
279
330
|
} catch (error) {
|
|
331
|
+
if (error instanceof HostStoppingError) return stopping();
|
|
280
332
|
return { status: "failed", error: String(error) };
|
|
281
333
|
}
|
|
282
334
|
}
|
|
@@ -8,7 +8,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
|
|
|
8
8
|
import type { TurnResult } from "../domain/conversation.ts";
|
|
9
9
|
import type { TurnConversation } from "../domain/ports.ts";
|
|
10
10
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
11
|
-
import { PluginError } from "../errors.ts";
|
|
11
|
+
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
12
12
|
import { messages } from "../i18n/index.ts";
|
|
13
13
|
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
14
14
|
import type { Logger } from "../log.ts";
|
|
@@ -61,7 +61,8 @@ export interface ConversationTurns {
|
|
|
61
61
|
* task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
|
|
62
62
|
* `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
|
|
63
63
|
* posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
|
|
64
|
-
* It rejects during setup (NotLinkedError),
|
|
64
|
+
* It rejects during setup (NotLinkedError), once the host is shutting down (HostStoppingError),
|
|
65
|
+
* when the host has no runtime to run the turn on,
|
|
65
66
|
* when the conversation cannot be recorded in the host's registry (a database error, or a
|
|
66
67
|
* `private` conversation without a speaker), and when the conversation is recorded private to a
|
|
67
68
|
* principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
|
|
@@ -75,6 +76,8 @@ export interface ConversationTurns {
|
|
|
75
76
|
export interface ConversationTurnsOptions {
|
|
76
77
|
/** Throws NotLinkedError until every plugin is set up and linked. */
|
|
77
78
|
linked: () => void;
|
|
79
|
+
/** Whether the host is shutting down, so no turn starts; undefined or false while it serves. */
|
|
80
|
+
stopping?: () => boolean;
|
|
78
81
|
/** The runtime that runs the turn; throws PluginError when the host has none yet. */
|
|
79
82
|
runtime: () => AgentRuntime;
|
|
80
83
|
/** Where each turn records its conversation; absent, or undefined, records none. */
|
|
@@ -143,6 +146,8 @@ export function conversationTurns(
|
|
|
143
146
|
async run(input) {
|
|
144
147
|
const { channel, kind, speaker } = input;
|
|
145
148
|
options.linked();
|
|
149
|
+
// Refused before anything is shown or recorded, like a turn the host cannot run.
|
|
150
|
+
if (options.stopping?.()) throw new HostStoppingError();
|
|
146
151
|
// A host without a runtime is a setup mistake, so it is refused before anything is shown.
|
|
147
152
|
const runtime = options.runtime();
|
|
148
153
|
// A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a run told to abort gets to end before its session is disposed of; default for the guard. */
|
|
5
|
+
const ABORT_GRACE_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Aborts a Pi session's run and does not trust the abort to work. A model behind a subprocess that
|
|
9
|
+
* ignores the abort would leave the run, and the queue behind it, waiting for ever, so a run still
|
|
10
|
+
* going once the grace is over has its session disposed of, which drops its model connection, and
|
|
11
|
+
* `gaveUp` rejects so the caller can end the turn as failed.
|
|
12
|
+
*/
|
|
13
|
+
export class AbortGuard {
|
|
14
|
+
/** Rejects when the abort did not end the run in time; never resolves. */
|
|
15
|
+
readonly gaveUp: Promise<never>;
|
|
16
|
+
readonly #session: Pick<AgentSession, "abort" | "dispose">;
|
|
17
|
+
readonly #graceMs: number;
|
|
18
|
+
readonly #onGiveUp: () => void;
|
|
19
|
+
#reject: (error: Error) => void = () => undefined;
|
|
20
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
session: Pick<AgentSession, "abort" | "dispose">,
|
|
24
|
+
options: { graceMs?: number; onGiveUp?: () => void } = {},
|
|
25
|
+
) {
|
|
26
|
+
this.#session = session;
|
|
27
|
+
this.#graceMs = options.graceMs ?? ABORT_GRACE_MS;
|
|
28
|
+
this.#onGiveUp = options.onGiveUp ?? (() => undefined);
|
|
29
|
+
this.gaveUp = new Promise<never>((_resolve, reject) => {
|
|
30
|
+
this.#reject = reject;
|
|
31
|
+
});
|
|
32
|
+
// Nobody waits on it unless the abort fails.
|
|
33
|
+
this.gaveUp.catch(() => undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aborts the run, and starts the grace the first time. */
|
|
37
|
+
abort(): void {
|
|
38
|
+
void this.#session.abort();
|
|
39
|
+
this.#timer ??= setTimeout(() => {
|
|
40
|
+
this.#onGiveUp();
|
|
41
|
+
try {
|
|
42
|
+
this.#session.dispose();
|
|
43
|
+
} catch {
|
|
44
|
+
// A session that cannot be disposed of is dropped all the same.
|
|
45
|
+
}
|
|
46
|
+
this.#reject(
|
|
47
|
+
new AgentRunError(
|
|
48
|
+
"the run did not stop after it was aborted, so its session was disposed of",
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}, this.#graceMs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The run ended: no session is disposed of any more. */
|
|
55
|
+
release(): void {
|
|
56
|
+
clearTimeout(this.#timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -262,6 +262,21 @@ export class ConversationSessions {
|
|
|
262
262
|
);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Forgets a session that was disposed of while its turn still ran, so the conversation's next
|
|
267
|
+
* turn opens a new one from its files; a newer session of the conversation stays.
|
|
268
|
+
*/
|
|
269
|
+
drop(key: ChannelKey, disposed: ChannelSession): void {
|
|
270
|
+
const open = this.#sessions.get(key);
|
|
271
|
+
if (!open) return;
|
|
272
|
+
void open
|
|
273
|
+
.then((current) => {
|
|
274
|
+
if (current === disposed && this.#sessions.get(key) === open)
|
|
275
|
+
this.#sessions.delete(key);
|
|
276
|
+
})
|
|
277
|
+
.catch(() => undefined);
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
/** Drops the open session and everything held for the conversation, leaving its files. */
|
|
266
281
|
// pi-lens-ignore: mixed-async-styles — disposal failures of a dropped session are swallowed in a chain on purpose
|
|
267
282
|
async forget(channel: ChannelKey): Promise<void> {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { rmSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
|
-
type AgentSession,
|
|
4
3
|
type ModelRuntime,
|
|
5
4
|
SessionManager,
|
|
6
5
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -16,20 +15,16 @@ import type {
|
|
|
16
15
|
} from "../domain/conversation.ts";
|
|
17
16
|
import { AgentRunError } from "../domain/errors.ts";
|
|
18
17
|
import type { TurnRequest } from "../domain/ports.ts";
|
|
18
|
+
import { HostStoppingError } from "../errors.ts";
|
|
19
19
|
import { assistantName } from "../i18n/index.ts";
|
|
20
20
|
import { promptScopeOf } from "../interactions/prompts.ts";
|
|
21
|
-
import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
|
|
22
21
|
import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
|
|
23
22
|
import { withoutReplyFiles } from "../reply-files.ts";
|
|
24
23
|
import type { TransientTask } from "../sessions.ts";
|
|
25
24
|
import type { Speaker, Tier } from "../speakers.ts";
|
|
26
25
|
import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
bridgeTurnRefusal,
|
|
30
|
-
chosenAgentModel,
|
|
31
|
-
useChosenAgentModel,
|
|
32
|
-
} from "./bridge-guard.ts";
|
|
26
|
+
import { AbortGuard } from "./abort-guard.ts";
|
|
27
|
+
import { bridgeTurnRefusal, chosenAgentModel } from "./bridge-guard.ts";
|
|
33
28
|
import { ConversationSessions } from "./conversation-sessions.ts";
|
|
34
29
|
import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
|
|
35
30
|
import { loadsMemory } from "./extensions/private-memory.ts";
|
|
@@ -47,14 +42,11 @@ import {
|
|
|
47
42
|
} from "./session-conversation.ts";
|
|
48
43
|
import { SessionFactory } from "./session-factory.ts";
|
|
49
44
|
import { promptImages, SteerableRun } from "./steerable-run.ts";
|
|
45
|
+
import { thinkingLevel } from "./thinking-level.ts";
|
|
50
46
|
import { runningCalls } from "./tool-call-scope.ts";
|
|
51
|
-
import {
|
|
52
|
-
lastReply,
|
|
53
|
-
transcriptOf,
|
|
54
|
-
turnAnswer,
|
|
55
|
-
unspokenTurn,
|
|
56
|
-
} from "./turn-answer.ts";
|
|
47
|
+
import { transcriptOf, turnAnswer, unspokenTurn } from "./turn-answer.ts";
|
|
57
48
|
import { progressReporter } from "./turn-progress.ts";
|
|
49
|
+
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
58
50
|
import { workerReport } from "./worker-task.ts";
|
|
59
51
|
|
|
60
52
|
export {
|
|
@@ -118,6 +110,12 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
118
110
|
async runTurn(request: TurnRequest): Promise<TurnResult> {
|
|
119
111
|
const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
|
|
120
112
|
if (!request.speaker) return unspokenTurn();
|
|
113
|
+
// A turn that is only asked for now, such as a group's next member, starts nothing.
|
|
114
|
+
if (this.#options.stopping?.())
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: new AgentRunError(new HostStoppingError().message),
|
|
118
|
+
};
|
|
121
119
|
const key = request.agent?.session ?? request.channel;
|
|
122
120
|
// Resolve current scope; refuse a foreign private speaker before touching its session.
|
|
123
121
|
const conversation = await this.#sessions.conversation(key, request);
|
|
@@ -147,7 +145,17 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
147
145
|
: undefined;
|
|
148
146
|
if (bridgeRefused)
|
|
149
147
|
return { ok: false, error: new AgentRunError(bridgeRefused) };
|
|
150
|
-
const level = await
|
|
148
|
+
const level = await thinkingLevel(
|
|
149
|
+
{
|
|
150
|
+
modelRuntime: this.#modelRuntime,
|
|
151
|
+
effort: this.#options.effort,
|
|
152
|
+
judged: this.#sessions.judged,
|
|
153
|
+
},
|
|
154
|
+
key,
|
|
155
|
+
session,
|
|
156
|
+
request,
|
|
157
|
+
agentModel,
|
|
158
|
+
);
|
|
151
159
|
if (session.thinkingLevel !== level) session.setThinkingLevel(level);
|
|
152
160
|
const gate = await this.#sessions.gate(key, request.agent !== undefined);
|
|
153
161
|
const pending = gate.pending();
|
|
@@ -240,17 +248,29 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
240
248
|
request.agent?.name ?? assistantName(),
|
|
241
249
|
interim ? () => interim.flush() : undefined,
|
|
242
250
|
);
|
|
251
|
+
// A model that ignores the abort must not hold the turn: its session is disposed of instead.
|
|
252
|
+
const guard = new AbortGuard(session, {
|
|
253
|
+
graceMs: this.#options.turnAbortGraceMs,
|
|
254
|
+
onGiveUp: () => {
|
|
255
|
+
logger.error(
|
|
256
|
+
{ channel: request.channel },
|
|
257
|
+
"turn did not stop after its abort; disposing of its session",
|
|
258
|
+
);
|
|
259
|
+
this.#sessions.drop(key, channelSession);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
243
262
|
// Time spent waiting on the owner's cards does not count towards the timeout.
|
|
244
263
|
const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
|
|
245
264
|
logger.warn(
|
|
246
265
|
{ channel: request.channel, turnTimeoutMs },
|
|
247
266
|
"turn timed out; aborting",
|
|
248
267
|
);
|
|
249
|
-
|
|
268
|
+
guard.abort();
|
|
250
269
|
});
|
|
251
270
|
const running = new SteerableRun(
|
|
252
271
|
session,
|
|
253
272
|
() => request.steerable === true && !gate.pending(),
|
|
273
|
+
() => guard.abort(),
|
|
254
274
|
);
|
|
255
275
|
this.#running.set(key, running);
|
|
256
276
|
|
|
@@ -260,7 +280,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
260
280
|
request.confirmed && pending
|
|
261
281
|
? confirmedTurnText(pending, request.text, who)
|
|
262
282
|
: request.text;
|
|
263
|
-
|
|
283
|
+
const prompted = running.run(() =>
|
|
264
284
|
session.prompt(
|
|
265
285
|
withAttachmentsBlock(text, attachments),
|
|
266
286
|
attachments.images.length > 0
|
|
@@ -268,6 +288,9 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
268
288
|
: undefined,
|
|
269
289
|
),
|
|
270
290
|
);
|
|
291
|
+
// A run abandoned by the guard may still reject later; nobody waits for it then.
|
|
292
|
+
prompted.catch(() => undefined);
|
|
293
|
+
await Promise.race([prompted, guard.gaveUp]);
|
|
271
294
|
} catch (error) {
|
|
272
295
|
if (running.stopped)
|
|
273
296
|
return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
|
|
@@ -280,42 +303,20 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
280
303
|
this.#turns.delete(key);
|
|
281
304
|
this.#memoryTurns.delete(key);
|
|
282
305
|
cancelTimeout();
|
|
306
|
+
guard.release();
|
|
283
307
|
slot.unbind();
|
|
284
308
|
unsubscribe();
|
|
285
309
|
progress?.close();
|
|
286
310
|
await interim?.flush();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.save(key, held)
|
|
297
|
-
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
298
|
-
.catch((error: unknown) =>
|
|
299
|
-
logger.error({ channel: key, err: error }, "held actions not saved"),
|
|
300
|
-
);
|
|
301
|
-
if (held)
|
|
302
|
-
logger.info(
|
|
303
|
-
{ channel: key, held: held.calls.map((call) => call.action) },
|
|
304
|
-
"actions held for confirmation",
|
|
305
|
-
);
|
|
306
|
-
logger.info(
|
|
307
|
-
{
|
|
308
|
-
channel: key,
|
|
309
|
-
selection: request.selection.id,
|
|
310
|
-
toolCalls,
|
|
311
|
-
thinking: session.thinkingLevel,
|
|
312
|
-
// pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
|
|
313
|
-
...(request.agent
|
|
314
|
-
? { model: `${session.model?.provider}/${session.model?.id}` }
|
|
315
|
-
: {}),
|
|
316
|
-
},
|
|
317
|
-
"turn finished",
|
|
318
|
-
);
|
|
311
|
+
await wrapUpTurn({
|
|
312
|
+
key,
|
|
313
|
+
request,
|
|
314
|
+
session,
|
|
315
|
+
gate,
|
|
316
|
+
toolCalls,
|
|
317
|
+
options: this.#options,
|
|
318
|
+
recordUsage: (usage) => this.#sessions.usage.set(key, usage),
|
|
319
|
+
});
|
|
319
320
|
}
|
|
320
321
|
|
|
321
322
|
if (running.stopped)
|
|
@@ -368,6 +369,8 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
368
369
|
): Promise<string> {
|
|
369
370
|
const { dataDir } = this.#options;
|
|
370
371
|
const { signal } = task;
|
|
372
|
+
if (this.#options.stopping?.())
|
|
373
|
+
throw new AgentRunError(new HostStoppingError().message);
|
|
371
374
|
// A task works at the tier of the turn that started it; without one there is no tier to take.
|
|
372
375
|
const turn = this.#turns.get(scope.turn);
|
|
373
376
|
if (!turn)
|
|
@@ -393,7 +396,11 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
393
396
|
const unsubscribe = session.subscribe((event) => {
|
|
394
397
|
if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
|
|
395
398
|
});
|
|
396
|
-
|
|
399
|
+
// A worker whose model ignores the abort is given up on, like a turn's.
|
|
400
|
+
const guard = new AbortGuard(session, {
|
|
401
|
+
graceMs: this.#options.turnAbortGraceMs,
|
|
402
|
+
});
|
|
403
|
+
const abort = () => guard.abort();
|
|
397
404
|
signal?.addEventListener("abort", abort, { once: true });
|
|
398
405
|
const timer = setTimeout(abort, task.timeoutMs);
|
|
399
406
|
try {
|
|
@@ -411,13 +418,16 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
411
418
|
(name) => registered.has(name) && this.#tiers.allows(tier, name),
|
|
412
419
|
);
|
|
413
420
|
session.setActiveToolsByName([...worker.tools]);
|
|
414
|
-
|
|
421
|
+
const prompted = withoutReplyFiles(() => session.prompt(task.text));
|
|
422
|
+
prompted.catch(() => undefined);
|
|
423
|
+
await Promise.race([prompted, guard.gaveUp]);
|
|
415
424
|
return workerReport(
|
|
416
425
|
session.messages,
|
|
417
426
|
"the worker stopped before it reported",
|
|
418
427
|
);
|
|
419
428
|
} finally {
|
|
420
429
|
clearTimeout(timer);
|
|
430
|
+
guard.release();
|
|
421
431
|
signal?.removeEventListener("abort", abort);
|
|
422
432
|
unsubscribe();
|
|
423
433
|
session.dispose();
|
|
@@ -467,33 +477,6 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
467
477
|
return transcriptOf(await this.#sessions.messages(channel), limit);
|
|
468
478
|
}
|
|
469
479
|
|
|
470
|
-
/** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
|
|
471
|
-
async #thinkingLevel(
|
|
472
|
-
key: ChannelKey,
|
|
473
|
-
session: AgentSession,
|
|
474
|
-
request: TurnRequest,
|
|
475
|
-
agentModel?: AgentModelSettings,
|
|
476
|
-
): Promise<ThinkingLevel> {
|
|
477
|
-
const setting = request.agent
|
|
478
|
-
? await useChosenAgentModel(
|
|
479
|
-
session,
|
|
480
|
-
this.#modelRuntime,
|
|
481
|
-
request.agent.name,
|
|
482
|
-
agentModel,
|
|
483
|
-
)
|
|
484
|
-
: AUTO_THINKING;
|
|
485
|
-
if (setting !== AUTO_THINKING) {
|
|
486
|
-
this.#sessions.judged.delete(key);
|
|
487
|
-
return setting;
|
|
488
|
-
}
|
|
489
|
-
const level = await this.#options.effort.judge(request.text, {
|
|
490
|
-
reply: lastReply(session.messages),
|
|
491
|
-
level: this.#sessions.judged.get(key),
|
|
492
|
-
});
|
|
493
|
-
this.#sessions.judged.set(key, level);
|
|
494
|
-
return level;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
480
|
dispose(): void {
|
|
498
481
|
this.#sessions.dispose();
|
|
499
482
|
}
|
|
@@ -80,6 +80,14 @@ export interface PiAgentRuntimeOptions {
|
|
|
80
80
|
toolTiers?: ToolTiers;
|
|
81
81
|
/** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
|
|
82
82
|
turnTimeoutMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
|
|
85
|
+
* disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
|
|
86
|
+
* abort would otherwise hold the turn, and the shutdown drain, for ever.
|
|
87
|
+
*/
|
|
88
|
+
turnAbortGraceMs?: number;
|
|
89
|
+
/** Whether the host is shutting down: a turn or a task is then refused before it starts. */
|
|
90
|
+
stopping?: () => boolean;
|
|
83
91
|
/**
|
|
84
92
|
* Whether a turn given a place for interim posts shows the text it writes before its final
|
|
85
93
|
* answer as it goes; default "on". "off" posts only the final reply.
|
|
@@ -33,13 +33,22 @@ export class SteerableRun {
|
|
|
33
33
|
readonly #canSteer: () => boolean;
|
|
34
34
|
/** Steered prompts in arrival order, each with its images. */
|
|
35
35
|
readonly #steers: { text: string; images: PromptImages }[] = [];
|
|
36
|
+
readonly #abort: () => void;
|
|
36
37
|
#stopped = false;
|
|
37
38
|
#done = false;
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `canSteer` is asked at each steer: the turn is the owner's and holds no actions. `abort`
|
|
42
|
+
* ends the session's run when the turn is stopped; by default the session's own abort.
|
|
43
|
+
*/
|
|
44
|
+
constructor(
|
|
45
|
+
session: SteeringSession,
|
|
46
|
+
canSteer: () => boolean,
|
|
47
|
+
abort: () => void = () => void session.abort(),
|
|
48
|
+
) {
|
|
41
49
|
this.#session = session;
|
|
42
50
|
this.#canSteer = canSteer;
|
|
51
|
+
this.#abort = abort;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
get stopped(): boolean {
|
|
@@ -106,7 +115,7 @@ export class SteerableRun {
|
|
|
106
115
|
if (this.#done) return false;
|
|
107
116
|
this.#stopped = true;
|
|
108
117
|
this.#session.clearQueue();
|
|
109
|
-
|
|
118
|
+
this.#abort();
|
|
110
119
|
return true;
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
ModelRuntime,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
6
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
7
|
+
import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
|
|
8
|
+
import {
|
|
9
|
+
type AgentModelSettings,
|
|
10
|
+
useChosenAgentModel,
|
|
11
|
+
} from "./bridge-guard.ts";
|
|
12
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
13
|
+
import { lastReply } from "./turn-answer.ts";
|
|
14
|
+
|
|
15
|
+
/** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
|
|
16
|
+
export async function thinkingLevel(
|
|
17
|
+
parts: {
|
|
18
|
+
modelRuntime: ModelRuntime;
|
|
19
|
+
effort: PiAgentRuntimeOptions["effort"];
|
|
20
|
+
/** The level last judged for each conversation. */
|
|
21
|
+
judged: Map<ChannelKey, ThinkingLevel>;
|
|
22
|
+
},
|
|
23
|
+
key: ChannelKey,
|
|
24
|
+
session: AgentSession,
|
|
25
|
+
request: TurnRequest,
|
|
26
|
+
agentModel?: AgentModelSettings,
|
|
27
|
+
): Promise<ThinkingLevel> {
|
|
28
|
+
const { modelRuntime, effort, judged } = parts;
|
|
29
|
+
const setting = request.agent
|
|
30
|
+
? await useChosenAgentModel(
|
|
31
|
+
session,
|
|
32
|
+
modelRuntime,
|
|
33
|
+
request.agent.name,
|
|
34
|
+
agentModel,
|
|
35
|
+
)
|
|
36
|
+
: AUTO_THINKING;
|
|
37
|
+
if (setting !== AUTO_THINKING) {
|
|
38
|
+
judged.delete(key);
|
|
39
|
+
return setting;
|
|
40
|
+
}
|
|
41
|
+
const level = await effort.judge(request.text, {
|
|
42
|
+
reply: lastReply(session.messages),
|
|
43
|
+
level: judged.get(key),
|
|
44
|
+
});
|
|
45
|
+
judged.set(key, level);
|
|
46
|
+
return level;
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
4
|
+
import type { ConfirmationGate } from "./extensions/confirmation-gate.ts";
|
|
5
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a turn leaves behind when it ends, whatever way it ended: the gate stops counting the turn,
|
|
9
|
+
* the conversation's context usage and held actions are recorded, and the turn is logged. A
|
|
10
|
+
* session disposed of under a turn that would not stop has no usage to read.
|
|
11
|
+
*/
|
|
12
|
+
export async function wrapUpTurn(parts: {
|
|
13
|
+
key: ChannelKey;
|
|
14
|
+
request: TurnRequest;
|
|
15
|
+
session: AgentSession;
|
|
16
|
+
gate: ConfirmationGate;
|
|
17
|
+
toolCalls: readonly string[];
|
|
18
|
+
options: Pick<PiAgentRuntimeOptions, "confirmations" | "logger">;
|
|
19
|
+
recordUsage: (usage: {
|
|
20
|
+
tokens: number | null;
|
|
21
|
+
contextWindow: number;
|
|
22
|
+
}) => void;
|
|
23
|
+
}): Promise<void> {
|
|
24
|
+
const { key, request, session, gate, toolCalls, options, recordUsage } =
|
|
25
|
+
parts;
|
|
26
|
+
const { logger } = options;
|
|
27
|
+
gate.endTurn();
|
|
28
|
+
try {
|
|
29
|
+
const usage = session.getContextUsage();
|
|
30
|
+
if (usage)
|
|
31
|
+
recordUsage({
|
|
32
|
+
tokens: usage.tokens,
|
|
33
|
+
contextWindow: usage.contextWindow,
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.warn({ channel: key, err: error }, "context usage not read");
|
|
37
|
+
}
|
|
38
|
+
const held = gate.pending();
|
|
39
|
+
await options.confirmations
|
|
40
|
+
.save(key, held)
|
|
41
|
+
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
42
|
+
.catch((error: unknown) =>
|
|
43
|
+
logger.error({ channel: key, err: error }, "held actions not saved"),
|
|
44
|
+
);
|
|
45
|
+
if (held)
|
|
46
|
+
logger.info(
|
|
47
|
+
{ channel: key, held: held.calls.map((call) => call.action) },
|
|
48
|
+
"actions held for confirmation",
|
|
49
|
+
);
|
|
50
|
+
logger.info(
|
|
51
|
+
{
|
|
52
|
+
channel: key,
|
|
53
|
+
selection: request.selection.id,
|
|
54
|
+
toolCalls,
|
|
55
|
+
thinking: session.thinkingLevel,
|
|
56
|
+
// pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
|
|
57
|
+
...(request.agent
|
|
58
|
+
? { model: `${session.model?.provider}/${session.model?.id}` }
|
|
59
|
+
: {}),
|
|
60
|
+
},
|
|
61
|
+
"turn finished",
|
|
62
|
+
);
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,7 @@ export type { TurnConversation, TurnRequest } from "./core/domain/ports.ts";
|
|
|
113
113
|
export type { TurnProgress } from "./core/domain/progress.ts";
|
|
114
114
|
export type { DrainOptions } from "./core/drain.ts";
|
|
115
115
|
export {
|
|
116
|
+
HostStoppingError,
|
|
116
117
|
JudgeError,
|
|
117
118
|
MigrationError,
|
|
118
119
|
NotLinkedError,
|