pi-roundtable 0.7.8 → 0.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/docs/plugins.md +81 -1
- package/examples/prechecks.test.ts +30 -0
- package/examples/prechecks.ts +35 -0
- package/package.json +1 -1
- package/src/core/builtin/modules.ts +19 -1
- package/src/core/builtin/stores.ts +18 -2
- package/src/core/define-roundtable.ts +6 -1
- package/src/core/discord/schedule-commands.ts +3 -0
- package/src/core/i18n/schedules.ts +6 -0
- package/src/core/modules/background/background-turns.ts +8 -3
- package/src/core/modules/schedules/prechecks.ts +156 -0
- package/src/core/modules/schedules/schedule-store.ts +26 -3
- package/src/core/modules/schedules/schedule-tools.ts +70 -9
- package/src/core/modules/schedules/scheduler.ts +108 -6
- package/src/core/modules/schedules/schedules.ts +5 -1
- package/src/core/services.ts +17 -2
- package/src/core/shared/schedule-tools.ts +14 -2
- package/src/core/testing/prechecks.ts +56 -0
- package/src/index.ts +9 -0
- package/src/kit/compaction.ts +14 -0
- package/src/kit/index.ts +12 -0
- package/src/testing.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.10] - 2026-10-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Kit: the core's compaction tiers for a host that builds its own Pi session, such as a sandbox worker: `CompactionTiers`, `compactionEngine`, `SOFT_COMPACT_TOKENS`, `HARD_COMPACT_TOKENS`, `COMPACT_HEADROOM_TOKENS`, and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`.
|
|
13
|
+
- Sandbox: Pi worker sessions compact with those tiers, through an optional host compactor (`compaction` on `PiSandboxRuntime`) at 300,000 tokens and Pi's summary past 500,000, and the host logs each compaction and fallback per channel.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Sandbox: a failed Pi turn keeps its cause and is logged, the broker logs upstream failures, a timed-out worker's last log lines are logged before its container is removed, and Pi containers log to journald tagged `sandbox/<channel>` by default instead of local files (the Docker daemon must have journald).
|
|
18
|
+
|
|
19
|
+
## [0.7.9] - 2026-10-03
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Schedule prechecks: host code registers a named check with `PRECHECKS` (`register`, as `register({ name, description, timeoutMs?, run })`, provided by the new built-in `prechecks` plugin), and a schedule may name one in the new `precheck` parameter of `schedule_create` and `schedule_update` (`null` removes it). When the schedule falls due, the scheduler takes it as before and then runs the check: `{ wake: false, note? }` skips the turn and posts the note as the bot's own small message, `{ wake: true, context }` runs the turn with the context under a "Precheck found" heading, and a throw or a timeout (`PRECHECK_TIMEOUT_MS`, 60 seconds by default) runs it with the error. `schedule_list` and `/<root> schedule list` show a schedule's precheck and the last outcome, and `schedule_list` names the registered prechecks with their descriptions. New types: `Precheck`, `PrecheckContext`, `PrecheckResult`, `PrecheckRegistry`, `PrecheckFinding`. `Schedule`, `NewSchedule`, and `ScheduleChange` gain an optional `precheck`; `BackgroundTurns.runScheduled` takes the `PrecheckFinding` as an optional third argument. The `schedules` table gains a nullable `precheck` column through the new `schedules-precheck` migration.
|
|
24
|
+
- `pi-roundtable/testing`: `fakePrecheck` (`fakePrecheck(name, answer, options?)`, a precheck that answers as the test says and records its calls) and `fakePrechecks` (a real in-memory registry with the given prechecks registered), with the types `FakePrecheck` and `FakePrecheckAnswer`.
|
|
25
|
+
|
|
8
26
|
## [0.7.8] - 2026-10-03
|
|
9
27
|
|
|
10
28
|
### Fixed
|
package/docs/plugins.md
CHANGED
|
@@ -10,7 +10,7 @@ Each example has a test next to it that runs it without Discord or PostgreSQL, e
|
|
|
10
10
|
A plugin is an object with a name and a `setup` function.
|
|
11
11
|
`setup` returns the parts the plugin adds to the bot: tools agents can call, text added to their prompt, agents to create, handlers for events, long-lived services, slash commands, HTTP routes, and so on.
|
|
12
12
|
The bot itself is assembled from plugins too.
|
|
13
|
-
The core ships built-in plugins for its memory and schedule stores, Discord connection, agent server, notifications, delegation, and schedules.
|
|
13
|
+
The core ships built-in plugins for its memory and schedule stores, schedule prechecks, Discord connection, agent server, notifications, delegation, and schedules.
|
|
14
14
|
You can switch off three [addons](#addons-memory-skills-and-discord-administration): memory, skills, and Discord administration.
|
|
15
15
|
Your plugins are added after them and can read or replace the built-ins' [keyed services](#services-what-plugins-provide-to-each-other).
|
|
16
16
|
|
|
@@ -61,10 +61,13 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
|
|
|
61
61
|
Ordinary 48 MP images and thin 9000×1 images remain admitted.
|
|
62
62
|
This helper is not a downloader or filesystem validator.
|
|
63
63
|
- Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
|
|
64
|
+
- Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
|
|
65
|
+
It scans only the first `max * 4` characters (at least 4,096), in linear time, so pass it git, gh or provider error text before showing that text to a user.
|
|
64
66
|
- Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
|
|
65
67
|
- Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
|
|
66
68
|
- Mirroring a built-in tool in a worker that cannot reach the host: `SCHEDULE_TOOLS`, `scheduleToolSpecs({ locale, timeZone })`, `isScheduleTool`, `callScheduleTool`, `DELEGATE_TOOL` and `DELEGATE_TOOL_SPEC`.
|
|
67
69
|
The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
|
|
70
|
+
- Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass)` to hold a compaction extension back past the ceiling, `latest()`), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`.
|
|
68
71
|
- Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
|
|
69
72
|
- Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
|
|
70
73
|
|
|
@@ -164,6 +167,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
164
167
|
| `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on, `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
|
|
165
168
|
| `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
|
|
166
169
|
| `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
|
|
170
|
+
| `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list` |
|
|
167
171
|
| `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(id)` gives that speaker's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
|
|
168
172
|
| `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
|
|
169
173
|
| `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
|
|
@@ -740,6 +744,62 @@ export function heartbeat(everyMs: number, beat: () => Promise<void> | void) {
|
|
|
740
744
|
```
|
|
741
745
|
<!-- /example -->
|
|
742
746
|
|
|
747
|
+
#### Prechecks: wake a schedule only when it has work
|
|
748
|
+
|
|
749
|
+
A schedule whose answer is "all normal" on most days still costs a model turn each time it fires.
|
|
750
|
+
A precheck is host code that runs first and decides whether the turn runs at all.
|
|
751
|
+
Register it with `services.get(PRECHECKS).register({ name, description, timeoutMs?, run })` during setup; the agent attaches it to a schedule by name with the `precheck` parameter of `schedule_create` or `schedule_update` (`null` removes it), and `schedule_list` shows the registered names with their descriptions.
|
|
752
|
+
The model only picks a name: it never supplies code or a command, so attaching a precheck grants nothing beyond what the schedule already has, and the same tier rules apply.
|
|
753
|
+
|
|
754
|
+
When a schedule with a precheck falls due, the scheduler takes it first (moves it to its next run, or deletes a one-time schedule) exactly as before, so a slow precheck never fires it twice, and then calls `run({ schedule, firedAt, signal })`:
|
|
755
|
+
|
|
756
|
+
- `{ wake: false, note? }` skips the turn. A `note` is posted in the schedule's channel as the bot's own small message, which starts no turn. The last status reads `skipped by precheck (note)`.
|
|
757
|
+
- `{ wake: true, context }` runs the turn; its text carries `context` under a `### Precheck found (<name>):` heading after the prompt. The last status reads `woken by precheck; ran`.
|
|
758
|
+
- A throw, a wrong answer, a name no longer registered, or running past `timeoutMs` (default `PRECHECK_TIMEOUT_MS`, 60 seconds; `signal` is aborted then) runs the turn with the error under `### Precheck failed: <name>`, so the agent can look into it. The error is logged, and the last status reads `precheck failed (…), woke; ran`.
|
|
759
|
+
|
|
760
|
+
A name is lower-case letters, digits, `.`, `_`, and `-`; registering one twice throws a `PluginError`.
|
|
761
|
+
A `BackgroundTurns` of your own receives what the precheck found as `runScheduled`'s third argument, a `PrecheckFinding`.
|
|
762
|
+
|
|
763
|
+
<!-- example: examples/prechecks.ts -->
|
|
764
|
+
```ts
|
|
765
|
+
import { definePlugin, PRECHECKS } from "pi-roundtable";
|
|
766
|
+
|
|
767
|
+
/** Last night's reading and its usual level, from wherever the host keeps them. */
|
|
768
|
+
export interface RecoveryReading {
|
|
769
|
+
hrv: number;
|
|
770
|
+
baseline: number;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* A precheck a daily schedule can name: the host reads the numbers first and wakes the agent
|
|
775
|
+
* only when they are off, so an ordinary morning costs no model turn.
|
|
776
|
+
*/
|
|
777
|
+
export function recoveryPrecheck(read: () => Promise<RecoveryReading>) {
|
|
778
|
+
return definePlugin({
|
|
779
|
+
name: "recovery-precheck",
|
|
780
|
+
setup: ({ services }) => {
|
|
781
|
+
services.get(PRECHECKS).register({
|
|
782
|
+
name: "health.recovery",
|
|
783
|
+
description:
|
|
784
|
+
"Reads last night's HRV; wakes you when it is a fifth or more under its baseline.",
|
|
785
|
+
timeoutMs: 15_000,
|
|
786
|
+
run: async () => {
|
|
787
|
+
const { hrv, baseline } = await read();
|
|
788
|
+
if (hrv >= baseline * 0.8)
|
|
789
|
+
return { wake: false, note: `HRV ${hrv} ms, as usual.` };
|
|
790
|
+
return {
|
|
791
|
+
wake: true,
|
|
792
|
+
context: `HRV ${hrv} ms against a baseline of ${baseline} ms.`,
|
|
793
|
+
};
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
return {};
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
```
|
|
801
|
+
<!-- /example -->
|
|
802
|
+
|
|
743
803
|
### `migrations` and `context.database()`: tables of your own
|
|
744
804
|
|
|
745
805
|
Declare `migrations` on the plugin object.
|
|
@@ -2074,6 +2134,7 @@ Call `useTestLocale()` after a test changes the process-wide locale or time zone
|
|
|
2074
2134
|
`recordingLogger()` is a logger that keeps what it is asked to write: its `lines` hold each call's `level`, `fields` (those of a `child` included), and `message`, for a test of what the code logs.
|
|
2075
2135
|
Use `partial<Port>({ ... })` to stand in for a port your code takes as an argument.
|
|
2076
2136
|
It provides the members you give it and throws an error naming any missing member you read, so the test needs no `as unknown as Port` cast.
|
|
2137
|
+
`fakePrecheck(name, answer, { description?, timeoutMs? })` is a precheck that answers `answer` (a `PrecheckResult`, an `Error` it throws, or a function of its context) and records each context in `calls`; `fakePrechecks(...prechecks)` is a real in-memory `PrecheckRegistry` with them registered, which a test gives a plugin as `servicePair(PRECHECKS, registry)` and reads back with `registry.get(name)`.
|
|
2077
2138
|
`fakeDiscord({ ownerId?, rootCommand? })` is the `DISCORD` service for a plugin that adds slash commands: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`.
|
|
2078
2139
|
Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
|
|
2079
2140
|
|
|
@@ -2439,6 +2500,13 @@ Import from the entries listed below; source area files are internal.
|
|
|
2439
2500
|
| `RuntimeDeps` | `pi-roundtable` | type |
|
|
2440
2501
|
| `RuntimeFactory` | `pi-roundtable` | type |
|
|
2441
2502
|
| `SCHEDULES` | `pi-roundtable` | value |
|
|
2503
|
+
| `PRECHECKS` | `pi-roundtable` | value |
|
|
2504
|
+
| `PRECHECK_TIMEOUT_MS` | `pi-roundtable` | value |
|
|
2505
|
+
| `Precheck` | `pi-roundtable` | type |
|
|
2506
|
+
| `PrecheckContext` | `pi-roundtable` | type |
|
|
2507
|
+
| `PrecheckFinding` | `pi-roundtable` | type |
|
|
2508
|
+
| `PrecheckRegistry` | `pi-roundtable` | type |
|
|
2509
|
+
| `PrecheckResult` | `pi-roundtable` | type |
|
|
2442
2510
|
| `SKILLS` | `pi-roundtable` | value |
|
|
2443
2511
|
| `Schedule` | `pi-roundtable` | type |
|
|
2444
2512
|
| `ScheduleChange` | `pi-roundtable` | type |
|
|
@@ -2513,6 +2581,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
2513
2581
|
| `describeDb` | `pi-roundtable/testing` | value |
|
|
2514
2582
|
| `eagerText` | `pi-roundtable/testing` | value |
|
|
2515
2583
|
| `fakeDiscord` | `pi-roundtable/testing` | value |
|
|
2584
|
+
| `fakePrecheck` | `pi-roundtable/testing` | value |
|
|
2585
|
+
| `fakePrechecks` | `pi-roundtable/testing` | value |
|
|
2586
|
+
| `FakePrecheck` | `pi-roundtable/testing` | type |
|
|
2587
|
+
| `FakePrecheckAnswer` | `pi-roundtable/testing` | type |
|
|
2516
2588
|
| `fakeThreads` | `pi-roundtable/testing` | value |
|
|
2517
2589
|
| `openTestStore` | `pi-roundtable/testing` | value |
|
|
2518
2590
|
| `servicePair` | `pi-roundtable/testing` | value |
|
|
@@ -2594,6 +2666,14 @@ Import from the entries listed below; source area files are internal.
|
|
|
2594
2666
|
| `formatModelRef` | `pi-roundtable/kit` | value |
|
|
2595
2667
|
| `headline` | `pi-roundtable/kit` | value |
|
|
2596
2668
|
| `holdChain` | `pi-roundtable/kit` | value |
|
|
2669
|
+
| `COMPACT_HEADROOM_TOKENS` | `pi-roundtable/kit` | value |
|
|
2670
|
+
| `CompactionTiers` | `pi-roundtable/kit` | value |
|
|
2671
|
+
| `compactionEngine` | `pi-roundtable/kit` | value |
|
|
2672
|
+
| `HARD_COMPACT_TOKENS` | `pi-roundtable/kit` | value |
|
|
2673
|
+
| `SOFT_COMPACT_TOKENS` | `pi-roundtable/kit` | value |
|
|
2674
|
+
| `CompactionEngine` | `pi-roundtable/kit` | type |
|
|
2675
|
+
| `CompactionHistory` | `pi-roundtable/kit` | type |
|
|
2676
|
+
| `LatestCompaction` | `pi-roundtable/kit` | type |
|
|
2597
2677
|
| `isScheduleTool` | `pi-roundtable/kit` | value |
|
|
2598
2678
|
| `lastAssistant` | `pi-roundtable/kit` | value |
|
|
2599
2679
|
| `mcpAdapterExtension` | `pi-roundtable/kit` | value |
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PRECHECKS, type Schedule } from "pi-roundtable";
|
|
3
|
+
import { fakePrechecks, servicePair, testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import { recoveryPrecheck } from "./prechecks.ts";
|
|
5
|
+
|
|
6
|
+
test("the precheck stays quiet on a normal reading and wakes the agent on a low one", async () => {
|
|
7
|
+
const prechecks = fakePrechecks();
|
|
8
|
+
let reading = { hrv: 52, baseline: 55 };
|
|
9
|
+
const harness = await testPlugin(
|
|
10
|
+
recoveryPrecheck(async () => reading),
|
|
11
|
+
{ services: [servicePair(PRECHECKS, prechecks)] },
|
|
12
|
+
);
|
|
13
|
+
const precheck = prechecks.get("health.recovery");
|
|
14
|
+
if (!precheck) throw new Error("the plugin registered no precheck");
|
|
15
|
+
const context = {
|
|
16
|
+
schedule: { id: 1, title: "recovery" } as Schedule,
|
|
17
|
+
firedAt: new Date(),
|
|
18
|
+
signal: new AbortController().signal,
|
|
19
|
+
};
|
|
20
|
+
expect(await precheck.run(context)).toEqual({
|
|
21
|
+
wake: false,
|
|
22
|
+
note: "HRV 52 ms, as usual.",
|
|
23
|
+
});
|
|
24
|
+
reading = { hrv: 31, baseline: 55 };
|
|
25
|
+
expect(await precheck.run(context)).toEqual({
|
|
26
|
+
wake: true,
|
|
27
|
+
context: "HRV 31 ms against a baseline of 55 ms.",
|
|
28
|
+
});
|
|
29
|
+
await harness.stop();
|
|
30
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { definePlugin, PRECHECKS } from "pi-roundtable";
|
|
2
|
+
|
|
3
|
+
/** Last night's reading and its usual level, from wherever the host keeps them. */
|
|
4
|
+
export interface RecoveryReading {
|
|
5
|
+
hrv: number;
|
|
6
|
+
baseline: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A precheck a daily schedule can name: the host reads the numbers first and wakes the agent
|
|
11
|
+
* only when they are off, so an ordinary morning costs no model turn.
|
|
12
|
+
*/
|
|
13
|
+
export function recoveryPrecheck(read: () => Promise<RecoveryReading>) {
|
|
14
|
+
return definePlugin({
|
|
15
|
+
name: "recovery-precheck",
|
|
16
|
+
setup: ({ services }) => {
|
|
17
|
+
services.get(PRECHECKS).register({
|
|
18
|
+
name: "health.recovery",
|
|
19
|
+
description:
|
|
20
|
+
"Reads last night's HRV; wakes you when it is a fifth or more under its baseline.",
|
|
21
|
+
timeoutMs: 15_000,
|
|
22
|
+
run: async () => {
|
|
23
|
+
const { hrv, baseline } = await read();
|
|
24
|
+
if (hrv >= baseline * 0.8)
|
|
25
|
+
return { wake: false, note: `HRV ${hrv} ms, as usual.` };
|
|
26
|
+
return {
|
|
27
|
+
wake: true,
|
|
28
|
+
context: `HRV ${hrv} ms against a baseline of ${baseline} ms.`,
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return {};
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import type { SurfacePort } from "../contract/surface.ts";
|
|
4
4
|
import { scheduleCommands } from "../discord/schedule-commands.ts";
|
|
5
5
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
6
|
+
import { messages } from "../i18n/index.ts";
|
|
6
7
|
import type { OwnerIdentity } from "../identity.ts";
|
|
7
8
|
import type { ModelRef, ThinkingLevel } from "../models.ts";
|
|
8
9
|
import { ConversationBackgroundTurns } from "../modules/background/background-turns.ts";
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
AGENTS,
|
|
21
22
|
BACKGROUND_TURNS,
|
|
22
23
|
DELEGATION,
|
|
24
|
+
PRECHECKS,
|
|
23
25
|
SCHEDULES,
|
|
24
26
|
} from "../services.ts";
|
|
25
27
|
import type { SessionContext } from "../sessions.ts";
|
|
@@ -77,6 +79,8 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
77
79
|
provides: [BACKGROUND_TURNS, DELEGATION],
|
|
78
80
|
setup: ({ conversations, services, logger, surfaces }) => {
|
|
79
81
|
const schedules = services.get(SCHEDULES);
|
|
82
|
+
// Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
|
|
83
|
+
const prechecks = services.find(PRECHECKS);
|
|
80
84
|
const discord = services.get(DISCORD);
|
|
81
85
|
const { connection } = discord;
|
|
82
86
|
// Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
|
|
@@ -121,6 +125,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
121
125
|
store: schedules,
|
|
122
126
|
owner: { id: owner.id, name: owner.name },
|
|
123
127
|
channelFor: ownerChannelFor,
|
|
128
|
+
...(prechecks ? { prechecks } : {}),
|
|
124
129
|
},
|
|
125
130
|
session.homeChannel,
|
|
126
131
|
served(session),
|
|
@@ -155,11 +160,24 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
155
160
|
export function schedulerPlugin(): RoundtablePlugin {
|
|
156
161
|
return {
|
|
157
162
|
name: "schedules",
|
|
158
|
-
setup: ({ conversations, services, env, logger }) => {
|
|
163
|
+
setup: ({ conversations, services, env, logger, surfaces }) => {
|
|
159
164
|
const schedules = services.get(SCHEDULES);
|
|
165
|
+
const prechecks = services.find(PRECHECKS);
|
|
160
166
|
const scheduler = new Scheduler({
|
|
161
167
|
store: schedules,
|
|
162
168
|
runner: services.get(BACKGROUND_TURNS),
|
|
169
|
+
...(prechecks ? { prechecks } : {}),
|
|
170
|
+
// The bot's own message: the surface drops it, so it starts no turn.
|
|
171
|
+
notify: (schedule, note) =>
|
|
172
|
+
surfaces.sendReply(schedule.channel, {
|
|
173
|
+
chunks: [
|
|
174
|
+
messages().schedulePrecheckNote(
|
|
175
|
+
schedule.id,
|
|
176
|
+
schedule.title,
|
|
177
|
+
note,
|
|
178
|
+
),
|
|
179
|
+
],
|
|
180
|
+
}),
|
|
163
181
|
logger,
|
|
164
182
|
});
|
|
165
183
|
// Without Discord there are no schedule commands; the scheduler still fires.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { OwnerIdentity } from "../identity.ts";
|
|
2
2
|
import { ownerMemoryExtension } from "../modules/memory/owner-memory.ts";
|
|
3
3
|
import { PgMemoryStore } from "../modules/memory/owner-memory-store.ts";
|
|
4
|
+
import { memoryPrecheckRegistry } from "../modules/schedules/prechecks.ts";
|
|
4
5
|
import { PgScheduleStore } from "../modules/schedules/schedule-store.ts";
|
|
5
6
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
6
|
-
import { MEMORY, SCHEDULES } from "../services.ts";
|
|
7
|
+
import { MEMORY, PRECHECKS, SCHEDULES } from "../services.ts";
|
|
7
8
|
import { THE_SPEAKER, type Tier } from "../speakers.ts";
|
|
8
9
|
import { fixed } from "./session-tool.ts";
|
|
9
10
|
|
|
@@ -56,7 +57,7 @@ export function memoryPlugin(options: MemoryOptions): RoundtablePlugin {
|
|
|
56
57
|
export function scheduleStorePlugin(): RoundtablePlugin {
|
|
57
58
|
return {
|
|
58
59
|
name: "schedule-store",
|
|
59
|
-
migrations:
|
|
60
|
+
migrations: PgScheduleStore.migrations(),
|
|
60
61
|
provides: [SCHEDULES],
|
|
61
62
|
setup: async ({ database, services }) => {
|
|
62
63
|
services.provide(SCHEDULES, await PgScheduleStore.attach(database()));
|
|
@@ -64,3 +65,18 @@ export function scheduleStorePlugin(): RoundtablePlugin {
|
|
|
64
65
|
},
|
|
65
66
|
};
|
|
66
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The registry of the host's prechecks, provided as `PRECHECKS`; the plugins after it register
|
|
71
|
+
* theirs during setup. Apart from the store, so a plugin that replaces the store need not provide it.
|
|
72
|
+
*/
|
|
73
|
+
export function precheckPlugin(): RoundtablePlugin {
|
|
74
|
+
return {
|
|
75
|
+
name: "prechecks",
|
|
76
|
+
provides: [PRECHECKS],
|
|
77
|
+
setup: ({ services }) => {
|
|
78
|
+
services.provide(PRECHECKS, memoryPrecheckRegistry());
|
|
79
|
+
return {};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -8,7 +8,11 @@ import { discordAdminPlugin } from "./builtin/discord-admin.ts";
|
|
|
8
8
|
import { modulesPlugin, schedulerPlugin } from "./builtin/modules.ts";
|
|
9
9
|
import { seedsPlugin } from "./builtin/seeds.ts";
|
|
10
10
|
import { skillsPlugin } from "./builtin/skills.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
memoryPlugin,
|
|
13
|
+
precheckPlugin,
|
|
14
|
+
scheduleStorePlugin,
|
|
15
|
+
} from "./builtin/stores.ts";
|
|
12
16
|
import { type RoundtableConfig, resolveConfig } from "./config/config.ts";
|
|
13
17
|
import { ConfigError } from "./domain/errors.ts";
|
|
14
18
|
import { JudgeError } from "./errors.ts";
|
|
@@ -163,6 +167,7 @@ export async function defineRoundtable(
|
|
|
163
167
|
plugins: [
|
|
164
168
|
...(config.memory ? [memoryPlugin({ owner })] : []),
|
|
165
169
|
scheduleStorePlugin(),
|
|
170
|
+
precheckPlugin(),
|
|
166
171
|
discordPlugin({
|
|
167
172
|
token: discord.token,
|
|
168
173
|
ownerId: owner.id,
|
|
@@ -76,6 +76,9 @@ function section(schedule: Schedule, label: TargetLabel): string {
|
|
|
76
76
|
unix(schedule.nextRun),
|
|
77
77
|
),
|
|
78
78
|
text.scheduleSetBy(plain(schedule.createdByName), last),
|
|
79
|
+
...(schedule.precheck
|
|
80
|
+
? [text.schedulePrecheck(plain(schedule.precheck))]
|
|
81
|
+
: []),
|
|
79
82
|
`-# ${plain(prompt)}`,
|
|
80
83
|
].join("\n");
|
|
81
84
|
}
|
|
@@ -48,6 +48,9 @@ export function schedulesEn(ctx: CatalogContext) {
|
|
|
48
48
|
`${recurrence}; next run <t:${unix}:f>`,
|
|
49
49
|
scheduleSetBy: (name: string, lastRun: string) =>
|
|
50
50
|
`Set by ${name}${lastRun}`,
|
|
51
|
+
schedulePrecheck: (name: string) => `Checked first by precheck ${name}`,
|
|
52
|
+
schedulePrecheckNote: (id: number, title: string, note: string) =>
|
|
53
|
+
`-# Schedule #${id} ${title}, skipped by its precheck: ${note.replace(/\n+/g, "\n-# ")}`,
|
|
51
54
|
scheduleChoice: (id: number, title: string, recurrence: string) =>
|
|
52
55
|
`#${id} ${title} (${recurrence})`,
|
|
53
56
|
scheduleTitle: "Schedules",
|
|
@@ -87,6 +90,9 @@ export function schedulesZhTW(
|
|
|
87
90
|
`${recurrence};下次 <t:${unix}:f>`,
|
|
88
91
|
scheduleSetBy: (name: string, lastRun: string) =>
|
|
89
92
|
`由 ${name} 設定${lastRun}`,
|
|
93
|
+
schedulePrecheck: (name: string) => `先由預檢 ${name} 判斷`,
|
|
94
|
+
schedulePrecheckNote: (id: number, title: string, note: string) =>
|
|
95
|
+
`-# 排程 #${id} ${title} 經預檢略過:${note.replace(/\n+/g, "\n-# ")}`,
|
|
90
96
|
scheduleChoice: (id: number, title: string, recurrence: string) =>
|
|
91
97
|
`#${id} ${title}(${recurrence})`,
|
|
92
98
|
scheduleTitle: "排程",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type DelegationOutcome,
|
|
13
13
|
delegatedTurnText,
|
|
14
14
|
} from "../delegation/delegator.ts";
|
|
15
|
+
import type { PrecheckFinding } from "../schedules/prechecks.ts";
|
|
15
16
|
import type { Schedule } from "../schedules/schedule-store.ts";
|
|
16
17
|
import { scheduledTurnText } from "../schedules/schedule-tools.ts";
|
|
17
18
|
|
|
@@ -33,15 +34,19 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
|
|
|
33
34
|
this.#options = options;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/** A due schedule's turn, run as its creator's. */
|
|
37
|
-
runScheduled(
|
|
37
|
+
/** A due schedule's turn, run as its creator's; with what its precheck found, when it has one. */
|
|
38
|
+
runScheduled(
|
|
39
|
+
schedule: Schedule,
|
|
40
|
+
firedAt: Date,
|
|
41
|
+
finding?: PrecheckFinding,
|
|
42
|
+
): Promise<ScheduledOutcome> {
|
|
38
43
|
return this.#options.conversations.background({
|
|
39
44
|
channel: schedule.channel,
|
|
40
45
|
target: schedule.target,
|
|
41
46
|
author: { id: schedule.createdById, name: schedule.createdByName },
|
|
42
47
|
tier: schedule.createdTier,
|
|
43
48
|
turnId: `schedule-${schedule.id}-${firedAt.getTime()}`,
|
|
44
|
-
text: scheduledTurnText(schedule, firedAt),
|
|
49
|
+
text: scheduledTurnText(schedule, firedAt, finding),
|
|
45
50
|
});
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { PluginError } from "../../errors.ts";
|
|
2
|
+
import type { Schedule } from "./schedule-store.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a precheck may run before it counts as failed, unless it sets its own `timeoutMs`. */
|
|
5
|
+
export const PRECHECK_TIMEOUT_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
/** A precheck name: lower-case letters, digits, `.`, `_`, and `-`, starting with a letter or digit. */
|
|
8
|
+
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
9
|
+
|
|
10
|
+
/** What a precheck decides about a due schedule. */
|
|
11
|
+
export type PrecheckResult =
|
|
12
|
+
| {
|
|
13
|
+
/** Skip the turn. A note is posted in the schedule's channel as the bot's own small message, which starts no turn. */
|
|
14
|
+
wake: false;
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
/** Start the scheduled turn; its text carries `context` under a "Precheck found" heading. */
|
|
19
|
+
wake: true;
|
|
20
|
+
context: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** What a precheck is given when its schedule falls due. */
|
|
24
|
+
export interface PrecheckContext {
|
|
25
|
+
/** The due schedule, already moved to its next run (or deleted, when it runs once). */
|
|
26
|
+
schedule: Schedule;
|
|
27
|
+
firedAt: Date;
|
|
28
|
+
/** Aborted when the precheck runs out of time; its answer is then ignored. */
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A cheap check the host runs before a schedule's turn, so the agent is woken only when there is
|
|
34
|
+
* something to do. Host code registers it by name; the model can only attach a registered name to
|
|
35
|
+
* a schedule, never supply code or a command.
|
|
36
|
+
*/
|
|
37
|
+
export interface Precheck {
|
|
38
|
+
/** Unique on the host, such as `"health.recovery"`: lower-case letters, digits, `.`, `_`, `-`. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** What it checks and when it wakes the agent, shown to the model by schedule_list. */
|
|
41
|
+
description: string;
|
|
42
|
+
/** How long it may run; default 60 seconds. A timeout counts as a throw. */
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
/** Decides whether the turn runs. A throw starts the turn with the error, so the agent can look into it. */
|
|
45
|
+
run(context: PrecheckContext): PrecheckResult | Promise<PrecheckResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The host's prechecks, by name. Provided as `PRECHECKS` by the `prechecks` plugin. */
|
|
49
|
+
export interface PrecheckRegistry {
|
|
50
|
+
/** Adds a precheck; throws PluginError for a bad or repeated name, an empty description, or a bad timeout. */
|
|
51
|
+
register(precheck: Precheck): void;
|
|
52
|
+
get(name: string): Precheck | undefined;
|
|
53
|
+
/** Every registered precheck, by name. */
|
|
54
|
+
list(): readonly Precheck[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What came of a precheck that let the turn run, carried into the turn's text. */
|
|
58
|
+
export type PrecheckFinding =
|
|
59
|
+
| { precheck: string; context: string }
|
|
60
|
+
| { precheck: string; error: string };
|
|
61
|
+
|
|
62
|
+
/** The decision of one precheck run, with a throw, a timeout, or a bad answer as `failed`. */
|
|
63
|
+
export type PrecheckOutcome =
|
|
64
|
+
| { kind: "skip"; note?: string }
|
|
65
|
+
| { kind: "wake"; context: string }
|
|
66
|
+
| { kind: "failed"; error: string };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Prechecks kept in memory, as the host registers them while its plugins set up. Its methods
|
|
70
|
+
* close over the map instead of private fields, so a test may hand it over through a proxy.
|
|
71
|
+
*/
|
|
72
|
+
export function memoryPrecheckRegistry(): PrecheckRegistry {
|
|
73
|
+
const prechecks = new Map<string, Precheck>();
|
|
74
|
+
return {
|
|
75
|
+
register(precheck) {
|
|
76
|
+
const { name, description, timeoutMs, run } = precheck ?? {};
|
|
77
|
+
if (typeof name !== "string" || !NAME.test(name))
|
|
78
|
+
throw new PluginError(
|
|
79
|
+
`a precheck needs a name of lower-case letters, digits, ".", "_", and "-", such as "health.recovery"; got ${JSON.stringify(name)}.`,
|
|
80
|
+
);
|
|
81
|
+
if (prechecks.has(name))
|
|
82
|
+
throw new PluginError(
|
|
83
|
+
`precheck ${name} is already registered; give each precheck its own name.`,
|
|
84
|
+
);
|
|
85
|
+
if (typeof description !== "string" || !description.trim())
|
|
86
|
+
throw new PluginError(
|
|
87
|
+
`precheck ${name} needs a description: what it checks and when it wakes the agent.`,
|
|
88
|
+
);
|
|
89
|
+
if (
|
|
90
|
+
timeoutMs !== undefined &&
|
|
91
|
+
!(Number.isInteger(timeoutMs) && timeoutMs > 0)
|
|
92
|
+
)
|
|
93
|
+
throw new PluginError(
|
|
94
|
+
`precheck ${name}: timeoutMs must be a positive whole number of milliseconds; got ${String(timeoutMs)}.`,
|
|
95
|
+
);
|
|
96
|
+
if (typeof run !== "function")
|
|
97
|
+
throw new PluginError(`precheck ${name} needs a run function.`);
|
|
98
|
+
prechecks.set(name, Object.freeze({ ...precheck }));
|
|
99
|
+
},
|
|
100
|
+
get: (name) => prechecks.get(name),
|
|
101
|
+
list: () =>
|
|
102
|
+
[...prechecks.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorText(error: unknown): string {
|
|
107
|
+
return error instanceof Error ? error.message || error.name : String(error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Checks a precheck's answer, so a wrong shape fails like a throw instead of waking silently. */
|
|
111
|
+
function decided(result: unknown): PrecheckOutcome {
|
|
112
|
+
if (result && typeof result === "object" && "wake" in result) {
|
|
113
|
+
const answer = result as Record<string, unknown>;
|
|
114
|
+
if (answer.wake === false) {
|
|
115
|
+
const note = typeof answer.note === "string" ? answer.note.trim() : "";
|
|
116
|
+
return note ? { kind: "skip", note } : { kind: "skip" };
|
|
117
|
+
}
|
|
118
|
+
if (answer.wake === true && typeof answer.context === "string")
|
|
119
|
+
return { kind: "wake", context: answer.context };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
kind: "failed",
|
|
123
|
+
error: `it answered ${JSON.stringify(result)}, not { wake: false, note? } or { wake: true, context }`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Runs one precheck within its timeout; never rejects. */
|
|
128
|
+
export async function runPrecheck(
|
|
129
|
+
precheck: Precheck,
|
|
130
|
+
context: Omit<PrecheckContext, "signal">,
|
|
131
|
+
): Promise<PrecheckOutcome> {
|
|
132
|
+
const timeoutMs = precheck.timeoutMs ?? PRECHECK_TIMEOUT_MS;
|
|
133
|
+
const abort = new AbortController();
|
|
134
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
135
|
+
const timeout = new Promise<PrecheckOutcome>((resolve) => {
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
abort.abort();
|
|
138
|
+
resolve({
|
|
139
|
+
kind: "failed",
|
|
140
|
+
error: `it did not answer within ${timeoutMs / 1000} seconds`,
|
|
141
|
+
});
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
});
|
|
144
|
+
const answer = (async (): Promise<PrecheckOutcome> => {
|
|
145
|
+
try {
|
|
146
|
+
return decided(await precheck.run({ ...context, signal: abort.signal }));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { kind: "failed", error: `it threw: ${errorText(error)}` };
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
try {
|
|
152
|
+
return await Promise.race([answer, timeout]);
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface Schedule {
|
|
|
19
19
|
/** The creator's tier when it was set; the schedule runs at it and lower tiers may not change it. */
|
|
20
20
|
createdTier: Tier;
|
|
21
21
|
createdAt: Date;
|
|
22
|
+
/** The name of the precheck the host runs before its turn; absent, the turn always runs. */
|
|
23
|
+
precheck?: string;
|
|
22
24
|
lastRun?: Date;
|
|
23
25
|
lastStatus?: string;
|
|
24
26
|
}
|
|
@@ -33,6 +35,8 @@ export interface NewSchedule {
|
|
|
33
35
|
createdById: string;
|
|
34
36
|
createdByName: string;
|
|
35
37
|
createdTier: Tier;
|
|
38
|
+
/** A registered precheck's name, run before each turn. */
|
|
39
|
+
precheck?: string;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface ScheduleChange {
|
|
@@ -40,6 +44,8 @@ export interface ScheduleChange {
|
|
|
40
44
|
prompt?: string;
|
|
41
45
|
recurrence?: Recurrence;
|
|
42
46
|
nextRun?: Date;
|
|
47
|
+
/** A registered precheck's name to run before each turn; null removes the schedule's precheck. */
|
|
48
|
+
precheck?: string | null;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
interface Row {
|
|
@@ -55,6 +61,7 @@ interface Row {
|
|
|
55
61
|
created_by_name: string;
|
|
56
62
|
created_tier: Tier;
|
|
57
63
|
created_at: Date;
|
|
64
|
+
precheck: string | null;
|
|
58
65
|
last_run: Date | null;
|
|
59
66
|
last_status: string | null;
|
|
60
67
|
}
|
|
@@ -73,6 +80,7 @@ function toSchedule(row: Row): Schedule {
|
|
|
73
80
|
createdByName: row.created_by_name,
|
|
74
81
|
createdTier: row.created_tier,
|
|
75
82
|
createdAt: row.created_at,
|
|
83
|
+
...(row.precheck ? { precheck: row.precheck } : {}),
|
|
76
84
|
...(row.last_run ? { lastRun: row.last_run } : {}),
|
|
77
85
|
...(row.last_status ? { lastStatus: row.last_status } : {}),
|
|
78
86
|
};
|
|
@@ -112,6 +120,19 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
112
120
|
},
|
|
113
121
|
};
|
|
114
122
|
|
|
123
|
+
/** The table, then the precheck each schedule may name; schedules made before prechecks have none. */
|
|
124
|
+
static migrations(): Migration[] {
|
|
125
|
+
return [
|
|
126
|
+
PgScheduleStore.migration,
|
|
127
|
+
{
|
|
128
|
+
name: "schedules-precheck",
|
|
129
|
+
up: async (sql) => {
|
|
130
|
+
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck text`;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
/** The store over the host's migrated pool. */
|
|
116
137
|
static async attach(sql: SQL): Promise<PgScheduleStore> {
|
|
117
138
|
return new PgScheduleStore(sql);
|
|
@@ -120,10 +141,11 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
120
141
|
async create(schedule: NewSchedule): Promise<Schedule> {
|
|
121
142
|
const rows: Row[] = await this.#sql`
|
|
122
143
|
INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
|
|
123
|
-
created_by_id, created_by_name, created_tier)
|
|
144
|
+
created_by_id, created_by_name, created_tier, precheck)
|
|
124
145
|
VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
|
|
125
146
|
${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
|
|
126
|
-
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier}
|
|
147
|
+
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
|
|
148
|
+
${schedule.precheck ?? null})
|
|
127
149
|
RETURNING *`;
|
|
128
150
|
const [row] = rows;
|
|
129
151
|
if (!row) throw new Error("the schedule insert returned no row");
|
|
@@ -161,7 +183,8 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
161
183
|
title = ${change.title ?? current.title},
|
|
162
184
|
prompt = ${change.prompt ?? current.prompt},
|
|
163
185
|
recurrence = ${JSON.stringify(change.recurrence ?? current.recurrence)},
|
|
164
|
-
next_run = ${change.nextRun ?? current.nextRun}
|
|
186
|
+
next_run = ${change.nextRun ?? current.nextRun},
|
|
187
|
+
precheck = ${change.precheck === undefined ? (current.precheck ?? null) : change.precheck}
|
|
165
188
|
WHERE id = ${id}
|
|
166
189
|
RETURNING *`;
|
|
167
190
|
return rows[0] ? toSchedule(rows[0]) : undefined;
|
|
@@ -6,6 +6,7 @@ import type { ScheduleStore } from "../../services.ts";
|
|
|
6
6
|
import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
|
|
7
7
|
import { type Tier, tierAtLeast } from "../../speakers.ts";
|
|
8
8
|
import { timeZone, zonedStamp } from "../../time.ts";
|
|
9
|
+
import type { PrecheckFinding, PrecheckRegistry } from "./prechecks.ts";
|
|
9
10
|
import {
|
|
10
11
|
describeRecurrence,
|
|
11
12
|
nextRun,
|
|
@@ -30,6 +31,8 @@ export interface ScheduleToolContext {
|
|
|
30
31
|
/** Who asked, recorded on created schedules; a scheduled run speaks for its creator. */
|
|
31
32
|
author: { id: string; name: string; tier?: Tier };
|
|
32
33
|
now: Date;
|
|
34
|
+
/** The host's prechecks a schedule may name; without them, none can be attached. */
|
|
35
|
+
prechecks?: Pick<PrecheckRegistry, "get" | "list">;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** The limits of the context's target; a target without them may not schedule. */
|
|
@@ -64,6 +67,30 @@ function id(input: Input): number {
|
|
|
64
67
|
return value;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/** A registered precheck's name; an unknown one is refused with the names there are. */
|
|
71
|
+
function precheckName(ctx: ScheduleToolContext, value: unknown): string {
|
|
72
|
+
const names = (ctx.prechecks?.list() ?? []).map((p) => p.name);
|
|
73
|
+
if (typeof value !== "string" || !value.trim())
|
|
74
|
+
throw new ScheduleError(
|
|
75
|
+
`precheck must be the name of a registered precheck${names.length ? `: ${names.join(", ")}` : "; this host registers none"}`,
|
|
76
|
+
);
|
|
77
|
+
const name = value.trim();
|
|
78
|
+
if (!ctx.prechecks?.get(name))
|
|
79
|
+
throw new ScheduleError(
|
|
80
|
+
names.length
|
|
81
|
+
? `there is no precheck "${name}"; the registered ones are ${names.join(", ")}`
|
|
82
|
+
: `there is no precheck "${name}"; this host registers none`,
|
|
83
|
+
);
|
|
84
|
+
return name;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The prechecks a schedule may name, for schedule_list; empty when the host registers none. */
|
|
88
|
+
function precheckCatalog(ctx: ScheduleToolContext): string {
|
|
89
|
+
const all = ctx.prechecks?.list() ?? [];
|
|
90
|
+
if (all.length === 0) return "";
|
|
91
|
+
return `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
function hasTiming(input: Input): boolean {
|
|
68
95
|
return [
|
|
69
96
|
"in_minutes",
|
|
@@ -94,10 +121,11 @@ function line(schedule: Schedule): string {
|
|
|
94
121
|
schedule.prompt.length > LIST_PROMPT_PREVIEW
|
|
95
122
|
? `${schedule.prompt.slice(0, LIST_PROMPT_PREVIEW)}…`
|
|
96
123
|
: schedule.prompt;
|
|
124
|
+
const precheck = schedule.precheck ? `; precheck ${schedule.precheck}` : "";
|
|
97
125
|
const last = schedule.lastRun
|
|
98
126
|
? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
|
|
99
127
|
: "";
|
|
100
|
-
return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}; set by ${schedule.createdByName}${last}\n ${prompt.replace(/\n/g, " ")}`;
|
|
128
|
+
return `- #${schedule.id} ${schedule.title}: ${describeRecurrence(schedule.recurrence)}, next ${zonedStamp(schedule.nextRun)}; set by ${schedule.createdByName}${precheck}${last}\n ${prompt.replace(/\n/g, " ")}`;
|
|
101
129
|
}
|
|
102
130
|
|
|
103
131
|
async function own(ctx: ScheduleToolContext, input: Input): Promise<Schedule> {
|
|
@@ -131,6 +159,10 @@ export async function callScheduleTool(
|
|
|
131
159
|
const title = text(input, "title", TITLE_CHARS);
|
|
132
160
|
const prompt = text(input, "prompt", limits.promptChars);
|
|
133
161
|
const [recurrence, next] = timing(ctx, input);
|
|
162
|
+
const precheck =
|
|
163
|
+
input.precheck === undefined || input.precheck === null
|
|
164
|
+
? undefined
|
|
165
|
+
: precheckName(ctx, input.precheck);
|
|
134
166
|
const existing = await ctx.store.forChannel(ctx.channel);
|
|
135
167
|
if (existing.length >= limits.perChannel)
|
|
136
168
|
throw new ScheduleError(
|
|
@@ -146,8 +178,10 @@ export async function callScheduleTool(
|
|
|
146
178
|
createdById: ctx.author.id,
|
|
147
179
|
createdByName: ctx.author.name,
|
|
148
180
|
createdTier: ctx.author.tier ?? "owner",
|
|
181
|
+
...(precheck ? { precheck } : {}),
|
|
149
182
|
});
|
|
150
|
-
|
|
183
|
+
const checked = precheck ? `; precheck ${precheck} runs first` : "";
|
|
184
|
+
return `Scheduled #${created.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
|
|
151
185
|
}
|
|
152
186
|
case "schedule_list": {
|
|
153
187
|
if (input.id !== undefined) {
|
|
@@ -155,9 +189,11 @@ export async function callScheduleTool(
|
|
|
155
189
|
return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}`;
|
|
156
190
|
}
|
|
157
191
|
const all = await ctx.store.forChannel(ctx.channel);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
192
|
+
const listed =
|
|
193
|
+
all.length === 0
|
|
194
|
+
? "This channel has no schedules."
|
|
195
|
+
: `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map(line).join("\n")}`;
|
|
196
|
+
return `${listed}${precheckCatalog(ctx)}`;
|
|
161
197
|
}
|
|
162
198
|
case "schedule_update": {
|
|
163
199
|
const schedule = changeable(ctx, await own(ctx, input));
|
|
@@ -171,11 +207,20 @@ export async function callScheduleTool(
|
|
|
171
207
|
change.recurrence = recurrence;
|
|
172
208
|
change.nextRun = next;
|
|
173
209
|
}
|
|
210
|
+
// null removes the precheck; a name must be registered.
|
|
211
|
+
if (input.precheck !== undefined)
|
|
212
|
+
change.precheck =
|
|
213
|
+
input.precheck === null ? null : precheckName(ctx, input.precheck);
|
|
174
214
|
if (Object.keys(change).length === 0)
|
|
175
|
-
throw new ScheduleError(
|
|
215
|
+
throw new ScheduleError(
|
|
216
|
+
"give a title, prompt, timing, or precheck to change",
|
|
217
|
+
);
|
|
176
218
|
const updated = await ctx.store.update(ctx.channel, schedule.id, change);
|
|
177
219
|
if (!updated) throw new ScheduleError(`schedule #${schedule.id} is gone`);
|
|
178
|
-
|
|
220
|
+
const checked = updated.precheck
|
|
221
|
+
? `; precheck ${updated.precheck} runs first`
|
|
222
|
+
: "";
|
|
223
|
+
return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
|
|
179
224
|
}
|
|
180
225
|
case "schedule_cancel": {
|
|
181
226
|
const schedule = changeable(ctx, await own(ctx, input));
|
|
@@ -187,12 +232,28 @@ export async function callScheduleTool(
|
|
|
187
232
|
}
|
|
188
233
|
}
|
|
189
234
|
|
|
190
|
-
/** What
|
|
191
|
-
|
|
235
|
+
/** What the schedule's precheck found, or how it failed, under its own heading after the task. */
|
|
236
|
+
function findingText(finding: PrecheckFinding): string[] {
|
|
237
|
+
return "error" in finding
|
|
238
|
+
? [
|
|
239
|
+
"",
|
|
240
|
+
`### Precheck failed: ${finding.precheck}`,
|
|
241
|
+
`The precheck that decides whether this task needs you failed, so you were woken anyway: ${finding.error}. Check what it watches yourself, and say so if it needs fixing.`,
|
|
242
|
+
]
|
|
243
|
+
: ["", `### Precheck found (${finding.precheck}):`, finding.context];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What a scheduled run receives as its message, with what its precheck found when it has one. */
|
|
247
|
+
export function scheduledTurnText(
|
|
248
|
+
schedule: Schedule,
|
|
249
|
+
firedAt: Date,
|
|
250
|
+
finding?: PrecheckFinding,
|
|
251
|
+
): string {
|
|
192
252
|
return [
|
|
193
253
|
`## Scheduled task #${schedule.id}: ${schedule.title}`,
|
|
194
254
|
`${schedule.createdByName} set this schedule (${describeRecurrence(schedule.recurrence)}). It is due now, ${zonedStamp(firedAt)} ${messages().zoneTime(timeZone())}. Nobody wrote a new message: carry out the task below and write what you would post in this channel. If the task keeps a record for later runs, update it with schedule_update on #${schedule.id}.`,
|
|
195
255
|
"",
|
|
196
256
|
schedule.prompt,
|
|
257
|
+
...(finding ? findingText(finding) : []),
|
|
197
258
|
].join("\n");
|
|
198
259
|
}
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { ScheduledOutcome } from "../../contract/channels.ts";
|
|
2
2
|
import type { Logger } from "../../log.ts";
|
|
3
3
|
import type { ScheduleStore } from "../../services.ts";
|
|
4
|
+
import {
|
|
5
|
+
type PrecheckFinding,
|
|
6
|
+
type PrecheckOutcome,
|
|
7
|
+
type PrecheckRegistry,
|
|
8
|
+
runPrecheck,
|
|
9
|
+
} from "./prechecks.ts";
|
|
4
10
|
import { nextRun } from "./recurrence.ts";
|
|
5
11
|
import type { Schedule } from "./schedule-store.ts";
|
|
6
12
|
|
|
7
13
|
export type { ScheduledOutcome };
|
|
8
14
|
|
|
9
15
|
export interface ScheduledRunner {
|
|
10
|
-
/** Runs one due schedule in its channel and posts the answer there; never rejects. */
|
|
11
|
-
runScheduled(
|
|
16
|
+
/** Runs one due schedule in its channel and posts the answer there, with what its precheck found; never rejects. */
|
|
17
|
+
runScheduled(
|
|
18
|
+
schedule: Schedule,
|
|
19
|
+
firedAt: Date,
|
|
20
|
+
finding?: PrecheckFinding,
|
|
21
|
+
): Promise<ScheduledOutcome>;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
/** A run found this late, for example after the service was down, is skipped instead of run. */
|
|
@@ -17,18 +27,24 @@ export const LATE_LIMIT_MS = 12 * 3_600_000;
|
|
|
17
27
|
export interface SchedulerOptions {
|
|
18
28
|
store: Pick<ScheduleStore, "due" | "claim" | "recordStatus">;
|
|
19
29
|
runner: ScheduledRunner;
|
|
30
|
+
/** The host's prechecks; without them, a schedule that names one runs with that as its error. */
|
|
31
|
+
prechecks?: Pick<PrecheckRegistry, "get">;
|
|
32
|
+
/** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
|
|
33
|
+
notify?: (schedule: Schedule, note: string) => Promise<void>;
|
|
20
34
|
logger: Logger;
|
|
21
35
|
intervalMs?: number;
|
|
22
36
|
/** Replaceable in tests. */
|
|
23
37
|
now?: () => Date;
|
|
24
38
|
}
|
|
25
39
|
|
|
40
|
+
const STATUS_CHARS = 300;
|
|
41
|
+
|
|
26
42
|
function statusText(outcome: ScheduledOutcome): string {
|
|
27
43
|
switch (outcome.status) {
|
|
28
44
|
case "ran":
|
|
29
45
|
return "ran";
|
|
30
46
|
case "failed":
|
|
31
|
-
return `failed: ${outcome.error}
|
|
47
|
+
return `failed: ${outcome.error}`;
|
|
32
48
|
case "skipped":
|
|
33
49
|
return `skipped: ${outcome.reason}`;
|
|
34
50
|
default:
|
|
@@ -36,6 +52,14 @@ function statusText(outcome: ScheduledOutcome): string {
|
|
|
36
52
|
}
|
|
37
53
|
}
|
|
38
54
|
|
|
55
|
+
/** How the precheck let the turn run, before the turn's own outcome. */
|
|
56
|
+
function precheckPrefix(finding: PrecheckFinding | undefined): string {
|
|
57
|
+
if (!finding) return "";
|
|
58
|
+
return "error" in finding
|
|
59
|
+
? `precheck failed (${finding.error}), woke; `
|
|
60
|
+
: "woken by precheck; ";
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
/**
|
|
40
64
|
* Checks for due schedules on an interval. Each due schedule is claimed first, moved to its
|
|
41
65
|
* next run or deleted, so a slow or failing run never fires twice; missed runs are not caught up.
|
|
@@ -106,7 +130,23 @@ export class Scheduler {
|
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
async #fire(schedule: Schedule, firedAt: Date): Promise<void> {
|
|
109
|
-
const {
|
|
133
|
+
const { runner, logger } = this.#options;
|
|
134
|
+
let finding: PrecheckFinding | undefined;
|
|
135
|
+
if (schedule.precheck) {
|
|
136
|
+
const decision = await this.#precheck(
|
|
137
|
+
schedule,
|
|
138
|
+
schedule.precheck,
|
|
139
|
+
firedAt,
|
|
140
|
+
);
|
|
141
|
+
if (decision.kind === "skip") {
|
|
142
|
+
await this.#skip(schedule, decision.note);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
finding =
|
|
146
|
+
decision.kind === "wake"
|
|
147
|
+
? { precheck: schedule.precheck, context: decision.context }
|
|
148
|
+
: { precheck: schedule.precheck, error: decision.error };
|
|
149
|
+
}
|
|
110
150
|
logger.info(
|
|
111
151
|
{
|
|
112
152
|
schedule: schedule.id,
|
|
@@ -115,10 +155,72 @@ export class Scheduler {
|
|
|
115
155
|
},
|
|
116
156
|
"schedule firing",
|
|
117
157
|
);
|
|
118
|
-
const outcome = await runner.runScheduled(schedule, firedAt);
|
|
158
|
+
const outcome = await runner.runScheduled(schedule, firedAt, finding);
|
|
119
159
|
logger.info({ schedule: schedule.id, outcome }, "schedule finished");
|
|
160
|
+
await this.#record(
|
|
161
|
+
schedule,
|
|
162
|
+
`${precheckPrefix(finding)}${statusText(outcome)}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
|
|
167
|
+
async #precheck(
|
|
168
|
+
schedule: Schedule,
|
|
169
|
+
name: string,
|
|
170
|
+
firedAt: Date,
|
|
171
|
+
): Promise<PrecheckOutcome> {
|
|
172
|
+
const { prechecks, logger } = this.#options;
|
|
173
|
+
let decision: PrecheckOutcome;
|
|
174
|
+
try {
|
|
175
|
+
const precheck = prechecks?.get(name);
|
|
176
|
+
decision = precheck
|
|
177
|
+
? await runPrecheck(precheck, { schedule, firedAt })
|
|
178
|
+
: { kind: "failed", error: "no precheck of that name is registered" };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
decision = {
|
|
181
|
+
kind: "failed",
|
|
182
|
+
error: `the precheck could not be looked up: ${error instanceof Error ? error.message : String(error)}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// A warning, not an error: the woken turn carries the error already, and an error line would
|
|
186
|
+
// wake the ops agent's report turn for the same failure.
|
|
187
|
+
if (decision.kind === "failed")
|
|
188
|
+
logger.warn(
|
|
189
|
+
{ schedule: schedule.id, precheck: name, error: decision.error },
|
|
190
|
+
"precheck failed; the scheduled turn runs with the error",
|
|
191
|
+
);
|
|
192
|
+
else
|
|
193
|
+
logger.info(
|
|
194
|
+
{
|
|
195
|
+
schedule: schedule.id,
|
|
196
|
+
precheck: name,
|
|
197
|
+
wake: decision.kind === "wake",
|
|
198
|
+
},
|
|
199
|
+
"precheck decided",
|
|
200
|
+
);
|
|
201
|
+
return decision;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Records a run the precheck skipped and posts its note; a note that cannot be posted is logged. */
|
|
205
|
+
async #skip(schedule: Schedule, note: string | undefined): Promise<void> {
|
|
206
|
+
const { notify, logger } = this.#options;
|
|
207
|
+
if (note && notify)
|
|
208
|
+
await notify(schedule, note).catch((error: unknown) =>
|
|
209
|
+
logger.warn(
|
|
210
|
+
{ schedule: schedule.id, err: error },
|
|
211
|
+
"precheck note not posted",
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
await this.#record(
|
|
215
|
+
schedule,
|
|
216
|
+
note ? `skipped by precheck (${note})` : "skipped by precheck",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async #record(schedule: Schedule, status: string): Promise<void> {
|
|
221
|
+
const { store, logger } = this.#options;
|
|
120
222
|
await store
|
|
121
|
-
.recordStatus(schedule.id,
|
|
223
|
+
.recordStatus(schedule.id, status.slice(0, STATUS_CHARS))
|
|
122
224
|
.catch((error: unknown) =>
|
|
123
225
|
logger.error(
|
|
124
226
|
{ schedule: schedule.id, err: error },
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "../../shared/schedule-tools.ts";
|
|
17
17
|
import type { Speaker } from "../../speakers.ts";
|
|
18
18
|
import { timeZone } from "../../time.ts";
|
|
19
|
+
import type { PrecheckRegistry } from "./prechecks.ts";
|
|
19
20
|
import { callScheduleTool } from "./schedule-tools.ts";
|
|
20
21
|
|
|
21
22
|
export interface OwnerSchedules {
|
|
@@ -26,6 +27,8 @@ export interface OwnerSchedules {
|
|
|
26
27
|
* messages for a conversation no chat surface carries, where a run could not be posted.
|
|
27
28
|
*/
|
|
28
29
|
channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
|
|
30
|
+
/** The host's prechecks a schedule may name; without them, none can be attached. */
|
|
31
|
+
prechecks?: Pick<PrecheckRegistry, "get" | "list">;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
|
|
@@ -58,7 +61,7 @@ export function schedulesExtension(
|
|
|
58
61
|
/** The person the running turn is for; their schedules run at their tier. */
|
|
59
62
|
speaker: () => Speaker | undefined = () => undefined,
|
|
60
63
|
): ExtensionFactory {
|
|
61
|
-
const { store, owner, channelFor } = schedules;
|
|
64
|
+
const { store, owner, channelFor, prechecks } = schedules;
|
|
62
65
|
const defs = scheduleToolSpecs({
|
|
63
66
|
locale: activeLocale(),
|
|
64
67
|
timeZone: timeZone(),
|
|
@@ -79,6 +82,7 @@ export function schedulesExtension(
|
|
|
79
82
|
target: OWNER_TARGET,
|
|
80
83
|
author: speaker() ?? owner,
|
|
81
84
|
now: new Date(),
|
|
85
|
+
...(prechecks ? { prechecks } : {}),
|
|
82
86
|
},
|
|
83
87
|
spec.name,
|
|
84
88
|
input,
|
package/src/core/services.ts
CHANGED
|
@@ -16,6 +16,10 @@ import type {
|
|
|
16
16
|
MemoryKind,
|
|
17
17
|
PromptMemory,
|
|
18
18
|
} from "./modules/memory/owner-memory-store.ts";
|
|
19
|
+
import type {
|
|
20
|
+
PrecheckFinding,
|
|
21
|
+
PrecheckRegistry,
|
|
22
|
+
} from "./modules/schedules/prechecks.ts";
|
|
19
23
|
import type {
|
|
20
24
|
NewSchedule,
|
|
21
25
|
Schedule,
|
|
@@ -38,6 +42,13 @@ export const AGENTS: ServiceKey<AgentServer> =
|
|
|
38
42
|
export const SCHEDULES: ServiceKey<ScheduleStore> = serviceKey<ScheduleStore>(
|
|
39
43
|
"roundtable.schedules",
|
|
40
44
|
);
|
|
45
|
+
/**
|
|
46
|
+
* The host's named prechecks, which a schedule may run before its turn to decide whether the
|
|
47
|
+
* agent is woken at all. Provided by the `prechecks` plugin; register yours during setup:
|
|
48
|
+
* `services.get(PRECHECKS).register({ name, description, run })`.
|
|
49
|
+
*/
|
|
50
|
+
export const PRECHECKS: ServiceKey<PrecheckRegistry> =
|
|
51
|
+
serviceKey<PrecheckRegistry>("roundtable.prechecks");
|
|
41
52
|
/** Turns nobody wrote: a due schedule's, a delegated task's report, a logged error's. Provided by the modules plugin. */
|
|
42
53
|
export const BACKGROUND_TURNS: ServiceKey<BackgroundTurns> =
|
|
43
54
|
serviceKey<BackgroundTurns>("roundtable.background-turns");
|
|
@@ -177,8 +188,12 @@ export interface ScheduleStore {
|
|
|
177
188
|
|
|
178
189
|
/** Turns nobody wrote, each answered in its channel by the claim that owns it. */
|
|
179
190
|
export interface BackgroundTurns {
|
|
180
|
-
/** A due schedule's turn, run as its creator's. */
|
|
181
|
-
runScheduled(
|
|
191
|
+
/** A due schedule's turn, run as its creator's; with what its precheck found, when it has one. */
|
|
192
|
+
runScheduled(
|
|
193
|
+
schedule: Schedule,
|
|
194
|
+
firedAt: Date,
|
|
195
|
+
finding?: PrecheckFinding,
|
|
196
|
+
): Promise<ScheduledOutcome>;
|
|
182
197
|
/** A delegated task's report, answered in its channel under the same rules as a schedule. */
|
|
183
198
|
runDelegated(job: DelegationJob, result: DelegationOutcome): Promise<void>;
|
|
184
199
|
/** The process's own logged error, reported to an agent in its channel as a report turn. */
|
|
@@ -91,12 +91,18 @@ export function scheduleToolSpecs(
|
|
|
91
91
|
description: "What to do when it runs, self-contained.",
|
|
92
92
|
}),
|
|
93
93
|
...timing(zone),
|
|
94
|
+
precheck: Type.Optional(
|
|
95
|
+
Type.String({
|
|
96
|
+
description:
|
|
97
|
+
"The name of a precheck the host runs first, from schedule_list; you are woken only when it finds something.",
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
94
100
|
}),
|
|
95
101
|
},
|
|
96
102
|
{
|
|
97
103
|
name: "schedule_list",
|
|
98
104
|
label: "List schedules",
|
|
99
|
-
description: `List this channel's schedules with their next run, and the current ${zone}. Give id to read one schedule's full prompt.`,
|
|
105
|
+
description: `List this channel's schedules with their next run, and the current ${zone}, and the prechecks a schedule may attach. Give id to read one schedule's full prompt.`,
|
|
100
106
|
parameters: Type.Object({
|
|
101
107
|
id: Type.Optional(Type.Integer({ description: "Schedule id." })),
|
|
102
108
|
}),
|
|
@@ -105,7 +111,7 @@ export function scheduleToolSpecs(
|
|
|
105
111
|
name: "schedule_update",
|
|
106
112
|
label: "Update schedule",
|
|
107
113
|
description:
|
|
108
|
-
"Change one of this channel's schedules: its title, its prompt,
|
|
114
|
+
"Change one of this channel's schedules: its title, its prompt, its timing (timing fields replace the old timing), or its precheck. A run can use this to keep its own prompt current, such as adding what it already reported.",
|
|
109
115
|
parameters: Type.Object({
|
|
110
116
|
id: Type.Integer({ description: "Schedule id." }),
|
|
111
117
|
title: Type.Optional(Type.String()),
|
|
@@ -113,6 +119,12 @@ export function scheduleToolSpecs(
|
|
|
113
119
|
Type.String({ description: "The whole new prompt." }),
|
|
114
120
|
),
|
|
115
121
|
...timing(zone),
|
|
122
|
+
precheck: Type.Optional(
|
|
123
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
124
|
+
description:
|
|
125
|
+
"A precheck's name from schedule_list to run first, or null to remove the schedule's precheck.",
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
116
128
|
}),
|
|
117
129
|
},
|
|
118
130
|
{
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
memoryPrecheckRegistry,
|
|
3
|
+
type Precheck,
|
|
4
|
+
type PrecheckContext,
|
|
5
|
+
type PrecheckRegistry,
|
|
6
|
+
type PrecheckResult,
|
|
7
|
+
} from "../modules/schedules/prechecks.ts";
|
|
8
|
+
|
|
9
|
+
/** What a fake precheck answers: a result, an Error it throws, or a function of its context. */
|
|
10
|
+
export type FakePrecheckAnswer =
|
|
11
|
+
| PrecheckResult
|
|
12
|
+
| Error
|
|
13
|
+
| ((context: PrecheckContext) => PrecheckResult | Promise<PrecheckResult>);
|
|
14
|
+
|
|
15
|
+
/** A precheck for tests, which records every context it ran with. */
|
|
16
|
+
export interface FakePrecheck extends Precheck {
|
|
17
|
+
readonly calls: PrecheckContext[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A precheck that answers as the test says, for registering where host code would:
|
|
22
|
+
* `services.get(PRECHECKS).register(fakePrecheck("health.recovery", { wake: false }))`, or into
|
|
23
|
+
* `fakePrechecks(...)`. An Error answer is thrown, as a failing check would.
|
|
24
|
+
*/
|
|
25
|
+
export function fakePrecheck(
|
|
26
|
+
name: string,
|
|
27
|
+
answer: FakePrecheckAnswer,
|
|
28
|
+
options: { description?: string; timeoutMs?: number } = {},
|
|
29
|
+
): FakePrecheck {
|
|
30
|
+
const calls: PrecheckContext[] = [];
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
description: options.description ?? `A test precheck, ${name}.`,
|
|
34
|
+
...(options.timeoutMs === undefined
|
|
35
|
+
? {}
|
|
36
|
+
: { timeoutMs: options.timeoutMs }),
|
|
37
|
+
calls,
|
|
38
|
+
run: async (context) => {
|
|
39
|
+
calls.push(context);
|
|
40
|
+
if (answer instanceof Error) throw answer;
|
|
41
|
+
return typeof answer === "function" ? answer(context) : answer;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A real in-memory precheck registry with the given prechecks registered, for a test that builds
|
|
48
|
+
* a scheduler or schedule tools itself. It refuses what the host's refuses.
|
|
49
|
+
*/
|
|
50
|
+
export function fakePrechecks(
|
|
51
|
+
...prechecks: readonly Precheck[]
|
|
52
|
+
): PrecheckRegistry {
|
|
53
|
+
const registry = memoryPrecheckRegistry();
|
|
54
|
+
for (const precheck of prechecks) registry.register(precheck);
|
|
55
|
+
return registry;
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -126,6 +126,14 @@ export type {
|
|
|
126
126
|
PromptMemory,
|
|
127
127
|
} from "./core/modules/memory/owner-memory-store.ts";
|
|
128
128
|
export { MEMORY_KINDS } from "./core/modules/memory/owner-memory-store.ts";
|
|
129
|
+
export type {
|
|
130
|
+
Precheck,
|
|
131
|
+
PrecheckContext,
|
|
132
|
+
PrecheckFinding,
|
|
133
|
+
PrecheckRegistry,
|
|
134
|
+
PrecheckResult,
|
|
135
|
+
} from "./core/modules/schedules/prechecks.ts";
|
|
136
|
+
export { PRECHECK_TIMEOUT_MS } from "./core/modules/schedules/prechecks.ts";
|
|
129
137
|
export type {
|
|
130
138
|
Recurrence,
|
|
131
139
|
Weekday,
|
|
@@ -187,6 +195,7 @@ export {
|
|
|
187
195
|
BACKGROUND_TURNS,
|
|
188
196
|
DELEGATION,
|
|
189
197
|
MEMORY,
|
|
198
|
+
PRECHECKS,
|
|
190
199
|
SCHEDULES,
|
|
191
200
|
SKILLS,
|
|
192
201
|
} from "./core/services.ts";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The core's compaction tiers, for a host that builds its own Pi session (a sandbox worker, say).
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
CompactionEngine,
|
|
5
|
+
CompactionHistory,
|
|
6
|
+
LatestCompaction,
|
|
7
|
+
} from "../core/runtime/compaction-tiers.ts";
|
|
8
|
+
export {
|
|
9
|
+
COMPACT_HEADROOM_TOKENS,
|
|
10
|
+
CompactionTiers,
|
|
11
|
+
compactionEngine,
|
|
12
|
+
HARD_COMPACT_TOKENS,
|
|
13
|
+
SOFT_COMPACT_TOKENS,
|
|
14
|
+
} from "../core/runtime/compaction-tiers.ts";
|
package/src/kit/index.ts
CHANGED
|
@@ -14,6 +14,18 @@ export {
|
|
|
14
14
|
withAttachmentsBlock,
|
|
15
15
|
withReference,
|
|
16
16
|
} from "./channels.ts";
|
|
17
|
+
export type {
|
|
18
|
+
CompactionEngine,
|
|
19
|
+
CompactionHistory,
|
|
20
|
+
LatestCompaction,
|
|
21
|
+
} from "./compaction.ts";
|
|
22
|
+
export {
|
|
23
|
+
COMPACT_HEADROOM_TOKENS,
|
|
24
|
+
CompactionTiers,
|
|
25
|
+
compactionEngine,
|
|
26
|
+
HARD_COMPACT_TOKENS,
|
|
27
|
+
SOFT_COMPACT_TOKENS,
|
|
28
|
+
} from "./compaction.ts";
|
|
17
29
|
export { scrubDiagnostic } from "./diagnostics.ts";
|
|
18
30
|
export type {
|
|
19
31
|
ChoiceAnswer,
|
package/src/testing.ts
CHANGED
|
@@ -83,6 +83,11 @@ export type { TestLocale } from "./core/testing/locale.ts";
|
|
|
83
83
|
export { useTestLocale } from "./core/testing/locale.ts";
|
|
84
84
|
export { OWNER_SPEAKER } from "./core/testing/owner.ts";
|
|
85
85
|
export { partial } from "./core/testing/partial.ts";
|
|
86
|
+
export type {
|
|
87
|
+
FakePrecheck,
|
|
88
|
+
FakePrecheckAnswer,
|
|
89
|
+
} from "./core/testing/prechecks.ts";
|
|
90
|
+
export { fakePrecheck, fakePrechecks } from "./core/testing/prechecks.ts";
|
|
86
91
|
export type {
|
|
87
92
|
RecordedLog,
|
|
88
93
|
RecordingLogger,
|