pi-roundtable 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/docs/plugins.md +75 -6
- package/package.json +8 -6
- package/src/core/builtin/discord.ts +11 -0
- package/src/core/contract/runtime.ts +1 -1
- package/src/core/discord/discord-surface.ts +8 -0
- package/src/core/discord/poster/poster-checks.ts +272 -0
- package/src/core/discord/poster/poster-errors.ts +106 -0
- package/src/core/discord/poster/poster.ts +301 -0
- package/src/core/discord/poster/rest-poster-core.ts +112 -0
- package/src/core/discord/poster/rest-poster-guild.ts +329 -0
- package/src/core/discord/poster/rest-poster.ts +488 -0
- package/src/core/host.ts +3 -0
- package/src/core/modules/delegation/web-research-worker.ts +1 -1
- package/src/core/ops/alert-throttle.ts +81 -0
- package/src/core/ops/error-reporter.ts +20 -17
- package/src/core/ops/ops-alerts.ts +132 -0
- package/src/core/runtime/prompt-slot.ts +4 -12
- package/src/core/runtime/runtime-plugin.ts +0 -3
- package/src/core/runtime/turn-budget.ts +10 -1
- package/src/core/testing/fake-poster-guild.ts +491 -0
- package/src/core/testing/fake-poster.ts +475 -0
- package/src/core/testing/test-host.ts +16 -5
- package/src/discord/index.ts +21 -0
- package/src/kit/index.ts +6 -0
- package/src/kit/ops-alerts.ts +9 -0
- package/src/testing.ts +19 -4
- package/templates/adapters/web/package.json.tmpl +2 -2
- package/templates/package.json.tmpl +2 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Logger } from "../log.ts";
|
|
2
|
+
import { AlertThrottle } from "./alert-throttle.ts";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_WINDOW_MS = 60 * 60_000;
|
|
5
|
+
const DEFAULT_KIND_CAP = 8;
|
|
6
|
+
|
|
7
|
+
export interface OpsAlertsOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Posts one alert's text where the people who run the host read it. Resolves true when it was
|
|
10
|
+
* posted and false when the sink chose not to (it logs why itself); a throw is a failed post.
|
|
11
|
+
* Either way the key and the slot are given back, so the next raise tries again. Absent: alerts are
|
|
12
|
+
* only recorded and logged, the key still held so the log has one line per window.
|
|
13
|
+
*/
|
|
14
|
+
sink?: (text: string) => Promise<boolean>;
|
|
15
|
+
logger: Logger;
|
|
16
|
+
/** What `faults()` calls each kind; a kind not listed is called by itself. */
|
|
17
|
+
kinds?: Readonly<Record<string, string>>;
|
|
18
|
+
/** Default one hour. */
|
|
19
|
+
windowMs?: number;
|
|
20
|
+
/** The most alerts of one kind posted inside a window, whatever their keys; default 8. */
|
|
21
|
+
kindCap?: number;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OpsRaiseOptions {
|
|
26
|
+
/** A short code (letters, digits, `_`, `:`) shown in `faults()`; never a name or anyone's words. */
|
|
27
|
+
code?: string;
|
|
28
|
+
/** News, not a fault: posted at most once per key, not counted against the kind's cap, not listed. */
|
|
29
|
+
info?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The last fault of one kind since the start, and how often it came. */
|
|
33
|
+
export interface OpsAlertKind {
|
|
34
|
+
kind: string;
|
|
35
|
+
name: string;
|
|
36
|
+
at: number;
|
|
37
|
+
code: string | undefined;
|
|
38
|
+
/** Raised since the start, posted or not. */
|
|
39
|
+
raised: number;
|
|
40
|
+
/** Of those, held back because the key or the kind had been raised inside the window. */
|
|
41
|
+
suppressed: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The kind of an alert is the word before the first colon of its key. */
|
|
45
|
+
function kindOf(key: string): string {
|
|
46
|
+
const colon = key.indexOf(":");
|
|
47
|
+
return colon === -1 ? key : key.slice(0, colon);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Short alerts for the people who run a host. Every alert has a key; its kind is the key's word
|
|
52
|
+
* before the first colon. A key is posted at most once a window and a kind at most `kindCap` times,
|
|
53
|
+
* so a fault that touches everything cannot flood the sink; what is held back is counted, never
|
|
54
|
+
* summed up in a post. An alert is posted when it is raised, not queued. A failed post is logged by
|
|
55
|
+
* the error's name and never thrown.
|
|
56
|
+
*/
|
|
57
|
+
export class OpsAlerts {
|
|
58
|
+
readonly #options: OpsAlertsOptions;
|
|
59
|
+
readonly #throttle: AlertThrottle;
|
|
60
|
+
readonly #kinds = new Map<string, OpsAlertKind>();
|
|
61
|
+
|
|
62
|
+
constructor(options: OpsAlertsOptions) {
|
|
63
|
+
this.#options = options;
|
|
64
|
+
this.#throttle = new AlertThrottle({
|
|
65
|
+
windowMs: options.windowMs ?? DEFAULT_WINDOW_MS,
|
|
66
|
+
groupCap: options.kindCap ?? DEFAULT_KIND_CAP,
|
|
67
|
+
now: options.now ?? Date.now,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether alerts reach a sink. */
|
|
72
|
+
get posts(): boolean {
|
|
73
|
+
return this.#options.sink !== undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The last fault of each kind, newest first; memory only. */
|
|
77
|
+
faults(): OpsAlertKind[] {
|
|
78
|
+
return [...this.#kinds.values()].sort((a, b) => b.at - a.at);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Posts the alert unless its key or its kind is held inside the window; whether it was posted. */
|
|
82
|
+
async raise(
|
|
83
|
+
key: string,
|
|
84
|
+
text: string,
|
|
85
|
+
options: OpsRaiseOptions = {},
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
const { sink, logger } = this.#options;
|
|
88
|
+
const kind = kindOf(key);
|
|
89
|
+
const info = options.info === true;
|
|
90
|
+
const admission = this.#throttle.admit(key, kind, info);
|
|
91
|
+
if (!info) this.#record(kind, options.code, admission !== "open");
|
|
92
|
+
if (admission !== "open") return false;
|
|
93
|
+
// Taken before the post, so two alerts raised together for one key post once.
|
|
94
|
+
this.#throttle.hold(key);
|
|
95
|
+
if (sink === undefined) {
|
|
96
|
+
// Nowhere to post: the key stays held so that the log has one line per window, not one per event.
|
|
97
|
+
logger.warn(
|
|
98
|
+
{ kind, ...(options.code ? { code: options.code } : {}) },
|
|
99
|
+
"an ops alert was raised and there is no sink to post it to",
|
|
100
|
+
);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (!info) this.#throttle.slot(kind);
|
|
104
|
+
const undo = () => this.#throttle.release(key, info ? undefined : kind);
|
|
105
|
+
try {
|
|
106
|
+
if (await sink(text)) return true;
|
|
107
|
+
undo();
|
|
108
|
+
return false;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Asked again by the next event instead of staying silent for the whole window.
|
|
111
|
+
undo();
|
|
112
|
+
logger.warn(
|
|
113
|
+
{ error: error instanceof Error ? error.name : "unknown" },
|
|
114
|
+
"an ops alert could not be posted",
|
|
115
|
+
);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#record(kind: string, code: string | undefined, suppressed: boolean): void {
|
|
121
|
+
const before = this.#kinds.get(kind);
|
|
122
|
+
const { kinds, now } = this.#options;
|
|
123
|
+
this.#kinds.set(kind, {
|
|
124
|
+
kind,
|
|
125
|
+
name: kinds && Object.hasOwn(kinds, kind) ? (kinds[kind] ?? kind) : kind,
|
|
126
|
+
at: (now ?? Date.now)(),
|
|
127
|
+
code,
|
|
128
|
+
raised: (before?.raised ?? 0) + 1,
|
|
129
|
+
suppressed: (before?.suppressed ?? 0) + (suppressed ? 1 : 0),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HoldRefusal } from "../domain/progress.ts";
|
|
2
2
|
import { assistantName } from "../i18n/index.ts";
|
|
3
3
|
import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
|
|
4
|
+
import { turnBudget } from "./turn-budget.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* A run's way to ask the owner while it works. A turn they started or a report turn binds the
|
|
@@ -101,22 +102,13 @@ export function promptSlot(): PromptSlot {
|
|
|
101
102
|
|
|
102
103
|
/**
|
|
103
104
|
* Calls `expire` once the run has worked `ms`, not counting time the slot spent waiting on the
|
|
104
|
-
* owner's cards; returns a function that cancels it.
|
|
105
|
+
* owner's cards; returns a function that cancels it. It is a turn budget with nothing to renew,
|
|
106
|
+
* so it looks again at a run that waits on a card no oftener than `turnBudget` does.
|
|
105
107
|
*/
|
|
106
108
|
export function workTimeout(
|
|
107
109
|
ms: number,
|
|
108
110
|
slot: Pick<PromptSlot, "waitedMs">,
|
|
109
111
|
expire: () => void,
|
|
110
112
|
): () => void {
|
|
111
|
-
|
|
112
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
-
const arm = (delay: number) => {
|
|
114
|
-
timer = setTimeout(() => {
|
|
115
|
-
const left = ms - (Date.now() - started - slot.waitedMs());
|
|
116
|
-
if (left > 0) return arm(left);
|
|
117
|
-
expire();
|
|
118
|
-
}, delay);
|
|
119
|
-
};
|
|
120
|
-
arm(ms);
|
|
121
|
-
return () => clearTimeout(timer);
|
|
113
|
+
return turnBudget(ms, ms, slot, () => expire()).cancel;
|
|
122
114
|
}
|
|
@@ -97,7 +97,6 @@ export function runtimePlugin(
|
|
|
97
97
|
) => Promise<HeldActionStore> = (context) =>
|
|
98
98
|
PendingConfirmationStore.attach(context.database()),
|
|
99
99
|
): RoundtablePlugin {
|
|
100
|
-
let runtime: AgentRuntime | undefined;
|
|
101
100
|
let identity: IdentityService | undefined;
|
|
102
101
|
let warn: ((message: string) => void) | undefined;
|
|
103
102
|
// Admission is a startup risk warning, not proof that this conversation holds private history.
|
|
@@ -119,7 +118,6 @@ export function runtimePlugin(
|
|
|
119
118
|
`${bridgeMemoryProblem(formatModelRef(options.model), why)} ${BRIDGE_MEMORY_FIX}`,
|
|
120
119
|
);
|
|
121
120
|
}
|
|
122
|
-
await runtime?.preflight?.();
|
|
123
121
|
},
|
|
124
122
|
setup: async (context) => {
|
|
125
123
|
const { logger, providers, services } = context;
|
|
@@ -194,7 +192,6 @@ export function runtimePlugin(
|
|
|
194
192
|
? { interimPrimaryChars: options.interimPrimaryChars }
|
|
195
193
|
: {}),
|
|
196
194
|
});
|
|
197
|
-
runtime = running;
|
|
198
195
|
context.services.provide(RUNTIME, running);
|
|
199
196
|
return {
|
|
200
197
|
services: [{ name: "runtime", stop: () => running.dispose?.() }],
|
|
@@ -31,6 +31,15 @@ const REAL_CLOCK: BudgetClock = {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The shortest wait before a budget looks again at a turn that still has time. While a card is
|
|
36
|
+
* open the turn's work time stands still, so a timer that fires with a few milliseconds left would
|
|
37
|
+
* otherwise re-arm for those milliseconds, again and again, for as long as the card waits. The
|
|
38
|
+
* floor bounds that to one look a second; the cost is that a limit that waiting on a card pushed
|
|
39
|
+
* back, by less than the floor, may fire up to this much late.
|
|
40
|
+
*/
|
|
41
|
+
const REARM_FLOOR_MS = 1_000;
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Calls `expire` once the turn has worked `ms` since it started or since its last renewal, or
|
|
36
45
|
* `maxMs` in all, whichever comes first; time the slot spent waiting on the owner's cards counts
|
|
@@ -54,7 +63,7 @@ export function turnBudget(
|
|
|
54
63
|
const arm = (delay: number) => {
|
|
55
64
|
cancelTimer = clock.schedule(() => {
|
|
56
65
|
const left = deadline - worked();
|
|
57
|
-
if (left > 0) return arm(left);
|
|
66
|
+
if (left > 0) return arm(Math.max(left, REARM_FLOOR_MS));
|
|
58
67
|
expired = true;
|
|
59
68
|
expire(deadline >= maxMs ? "turnMax" : "turn");
|
|
60
69
|
}, delay);
|