@hank-warren/pi-loop 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +25 -52
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +155 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +54 -13
- package/src/decide.ts +13 -16
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +204 -10
- package/src/loop.ts +185 -55
- package/src/manager.ts +80 -11
- package/src/messages.ts +12 -5
- package/src/objective.ts +13 -2
- package/src/planning.ts +98 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +119 -0
- package/src/settings.ts +48 -17
- package/src/start-tool.ts +56 -2
- package/src/state.ts +40 -20
- package/src/widget.ts +103 -11
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop_progress`: the only supported write path into the loop ledger.
|
|
3
|
+
*
|
|
4
|
+
* The ledger is the one thing that survives compaction, and until this tool
|
|
5
|
+
* existed the model was told to maintain it with no way to do so — so it
|
|
6
|
+
* reached for `write` or a shell heredoc, and a single `cat > PROGRESS.md`
|
|
7
|
+
* replaced the objective line, the other three sections, and days of
|
|
8
|
+
* failed-approach notes. `createLedger` opens that file with `flag: "wx"`
|
|
9
|
+
* precisely so the *engine* can never do that; leaving the *agent* a path that
|
|
10
|
+
* can made the protection decorative.
|
|
11
|
+
*
|
|
12
|
+
* Two operations, deliberately in one tool: record a note in a named section,
|
|
13
|
+
* and flip a criterion with the citation that justified it. They travel
|
|
14
|
+
* together — "here is what I did, and here is the criterion it proves" is one
|
|
15
|
+
* thought, and one tool call per turn keeps the ledger current without a
|
|
16
|
+
* second round trip.
|
|
17
|
+
*
|
|
18
|
+
* Registered unconditionally like `loop_complete` and `loop_wait`: tools are
|
|
19
|
+
* part of the cached request prefix, so adding one mid-session would
|
|
20
|
+
* invalidate the whole conversation cache. It refuses when no loop is active.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
25
|
+
import { Type } from "typebox";
|
|
26
|
+
import {
|
|
27
|
+
MAX_EVIDENCE_LENGTH,
|
|
28
|
+
MAX_PROGRESS_TEXT_LENGTH,
|
|
29
|
+
markCriterion,
|
|
30
|
+
PROGRESS_SECTIONS,
|
|
31
|
+
type ProgressSection,
|
|
32
|
+
writeProgressSection,
|
|
33
|
+
} from "./ledger.js";
|
|
34
|
+
import type { LoopController } from "./loop.js";
|
|
35
|
+
|
|
36
|
+
export const LOOP_PROGRESS_TOOL = "loop_progress";
|
|
37
|
+
|
|
38
|
+
export function registerLoopProgressTool(pi: ExtensionAPI, controller: LoopController) {
|
|
39
|
+
pi.registerTool(
|
|
40
|
+
defineTool({
|
|
41
|
+
name: LOOP_PROGRESS_TOOL,
|
|
42
|
+
label: "Loop Progress",
|
|
43
|
+
description:
|
|
44
|
+
"Record progress in the active /loop's durable ledger: append a note to one PROGRESS.md section, and/or mark a completion criterion met with the evidence that proves it. The only supported way to write to the ledger — never edit PROGRESS.md or criteria.json with file or shell tools.",
|
|
45
|
+
promptSnippet: "Record loop progress and mark criteria met with evidence",
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
"Use loop_progress to update the loop ledger. Never write PROGRESS.md or criteria.json with the file or shell tools: a whole-file write destroys the objective line and the other sections, and hand-editing criteria.json bypasses the rule that only `passes` may change.",
|
|
48
|
+
"Record a note the same turn you learn something, not at the end. The failed-approaches section carries the most value, because it is the only thing that stops the next continuation from re-running an experiment that already failed.",
|
|
49
|
+
"'current status' replaces what is there (it is one current value); the other three sections append.",
|
|
50
|
+
"Mark a criterion met only with authoritative evidence: the command and what it printed, the file and what it now contains, the URL and its state. The citation is stored next to the criterion and is what loop_complete answers for later.",
|
|
51
|
+
],
|
|
52
|
+
parameters: Type.Object({
|
|
53
|
+
section: Type.Optional(
|
|
54
|
+
StringEnum([...PROGRESS_SECTIONS], {
|
|
55
|
+
description:
|
|
56
|
+
"Which PROGRESS.md section to write. 'current status' replaces its contents; the others append.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
note: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
maxLength: MAX_PROGRESS_TEXT_LENGTH,
|
|
62
|
+
description:
|
|
63
|
+
"Markdown to record in that section. Write list sections as '- ' bullets to match the file.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
criterion: Type.Optional(
|
|
67
|
+
Type.String({
|
|
68
|
+
maxLength: 40,
|
|
69
|
+
description: "Criterion id to mark, e.g. 'c2'. Ids come from the loop's criteria.json.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
evidence: Type.Optional(
|
|
73
|
+
Type.String({
|
|
74
|
+
maxLength: MAX_EVIDENCE_LENGTH,
|
|
75
|
+
description:
|
|
76
|
+
"The citation proving that criterion: the command and its output, the file and its contents, the URL and its state. Required when marking one met.",
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
met: Type.Optional(
|
|
80
|
+
Type.Boolean({
|
|
81
|
+
description:
|
|
82
|
+
"Whether the criterion is met. Defaults to true; pass false to retract a criterion marked met in error.",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
}),
|
|
86
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
87
|
+
const loop = controller.state;
|
|
88
|
+
if (!loop || loop.objective === undefined) {
|
|
89
|
+
return failure(
|
|
90
|
+
"No /loop with an objective is active, so there is no ledger to write. Start one with /loop <interval> <objective>.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const paths = controller.ledger;
|
|
94
|
+
if (!paths) {
|
|
95
|
+
return failure(
|
|
96
|
+
"This loop has no ledger (it could not be created), so progress cannot be recorded. Keep the state in your reply instead.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const section = params.section as ProgressSection | undefined;
|
|
101
|
+
const note = params.note?.trim();
|
|
102
|
+
const criterion = params.criterion?.trim();
|
|
103
|
+
// A tool call that writes nothing is a mistake worth naming: the
|
|
104
|
+
// model believed it recorded something and it did not.
|
|
105
|
+
if (!note && !criterion) {
|
|
106
|
+
return failure(
|
|
107
|
+
"Nothing to record. Pass section + note to write a ledger entry, criterion + evidence to mark a criterion, or both.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (note && !section) return failure("A note needs a section to write it to.");
|
|
111
|
+
if (section && !note) return failure("A section needs a note to write into it.");
|
|
112
|
+
|
|
113
|
+
const done: string[] = [];
|
|
114
|
+
if (section && note) {
|
|
115
|
+
const failed = writeProgressSection(paths, section, note);
|
|
116
|
+
if (failed) return failure(`Could not write the ledger: ${failed}`);
|
|
117
|
+
done.push(`Recorded under "${section}".`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let remaining: string | undefined;
|
|
121
|
+
if (criterion) {
|
|
122
|
+
const met = params.met ?? true;
|
|
123
|
+
const result = markCriterion(paths, criterion, params.evidence ?? "", met, Date.now());
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
// A half-applied call still reports the half that landed, so the
|
|
126
|
+
// model does not record the note twice on the retry.
|
|
127
|
+
return failure(
|
|
128
|
+
[...done, `Could not mark ${criterion}: ${result.message}`].join(" "),
|
|
129
|
+
done.length > 0,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
done.push(result.message);
|
|
133
|
+
const unmet = (result.criteria ?? []).filter((entry) => !entry.passes);
|
|
134
|
+
remaining =
|
|
135
|
+
unmet.length > 0
|
|
136
|
+
? `Still unmet: ${unmet.map((entry) => entry.id).join(", ")}.`
|
|
137
|
+
: "Every criterion is now marked met; audit them against authoritative current state before calling loop_complete.";
|
|
138
|
+
controller.updateWidget();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{ type: "text" as const, text: [...done, remaining].filter(Boolean).join(" ") },
|
|
144
|
+
],
|
|
145
|
+
details: {
|
|
146
|
+
loopId: loop.id,
|
|
147
|
+
...(section && note ? { section } : {}),
|
|
148
|
+
...(criterion ? { criterion, met: params.met ?? true } : {}),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function failure(text: string, partial = false) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text" as const, text }],
|
|
159
|
+
details: { partial },
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop_propose`: put a drafted loop up for the user's approval.
|
|
3
|
+
*
|
|
4
|
+
* It starts nothing. That separation is the point — the model drafts, the user
|
|
5
|
+
* approves, and the approval is what arms the start. `loop_start`'s gate
|
|
6
|
+
* exists because a loop is self-continuing and must never begin on model
|
|
7
|
+
* initiative; an explicit approval on a card showing the objective, the
|
|
8
|
+
* criteria, the cadence and the caps is stronger evidence of intent than a
|
|
9
|
+
* typed token, not weaker, so it arms the same gate rather than bypassing it.
|
|
10
|
+
*
|
|
11
|
+
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
12
|
+
* of the cached request prefix, so it never changes with loop state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
18
|
+
import type { LoopController } from "./loop.js";
|
|
19
|
+
import { renderProposalCard } from "./planning.js";
|
|
20
|
+
|
|
21
|
+
export const LOOP_PROPOSE_TOOL = "loop_propose";
|
|
22
|
+
|
|
23
|
+
export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopController) {
|
|
24
|
+
pi.registerTool(
|
|
25
|
+
defineTool({
|
|
26
|
+
name: LOOP_PROPOSE_TOOL,
|
|
27
|
+
label: "Loop Propose",
|
|
28
|
+
description:
|
|
29
|
+
"Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce. Starts nothing: the user approves, edits, or cancels.",
|
|
30
|
+
promptSnippet: "Propose a drafted loop objective for approval",
|
|
31
|
+
promptGuidelines: [
|
|
32
|
+
"Call loop_propose only while loop planning is open, and only once the objective reads as an acceptance test: one requirement per bullet, each naming the check that proves it.",
|
|
33
|
+
"Pass the objective you and the user agreed on, not a tidier version of it. The criteria are derived from this text and frozen when the loop starts.",
|
|
34
|
+
"Do not call loop_start for a planned loop; approving the card is what starts it.",
|
|
35
|
+
"loop_propose starts nothing, so the rule that a loop needs an inline /loop token in the user's message does not apply to it. While planning is open, a conversational request for a loop is the signal to draft one and propose it — never to refuse and ask the user to type /loop instead.",
|
|
36
|
+
],
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
objective: Type.String({
|
|
39
|
+
minLength: 1,
|
|
40
|
+
maxLength: 100_000,
|
|
41
|
+
description:
|
|
42
|
+
"The drafted objective, one requirement per bullet, each naming how it is verified.",
|
|
43
|
+
}),
|
|
44
|
+
interval: Type.Optional(
|
|
45
|
+
Type.String({
|
|
46
|
+
description:
|
|
47
|
+
"Fallback heartbeat as <number><unit> (s, m, h, d), e.g. 30m. Omit to use the configured default.",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
max_turns: Type.Optional(
|
|
51
|
+
Type.Number({
|
|
52
|
+
description:
|
|
53
|
+
"Cap on loop-caused turns. Omit for the configured default; the user can change it on the card.",
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
expires: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
63
|
+
if (!controller.planning.active) {
|
|
64
|
+
return failure(
|
|
65
|
+
"Loop planning is not open, so there is nothing to propose. The user opens it by running /loop with no loop running.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (controller.state && controller.state.status !== "stopped") {
|
|
69
|
+
return failure(
|
|
70
|
+
"A loop is already running in this session. Stop it with /loop stop before planning another.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const objective = params.objective.trim();
|
|
74
|
+
if (!objective) return failure("A proposal needs an objective.");
|
|
75
|
+
|
|
76
|
+
const overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } =
|
|
77
|
+
{};
|
|
78
|
+
if (params.interval !== undefined) {
|
|
79
|
+
const parsed = parseDuration(params.interval);
|
|
80
|
+
if (parsed === undefined) {
|
|
81
|
+
return failure(
|
|
82
|
+
`Invalid interval: ${params.interval}. Use <number><unit> with unit s, m, h, or d, e.g. 30m.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
overrides.intervalMs = Math.min(parsed, MAX_INTERVAL_MS);
|
|
86
|
+
}
|
|
87
|
+
if (params.expires !== undefined) {
|
|
88
|
+
const parsed = parseDuration(params.expires);
|
|
89
|
+
if (parsed === undefined) {
|
|
90
|
+
return failure(
|
|
91
|
+
`Invalid expires: ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
overrides.expiresInMs = parsed;
|
|
95
|
+
}
|
|
96
|
+
if (params.max_turns !== undefined) {
|
|
97
|
+
if (!Number.isSafeInteger(params.max_turns) || params.max_turns <= 0) {
|
|
98
|
+
return failure("max_turns must be a positive whole number.");
|
|
99
|
+
}
|
|
100
|
+
overrides.maxTurns = params.max_turns;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const proposal = controller.propose(objective, overrides);
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text" as const, text: renderProposalCard(proposal).join("\n") }],
|
|
106
|
+
details: {
|
|
107
|
+
criteria: proposal.criteria.length,
|
|
108
|
+
intervalMs: proposal.intervalMs,
|
|
109
|
+
maxTurns: proposal.maxTurns,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function failure(text: string) {
|
|
118
|
+
return { content: [{ type: "text" as const, text }], details: {}, isError: true };
|
|
119
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -21,15 +21,22 @@ export interface LoopCompactionSettings {
|
|
|
21
21
|
instructions: string | null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The cap fields this one replaced: a delivered-wake cap (`maxIterations`,
|
|
26
|
+
* `--max`) and a loop-caused-turn cap (`automaticTurns`). A settle-paced loop
|
|
27
|
+
* can run its whole life without delivering a single fallback wake, so the
|
|
28
|
+
* wake cap bounded nothing the turn cap did not already bound.
|
|
29
|
+
*/
|
|
30
|
+
const LEGACY_CAP_KEYS = ["maxIterations", "automaticTurns"] as const;
|
|
31
|
+
|
|
24
32
|
export interface LoopSettings {
|
|
25
|
-
/** Delivered-wake cap; null means unlimited (explicit opt-in). */
|
|
26
|
-
maxIterations: number | null;
|
|
27
33
|
/**
|
|
28
|
-
* Cap on turns the loop itself causes (settle continuations plus
|
|
29
|
-
* pokes); null means unlimited
|
|
30
|
-
* wake
|
|
34
|
+
* Cap on the turns the loop itself causes (settle continuations plus
|
|
35
|
+
* fallback pokes); null means unlimited (explicit opt-in). The only cap
|
|
36
|
+
* there is: one wake can yield many turns, so counting turns is what
|
|
37
|
+
* actually bounds a loop.
|
|
31
38
|
*/
|
|
32
|
-
|
|
39
|
+
maxTurns: number | null;
|
|
33
40
|
/**
|
|
34
41
|
* Consecutive tool-free loop turns with identical output that pause the
|
|
35
42
|
* loop; null disables the breaker.
|
|
@@ -54,8 +61,7 @@ export interface LoopSettings {
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
57
|
-
|
|
58
|
-
automaticTurns: 25,
|
|
64
|
+
maxTurns: 25,
|
|
59
65
|
noProgressTurns: 3,
|
|
60
66
|
maxLoopDuration: "7d",
|
|
61
67
|
inlineInvocation: true,
|
|
@@ -76,11 +82,8 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
76
82
|
const record = ownRecord(value);
|
|
77
83
|
if (!record) return undefined;
|
|
78
84
|
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
const automaticTurns = normalizeCap(record.automaticTurns, DEFAULT_LOOP_SETTINGS.automaticTurns);
|
|
83
|
-
if (automaticTurns === false) return undefined;
|
|
85
|
+
const maxTurns = normalizeTurnCap(record);
|
|
86
|
+
if (maxTurns === false) return undefined;
|
|
84
87
|
|
|
85
88
|
const noProgressTurns = normalizeCap(
|
|
86
89
|
record.noProgressTurns,
|
|
@@ -134,8 +137,7 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
return {
|
|
137
|
-
|
|
138
|
-
automaticTurns,
|
|
140
|
+
maxTurns,
|
|
139
141
|
noProgressTurns,
|
|
140
142
|
maxLoopDuration,
|
|
141
143
|
inlineInvocation,
|
|
@@ -151,6 +153,32 @@ function normalizeCap(value: unknown, fallback: number | null): number | null |
|
|
|
151
153
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : false;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The turn cap, accepting the two caps it replaced.
|
|
158
|
+
*
|
|
159
|
+
* A settings file written by an older version names no `maxTurns`, and asking
|
|
160
|
+
* users to rewrite their settings to keep a cap they already chose is not a
|
|
161
|
+
* trade worth making. So a file carrying only the legacy keys keeps the
|
|
162
|
+
* tighter of the two: that is the bound their loops were already running
|
|
163
|
+
* under. An invalid value in either key still fails the whole file closed,
|
|
164
|
+
* exactly as it did when the key was current.
|
|
165
|
+
*/
|
|
166
|
+
function normalizeTurnCap(record: Record<string, unknown>): number | null | false {
|
|
167
|
+
if (Object.hasOwn(record, "maxTurns")) {
|
|
168
|
+
return normalizeCap(record.maxTurns, DEFAULT_LOOP_SETTINGS.maxTurns);
|
|
169
|
+
}
|
|
170
|
+
let adopted: number | null | undefined;
|
|
171
|
+
for (const key of LEGACY_CAP_KEYS) {
|
|
172
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
173
|
+
const cap = normalizeCap(record[key], DEFAULT_LOOP_SETTINGS.maxTurns);
|
|
174
|
+
if (cap === false) return false;
|
|
175
|
+
// null is unlimited, so it only wins when every legacy cap is unlimited.
|
|
176
|
+
if (adopted === undefined || adopted === null) adopted = cap;
|
|
177
|
+
else if (cap !== null) adopted = Math.min(adopted, cap);
|
|
178
|
+
}
|
|
179
|
+
return adopted === undefined ? DEFAULT_LOOP_SETTINGS.maxTurns : adopted;
|
|
180
|
+
}
|
|
181
|
+
|
|
154
182
|
function readBoolean(record: Record<string, unknown>, key: string, fallback: boolean): unknown {
|
|
155
183
|
return Object.hasOwn(record, key) ? record[key] : fallback;
|
|
156
184
|
}
|
|
@@ -221,11 +249,14 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
|
|
|
221
249
|
}
|
|
222
250
|
|
|
223
251
|
const compaction = ownRecord(raw.compaction) ?? {};
|
|
252
|
+
// Unknown fields are preserved, but the two caps `maxTurns` replaced are not
|
|
253
|
+
// unknown: leaving them next to a cap that supersedes them would show the
|
|
254
|
+
// user two numbers where only one applies.
|
|
255
|
+
for (const key of LEGACY_CAP_KEYS) delete raw[key];
|
|
224
256
|
const document = `${JSON.stringify(
|
|
225
257
|
{
|
|
226
258
|
...raw,
|
|
227
|
-
|
|
228
|
-
automaticTurns: normalized.automaticTurns,
|
|
259
|
+
maxTurns: normalized.maxTurns,
|
|
229
260
|
noProgressTurns: normalized.noProgressTurns,
|
|
230
261
|
maxLoopDuration: normalized.maxLoopDuration,
|
|
231
262
|
inlineInvocation: normalized.inlineInvocation,
|
package/src/start-tool.ts
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
* until a cap. So the tool refuses outright unless the inline hint armed for
|
|
16
16
|
* the turn that is calling it.
|
|
17
17
|
*
|
|
18
|
+
* The one thing this path may decide that the `/loop` command cannot is the
|
|
19
|
+
* loop's completion criteria. They are otherwise a deterministic split of the
|
|
20
|
+
* objective's grammar, which turns a context sentence into a gate criterion;
|
|
21
|
+
* a model that read the objective can do better. It is accepted only *here*,
|
|
22
|
+
* at start, before any work exists to grade and with the user seeing the
|
|
23
|
+
* criteria echoed back — the point where the incentive to write an easy gate
|
|
24
|
+
* is weakest. After start they are immutable, exactly as a derived set is.
|
|
25
|
+
*
|
|
18
26
|
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
19
27
|
* of the cached request prefix, so it never changes with loop state.
|
|
20
28
|
*/
|
|
@@ -23,6 +31,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
23
31
|
import { Type } from "typebox";
|
|
24
32
|
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
25
33
|
import { formatDuration, parseDuration, parseInterval } from "./interval.js";
|
|
34
|
+
import { MAX_CRITERIA, MAX_DESCRIPTION_LENGTH } from "./ledger.js";
|
|
26
35
|
import type { InlineInvocationState } from "./inline-invocation.js";
|
|
27
36
|
import type { LoopController } from "./loop.js";
|
|
28
37
|
|
|
@@ -49,7 +58,11 @@ export function registerLoopStartTool(
|
|
|
49
58
|
"If the user is discussing, quoting, or documenting the /loop command rather than invoking it — asking how it works, pasting a transcript, or editing text that mentions it — do not call loop_start.",
|
|
50
59
|
"Pass the objective text that follows the token, without the token itself. A leading interval (`10m`, `2h`) and flags like `--max 5` or `--expires 3d` become the interval, max, and expires parameters, not part of the objective.",
|
|
51
60
|
"Call loop_start before doing any of the objective's work, then continue working toward it in the same turn.",
|
|
61
|
+
"Leave the criteria parameter out by default: the extension splits the objective into completion criteria on its own (bullets, else sentences, else the whole objective). Propose criteria only when that split would misfire — when the objective mixes requirements with context sentences (`fix CI. it has been red since Tuesday.`), or packs several requirements into one sentence.",
|
|
62
|
+
"Every criterion you propose must be a faithful restatement of something the user asked for: never fewer, weaker, or easier than the objective as typed, and never a requirement they did not state. They are echoed back to the user at start and frozen afterwards — you may only ever flip a criterion's passes field.",
|
|
63
|
+
"When in doubt, omit criteria and let the deterministic split stand.",
|
|
52
64
|
"Never call loop_complete in the same turn as loop_start: the starting turn has not done the work, and completion needs cited evidence per criterion.",
|
|
65
|
+
"Before your first loop_start this session, read the pi-loop skill: the objective is split into the completion criteria this loop will be gated on, so its wording is the leverage point.",
|
|
53
66
|
],
|
|
54
67
|
parameters: Type.Object({
|
|
55
68
|
objective: Type.String({
|
|
@@ -67,7 +80,8 @@ export function registerLoopStartTool(
|
|
|
67
80
|
max: Type.Optional(
|
|
68
81
|
Type.Integer({
|
|
69
82
|
minimum: 1,
|
|
70
|
-
description:
|
|
83
|
+
description:
|
|
84
|
+
"Cap on the turns the loop causes (continuations and pokes), from a --max flag in the invocation.",
|
|
71
85
|
}),
|
|
72
86
|
),
|
|
73
87
|
expires: Type.Optional(
|
|
@@ -75,6 +89,17 @@ export function registerLoopStartTool(
|
|
|
75
89
|
description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
|
|
76
90
|
}),
|
|
77
91
|
),
|
|
92
|
+
criteria: Type.Optional(
|
|
93
|
+
Type.Array(
|
|
94
|
+
Type.String({ minLength: 1, maxLength: MAX_DESCRIPTION_LENGTH }),
|
|
95
|
+
{
|
|
96
|
+
minItems: 1,
|
|
97
|
+
maxItems: MAX_CRITERIA,
|
|
98
|
+
description:
|
|
99
|
+
"Optional completion criteria for this loop, each one checkable requirement restated faithfully from the user's objective. Replaces the deterministic split of the objective, so omit it unless that split would misfire.",
|
|
100
|
+
},
|
|
101
|
+
),
|
|
102
|
+
),
|
|
78
103
|
}),
|
|
79
104
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
80
105
|
// The gate. Everything below is ordinary validation; this is the
|
|
@@ -89,6 +114,14 @@ export function registerLoopStartTool(
|
|
|
89
114
|
if (!objective) {
|
|
90
115
|
return refusal("Loop not started: the objective is empty.", {});
|
|
91
116
|
}
|
|
117
|
+
const criteria = params.criteria?.map((description) => description.trim());
|
|
118
|
+
const badCriteria = criteria && describeBadCriteria(criteria);
|
|
119
|
+
if (badCriteria) {
|
|
120
|
+
return refusal(
|
|
121
|
+
`Loop not started: ${badCriteria}. Pass one short checkable requirement per entry, or omit criteria to split the objective deterministically.`,
|
|
122
|
+
{ objective },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
92
125
|
const existing = controller.state;
|
|
93
126
|
if (existing && existing.status !== "stopped") {
|
|
94
127
|
return refusal(
|
|
@@ -119,8 +152,9 @@ export function registerLoopStartTool(
|
|
|
119
152
|
requestedMs: interval.requestedMs,
|
|
120
153
|
intervalMs: interval.effectiveMs,
|
|
121
154
|
clamped: interval.clamped,
|
|
122
|
-
...(params.max === undefined ? {} : {
|
|
155
|
+
...(params.max === undefined ? {} : { maxTurns: params.max }),
|
|
123
156
|
...(expiresInMs === undefined ? {} : { expiresInMs }),
|
|
157
|
+
...(criteria === undefined ? {} : { criteria }),
|
|
124
158
|
prompt: objective,
|
|
125
159
|
});
|
|
126
160
|
if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
|
|
@@ -136,6 +170,26 @@ export function registerLoopStartTool(
|
|
|
136
170
|
);
|
|
137
171
|
}
|
|
138
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Why a proposed criteria list cannot be used, or undefined when it can.
|
|
175
|
+
*
|
|
176
|
+
* A list that says nothing is worse than no list: it would replace the
|
|
177
|
+
* deterministic split with a gate the model wrote and can pass by saying
|
|
178
|
+
* anything. So a malformed list refuses the start rather than falling back
|
|
179
|
+
* silently, which would leave the model believing its criteria were accepted.
|
|
180
|
+
*/
|
|
181
|
+
function describeBadCriteria(criteria: readonly string[]): string | undefined {
|
|
182
|
+
if (criteria.length === 0) return "the criteria list is empty";
|
|
183
|
+
if (criteria.length > MAX_CRITERIA) {
|
|
184
|
+
return `a loop takes at most ${MAX_CRITERIA} criteria and ${criteria.length} were given`;
|
|
185
|
+
}
|
|
186
|
+
if (criteria.some((description) => !description)) return "one of the criteria is blank";
|
|
187
|
+
if (criteria.some((description) => description.length > MAX_DESCRIPTION_LENGTH)) {
|
|
188
|
+
return `a criterion may be at most ${MAX_DESCRIPTION_LENGTH} characters`;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
139
193
|
function refusal(text: string, details: Record<string, unknown>) {
|
|
140
194
|
return { content: toolContent(text), details, isError: true };
|
|
141
195
|
}
|
package/src/state.ts
CHANGED
|
@@ -30,19 +30,17 @@ export interface LoopState {
|
|
|
30
30
|
*/
|
|
31
31
|
objective?: string;
|
|
32
32
|
intervalMs: number;
|
|
33
|
-
/** Delivered-poke cap; null means unlimited. */
|
|
34
|
-
maxIterations: number | null;
|
|
35
|
-
/** Proactive-compaction threshold fraction, or null when disabled per loop. */
|
|
36
|
-
compactAt: number | null;
|
|
37
33
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
34
|
+
* Cap on the turns this loop causes (continuations plus pokes); null means
|
|
35
|
+
* unlimited. The only cap: a settle-driven continuation chain runs without
|
|
36
|
+
* any wake at all, so a wake cap bounded nothing this one does not.
|
|
41
37
|
*/
|
|
42
|
-
|
|
43
|
-
/**
|
|
38
|
+
maxTurns: number | null;
|
|
39
|
+
/** Proactive-compaction threshold fraction, or null when disabled per loop. */
|
|
40
|
+
compactAt: number | null;
|
|
41
|
+
/** Delivered wakes so far (fallback pokes only); uncapped, and displayed. */
|
|
44
42
|
iteration: number;
|
|
45
|
-
/** Loop-caused turns so far (continuations + pokes). */
|
|
43
|
+
/** Loop-caused turns so far (continuations + pokes): what `maxTurns` caps. */
|
|
46
44
|
automaticTurns: number;
|
|
47
45
|
startedAt: number;
|
|
48
46
|
expiresAt: number;
|
|
@@ -91,8 +89,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
91
89
|
}
|
|
92
90
|
const intervalMs = record.intervalMs;
|
|
93
91
|
if (!isPositiveSafeInteger(intervalMs)) return undefined;
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
92
|
+
const maxTurns = readTurnCap(record);
|
|
93
|
+
if (maxTurns === false) return undefined;
|
|
96
94
|
const compactAt = record.compactAt;
|
|
97
95
|
if (
|
|
98
96
|
compactAt !== null &&
|
|
@@ -100,16 +98,12 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
100
98
|
) {
|
|
101
99
|
return undefined;
|
|
102
100
|
}
|
|
103
|
-
const maxAutomaticTurns = Object.hasOwn(record, "maxAutomaticTurns")
|
|
104
|
-
? record.maxAutomaticTurns
|
|
105
|
-
: null;
|
|
106
|
-
if (maxAutomaticTurns !== null && !isPositiveSafeInteger(maxAutomaticTurns)) return undefined;
|
|
107
101
|
const iteration = record.iteration;
|
|
108
102
|
if (typeof iteration !== "number" || !Number.isSafeInteger(iteration) || iteration < 0) {
|
|
109
103
|
return undefined;
|
|
110
104
|
}
|
|
111
|
-
//
|
|
112
|
-
// absent counter restores as zero rather than rejecting the whole state.
|
|
105
|
+
// A loop persisted before the turn counter existed carries no automaticTurns;
|
|
106
|
+
// an absent counter restores as zero rather than rejecting the whole state.
|
|
113
107
|
const automaticTurns = Object.hasOwn(record, "automaticTurns") ? record.automaticTurns : 0;
|
|
114
108
|
if (
|
|
115
109
|
typeof automaticTurns !== "number" ||
|
|
@@ -147,8 +141,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
147
141
|
...(prompt === undefined ? {} : { prompt }),
|
|
148
142
|
...(objective === undefined ? {} : { objective }),
|
|
149
143
|
intervalMs,
|
|
150
|
-
|
|
151
|
-
maxAutomaticTurns: maxAutomaticTurns as number | null,
|
|
144
|
+
maxTurns,
|
|
152
145
|
compactAt: compactAt as number | null,
|
|
153
146
|
iteration,
|
|
154
147
|
automaticTurns,
|
|
@@ -164,6 +157,33 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
164
157
|
};
|
|
165
158
|
}
|
|
166
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The turn cap, adopting the caps a loop persisted by an older version
|
|
162
|
+
* carries: `maxAutomaticTurns` (turns) and `maxIterations` (wakes). An
|
|
163
|
+
* in-flight loop restored mid-upgrade keeps the tighter of them rather than
|
|
164
|
+
* having its bound widened or being dropped as unparsable; its wake *counter*
|
|
165
|
+
* is kept for display but no longer caps anything. Returns the cap, or false
|
|
166
|
+
* when a present value is invalid.
|
|
167
|
+
*/
|
|
168
|
+
function readTurnCap(record: Record<string, unknown>): number | null | false {
|
|
169
|
+
if (Object.hasOwn(record, "maxTurns")) {
|
|
170
|
+
const value = record.maxTurns;
|
|
171
|
+
if (value === null) return null;
|
|
172
|
+
return isPositiveSafeInteger(value) ? value : false;
|
|
173
|
+
}
|
|
174
|
+
let adopted: number | null | undefined;
|
|
175
|
+
for (const key of ["maxAutomaticTurns", "maxIterations"]) {
|
|
176
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
177
|
+
const value = record[key];
|
|
178
|
+
if (value !== null && !isPositiveSafeInteger(value)) return false;
|
|
179
|
+
const cap = value as number | null;
|
|
180
|
+
// null is unlimited, so it only wins when every legacy cap is unlimited.
|
|
181
|
+
if (adopted === undefined || adopted === null) adopted = cap;
|
|
182
|
+
else if (cap !== null) adopted = Math.min(adopted, cap);
|
|
183
|
+
}
|
|
184
|
+
return adopted === undefined ? null : adopted;
|
|
185
|
+
}
|
|
186
|
+
|
|
167
187
|
// --- session-branch entry readers ---
|
|
168
188
|
|
|
169
189
|
interface SessionEntryLike {
|