pi-roundtable 0.9.0 → 0.9.2
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 +20 -0
- package/docs/plugins.md +46 -10
- package/package.json +1 -1
- package/src/core/attachments/attachment-port.ts +34 -0
- package/src/core/attachments/attachment-store.ts +248 -0
- package/src/core/attachments/model-images.ts +39 -0
- package/src/core/attachments/tool-attachment.ts +53 -0
- package/src/core/attachments/turn-attachments.ts +4 -21
- package/src/core/builtin/modules.ts +10 -1
- package/src/core/config/config.ts +9 -0
- package/src/core/contract/attachments.ts +71 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/define.ts +25 -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 +33 -60
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/modules/schedules/scheduler.ts +13 -2
- package/src/core/plugin.ts +6 -0
- 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/session-factory.ts +1 -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/core/sessions.ts +2 -0
- package/src/index.ts +8 -0
- package/src/testing.ts +9 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StoredAttachment,
|
|
3
|
+
TurnAttachments,
|
|
4
|
+
} from "../domain/attachment.ts";
|
|
5
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
6
|
+
|
|
7
|
+
/** A file a plugin hands the core to keep for a conversation. */
|
|
8
|
+
export interface AttachmentUpload {
|
|
9
|
+
/** The name the person gave it; shown to the model and kept as is. */
|
|
10
|
+
name: string;
|
|
11
|
+
contentType: string;
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Why the attachment store refused a call. */
|
|
16
|
+
export type AttachmentRefusalCode =
|
|
17
|
+
/** The conversation is private to someone else. */
|
|
18
|
+
| "forbidden"
|
|
19
|
+
/** No saved file of that name is waiting for this person in this conversation. */
|
|
20
|
+
| "unknown_file"
|
|
21
|
+
/** The file is over the core's limit of 25 MiB. */
|
|
22
|
+
| "too_large";
|
|
23
|
+
|
|
24
|
+
/** An attachment call the store refused; `code` says why, and the plugin decides what its client sees. */
|
|
25
|
+
export class AttachmentRefusal extends Error {
|
|
26
|
+
override name = "AttachmentRefusal";
|
|
27
|
+
readonly code: AttachmentRefusalCode;
|
|
28
|
+
|
|
29
|
+
constructor(code: AttachmentRefusalCode, message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Files a plugin takes from a person outside a turn, such as an upload over HTTP, kept for the
|
|
37
|
+
* conversation until a turn uses them. A saved file waits for its person: only the principal that
|
|
38
|
+
* saved it may use it, in the conversation it was saved for. A private conversation refuses every
|
|
39
|
+
* other principal; a shared one lets each use their own. Using a file moves it into the
|
|
40
|
+
* conversation's attachment directory, where the turn's `read_attachment` and
|
|
41
|
+
* `ToolTurn.attachment()` find it. Calls reject with `AttachmentRefusal` for a refused call, and
|
|
42
|
+
* throw a PluginError when the host has no `dataDir`.
|
|
43
|
+
*/
|
|
44
|
+
export interface AttachmentPort {
|
|
45
|
+
/** Keeps a file for the principal in the conversation until a turn uses it. */
|
|
46
|
+
save(
|
|
47
|
+
channel: ChannelKey,
|
|
48
|
+
principalId: string,
|
|
49
|
+
upload: AttachmentUpload,
|
|
50
|
+
): Promise<StoredAttachment>;
|
|
51
|
+
/**
|
|
52
|
+
* The turn's attachments for files `save` returned: each moves into the conversation, up to four
|
|
53
|
+
* images are prepared for the model, and every file is named in the turn's `## Attachments`
|
|
54
|
+
* block. Refuses the whole set when one file is unknown or already used; none moves then.
|
|
55
|
+
*/
|
|
56
|
+
turnAttachments(
|
|
57
|
+
channel: ChannelKey,
|
|
58
|
+
principalId: string,
|
|
59
|
+
files: readonly string[],
|
|
60
|
+
): Promise<TurnAttachments>;
|
|
61
|
+
/** Discards a saved file no turn used; false when there is none. */
|
|
62
|
+
remove(
|
|
63
|
+
channel: ChannelKey,
|
|
64
|
+
principalId: string,
|
|
65
|
+
file: string,
|
|
66
|
+
): Promise<boolean>;
|
|
67
|
+
/** Discards every saved file no turn used that was saved before `olderThan`; returns how many. */
|
|
68
|
+
discardPending(olderThan: Date): Promise<number>;
|
|
69
|
+
/** The bytes the principal has saved that no turn used yet, across their conversations. */
|
|
70
|
+
pendingBytes(principalId: string): Promise<number>;
|
|
71
|
+
}
|
|
@@ -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
|
}
|
|
@@ -276,6 +276,7 @@ export async function defineRoundtable(
|
|
|
276
276
|
agentDir: config.agentDir,
|
|
277
277
|
},
|
|
278
278
|
database: { url: config.databaseUrl },
|
|
279
|
+
dataDir: config.dataDir,
|
|
279
280
|
toolTiers: toolTiers(config.toolTiers, logger),
|
|
280
281
|
listeners: [
|
|
281
282
|
...(config.http ? [publicListener(config.http)] : []),
|
|
@@ -283,6 +284,7 @@ export async function defineRoundtable(
|
|
|
283
284
|
],
|
|
284
285
|
judgeModel: judgeThrough(modelRuntime, config.judge.model),
|
|
285
286
|
apiKey: (provider) => registry.getApiKeyForProvider(provider),
|
|
287
|
+
drain: { limitMs: config.drainMs },
|
|
286
288
|
...(overrides.aborted ? { aborted: overrides.aborted } : {}),
|
|
287
289
|
},
|
|
288
290
|
plugins: [
|
package/src/core/define.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { Static, TObject } from "typebox";
|
|
2
|
+
import {
|
|
3
|
+
AttachmentLookupError,
|
|
4
|
+
openAttachment,
|
|
5
|
+
type ToolAttachment,
|
|
6
|
+
} from "./attachments/tool-attachment.ts";
|
|
2
7
|
import type { ReplyFile } from "./domain/conversation.ts";
|
|
3
8
|
import { PluginError } from "./errors.ts";
|
|
4
9
|
import type { HoldRule } from "./holds.ts";
|
|
@@ -26,6 +31,13 @@ export interface ToolTurn {
|
|
|
26
31
|
signal: AbortSignal | undefined;
|
|
27
32
|
/** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
|
|
28
33
|
attachFile(file: ReplyFile): void;
|
|
34
|
+
/**
|
|
35
|
+
* Opens a file someone attached to this conversation, by the name `## Attachments` lists, to read
|
|
36
|
+
* its bytes or pass it on. Only this conversation's files open; a path, a hidden name, or a file
|
|
37
|
+
* that is not there throws a ToolRefusal the model reads. A session without an attachment store
|
|
38
|
+
* refuses every call.
|
|
39
|
+
*/
|
|
40
|
+
attachment(file: string): Promise<ToolAttachment>;
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
/**
|
|
@@ -113,6 +125,19 @@ export function defineTool<Schema extends TObject>(
|
|
|
113
125
|
agent: context.agent,
|
|
114
126
|
signal,
|
|
115
127
|
attachFile: attachReplyFile,
|
|
128
|
+
attachment: async (file) => {
|
|
129
|
+
if (context.attachmentDir === undefined)
|
|
130
|
+
throw new ToolRefusal(
|
|
131
|
+
"this session keeps no attachments",
|
|
132
|
+
);
|
|
133
|
+
try {
|
|
134
|
+
return await openAttachment(context.attachmentDir, file);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof AttachmentLookupError)
|
|
137
|
+
throw new ToolRefusal(error.message);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
116
141
|
}),
|
|
117
142
|
);
|
|
118
143
|
} catch (error) {
|
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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
+
import { attachmentPort } from "./attachments/attachment-port.ts";
|
|
2
3
|
import type { ConversationPort } from "./contract/channels.ts";
|
|
3
4
|
import type { SurfacePort } from "./contract/surface.ts";
|
|
4
5
|
import { openPool, runMigrations } from "./db/migrations.ts";
|
|
5
|
-
import { type DrainOptions,
|
|
6
|
+
import { type DrainOptions, drainWork, stopTurns } from "./drain.ts";
|
|
6
7
|
import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
|
|
7
8
|
import { EventBus } from "./events.ts";
|
|
9
|
+
import { attempt, startInBackground } from "./host-background.ts";
|
|
8
10
|
import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
|
|
9
11
|
import { type Locale, setLocale } from "./i18n/index.ts";
|
|
10
12
|
import { contactsOf } from "./identity/identity-view.ts";
|
|
@@ -15,7 +17,6 @@ import type {
|
|
|
15
17
|
LinkedSessions,
|
|
16
18
|
RoundtablePlugin,
|
|
17
19
|
Service,
|
|
18
|
-
ServiceStartedEvent,
|
|
19
20
|
} from "./plugin.ts";
|
|
20
21
|
import { refuseRemovedFields } from "./plugin.ts";
|
|
21
22
|
import {
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
} from "./routing/conversation-turns.ts";
|
|
35
36
|
import { surfacePort } from "./routing/surface-port.ts";
|
|
36
37
|
import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
|
|
38
|
+
import type { ChannelKey } from "./sessions.ts";
|
|
37
39
|
import { setTimeZone } from "./time.ts";
|
|
38
40
|
import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
|
|
39
41
|
|
|
@@ -76,9 +78,15 @@ export interface RoundtableOptions {
|
|
|
76
78
|
toolTiers?: ToolTierTable;
|
|
77
79
|
/** The database the plugins' migrations and stores use; the host owns its one pool. */
|
|
78
80
|
database?: { url: string };
|
|
81
|
+
/** The directory the host keeps files in; `context.attachments` keeps its files there. Without it that port refuses every call. */
|
|
82
|
+
dataDir?: string;
|
|
79
83
|
/** Receives the work a shutdown drain gave up on, before any service stops. */
|
|
80
84
|
aborted?: (left: string[]) => Promise<void>;
|
|
81
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* The shutdown drain: it waits up to `limitMs` (3 minutes by default) for the turns already
|
|
87
|
+
* running, then stops them, gives them `abortGraceMs` (10 seconds) to end, and goes on without
|
|
88
|
+
* them. Nothing new starts once it begins. Tests shorten the limits and the clock.
|
|
89
|
+
*/
|
|
82
90
|
drain?: Omit<DrainOptions, "busy">;
|
|
83
91
|
/** What `listen()` calls with the shutdown's exit code; the process's own exit by default. */
|
|
84
92
|
exit?: (code: number) => void;
|
|
@@ -87,63 +95,13 @@ export interface RoundtableOptions {
|
|
|
87
95
|
/** The host running in this process; the text catalog, time zone and environment are process-wide. */
|
|
88
96
|
let running: Roundtable | undefined;
|
|
89
97
|
|
|
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
98
|
/**
|
|
142
99
|
* The process around the plugins: it sets them all up, links what they add (session parts,
|
|
143
100
|
* HTTP routes) and runs the preflight, then starts their services in order and the
|
|
144
101
|
* 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
|
-
*
|
|
102
|
+
* every setup, link, and the preflight succeeded. On shutdown it starts no new work, waits for
|
|
103
|
+
* what runs for at most the drain limit, then closes the listeners and stops the services in
|
|
104
|
+
* reverse.
|
|
147
105
|
*/
|
|
148
106
|
// pi-lens-ignore: large-class
|
|
149
107
|
export class Roundtable {
|
|
@@ -243,6 +201,7 @@ export class Roundtable {
|
|
|
243
201
|
registry: () => this.#services?.find(CONVERSATIONS),
|
|
244
202
|
surfaces: this.#surfaces(),
|
|
245
203
|
events: this.#events.sink,
|
|
204
|
+
stopping: () => this.#queue.closed,
|
|
246
205
|
selection: () => {
|
|
247
206
|
if (!this.#sessions)
|
|
248
207
|
throw new NotLinkedError("session parts are not linked yet.");
|
|
@@ -320,6 +279,11 @@ export class Roundtable {
|
|
|
320
279
|
toolTiers: this.#tiers,
|
|
321
280
|
events: this.#events.sink,
|
|
322
281
|
conversations: this.#conversations(),
|
|
282
|
+
attachments: attachmentPort({
|
|
283
|
+
dataDir: this.#options.dataDir,
|
|
284
|
+
registry: () => this.#services?.find(CONVERSATIONS),
|
|
285
|
+
logger,
|
|
286
|
+
}),
|
|
323
287
|
surfaces: this.#surfaces(),
|
|
324
288
|
turns: this.#turns(),
|
|
325
289
|
database: () => {
|
|
@@ -423,7 +387,8 @@ export class Roundtable {
|
|
|
423
387
|
}
|
|
424
388
|
|
|
425
389
|
/**
|
|
426
|
-
* Stops the host once no work runs or waits
|
|
390
|
+
* Stops the host once no work runs or waits, or the drain limit has passed, and returns the
|
|
391
|
+
* exit code: 0, or 1 when the
|
|
427
392
|
* boot had failed or a listener, service or the pool did not stop. Every call shares the one
|
|
428
393
|
* shutdown.
|
|
429
394
|
*/
|
|
@@ -437,19 +402,27 @@ export class Roundtable {
|
|
|
437
402
|
// A signal during the boot waits for it to settle; a failed boot has already stopped.
|
|
438
403
|
await this.#booting?.catch(() => undefined);
|
|
439
404
|
if (running !== this) return this.#bootFailed ? 1 : 0;
|
|
440
|
-
//
|
|
405
|
+
// Turns already running finish, but nothing new starts from here on, whoever asks.
|
|
441
406
|
logger.info({ signal }, "shutting down once idle");
|
|
442
|
-
|
|
407
|
+
this.#queue.close();
|
|
408
|
+
const left = await drainWork({
|
|
443
409
|
...drain,
|
|
444
410
|
busy: () => [
|
|
445
411
|
...this.#queue.busy(),
|
|
446
412
|
...this.#started.flatMap((service) => service.busy?.() ?? []),
|
|
447
413
|
],
|
|
414
|
+
// A turn the limit finds still running is stopped, as the owner's stop would.
|
|
415
|
+
abort: () =>
|
|
416
|
+
stopTurns(
|
|
417
|
+
this.#queue.busy(),
|
|
418
|
+
(channel) => this.#router?.stop(channel as ChannelKey) ?? false,
|
|
419
|
+
logger,
|
|
420
|
+
),
|
|
448
421
|
});
|
|
449
422
|
if (left.length > 0) {
|
|
450
423
|
logger.warn(
|
|
451
424
|
{ aborted: left },
|
|
452
|
-
"still busy after the drain limit;
|
|
425
|
+
"still busy after the drain limit; aborted",
|
|
453
426
|
);
|
|
454
427
|
const recorded = await attempt(() => aborted?.(left));
|
|
455
428
|
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),
|
package/src/core/plugin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
2
|
import type { AgentSeed } from "./agents/agent-store.ts";
|
|
3
|
+
import type { AttachmentPort } from "./contract/attachments.ts";
|
|
3
4
|
import type {
|
|
4
5
|
BackgroundTarget,
|
|
5
6
|
ChannelClaim,
|
|
@@ -287,6 +288,11 @@ export interface PluginContext {
|
|
|
287
288
|
events: EventSink;
|
|
288
289
|
/** The claimed channels' conversations; calls during setup throw NotLinkedError. */
|
|
289
290
|
conversations: ConversationPort;
|
|
291
|
+
/**
|
|
292
|
+
* Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation
|
|
293
|
+
* until a turn uses them; a call throws PluginError when the host has no `dataDir`.
|
|
294
|
+
*/
|
|
295
|
+
attachments: AttachmentPort;
|
|
290
296
|
/** Every contributed chat surface, chosen by a channel's prefix; calls during setup throw NotLinkedError. */
|
|
291
297
|
surfaces: SurfacePort;
|
|
292
298
|
/** Every plugin's direct channels, which reach a person on their own; calls during setup throw NotLinkedError. */
|
|
@@ -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(() => {
|