pi-roundtable 0.7.11 → 0.7.13
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 +14 -0
- package/docs/plugins.md +12 -0
- package/package.json +2 -1
- package/src/core/agents/team-turns.ts +6 -2
- package/src/core/builtin/modules.ts +20 -2
- package/src/core/domain/conversation.ts +3 -0
- package/src/core/holds.ts +47 -3
- package/src/core/i18n/schedules.ts +14 -0
- package/src/core/modules/schedules/precheck-tools.ts +387 -0
- package/src/core/modules/schedules/prechecks.ts +15 -47
- package/src/core/modules/schedules/schedule-store.ts +43 -3
- package/src/core/modules/schedules/schedule-tools.ts +47 -12
- package/src/core/modules/schedules/scheduler.ts +39 -0
- package/src/core/modules/schedules/schedules.ts +5 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +12 -8
- package/src/core/testing/prechecks.ts +3 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.13] - 2026-10-03
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Behavior change: a precheck script's MCP calls follow the hold rules. Saving a script reads its `mcp.call` and `mcp.json` calls (server and tool written as strings, `mcp` used for nothing else, or the script is refused) and judges each with the host's hold rules; if any is held, the `schedule_create` or `schedule_update` call is itself held for the owner through the confirmation gate, once, and the scheduled runs do not ask again. The schedule keeps the tools its script may call (`Schedule.precheckTools`, `NewSchedule.precheckTools`, `ScheduleChange.precheckTools`, the type `PrecheckTool`) in a nullable `precheck_tools` column (the new `schedules-precheck-tools` migration); the runner gets them as `PrecheckScriptContext.tools` and must refuse every other call; `schedule_list` shows them, marking those the owner approved. A script saved before has no recorded tools: its first run reads them, runs it if none is held, and otherwise wakes the agent to save it again.
|
|
13
|
+
- Behavior change: `PrecheckScriptRunner` needs `toolName(server, tool)`, the name the hold rules know a script's call by.
|
|
14
|
+
- `HoldRule` takes an optional `mayHold(tool)`, for a rule whose verdict depends on the input, and an optional `approvalTier(tool, input, context)`, the lowest tier that may approve a call it holds when higher than the tool's own; `HoldCheck` (and `holdChain`'s) gains `mayHold` and `approvalTier`. A held call keeps that tier (`HeldCall.minTier`), and both its approval card and a confirming message require it, so saving a script whose runs send mail needs whoever may approve sending mail, not only whoever may schedule. `ScheduleToolContext` takes `holds`.
|
|
15
|
+
|
|
16
|
+
## [0.7.12] - 2026-10-03
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- pi-roundtable-sandbox: a host compactor's time shrinks with the turn's, and an error event in the middle of a successful model stream is logged; see its changelog.
|
|
21
|
+
|
|
8
22
|
## [0.7.11] - 2026-10-03
|
|
9
23
|
|
|
10
24
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -501,6 +501,10 @@ export const cleanup = definePlugin({
|
|
|
501
501
|
```
|
|
502
502
|
<!-- /example -->
|
|
503
503
|
|
|
504
|
+
A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
|
|
505
|
+
It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
|
|
506
|
+
A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it.
|
|
507
|
+
|
|
504
508
|
### `prompt`: text added to every agent turn
|
|
505
509
|
|
|
506
510
|
Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
|
|
@@ -812,6 +816,13 @@ pi-roundtable-sandbox's `precheckScriptRunner` runs each script in a sealed cont
|
|
|
812
816
|
- When the host stops, the scheduler aborts running scripts and waits up to 15 seconds for the runner to clean up; a precheck that ends then starts no turn.
|
|
813
817
|
- Without a runner, the tools neither take nor mention `precheck_script`, a script is refused with the registered names, and a schedule that already has one wakes with `### Precheck failed: script`, never a silent skip.
|
|
814
818
|
|
|
819
|
+
A script's MCP calls follow the hold rules as the agent's own calls do.
|
|
820
|
+
When a script is saved, the core reads its `mcp.call(server, tool, args)` and `mcp.json(...)` calls; server and tool must be written as strings, and `mcp` may be used for nothing else.
|
|
821
|
+
The runner's `toolName(server, tool)` gives the name the hold rules know each tool by, and each call is judged by its arguments when they are written out, or otherwise by `describe` with an empty input and the rules' `mayHold`.
|
|
822
|
+
If any call is held, saving the script is itself a held action, approved or refused through the confirmation gate like any other, so it is approved once, by someone who may approve each of those calls, and the scheduled runs do not ask again.
|
|
823
|
+
The schedule keeps the tools it may call as `precheckTools` (`PrecheckTool`, its held ones marked), the runner gets them as the context's `tools` and must refuse every other call, and `schedule_list` shows them.
|
|
824
|
+
A script saved before 0.7.13 has no recorded tools: its first run reads them, runs it if none is held, and otherwise wakes the agent to save it again for approval.
|
|
825
|
+
|
|
815
826
|
|
|
816
827
|
### `migrations` and `context.database()`: tables of your own
|
|
817
828
|
|
|
@@ -2524,6 +2535,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
2524
2535
|
| `PrecheckScope` | `pi-roundtable` | type |
|
|
2525
2536
|
| `PrecheckScriptContext` | `pi-roundtable` | type |
|
|
2526
2537
|
| `PrecheckScriptRunner` | `pi-roundtable` | type |
|
|
2538
|
+
| `PrecheckTool` | `pi-roundtable` | type |
|
|
2527
2539
|
| `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
|
|
2528
2540
|
| `SKILLS` | `pi-roundtable` | value |
|
|
2529
2541
|
| `Schedule` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
4
4
|
"description": "A plugin-driven Pi agent server for Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@babel/parser": "7.29.9",
|
|
59
|
+
"@babel/types": "7.29.8",
|
|
59
60
|
"@earendil-works/pi-ai": ">=0.99.2 <2",
|
|
60
61
|
"@earendil-works/pi-coding-agent": ">=0.99.2 <2",
|
|
61
62
|
"canvas": "3.2.3",
|
|
@@ -12,7 +12,7 @@ import { splitReply } from "../presentation/reply-splitter.ts";
|
|
|
12
12
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
13
13
|
import { withReplyFiles } from "../reply-files.ts";
|
|
14
14
|
import { endOf, settleTurn } from "../routing/settle-turn.ts";
|
|
15
|
-
import type
|
|
15
|
+
import { type Speaker, tierAtLeast } from "../speakers.ts";
|
|
16
16
|
import { toolTiers } from "../tool-tiers.ts";
|
|
17
17
|
import { AgentMessages } from "./agent-messages.ts";
|
|
18
18
|
import type { Agent } from "./agent-store.ts";
|
|
@@ -145,7 +145,11 @@ export class TeamTurns {
|
|
|
145
145
|
/** Whether the speaker's tier holds every tool of the held actions. */
|
|
146
146
|
#mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
|
|
147
147
|
const tiers = this.#options.toolTiers ?? toolTiers();
|
|
148
|
-
return pending.calls.every(
|
|
148
|
+
return pending.calls.every(
|
|
149
|
+
(call) =>
|
|
150
|
+
tiers.allows(speaker.tier, call.tool) &&
|
|
151
|
+
(!call.minTier || tierAtLeast(speaker.tier, call.minTier)),
|
|
152
|
+
);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
#agentOf(channel: ChannelKey): Agent {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../modules/delegation/delegator.ts";
|
|
15
15
|
import { WebResearchWorker } from "../modules/delegation/web-research-worker.ts";
|
|
16
16
|
import { notifyExtension } from "../modules/notify/notify.ts";
|
|
17
|
+
import { precheckScriptHoldRule } from "../modules/schedules/precheck-tools.ts";
|
|
17
18
|
import { Scheduler } from "../modules/schedules/scheduler.ts";
|
|
18
19
|
import { schedulesExtension } from "../modules/schedules/schedules.ts";
|
|
19
20
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
@@ -77,7 +78,14 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
77
78
|
return {
|
|
78
79
|
name: "modules",
|
|
79
80
|
provides: [BACKGROUND_TURNS, DELEGATION],
|
|
80
|
-
setup: ({
|
|
81
|
+
setup: ({
|
|
82
|
+
conversations,
|
|
83
|
+
services,
|
|
84
|
+
logger,
|
|
85
|
+
surfaces,
|
|
86
|
+
sessions,
|
|
87
|
+
toolTiers,
|
|
88
|
+
}) => {
|
|
81
89
|
const schedules = services.get(SCHEDULES);
|
|
82
90
|
// Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
|
|
83
91
|
const prechecks = services.find(PRECHECKS);
|
|
@@ -117,6 +125,14 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
117
125
|
services: [
|
|
118
126
|
{ name: "delegator", busy: () => delegator.runningChannels() },
|
|
119
127
|
],
|
|
128
|
+
// Saving a precheck script that calls a held tool waits for the owner, as the call would.
|
|
129
|
+
holdRules: [
|
|
130
|
+
precheckScriptHoldRule({
|
|
131
|
+
prechecks: () => prechecks,
|
|
132
|
+
holds: () => sessions().holds,
|
|
133
|
+
tiers: () => toolTiers,
|
|
134
|
+
}),
|
|
135
|
+
],
|
|
120
136
|
sessionTools: [
|
|
121
137
|
fixed("notify", () => notifyExtension(connection, owner)),
|
|
122
138
|
fixed("schedules", (session) =>
|
|
@@ -126,6 +142,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
126
142
|
owner: { id: owner.id, name: owner.name },
|
|
127
143
|
channelFor: ownerChannelFor,
|
|
128
144
|
...(prechecks ? { prechecks } : {}),
|
|
145
|
+
holds: () => sessions().holds,
|
|
129
146
|
},
|
|
130
147
|
session.homeChannel,
|
|
131
148
|
served(session),
|
|
@@ -160,13 +177,14 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
160
177
|
export function schedulerPlugin(): RoundtablePlugin {
|
|
161
178
|
return {
|
|
162
179
|
name: "schedules",
|
|
163
|
-
setup: ({ conversations, services, env, logger, surfaces }) => {
|
|
180
|
+
setup: ({ conversations, services, env, logger, surfaces, sessions }) => {
|
|
164
181
|
const schedules = services.get(SCHEDULES);
|
|
165
182
|
const prechecks = services.find(PRECHECKS);
|
|
166
183
|
const scheduler = new Scheduler({
|
|
167
184
|
store: schedules,
|
|
168
185
|
runner: services.get(BACKGROUND_TURNS),
|
|
169
186
|
...(prechecks ? { prechecks } : {}),
|
|
187
|
+
holds: () => sessions().holds,
|
|
170
188
|
// The bot's own message: the surface drops it, so it starts no turn.
|
|
171
189
|
notify: (schedule, note) =>
|
|
172
190
|
surfaces.sendReply(schedule.channel, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { InboundMessage } from "../contract/channels.ts";
|
|
2
2
|
import type { ChannelKey } from "../sessions.ts";
|
|
3
|
+
import type { Tier } from "../speakers.ts";
|
|
3
4
|
|
|
4
5
|
export { QUEUED_MARK, STEERED_MARK } from "../contract/channels.ts";
|
|
5
6
|
export type { ChannelKey, InboundMessage };
|
|
@@ -16,6 +17,8 @@ export interface HeldCall {
|
|
|
16
17
|
input: string;
|
|
17
18
|
/** What the call would do, in plain words. */
|
|
18
19
|
action: string;
|
|
20
|
+
/** The lowest tier that may approve it, when higher than its tool's own; see `HoldRule.approvalTier`. */
|
|
21
|
+
minTier?: Tier;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export interface PendingConfirmation {
|
package/src/core/holds.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PluginError } from "./errors.ts";
|
|
2
|
+
import { type Tier, tierAtLeast } from "./speakers.ts";
|
|
2
3
|
|
|
3
4
|
/** What a hold rule knows about the session making the call. */
|
|
4
5
|
export interface HoldContext {
|
|
@@ -17,14 +18,49 @@ export interface HoldRule {
|
|
|
17
18
|
input: Record<string, unknown>,
|
|
18
19
|
context: HoldContext,
|
|
19
20
|
): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Whether this rule may hold some call of `tool`, for a rule whose verdict depends on the input
|
|
23
|
+
* (such as an action argument). Asked when the input is not known yet, as for a precheck
|
|
24
|
+
* script's call whose arguments are computed when it runs; a rule without it is judged by
|
|
25
|
+
* `describe` with an empty input.
|
|
26
|
+
*/
|
|
27
|
+
mayHold?(tool: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The lowest tier that may approve a call this rule holds, when it is higher than the tool's own:
|
|
30
|
+
* for a call that stands for others, such as saving a script whose runs make held calls.
|
|
31
|
+
*/
|
|
32
|
+
approvalTier?(
|
|
33
|
+
tool: string,
|
|
34
|
+
input: Record<string, unknown>,
|
|
35
|
+
context: HoldContext,
|
|
36
|
+
): Tier | undefined;
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
/** The description of a call that must be approved first, or undefined to let it run. */
|
|
23
|
-
export type HoldCheck = (
|
|
40
|
+
export type HoldCheck = ((
|
|
24
41
|
tool: string,
|
|
25
42
|
input: Record<string, unknown>,
|
|
26
43
|
context: HoldContext,
|
|
27
|
-
) => string | undefined
|
|
44
|
+
) => string | undefined) & {
|
|
45
|
+
/** The name of a rule that may hold some call of `tool` whatever its input; see `HoldRule.mayHold`. */
|
|
46
|
+
mayHold?(tool: string): string | undefined;
|
|
47
|
+
/** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
|
|
48
|
+
approvalTier?(
|
|
49
|
+
tool: string,
|
|
50
|
+
input: Record<string, unknown>,
|
|
51
|
+
context: HoldContext,
|
|
52
|
+
): Tier | undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The higher of two tiers; undefined only when both are. */
|
|
56
|
+
export function higherTier(
|
|
57
|
+
a: Tier | undefined,
|
|
58
|
+
b: Tier | undefined,
|
|
59
|
+
): Tier | undefined {
|
|
60
|
+
if (!a) return b;
|
|
61
|
+
if (!b) return a;
|
|
62
|
+
return tierAtLeast(a, b) ? a : b;
|
|
63
|
+
}
|
|
28
64
|
|
|
29
65
|
/** Asks the rules in order; the first description holds the call. */
|
|
30
66
|
export function holdChain(rules: readonly HoldRule[]): HoldCheck {
|
|
@@ -36,11 +72,19 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
|
|
|
36
72
|
);
|
|
37
73
|
names.add(rule.name);
|
|
38
74
|
}
|
|
39
|
-
|
|
75
|
+
const check: HoldCheck = (tool, input, context) => {
|
|
40
76
|
for (const rule of rules) {
|
|
41
77
|
const description = rule.describe(tool, input, context);
|
|
42
78
|
if (description !== undefined) return description;
|
|
43
79
|
}
|
|
44
80
|
return undefined;
|
|
45
81
|
};
|
|
82
|
+
check.mayHold = (tool) => rules.find((rule) => rule.mayHold?.(tool))?.name;
|
|
83
|
+
check.approvalTier = (tool, input, context) =>
|
|
84
|
+
rules.reduce<Tier | undefined>(
|
|
85
|
+
(tier, rule) =>
|
|
86
|
+
higherTier(tier, rule.approvalTier?.(tool, input, context)),
|
|
87
|
+
undefined,
|
|
88
|
+
);
|
|
89
|
+
return check;
|
|
46
90
|
}
|
|
@@ -53,6 +53,13 @@ export function schedulesEn(ctx: CatalogContext) {
|
|
|
53
53
|
`Checked first by its own precheck script (${chars} characters)`,
|
|
54
54
|
schedulePrecheckNote: (id: number, title: string, note: string) =>
|
|
55
55
|
`-# Schedule #${id} ${title}, skipped by its precheck: ${note.replace(/\n+/g, "\n-# ")}`,
|
|
56
|
+
precheckScriptHeld: (
|
|
57
|
+
schedule: { title?: string; id?: number },
|
|
58
|
+
held: readonly string[],
|
|
59
|
+
) =>
|
|
60
|
+
`save schedule ${schedule.title ? `"${schedule.title}" ` : schedule.id === undefined ? "" : `#${schedule.id} `}with a precheck script that, each time it runs, may: ${held.join("; ")}`,
|
|
61
|
+
precheckToolMayHold: (tool: string, rule: string) =>
|
|
62
|
+
`call ${tool}, which the ${rule} rule may hold depending on its input`,
|
|
56
63
|
scheduleChoice: (id: number, title: string, recurrence: string) =>
|
|
57
64
|
`#${id} ${title} (${recurrence})`,
|
|
58
65
|
scheduleTitle: "Schedules",
|
|
@@ -97,6 +104,13 @@ export function schedulesZhTW(
|
|
|
97
104
|
`先由自訂的預檢腳本判斷(${chars} 字元)`,
|
|
98
105
|
schedulePrecheckNote: (id: number, title: string, note: string) =>
|
|
99
106
|
`-# 排程 #${id} ${title} 經預檢略過:${note.replace(/\n+/g, "\n-# ")}`,
|
|
107
|
+
precheckScriptHeld: (
|
|
108
|
+
schedule: { title?: string; id?: number },
|
|
109
|
+
held: readonly string[],
|
|
110
|
+
) =>
|
|
111
|
+
`儲存排程${schedule.title ? `「${schedule.title}」` : schedule.id === undefined ? "" : ` #${schedule.id} `},它的預檢腳本每次執行都可能:${held.join(";")}`,
|
|
112
|
+
precheckToolMayHold: (tool: string, rule: string) =>
|
|
113
|
+
`呼叫 ${tool}(規則 ${rule} 會依輸入決定是否需要確認)`,
|
|
100
114
|
scheduleChoice: (id: number, title: string, recurrence: string) =>
|
|
101
115
|
`#${id} ${title}(${recurrence})`,
|
|
102
116
|
scheduleTitle: "排程",
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
import type { Node, Program } from "@babel/types";
|
|
3
|
+
import { ScheduleError } from "../../domain/errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
type HoldCheck,
|
|
6
|
+
type HoldContext,
|
|
7
|
+
type HoldRule,
|
|
8
|
+
higherTier,
|
|
9
|
+
} from "../../holds.ts";
|
|
10
|
+
import { messages } from "../../i18n/index.ts";
|
|
11
|
+
import type { Tier } from "../../speakers.ts";
|
|
12
|
+
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
13
|
+
import type { PrecheckRegistry } from "./prechecks.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An MCP tool a schedule's precheck script may call, as approved when the script was saved. The
|
|
17
|
+
* runner lets the script call nothing else.
|
|
18
|
+
*/
|
|
19
|
+
export interface PrecheckTool {
|
|
20
|
+
server: string;
|
|
21
|
+
tool: string;
|
|
22
|
+
/** What the host's hold rules said a call of it would do; set, the owner approved it on saving the script. */
|
|
23
|
+
held?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One `mcp.call` or `mcp.json` in a script, with its arguments when they are written out literally. */
|
|
27
|
+
export interface PrecheckScriptCall {
|
|
28
|
+
server: string;
|
|
29
|
+
tool: string;
|
|
30
|
+
/** Undefined when the script computes them, so they are known only when it runs. */
|
|
31
|
+
args?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NOT_LITERAL = Symbol("not literal");
|
|
35
|
+
|
|
36
|
+
/** A value written out in the script itself. */
|
|
37
|
+
type Literal =
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| null
|
|
42
|
+
| Literal[]
|
|
43
|
+
| { [key: string]: Literal };
|
|
44
|
+
|
|
45
|
+
/** The value of an expression written out as JSON-like literals, or NOT_LITERAL. */
|
|
46
|
+
function literal(node: Node | null | undefined): Literal | typeof NOT_LITERAL {
|
|
47
|
+
if (!node) return NOT_LITERAL;
|
|
48
|
+
switch (node.type) {
|
|
49
|
+
case "StringLiteral":
|
|
50
|
+
case "NumericLiteral":
|
|
51
|
+
case "BooleanLiteral":
|
|
52
|
+
return node.value;
|
|
53
|
+
case "NullLiteral":
|
|
54
|
+
return null;
|
|
55
|
+
case "TemplateLiteral":
|
|
56
|
+
return node.expressions.length === 0
|
|
57
|
+
? node.quasis.map((q) => q.value.cooked ?? q.value.raw).join("")
|
|
58
|
+
: NOT_LITERAL;
|
|
59
|
+
case "UnaryExpression":
|
|
60
|
+
return node.operator === "-" && node.argument.type === "NumericLiteral"
|
|
61
|
+
? -node.argument.value
|
|
62
|
+
: NOT_LITERAL;
|
|
63
|
+
case "ArrayExpression": {
|
|
64
|
+
const items: Literal[] = [];
|
|
65
|
+
for (const item of node.elements) {
|
|
66
|
+
const value =
|
|
67
|
+
item && item.type !== "SpreadElement" ? literal(item) : NOT_LITERAL;
|
|
68
|
+
if (value === NOT_LITERAL) return NOT_LITERAL;
|
|
69
|
+
items.push(value);
|
|
70
|
+
}
|
|
71
|
+
return items;
|
|
72
|
+
}
|
|
73
|
+
case "ObjectExpression": {
|
|
74
|
+
const entries: [string, Literal][] = [];
|
|
75
|
+
for (const property of node.properties) {
|
|
76
|
+
if (property.type !== "ObjectProperty" || property.computed)
|
|
77
|
+
return NOT_LITERAL;
|
|
78
|
+
const key =
|
|
79
|
+
property.key.type === "Identifier"
|
|
80
|
+
? property.key.name
|
|
81
|
+
: property.key.type === "StringLiteral"
|
|
82
|
+
? property.key.value
|
|
83
|
+
: undefined;
|
|
84
|
+
const value = literal(property.value);
|
|
85
|
+
if (key === undefined || value === NOT_LITERAL) return NOT_LITERAL;
|
|
86
|
+
entries.push([key, value]);
|
|
87
|
+
}
|
|
88
|
+
return Object.fromEntries(entries);
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return NOT_LITERAL;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const REACH =
|
|
96
|
+
"a precheck script may use mcp only as mcp.call(server, tool, args) or mcp.json(server, tool, args), with server and tool written as strings, after taking it from the context as ({ mcp }) or const { mcp } = context";
|
|
97
|
+
|
|
98
|
+
function isNode(value: unknown): value is Node {
|
|
99
|
+
return (
|
|
100
|
+
typeof value === "object" &&
|
|
101
|
+
value !== null &&
|
|
102
|
+
typeof (value as { type?: unknown }).type === "string"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every node under `node`, with the key it sits under in its parent. */
|
|
107
|
+
function* walk(
|
|
108
|
+
node: Node,
|
|
109
|
+
parent?: Node,
|
|
110
|
+
key?: string,
|
|
111
|
+
): Generator<[Node, Node | undefined, string | undefined]> {
|
|
112
|
+
yield [node, parent, key];
|
|
113
|
+
for (const [childKey, value] of Object.entries(node)) {
|
|
114
|
+
if (
|
|
115
|
+
childKey === "loc" ||
|
|
116
|
+
childKey === "leadingComments" ||
|
|
117
|
+
childKey === "trailingComments" ||
|
|
118
|
+
childKey === "innerComments"
|
|
119
|
+
)
|
|
120
|
+
continue;
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
for (const item of value)
|
|
123
|
+
if (isNode(item)) yield* walk(item, node, childKey);
|
|
124
|
+
} else if (isNode(value)) yield* walk(value, node, childKey);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The longest precheck script a schedule may carry, in characters. */
|
|
129
|
+
export const PRECHECK_SCRIPT_CHARS = 8_000;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reads a precheck script without running it: a JavaScript module within the size limit whose
|
|
133
|
+
* default export is what the runner calls, and the MCP calls it makes. Server and tool must be
|
|
134
|
+
* written as strings, and `mcp` may be used only for those calls, so the calls are known before it
|
|
135
|
+
* runs; anything else throws ScheduleError with what to fix. The runner still lets the script call
|
|
136
|
+
* only the tools approved for it.
|
|
137
|
+
*/
|
|
138
|
+
export function readPrecheckScript(script: unknown): {
|
|
139
|
+
script: string;
|
|
140
|
+
calls: PrecheckScriptCall[];
|
|
141
|
+
} {
|
|
142
|
+
if (typeof script !== "string" || !script.trim())
|
|
143
|
+
throw new ScheduleError(
|
|
144
|
+
"precheck_script must be a JavaScript module with a default export",
|
|
145
|
+
);
|
|
146
|
+
if (script.length > PRECHECK_SCRIPT_CHARS)
|
|
147
|
+
throw new ScheduleError(
|
|
148
|
+
`precheck_script is ${script.length} characters; keep it within ${PRECHECK_SCRIPT_CHARS}`,
|
|
149
|
+
);
|
|
150
|
+
let program: Program;
|
|
151
|
+
try {
|
|
152
|
+
program = parse(script, {
|
|
153
|
+
sourceType: "module",
|
|
154
|
+
errorRecovery: false,
|
|
155
|
+
}).program;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new ScheduleError(
|
|
158
|
+
`precheck_script does not parse as a JavaScript module: ${error instanceof Error ? error.message || error.name : String(error)}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const exportsDefault = program.body.some(
|
|
162
|
+
(node) =>
|
|
163
|
+
node.type === "ExportDefaultDeclaration" ||
|
|
164
|
+
(node.type === "ExportNamedDeclaration" &&
|
|
165
|
+
node.specifiers.some((specifier) =>
|
|
166
|
+
specifier.exported.type === "Identifier"
|
|
167
|
+
? specifier.exported.name === "default"
|
|
168
|
+
: specifier.exported.value === "default",
|
|
169
|
+
)),
|
|
170
|
+
);
|
|
171
|
+
if (!exportsDefault)
|
|
172
|
+
throw new ScheduleError(
|
|
173
|
+
"precheck_script needs a default export: export default async (context) => ({ wake: false, note }) or ({ wake: true, context })",
|
|
174
|
+
);
|
|
175
|
+
return { script, calls: mcpCalls(program) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Checks a precheck script without running it, as `readPrecheckScript` does; returns it. */
|
|
179
|
+
export function checkPrecheckScript(script: unknown): string {
|
|
180
|
+
return readPrecheckScript(script).script;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mcpCalls(program: Program): PrecheckScriptCall[] {
|
|
184
|
+
const calls: PrecheckScriptCall[] = [];
|
|
185
|
+
const allowed = new Set<Node>();
|
|
186
|
+
for (const [node] of walk(program)) {
|
|
187
|
+
if (
|
|
188
|
+
node.type !== "CallExpression" ||
|
|
189
|
+
node.callee.type !== "MemberExpression" ||
|
|
190
|
+
node.callee.computed ||
|
|
191
|
+
node.callee.object.type !== "Identifier" ||
|
|
192
|
+
node.callee.object.name !== "mcp" ||
|
|
193
|
+
node.callee.property.type !== "Identifier" ||
|
|
194
|
+
!["call", "json"].includes(node.callee.property.name)
|
|
195
|
+
)
|
|
196
|
+
continue;
|
|
197
|
+
const [serverArg, toolArg, argsArg, ...rest] = node.arguments;
|
|
198
|
+
const server = literal(serverArg);
|
|
199
|
+
const tool = literal(toolArg);
|
|
200
|
+
if (typeof server !== "string" || typeof tool !== "string")
|
|
201
|
+
throw new ScheduleError(
|
|
202
|
+
`precheck_script computes a server or tool name for mcp.${node.callee.property.name}; ${REACH}`,
|
|
203
|
+
);
|
|
204
|
+
if (rest.length > 0)
|
|
205
|
+
throw new ScheduleError(
|
|
206
|
+
`precheck_script passes mcp.${node.callee.property.name} more than three arguments; ${REACH}`,
|
|
207
|
+
);
|
|
208
|
+
const args = argsArg === undefined ? {} : literal(argsArg);
|
|
209
|
+
calls.push({
|
|
210
|
+
server,
|
|
211
|
+
tool,
|
|
212
|
+
...(args !== NOT_LITERAL &&
|
|
213
|
+
typeof args === "object" &&
|
|
214
|
+
args !== null &&
|
|
215
|
+
!Array.isArray(args)
|
|
216
|
+
? { args }
|
|
217
|
+
: {}),
|
|
218
|
+
});
|
|
219
|
+
allowed.add(node.callee.object);
|
|
220
|
+
}
|
|
221
|
+
for (const [node, parent, key] of walk(program)) {
|
|
222
|
+
if (node.type === "Identifier") {
|
|
223
|
+
if (node.name === "arguments" || node.name === "eval")
|
|
224
|
+
throw new ScheduleError(
|
|
225
|
+
`precheck_script may not use ${node.name}; ${REACH}`,
|
|
226
|
+
);
|
|
227
|
+
if (node.name !== "mcp" || allowed.has(node)) continue;
|
|
228
|
+
// `{ mcp }` taken from the context, under its own name.
|
|
229
|
+
if (
|
|
230
|
+
parent?.type === "ObjectProperty" &&
|
|
231
|
+
key === "value" &&
|
|
232
|
+
parent.key.type === "Identifier" &&
|
|
233
|
+
parent.key.name === "mcp" &&
|
|
234
|
+
!parent.computed
|
|
235
|
+
)
|
|
236
|
+
continue;
|
|
237
|
+
// A property key named mcp, such as the `mcp` of `{ mcp }`, is no use of it.
|
|
238
|
+
if (
|
|
239
|
+
parent?.type === "ObjectProperty" &&
|
|
240
|
+
key === "key" &&
|
|
241
|
+
!parent.computed
|
|
242
|
+
)
|
|
243
|
+
continue;
|
|
244
|
+
if (parent?.type === "MemberExpression" && key === "property") {
|
|
245
|
+
if (!parent.computed)
|
|
246
|
+
throw new ScheduleError(
|
|
247
|
+
`precheck_script reads .mcp from an object; ${REACH}`,
|
|
248
|
+
);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
throw new ScheduleError(
|
|
252
|
+
`precheck_script uses mcp other than in a call; ${REACH}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
node.type === "ObjectProperty" &&
|
|
257
|
+
!node.computed &&
|
|
258
|
+
node.key.type === "Identifier" &&
|
|
259
|
+
node.key.name === "mcp" &&
|
|
260
|
+
parent?.type === "ObjectPattern" &&
|
|
261
|
+
!(node.value.type === "Identifier" && node.value.name === "mcp")
|
|
262
|
+
)
|
|
263
|
+
throw new ScheduleError(`precheck_script renames mcp; ${REACH}`);
|
|
264
|
+
if (
|
|
265
|
+
node.type === "MemberExpression" &&
|
|
266
|
+
node.computed &&
|
|
267
|
+
node.property.type === "StringLiteral" &&
|
|
268
|
+
node.property.value === "mcp"
|
|
269
|
+
)
|
|
270
|
+
throw new ScheduleError(`precheck_script reads ["mcp"]; ${REACH}`);
|
|
271
|
+
if (
|
|
272
|
+
node.type === "MemberExpression" &&
|
|
273
|
+
node.computed &&
|
|
274
|
+
node.object.type === "Identifier" &&
|
|
275
|
+
node.object.name === "mcp"
|
|
276
|
+
)
|
|
277
|
+
throw new ScheduleError(`precheck_script uses mcp[...]; ${REACH}`);
|
|
278
|
+
}
|
|
279
|
+
return calls;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The tools a script calls, each with what the host's hold rules say a call of it would do. A call
|
|
284
|
+
* whose arguments are computed is judged by an empty input, or by a rule that says it may hold it.
|
|
285
|
+
*/
|
|
286
|
+
export function precheckScriptTools(
|
|
287
|
+
script: unknown,
|
|
288
|
+
options: {
|
|
289
|
+
toolName: (server: string, tool: string) => string;
|
|
290
|
+
holds: HoldCheck;
|
|
291
|
+
context?: HoldContext;
|
|
292
|
+
},
|
|
293
|
+
): PrecheckTool[] {
|
|
294
|
+
const tools = new Map<string, PrecheckTool>();
|
|
295
|
+
for (const call of readPrecheckScript(script).calls) {
|
|
296
|
+
const name = options.toolName(call.server, call.tool);
|
|
297
|
+
const context = options.context ?? {};
|
|
298
|
+
let held: string | undefined;
|
|
299
|
+
if (call.args) held = options.holds(name, call.args, context);
|
|
300
|
+
else {
|
|
301
|
+
held = options.holds(name, {}, context);
|
|
302
|
+
const rule =
|
|
303
|
+
held === undefined ? options.holds.mayHold?.(name) : undefined;
|
|
304
|
+
if (rule !== undefined) held = messages().precheckToolMayHold(name, rule);
|
|
305
|
+
}
|
|
306
|
+
const key = `${call.server}\u0000${call.tool}`;
|
|
307
|
+
const known = tools.get(key);
|
|
308
|
+
if (!known)
|
|
309
|
+
tools.set(key, {
|
|
310
|
+
server: call.server,
|
|
311
|
+
tool: call.tool,
|
|
312
|
+
...(held ? { held } : {}),
|
|
313
|
+
});
|
|
314
|
+
else if (held && !known.held) known.held = held;
|
|
315
|
+
}
|
|
316
|
+
return [...tools.values()];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const SCHEDULE_TOOLS = new Set(["schedule_create", "schedule_update"]);
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Holds saving a precheck script that calls a held tool, so it is approved once, as each of its
|
|
323
|
+
* calls would be, by someone who could approve those calls; the scheduled runs then do not ask
|
|
324
|
+
* again. Contributed by the schedule tools' plugin; `holds` is the host's whole chain and `tiers`
|
|
325
|
+
* the host's tool tiers, read when a call is judged.
|
|
326
|
+
*/
|
|
327
|
+
export function precheckScriptHoldRule(options: {
|
|
328
|
+
prechecks: () => Partial<Pick<PrecheckRegistry, "scriptRunner">> | undefined;
|
|
329
|
+
holds: () => HoldCheck;
|
|
330
|
+
tiers?: () => ToolTiers;
|
|
331
|
+
}): HoldRule {
|
|
332
|
+
let judging = false;
|
|
333
|
+
/** The held tools a script being saved calls, by the names the hold rules know them by. */
|
|
334
|
+
const heldCalls = (
|
|
335
|
+
tool: string,
|
|
336
|
+
input: Record<string, unknown>,
|
|
337
|
+
context: HoldContext,
|
|
338
|
+
): { name: string; held: string }[] => {
|
|
339
|
+
const script = input.precheck_script;
|
|
340
|
+
if (!SCHEDULE_TOOLS.has(tool) || typeof script !== "string" || judging)
|
|
341
|
+
return [];
|
|
342
|
+
const runner = options.prechecks()?.scriptRunner?.();
|
|
343
|
+
if (!runner) return [];
|
|
344
|
+
judging = true;
|
|
345
|
+
try {
|
|
346
|
+
return precheckScriptTools(script, {
|
|
347
|
+
toolName: (server, name) => runner.toolName(server, name),
|
|
348
|
+
holds: options.holds(),
|
|
349
|
+
context,
|
|
350
|
+
}).flatMap((t) =>
|
|
351
|
+
t.held
|
|
352
|
+
? [{ name: runner.toolName(t.server, t.tool), held: t.held }]
|
|
353
|
+
: [],
|
|
354
|
+
);
|
|
355
|
+
} catch {
|
|
356
|
+
// The tool refuses the script itself, with the reason.
|
|
357
|
+
return [];
|
|
358
|
+
} finally {
|
|
359
|
+
judging = false;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
name: "precheck-scripts",
|
|
364
|
+
describe(tool, input, context) {
|
|
365
|
+
const held = heldCalls(tool, input, context);
|
|
366
|
+
if (held.length === 0) return undefined;
|
|
367
|
+
return messages().precheckScriptHeld(
|
|
368
|
+
{
|
|
369
|
+
...(typeof input.title === "string" && input.title
|
|
370
|
+
? { title: input.title }
|
|
371
|
+
: {}),
|
|
372
|
+
...(typeof input.id === "number" ? { id: input.id } : {}),
|
|
373
|
+
},
|
|
374
|
+
held.map((call) => call.held),
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
// Whoever approves must be able to approve each held call the script makes.
|
|
378
|
+
approvalTier(tool, input, context) {
|
|
379
|
+
const tiers = options.tiers?.();
|
|
380
|
+
return heldCalls(tool, input, context).reduce<Tier | undefined>(
|
|
381
|
+
(tier, call) =>
|
|
382
|
+
higherTier(tier, tiers ? tiers.minTier(call.name) : "owner"),
|
|
383
|
+
undefined,
|
|
384
|
+
);
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { parse } from "@babel/parser";
|
|
2
1
|
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
3
|
-
import { ScheduleError } from "../../domain/errors.ts";
|
|
4
2
|
import { PluginError } from "../../errors.ts";
|
|
5
3
|
import type { Tier } from "../../speakers.ts";
|
|
6
4
|
import type { Schedule } from "./schedule-store.ts";
|
|
@@ -8,8 +6,10 @@ import type { Schedule } from "./schedule-store.ts";
|
|
|
8
6
|
/** How long a precheck may run before it counts as failed, unless it sets its own `timeoutMs`. */
|
|
9
7
|
export const PRECHECK_TIMEOUT_MS = 60_000;
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
export {
|
|
10
|
+
checkPrecheckScript,
|
|
11
|
+
PRECHECK_SCRIPT_CHARS,
|
|
12
|
+
} from "./precheck-tools.ts";
|
|
13
13
|
|
|
14
14
|
/** What a finding names a schedule's own script by, in its heading and status. */
|
|
15
15
|
export const SCRIPT_PRECHECK = "script";
|
|
@@ -69,6 +69,8 @@ export interface PrecheckScope {
|
|
|
69
69
|
|
|
70
70
|
/** What a schedule's precheck script runs with when its schedule falls due. */
|
|
71
71
|
export interface PrecheckScriptContext extends PrecheckContext {
|
|
72
|
+
/** The MCP tools the script may call, as approved when it was saved; the runner must refuse every other call. */
|
|
73
|
+
tools: readonly { server: string; tool: string }[];
|
|
72
74
|
/** The host's IANA time zone, such as `Asia/Taipei`. */
|
|
73
75
|
timeZone: string;
|
|
74
76
|
/** The date in that zone when it fired, `YYYY-MM-DD`, so a script never reads a UTC date by mistake. */
|
|
@@ -88,6 +90,12 @@ export interface PrecheckScriptRunner {
|
|
|
88
90
|
): PrecheckResult | Promise<PrecheckResult>;
|
|
89
91
|
/** How long a script may run; default 60 seconds. A timeout counts as a throw. */
|
|
90
92
|
timeoutMs?: number;
|
|
93
|
+
/**
|
|
94
|
+
* The name the host's hold rules know a script's `mcp.call(server, tool)` by: the name its own
|
|
95
|
+
* agent calls that tool by. A script that calls a tool its rules hold needs the owner's approval
|
|
96
|
+
* to be saved.
|
|
97
|
+
*/
|
|
98
|
+
toolName(server: string, tool: string): string;
|
|
91
99
|
/**
|
|
92
100
|
* How to write a script for a schedule in this scope, and what it may call there, shown to the
|
|
93
101
|
* model by schedule_list.
|
|
@@ -134,10 +142,11 @@ export function memoryPrecheckRegistry(): PrecheckRegistry {
|
|
|
134
142
|
);
|
|
135
143
|
if (
|
|
136
144
|
typeof given?.run !== "function" ||
|
|
137
|
-
typeof given.describe !== "function"
|
|
145
|
+
typeof given.describe !== "function" ||
|
|
146
|
+
typeof given.toolName !== "function"
|
|
138
147
|
)
|
|
139
148
|
throw new PluginError(
|
|
140
|
-
"a precheck script runner needs run and
|
|
149
|
+
"a precheck script runner needs run, describe, and toolName functions.",
|
|
141
150
|
);
|
|
142
151
|
if (
|
|
143
152
|
given.timeoutMs !== undefined &&
|
|
@@ -180,47 +189,6 @@ export function memoryPrecheckRegistry(): PrecheckRegistry {
|
|
|
180
189
|
};
|
|
181
190
|
}
|
|
182
191
|
|
|
183
|
-
/**
|
|
184
|
-
* Checks a precheck script without running it: a JavaScript module within the size limit whose
|
|
185
|
-
* default export is what the runner calls. Throws ScheduleError with what to fix.
|
|
186
|
-
*/
|
|
187
|
-
export function checkPrecheckScript(script: unknown): string {
|
|
188
|
-
if (typeof script !== "string" || !script.trim())
|
|
189
|
-
throw new ScheduleError(
|
|
190
|
-
"precheck_script must be a JavaScript module with a default export",
|
|
191
|
-
);
|
|
192
|
-
if (script.length > PRECHECK_SCRIPT_CHARS)
|
|
193
|
-
throw new ScheduleError(
|
|
194
|
-
`precheck_script is ${script.length} characters; keep it within ${PRECHECK_SCRIPT_CHARS}`,
|
|
195
|
-
);
|
|
196
|
-
let program: ReturnType<typeof parse>["program"];
|
|
197
|
-
try {
|
|
198
|
-
program = parse(script, {
|
|
199
|
-
sourceType: "module",
|
|
200
|
-
errorRecovery: false,
|
|
201
|
-
}).program;
|
|
202
|
-
} catch (error) {
|
|
203
|
-
throw new ScheduleError(
|
|
204
|
-
`precheck_script does not parse as a JavaScript module: ${errorText(error)}`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
const exportsDefault = program.body.some(
|
|
208
|
-
(node) =>
|
|
209
|
-
node.type === "ExportDefaultDeclaration" ||
|
|
210
|
-
(node.type === "ExportNamedDeclaration" &&
|
|
211
|
-
node.specifiers.some((specifier) =>
|
|
212
|
-
specifier.exported.type === "Identifier"
|
|
213
|
-
? specifier.exported.name === "default"
|
|
214
|
-
: specifier.exported.value === "default",
|
|
215
|
-
)),
|
|
216
|
-
);
|
|
217
|
-
if (!exportsDefault)
|
|
218
|
-
throw new ScheduleError(
|
|
219
|
-
"precheck_script needs a default export: export default async (context) => ({ wake: false, note }) or ({ wake: true, context })",
|
|
220
|
-
);
|
|
221
|
-
return script;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
192
|
function errorText(error: unknown): string {
|
|
225
193
|
return error instanceof Error ? error.message || error.name : String(error);
|
|
226
194
|
}
|
|
@@ -4,6 +4,7 @@ import type { ChannelKey } from "../../domain/conversation.ts";
|
|
|
4
4
|
import { ScheduleError } from "../../domain/errors.ts";
|
|
5
5
|
import type { ScheduleStore } from "../../services.ts";
|
|
6
6
|
import type { Tier } from "../../speakers.ts";
|
|
7
|
+
import type { PrecheckTool } from "./precheck-tools.ts";
|
|
7
8
|
import type { Recurrence } from "./recurrence.ts";
|
|
8
9
|
|
|
9
10
|
export interface Schedule {
|
|
@@ -24,6 +25,11 @@ export interface Schedule {
|
|
|
24
25
|
precheck?: string;
|
|
25
26
|
/** The precheck script an agent wrote for it, run in the host's sandbox; a schedule has a name or a script, not both. */
|
|
26
27
|
precheckScript?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The MCP tools its script may call, with those the owner approved as held when it was saved;
|
|
30
|
+
* absent for a script saved before tools were recorded, which must be saved again if it calls a held tool.
|
|
31
|
+
*/
|
|
32
|
+
precheckTools?: PrecheckTool[];
|
|
27
33
|
lastRun?: Date;
|
|
28
34
|
lastStatus?: string;
|
|
29
35
|
}
|
|
@@ -42,6 +48,8 @@ export interface NewSchedule {
|
|
|
42
48
|
precheck?: string;
|
|
43
49
|
/** A precheck script, run in the host's sandbox before each turn; not with `precheck`. */
|
|
44
50
|
precheckScript?: string;
|
|
51
|
+
/** The tools the script may call, as approved; required with `precheckScript`. */
|
|
52
|
+
precheckTools?: PrecheckTool[];
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export interface ScheduleChange {
|
|
@@ -53,6 +61,8 @@ export interface ScheduleChange {
|
|
|
53
61
|
precheck?: string | null;
|
|
54
62
|
/** A precheck script to run before each turn; null removes it. */
|
|
55
63
|
precheckScript?: string | null;
|
|
64
|
+
/** The tools a new script may call, as approved; required with a new `precheckScript`. */
|
|
65
|
+
precheckTools?: PrecheckTool[];
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
interface Row {
|
|
@@ -70,6 +80,7 @@ interface Row {
|
|
|
70
80
|
created_at: Date;
|
|
71
81
|
precheck: string | null;
|
|
72
82
|
precheck_script: string | null;
|
|
83
|
+
precheck_tools: string | null;
|
|
73
84
|
last_run: Date | null;
|
|
74
85
|
last_status: string | null;
|
|
75
86
|
}
|
|
@@ -90,6 +101,10 @@ function toSchedule(row: Row): Schedule {
|
|
|
90
101
|
createdAt: row.created_at,
|
|
91
102
|
...(row.precheck ? { precheck: row.precheck } : {}),
|
|
92
103
|
...(row.precheck_script ? { precheckScript: row.precheck_script } : {}),
|
|
104
|
+
...(row.precheck_script && row.precheck_tools
|
|
105
|
+
? // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
|
|
106
|
+
{ precheckTools: JSON.parse(row.precheck_tools) as PrecheckTool[] }
|
|
107
|
+
: {}),
|
|
93
108
|
...(row.last_run ? { lastRun: row.last_run } : {}),
|
|
94
109
|
...(row.last_status ? { lastStatus: row.last_status } : {}),
|
|
95
110
|
};
|
|
@@ -145,6 +160,13 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
145
160
|
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_script text`;
|
|
146
161
|
},
|
|
147
162
|
},
|
|
163
|
+
{
|
|
164
|
+
// Scripts saved before have none, and their tools are checked when they next run.
|
|
165
|
+
name: "schedules-precheck-tools",
|
|
166
|
+
up: async (sql) => {
|
|
167
|
+
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_tools text`;
|
|
168
|
+
},
|
|
169
|
+
},
|
|
148
170
|
];
|
|
149
171
|
}
|
|
150
172
|
|
|
@@ -158,13 +180,18 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
158
180
|
throw new ScheduleError(
|
|
159
181
|
"a schedule has a precheck or a precheck script, not both",
|
|
160
182
|
);
|
|
183
|
+
if (schedule.precheckScript && !schedule.precheckTools)
|
|
184
|
+
throw new ScheduleError(
|
|
185
|
+
"a precheck script is saved with the tools it may call",
|
|
186
|
+
);
|
|
161
187
|
const rows: Row[] = await this.#sql`
|
|
162
188
|
INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
|
|
163
|
-
created_by_id, created_by_name, created_tier, precheck, precheck_script)
|
|
189
|
+
created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools)
|
|
164
190
|
VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
|
|
165
191
|
${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
|
|
166
192
|
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
|
|
167
|
-
${schedule.precheck ?? null}, ${schedule.precheckScript ?? null}
|
|
193
|
+
${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
|
|
194
|
+
${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null})
|
|
168
195
|
RETURNING *`;
|
|
169
196
|
const [row] = rows;
|
|
170
197
|
if (!row) throw new Error("the schedule insert returned no row");
|
|
@@ -212,6 +239,18 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
212
239
|
: change.precheckScript === undefined
|
|
213
240
|
? (current.precheckScript ?? null)
|
|
214
241
|
: change.precheckScript;
|
|
242
|
+
if (change.precheckScript && !change.precheckTools)
|
|
243
|
+
throw new ScheduleError(
|
|
244
|
+
"a precheck script is saved with the tools it may call",
|
|
245
|
+
);
|
|
246
|
+
// A new script brings its own tools; one kept keeps its tools; no script, no tools.
|
|
247
|
+
const precheckTools = !precheckScript
|
|
248
|
+
? null
|
|
249
|
+
: change.precheckScript
|
|
250
|
+
? JSON.stringify(change.precheckTools)
|
|
251
|
+
: current.precheckTools
|
|
252
|
+
? JSON.stringify(current.precheckTools)
|
|
253
|
+
: null;
|
|
215
254
|
const rows: Row[] = await this.#sql`
|
|
216
255
|
UPDATE schedules SET
|
|
217
256
|
title = ${change.title ?? current.title},
|
|
@@ -219,7 +258,8 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
219
258
|
recurrence = ${JSON.stringify(change.recurrence ?? current.recurrence)},
|
|
220
259
|
next_run = ${change.nextRun ?? current.nextRun},
|
|
221
260
|
precheck = ${precheck},
|
|
222
|
-
precheck_script = ${precheckScript}
|
|
261
|
+
precheck_script = ${precheckScript},
|
|
262
|
+
precheck_tools = ${precheckTools}
|
|
223
263
|
WHERE id = ${id}
|
|
224
264
|
RETURNING *`;
|
|
225
265
|
return rows[0] ? toSchedule(rows[0]) : undefined;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { BackgroundTarget } from "../../contract/channels.ts";
|
|
2
2
|
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
3
3
|
import { ScheduleError } from "../../domain/errors.ts";
|
|
4
|
+
import type { HoldCheck } from "../../holds.ts";
|
|
4
5
|
import { messages } from "../../i18n/index.ts";
|
|
5
6
|
import type { ScheduleStore } from "../../services.ts";
|
|
6
7
|
import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
|
|
7
8
|
import { type Tier, tierAtLeast } from "../../speakers.ts";
|
|
8
9
|
import { timeZone, zonedStamp } from "../../time.ts";
|
|
10
|
+
import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
|
|
9
11
|
import {
|
|
10
|
-
checkPrecheckScript,
|
|
11
12
|
PRECHECK_SCRIPT_CHARS,
|
|
12
13
|
type PrecheckFinding,
|
|
13
14
|
type PrecheckRegistry,
|
|
@@ -44,6 +45,11 @@ export interface ScheduleToolContext {
|
|
|
44
45
|
*/
|
|
45
46
|
prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
|
|
46
47
|
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
48
|
+
/**
|
|
49
|
+
* The host's hold rules, which mark the tools a saved script calls that the owner approved.
|
|
50
|
+
* The approval itself is the confirmation gate's, over this very call.
|
|
51
|
+
*/
|
|
52
|
+
holds?: () => HoldCheck;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/** The limits of the context's target; a target without them may not schedule. */
|
|
@@ -95,15 +101,41 @@ function precheckName(ctx: ScheduleToolContext, value: unknown): string {
|
|
|
95
101
|
return name;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
/**
|
|
105
|
+
* A precheck script the host's sandbox runs, with the tools it calls: those the hold rules hold
|
|
106
|
+
* were approved by the owner through the confirmation gate before this call ran. Refused when the
|
|
107
|
+
* host has no runner for scripts.
|
|
108
|
+
*/
|
|
109
|
+
function precheckScript(
|
|
110
|
+
ctx: ScheduleToolContext,
|
|
111
|
+
value: unknown,
|
|
112
|
+
): { script: string; tools: PrecheckTool[] } {
|
|
113
|
+
const runner = ctx.prechecks?.scriptRunner?.();
|
|
114
|
+
if (!runner) {
|
|
101
115
|
const names = (ctx.prechecks?.list() ?? []).map((p) => p.name);
|
|
102
116
|
throw new ScheduleError(
|
|
103
117
|
`this host runs no precheck scripts; ${names.length ? `attach a registered precheck instead: ${names.join(", ")}` : "it registers no prechecks either"}`,
|
|
104
118
|
);
|
|
105
119
|
}
|
|
106
|
-
|
|
120
|
+
const script = typeof value === "string" ? value : "";
|
|
121
|
+
const tools = precheckScriptTools(value, {
|
|
122
|
+
toolName: (server, tool) => runner.toolName(server, tool),
|
|
123
|
+
holds: ctx.holds?.() ?? (() => undefined),
|
|
124
|
+
});
|
|
125
|
+
return { script, tools };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The tools a script may call, the owner's approvals marked. */
|
|
129
|
+
function toolsText(tools: readonly PrecheckTool[] | undefined): string {
|
|
130
|
+
if (!tools)
|
|
131
|
+
return "not recorded; saved before tools were, so it must be saved again if it calls a held tool";
|
|
132
|
+
if (tools.length === 0) return "none";
|
|
133
|
+
return tools
|
|
134
|
+
.map(
|
|
135
|
+
(t) =>
|
|
136
|
+
`${t.server}/${t.tool}${t.held ? ` (approved by the owner: ${t.held})` : ""}`,
|
|
137
|
+
)
|
|
138
|
+
.join(", ");
|
|
107
139
|
}
|
|
108
140
|
|
|
109
141
|
/** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
|
|
@@ -181,7 +213,7 @@ function line(schedule: Schedule): string {
|
|
|
181
213
|
const precheck = schedule.precheck
|
|
182
214
|
? `; precheck ${schedule.precheck}`
|
|
183
215
|
: schedule.precheckScript
|
|
184
|
-
? `; precheck script (${schedule.precheckScript.length} characters)`
|
|
216
|
+
? `; precheck script (${schedule.precheckScript.length} characters; tools: ${toolsText(schedule.precheckTools)})`
|
|
185
217
|
: "";
|
|
186
218
|
const last = schedule.lastRun
|
|
187
219
|
? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
|
|
@@ -246,7 +278,9 @@ export async function callScheduleTool(
|
|
|
246
278
|
createdByName: ctx.author.name,
|
|
247
279
|
createdTier: ctx.author.tier ?? "owner",
|
|
248
280
|
...(precheck ? { precheck } : {}),
|
|
249
|
-
...(script
|
|
281
|
+
...(script
|
|
282
|
+
? { precheckScript: script.script, precheckTools: script.tools }
|
|
283
|
+
: {}),
|
|
250
284
|
});
|
|
251
285
|
const checked = precheck
|
|
252
286
|
? `; precheck ${precheck} runs first`
|
|
@@ -293,11 +327,12 @@ export async function callScheduleTool(
|
|
|
293
327
|
if (input.precheck !== undefined)
|
|
294
328
|
change.precheck =
|
|
295
329
|
input.precheck === null ? null : precheckName(ctx, input.precheck);
|
|
296
|
-
if (input.precheck_script
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
330
|
+
if (input.precheck_script === null) change.precheckScript = null;
|
|
331
|
+
else if (input.precheck_script !== undefined) {
|
|
332
|
+
const script = precheckScript(ctx, input.precheck_script);
|
|
333
|
+
change.precheckScript = script.script;
|
|
334
|
+
change.precheckTools = script.tools;
|
|
335
|
+
}
|
|
301
336
|
if (change.precheck) change.precheckScript = null;
|
|
302
337
|
if (change.precheckScript) change.precheck = null;
|
|
303
338
|
if (Object.keys(change).length === 0)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ScheduledOutcome } from "../../contract/channels.ts";
|
|
2
|
+
import type { HoldCheck } from "../../holds.ts";
|
|
2
3
|
import type { Logger } from "../../log.ts";
|
|
3
4
|
import type { ScheduleStore } from "../../services.ts";
|
|
4
5
|
import { timeZone, zonedStamp } from "../../time.ts";
|
|
6
|
+
import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
|
|
5
7
|
import {
|
|
6
8
|
type Precheck,
|
|
7
9
|
type PrecheckFinding,
|
|
@@ -36,6 +38,11 @@ export interface SchedulerOptions {
|
|
|
36
38
|
/** The host's prechecks and script runner; without them, a schedule that has one runs with that as its error. */
|
|
37
39
|
prechecks?: Pick<PrecheckRegistry, "get"> &
|
|
38
40
|
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
41
|
+
/**
|
|
42
|
+
* The host's hold rules, read when a script saved before its tools were recorded runs: it runs
|
|
43
|
+
* only if it calls no held tool. Without them, such a script does not run.
|
|
44
|
+
*/
|
|
45
|
+
holds?: () => HoldCheck;
|
|
39
46
|
/** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
|
|
40
47
|
notify?: (schedule: Schedule, note: string) => Promise<void>;
|
|
41
48
|
logger: Logger;
|
|
@@ -214,6 +221,8 @@ export class Scheduler {
|
|
|
214
221
|
const runner = prechecks?.scriptRunner?.();
|
|
215
222
|
if (!runner)
|
|
216
223
|
return "this host has no precheck script runner, so the script did not run";
|
|
224
|
+
const tools = this.#tools(schedule, script, runner.toolName.bind(runner));
|
|
225
|
+
if (typeof tools === "string") return tools;
|
|
217
226
|
const zone = timeZone();
|
|
218
227
|
return {
|
|
219
228
|
name: SCRIPT_PRECHECK,
|
|
@@ -226,10 +235,40 @@ export class Scheduler {
|
|
|
226
235
|
...context,
|
|
227
236
|
timeZone: zone,
|
|
228
237
|
today: zonedStamp(firedAt).slice(0, 10),
|
|
238
|
+
tools: tools.map(({ server, tool }) => ({ server, tool })),
|
|
229
239
|
}),
|
|
230
240
|
};
|
|
231
241
|
}
|
|
232
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The tools a script may call: those approved when it was saved, or, for a script saved before
|
|
245
|
+
* they were recorded, those it calls when none of them is held. Otherwise why it cannot run.
|
|
246
|
+
*/
|
|
247
|
+
#tools(
|
|
248
|
+
schedule: Schedule,
|
|
249
|
+
script: string,
|
|
250
|
+
toolName: (server: string, tool: string) => string,
|
|
251
|
+
): PrecheckTool[] | string {
|
|
252
|
+
if (schedule.precheckTools) return schedule.precheckTools;
|
|
253
|
+
const save =
|
|
254
|
+
"save it again with schedule_update, so the owner can approve the tools it calls";
|
|
255
|
+
const holds = this.#options.holds?.();
|
|
256
|
+
if (!holds)
|
|
257
|
+
return `this script was saved before its tools were recorded, and this host cannot check them; ${save}`;
|
|
258
|
+
let tools: PrecheckTool[];
|
|
259
|
+
try {
|
|
260
|
+
tools = precheckScriptTools(script, { toolName, holds });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return `this script was saved before its tools were recorded and cannot be checked now (${error instanceof Error ? error.message : String(error)}); fix it and ${save}`;
|
|
263
|
+
}
|
|
264
|
+
const held = tools.flatMap((t) =>
|
|
265
|
+
t.held ? [`${t.server}/${t.tool}`] : [],
|
|
266
|
+
);
|
|
267
|
+
if (held.length > 0)
|
|
268
|
+
return `this script was saved before its tools were recorded and calls tools that need the owner's approval (${held.join(", ")}), so it did not run; ${save}`;
|
|
269
|
+
return tools;
|
|
270
|
+
}
|
|
271
|
+
|
|
233
272
|
/** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
|
|
234
273
|
async #precheck(
|
|
235
274
|
schedule: Schedule,
|
|
@@ -3,6 +3,7 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { OWNER_TARGET } from "../../agents/agent-claim.ts";
|
|
4
4
|
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
5
5
|
import { ScheduleError } from "../../domain/errors.ts";
|
|
6
|
+
import type { HoldCheck } from "../../holds.ts";
|
|
6
7
|
import { activeLocale } from "../../i18n/index.ts";
|
|
7
8
|
import type { OwnerIdentity } from "../../identity.ts";
|
|
8
9
|
import {
|
|
@@ -30,6 +31,8 @@ export interface OwnerSchedules {
|
|
|
30
31
|
/** The host's prechecks a schedule may name, and the runner of scripts it may carry; without them, none can be attached. */
|
|
31
32
|
prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
|
|
32
33
|
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
34
|
+
/** The host's hold rules, which mark a saved script's approved tools. */
|
|
35
|
+
holds?: () => HoldCheck;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
|
|
@@ -62,7 +65,7 @@ export function schedulesExtension(
|
|
|
62
65
|
/** The person the running turn is for; their schedules run at their tier. */
|
|
63
66
|
speaker: () => Speaker | undefined = () => undefined,
|
|
64
67
|
): ExtensionFactory {
|
|
65
|
-
const { store, owner, channelFor, prechecks } = schedules;
|
|
68
|
+
const { store, owner, channelFor, prechecks, holds } = schedules;
|
|
66
69
|
const defs = scheduleToolSpecs({
|
|
67
70
|
locale: activeLocale(),
|
|
68
71
|
timeZone: timeZone(),
|
|
@@ -85,6 +88,7 @@ export function schedulesExtension(
|
|
|
85
88
|
author: speaker() ?? owner,
|
|
86
89
|
now: new Date(),
|
|
87
90
|
...(prechecks ? { prechecks } : {}),
|
|
91
|
+
...(holds ? { holds } : {}),
|
|
88
92
|
},
|
|
89
93
|
spec.name,
|
|
90
94
|
input,
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
PendingConfirmation,
|
|
5
5
|
} from "../../domain/conversation.ts";
|
|
6
6
|
import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
|
|
7
|
-
import type
|
|
7
|
+
import { type HoldCheck, higherTier } from "../../holds.ts";
|
|
8
8
|
import { messages } from "../../i18n/index.ts";
|
|
9
9
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
10
10
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
@@ -141,7 +141,7 @@ export class ConfirmationGate {
|
|
|
141
141
|
messages().confirmTitle(ask.asker),
|
|
142
142
|
approvalCard(call),
|
|
143
143
|
ask.signal,
|
|
144
|
-
this.#tiers?.minTier(call.tool),
|
|
144
|
+
higherTier(this.#tiers?.minTier(call.tool), call.minTier),
|
|
145
145
|
);
|
|
146
146
|
if (answer === "approved") return { approvedOnCard: true };
|
|
147
147
|
if (answer === "declined") {
|
|
@@ -156,13 +156,17 @@ export class ConfirmationGate {
|
|
|
156
156
|
|
|
157
157
|
/** The call when it needs the owner's confirmation and no approval releases it. */
|
|
158
158
|
#needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.#workspace === undefined ? {} : { workspace: this.#workspace },
|
|
163
|
-
);
|
|
159
|
+
const context =
|
|
160
|
+
this.#workspace === undefined ? {} : { workspace: this.#workspace };
|
|
161
|
+
const action = this.#holds(tool, input, context);
|
|
164
162
|
if (!action) return undefined;
|
|
165
|
-
const
|
|
163
|
+
const minTier = this.#holds.approvalTier?.(tool, input, context);
|
|
164
|
+
const call: HeldCall = {
|
|
165
|
+
tool,
|
|
166
|
+
input: canonicalJson(input),
|
|
167
|
+
action,
|
|
168
|
+
...(minTier ? { minTier } : {}),
|
|
169
|
+
};
|
|
166
170
|
const approved = this.#approved.findIndex(
|
|
167
171
|
(c) => c.tool === call.tool && c.input === call.input,
|
|
168
172
|
);
|
|
@@ -69,6 +69,8 @@ export function fakeScriptRunner(
|
|
|
69
69
|
answer: FakeScriptAnswer,
|
|
70
70
|
options: {
|
|
71
71
|
describe?: (scope: PrecheckScope) => string;
|
|
72
|
+
/** The name hold rules know a script's call by; default `${server}-${tool}`. */
|
|
73
|
+
toolName?: (server: string, tool: string) => string;
|
|
72
74
|
timeoutMs?: number;
|
|
73
75
|
} = {},
|
|
74
76
|
): FakeScriptRunner {
|
|
@@ -79,6 +81,7 @@ export function fakeScriptRunner(
|
|
|
79
81
|
? {}
|
|
80
82
|
: { timeoutMs: options.timeoutMs }),
|
|
81
83
|
describe: options.describe ?? (() => "A test script runner."),
|
|
84
|
+
toolName: options.toolName ?? ((server, tool) => `${server}-${tool}`),
|
|
82
85
|
run: async (script, context) => {
|
|
83
86
|
calls.push({ script, context });
|
|
84
87
|
if (answer instanceof Error) throw answer;
|
package/src/index.ts
CHANGED
|
@@ -126,6 +126,7 @@ 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 { PrecheckTool } from "./core/modules/schedules/precheck-tools.ts";
|
|
129
130
|
export type {
|
|
130
131
|
Precheck,
|
|
131
132
|
PrecheckContext,
|