@hank-warren/pi-loop 0.5.0 → 0.7.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 +29 -0
- package/README.md +60 -49
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +112 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +56 -15
- package/src/decide.ts +30 -81
- package/src/index.ts +24 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/ledger.ts +17 -4
- package/src/loop.ts +124 -200
- package/src/manager.ts +18 -28
- package/src/messages.ts +21 -38
- package/src/objective.ts +5 -7
- package/src/render.ts +3 -7
- package/src/settings.ts +83 -21
- package/src/start-tool.ts +199 -0
- package/src/state.ts +56 -116
- package/src/wait-tool.ts +2 -2
- package/src/widget.ts +5 -3
package/src/manager.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { formatDuration, parseInterval } from "./interval.js";
|
|
9
9
|
import type { LoopController } from "./loop.js";
|
|
10
|
-
import { readGoalSnapshot } from "./state.js";
|
|
11
10
|
import {
|
|
12
11
|
DEFAULT_LOOP_SETTINGS,
|
|
13
12
|
type LoopSettings,
|
|
@@ -79,39 +78,35 @@ async function startFromMenu(
|
|
|
79
78
|
ctx.ui.notify(`Invalid interval: ${intervalText}. Use <number><unit>, e.g. 5m.`, "error");
|
|
80
79
|
return;
|
|
81
80
|
}
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// required — asking for it here is what replaces the old dead-end refusal.
|
|
85
|
-
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
86
|
-
const goalBound = goal?.status === "active";
|
|
81
|
+
// The loop owns its objective, so the text is required — asking for it here
|
|
82
|
+
// is what replaces the old dead-end refusal.
|
|
87
83
|
const promptText = await ctx.ui.input(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: "Objective, including how the loop knows it is done",
|
|
91
|
-
goalBound ? undefined : "e.g. get CI green on main, verified by a passing run",
|
|
84
|
+
"Objective, including how the loop knows it is done",
|
|
85
|
+
"e.g. get CI green on main, verified by a passing run",
|
|
92
86
|
);
|
|
93
87
|
if (promptText === undefined) return;
|
|
94
88
|
const prompt = promptText.trim();
|
|
95
|
-
if (!
|
|
96
|
-
ctx.ui.notify(
|
|
97
|
-
"A loop with no active goal needs its own objective, so no loop was started.",
|
|
98
|
-
"warning",
|
|
99
|
-
);
|
|
89
|
+
if (!prompt) {
|
|
90
|
+
ctx.ui.notify("A loop needs an objective, so no loop was started.", "warning");
|
|
100
91
|
return;
|
|
101
92
|
}
|
|
102
|
-
controller.startLoop(ctx, {
|
|
93
|
+
const result = controller.startLoop(ctx, {
|
|
103
94
|
kind: "start",
|
|
104
95
|
requestedMs: interval.requestedMs,
|
|
105
96
|
intervalMs: interval.effectiveMs,
|
|
106
97
|
clamped: interval.clamped,
|
|
107
98
|
...(prompt ? { prompt } : {}),
|
|
108
99
|
});
|
|
100
|
+
if (!result.ok) ctx.ui.notify(result.message, "error");
|
|
109
101
|
}
|
|
110
102
|
|
|
111
103
|
async function editPrompt(controller: LoopController, ctx: ExtensionCommandContext): Promise<void> {
|
|
112
104
|
const loop = controller.state;
|
|
113
105
|
if (!loop || loop.status === "stopped") return;
|
|
114
|
-
const next = await ctx.ui.input(
|
|
106
|
+
const next = await ctx.ui.input(
|
|
107
|
+
"Loop focus (optional, restated on every loop message)",
|
|
108
|
+
loop.prompt ?? "",
|
|
109
|
+
);
|
|
115
110
|
if (next === undefined) return;
|
|
116
111
|
const prompt = next.trim();
|
|
117
112
|
if (prompt) controller.state = { ...loop, prompt };
|
|
@@ -159,8 +154,7 @@ export async function showLoopSettings(
|
|
|
159
154
|
for (;;) {
|
|
160
155
|
const s = controller.settings;
|
|
161
156
|
const items = [
|
|
162
|
-
`Max
|
|
163
|
-
`Max automatic turns: ${s.automaticTurns === null ? "Unlimited" : s.automaticTurns}`,
|
|
157
|
+
`Max loop turns: ${s.maxTurns === null ? "Unlimited" : s.maxTurns}`,
|
|
164
158
|
`No-progress breaker: ${s.noProgressTurns === null ? "Off" : `after ${s.noProgressTurns} repeats`}`,
|
|
165
159
|
`Max loop duration: ${s.maxLoopDuration}`,
|
|
166
160
|
`Proactive compaction: ${s.compaction.enabled ? `On at ${Math.round(s.compaction.threshold * 100)}%` : "Off"}`,
|
|
@@ -172,14 +166,10 @@ export async function showLoopSettings(
|
|
|
172
166
|
if (index === 0) {
|
|
173
167
|
// Unlimited is a first-class choice, not a magic word typed into a free
|
|
174
168
|
// text box: it is only reachable by discovery otherwise.
|
|
175
|
-
const cap = await editCap(ctx, "Max
|
|
169
|
+
const cap = await editCap(ctx, "Max loop turns", "no turn cap", s.maxTurns);
|
|
176
170
|
if (cap === undefined) continue;
|
|
177
|
-
next.
|
|
171
|
+
next.maxTurns = cap === "unlimited" ? null : cap;
|
|
178
172
|
} else if (index === 1) {
|
|
179
|
-
const cap = await editCap(ctx, "Max automatic turns", "no turn cap", s.automaticTurns);
|
|
180
|
-
if (cap === undefined) continue;
|
|
181
|
-
next.automaticTurns = cap === "unlimited" ? null : cap;
|
|
182
|
-
} else if (index === 2) {
|
|
183
173
|
const cap = await editCap(
|
|
184
174
|
ctx,
|
|
185
175
|
"No-progress breaker",
|
|
@@ -188,7 +178,7 @@ export async function showLoopSettings(
|
|
|
188
178
|
);
|
|
189
179
|
if (cap === undefined) continue;
|
|
190
180
|
next.noProgressTurns = cap === "unlimited" ? null : cap;
|
|
191
|
-
} else if (index ===
|
|
181
|
+
} else if (index === 2) {
|
|
192
182
|
const value = await ctx.ui.input("Max loop duration (e.g. 7d)", s.maxLoopDuration);
|
|
193
183
|
if (value === undefined) continue;
|
|
194
184
|
if (parseDuration(value.trim()) === undefined) {
|
|
@@ -196,7 +186,7 @@ export async function showLoopSettings(
|
|
|
196
186
|
continue;
|
|
197
187
|
}
|
|
198
188
|
next.maxLoopDuration = value.trim();
|
|
199
|
-
} else if (index ===
|
|
189
|
+
} else if (index === 3) {
|
|
200
190
|
if (s.compaction.enabled) next.compaction.enabled = false;
|
|
201
191
|
else {
|
|
202
192
|
const value = await ctx.ui.input(
|
|
@@ -221,7 +211,7 @@ export async function showLoopSettings(
|
|
|
221
211
|
}
|
|
222
212
|
|
|
223
213
|
/**
|
|
224
|
-
* One cap editor for
|
|
214
|
+
* One cap editor for every cap. Unlimited is a first-class choice, not a
|
|
225
215
|
* magic word typed into a free text box: it is only reachable by discovery
|
|
226
216
|
* otherwise. The typed word still works, so the /loop --max vocabulary and
|
|
227
217
|
* muscle memory keep working.
|
package/src/messages.ts
CHANGED
|
@@ -10,48 +10,35 @@ import { LOOP_OK_TOKEN } from "./ack.js";
|
|
|
10
10
|
import { formatDuration } from "./interval.js";
|
|
11
11
|
import { CRITERIA_FILE, type LedgerPaths, PROGRESS_FILE } from "./ledger.js";
|
|
12
12
|
import { appendContinuationMarker, appendPokeMarker } from "./markers.js";
|
|
13
|
-
import type {
|
|
13
|
+
import type { LoopState } from "./state.js";
|
|
14
14
|
|
|
15
15
|
/** Why the loop is talking: the first turn, an ordinary turn, or after a compaction. */
|
|
16
16
|
export type ContinuationKind = "kickoff" | "continue" | "reanchor";
|
|
17
17
|
|
|
18
|
-
function formatIteration(loop: LoopState): string {
|
|
19
|
-
const cap = loop.maxIterations === null ? "unlimited" : `${loop.maxIterations}`;
|
|
20
|
-
return `${loop.iteration + 1}/${cap}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
/**
|
|
24
|
-
* The
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* assumption). Only the dynamic per-wake state (iteration, reason) belongs in
|
|
30
|
-
* this tail message.
|
|
19
|
+
* The wake ordinal, and only the ordinal. It used to read `4/25`, the wake
|
|
20
|
+
* counter against the delivered-wake cap; that cap is gone, collapsed into
|
|
21
|
+
* the single loop-turn cap, and pairing a wake number with a turn cap would
|
|
22
|
+
* have been a number that reads as a budget and is not one. The cap is shown
|
|
23
|
+
* to the *user*, in the widget and `/loop status`, which is who it is for.
|
|
31
24
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`Scheduled loop wakeup ${formatIteration(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
35
|
-
reason === "goal-waiting"
|
|
36
|
-
? "This is the external wake for your waiting goal. Re-check whatever the goal was waiting on and continue."
|
|
37
|
-
: "The session went idle but the active goal is not complete. Continue working toward it — the objective and goal-mode rules are in the system prompt.",
|
|
38
|
-
];
|
|
39
|
-
if (loop.prompt) lines.push("", `Loop focus: ${loop.prompt}`);
|
|
40
|
-
return appendPokeMarker(lines.join("\n"), loop.id, loop.iteration + 1);
|
|
25
|
+
function formatWakeOrdinal(loop: LoopState): string {
|
|
26
|
+
return `${loop.iteration + 1}`;
|
|
41
27
|
}
|
|
42
28
|
|
|
43
29
|
/**
|
|
44
|
-
* The
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
30
|
+
* The poke. Deliberately slim: the loop's own objective injection puts the
|
|
31
|
+
* objective and loop-mode rules in the system prompt of every turn, so
|
|
32
|
+
* restating them here would store a duplicate copy on every wake. Only the
|
|
33
|
+
* dynamic per-wake state (the wake ordinal, the reason) belongs in this tail
|
|
34
|
+
* message.
|
|
48
35
|
*/
|
|
49
36
|
export function buildObjectivePoke(
|
|
50
37
|
loop: LoopState,
|
|
51
38
|
reason: "objective-stalled" | "wait-elapsed" = "objective-stalled",
|
|
52
39
|
): string {
|
|
53
40
|
const lines = [
|
|
54
|
-
`Scheduled loop wakeup ${
|
|
41
|
+
`Scheduled loop wakeup ${formatWakeOrdinal(loop)} (every ${formatDuration(loop.intervalMs)}).`,
|
|
55
42
|
reason === "wait-elapsed"
|
|
56
43
|
? "The wait you asked for has elapsed. Re-check the external state it depended on and continue — the objective and loop-mode rules are in the system prompt."
|
|
57
44
|
: "The session went idle but the loop objective's completion criteria are not met. Continue working it — the objective and loop-mode rules are in the system prompt.",
|
|
@@ -81,8 +68,8 @@ function addCancelledWaitHint(lines: string[], loop: LoopState): void {
|
|
|
81
68
|
}
|
|
82
69
|
|
|
83
70
|
/**
|
|
84
|
-
* The settle-driven continuation: the message that actually paces a
|
|
85
|
-
*
|
|
71
|
+
* The settle-driven continuation: the message that actually paces a loop.
|
|
72
|
+
* Pointer-sized for the same reason the pokes are — it
|
|
86
73
|
* only ever fires while the loop is active, so the byte-stable system append
|
|
87
74
|
* carrying the objective and loop-mode rules is guaranteed present on that
|
|
88
75
|
* turn.
|
|
@@ -207,20 +194,16 @@ export function extractNextActions(summary: string, maxLength = 240): string | u
|
|
|
207
194
|
*/
|
|
208
195
|
export function buildCompactionInstructions(
|
|
209
196
|
loop: LoopState,
|
|
210
|
-
/** The goal only when it is still active; a finished goal is not the objective. */
|
|
211
|
-
goal: GoalSnapshot | undefined,
|
|
212
197
|
override: string | null,
|
|
213
198
|
/** The loop's ledger, when it has one. */
|
|
214
199
|
ledger?: LedgerPaths,
|
|
215
200
|
): string {
|
|
216
201
|
if (override) return override;
|
|
217
|
-
const objective =
|
|
218
|
-
? `The session is working toward this
|
|
219
|
-
: loop.
|
|
220
|
-
? `The session is
|
|
221
|
-
: loop.
|
|
222
|
-
? `The session is running a recurring loop focused on: ${loop.prompt}`
|
|
223
|
-
: "The session is running a recurring loop.";
|
|
202
|
+
const objective = loop.objective
|
|
203
|
+
? `The session is working toward this loop objective: ${loop.objective}`
|
|
204
|
+
: loop.prompt
|
|
205
|
+
? `The session is running a recurring loop focused on: ${loop.prompt}`
|
|
206
|
+
: "The session is running a recurring loop.";
|
|
224
207
|
return [
|
|
225
208
|
`${objective}`,
|
|
226
209
|
"This summary must let that work continue seamlessly. Preserve verbatim:",
|
package/src/objective.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The loop's objective injection.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* discipline, because the alternative (restating the objective in every poke)
|
|
9
|
-
* is exactly the per-wake duplication that discipline exists to remove.
|
|
4
|
+
* The loop carries its own objective to the model on every active turn, which
|
|
5
|
+
* is what lets the pokes and continuations stay pointer-sized. The
|
|
6
|
+
* alternative — restating the objective in every poke — is exactly the
|
|
7
|
+
* per-wake duplication the cache-stability discipline below exists to remove.
|
|
10
8
|
*
|
|
11
9
|
* Cache-stability contract: this append lands inside the provider's cached
|
|
12
10
|
* system block (Anthropic caches tools -> system -> messages as one prefix),
|
package/src/render.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* text plus a provenance marker comment. This transformer collapses each into
|
|
6
6
|
* a one-line themed chip in the transcript. Display-only by Pi contract: the
|
|
7
7
|
* stored message and model context are untouched, and pokes keep being
|
|
8
|
-
* delivered through sendUserMessage so
|
|
9
|
-
* (which appends the
|
|
8
|
+
* delivered through sendUserMessage so the loop's own before_agent_start hook
|
|
9
|
+
* (which appends the objective) still fires for every poke turn.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -29,11 +29,7 @@ export function compactPokeMessage(markdown: string) {
|
|
|
29
29
|
if (!extractPokeMarker(markdown)) return undefined;
|
|
30
30
|
const head = POKE_HEAD_PATTERN.exec(markdown);
|
|
31
31
|
if (!head) return undefined;
|
|
32
|
-
const reason = markdown.includes("
|
|
33
|
-
? "waiting"
|
|
34
|
-
: markdown.includes("completion criteria are not met")
|
|
35
|
-
? "objective"
|
|
36
|
-
: "stalled";
|
|
32
|
+
const reason = markdown.includes("wait you asked for has elapsed") ? "wait elapsed" : "stalled";
|
|
37
33
|
const focus = POKE_FOCUS_PATTERN.exec(markdown)?.[1];
|
|
38
34
|
return `*⏰ loop wake ${head[1]} · ${reason}${focus ? ` · ${focus}` : ""}*`;
|
|
39
35
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global pi-loop settings: `~/.pi/agent/pi-loop.json`. Follows the sibling
|
|
3
|
-
* convention (pi-
|
|
3
|
+
* convention (pi-plan-mode): an absent file means defaults and is
|
|
4
4
|
* never created implicitly, saves are atomic and preserve unknown fields, and
|
|
5
5
|
* an invalid file warns and falls back to defaults without being overwritten.
|
|
6
6
|
*/
|
|
@@ -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.
|
|
@@ -37,14 +44,28 @@ export interface LoopSettings {
|
|
|
37
44
|
noProgressTurns: number | null;
|
|
38
45
|
/** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
|
|
39
46
|
maxLoopDuration: string;
|
|
47
|
+
/**
|
|
48
|
+
* Detect an inline `/loop` token or a `loop:` prefixed line mid-prompt and
|
|
49
|
+
* point the model at the `loop_start` tool. Pi only dispatches `/loop` from
|
|
50
|
+
* position 0, so without this a mid-prompt invocation is silently prose.
|
|
51
|
+
*/
|
|
52
|
+
inlineInvocation: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Fallback heartbeat used by an inline invocation that names no interval.
|
|
55
|
+
* In a settle-paced loop the interval is only a fallback — the settle
|
|
56
|
+
* boundary is the pacemaker — so this value is far less consequential than
|
|
57
|
+
* it looks; it is still clamped to MIN_INTERVAL_MS.
|
|
58
|
+
*/
|
|
59
|
+
defaultInterval: string;
|
|
40
60
|
compaction: LoopCompactionSettings;
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
44
|
-
|
|
45
|
-
automaticTurns: 25,
|
|
64
|
+
maxTurns: 25,
|
|
46
65
|
noProgressTurns: 3,
|
|
47
66
|
maxLoopDuration: "7d",
|
|
67
|
+
inlineInvocation: true,
|
|
68
|
+
defaultInterval: "10m",
|
|
48
69
|
compaction: {
|
|
49
70
|
enabled: true,
|
|
50
71
|
threshold: 0.7,
|
|
@@ -61,11 +82,8 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
61
82
|
const record = ownRecord(value);
|
|
62
83
|
if (!record) return undefined;
|
|
63
84
|
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
const automaticTurns = normalizeCap(record.automaticTurns, DEFAULT_LOOP_SETTINGS.automaticTurns);
|
|
68
|
-
if (automaticTurns === false) return undefined;
|
|
85
|
+
const maxTurns = normalizeTurnCap(record);
|
|
86
|
+
if (maxTurns === false) return undefined;
|
|
69
87
|
|
|
70
88
|
const noProgressTurns = normalizeCap(
|
|
71
89
|
record.noProgressTurns,
|
|
@@ -80,12 +98,24 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
80
98
|
return undefined;
|
|
81
99
|
}
|
|
82
100
|
|
|
101
|
+
const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
|
|
102
|
+
? record.inlineInvocation
|
|
103
|
+
: DEFAULT_LOOP_SETTINGS.inlineInvocation;
|
|
104
|
+
if (typeof inlineInvocation !== "boolean") return undefined;
|
|
105
|
+
|
|
106
|
+
const defaultInterval = Object.hasOwn(record, "defaultInterval")
|
|
107
|
+
? record.defaultInterval
|
|
108
|
+
: DEFAULT_LOOP_SETTINGS.defaultInterval;
|
|
109
|
+
if (typeof defaultInterval !== "string" || parseDuration(defaultInterval) === undefined) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
|
|
84
114
|
if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
|
|
85
115
|
const compactionRecord = ownRecord(compactionValue) ?? {};
|
|
86
|
-
// `postCompactContinuation` was removed in favour of
|
|
87
|
-
//
|
|
88
|
-
//
|
|
116
|
+
// `postCompactContinuation` was removed in favour of the loop's own
|
|
117
|
+
// re-anchor; a file still carrying it is preserved as an unknown field and
|
|
118
|
+
// ignored, never rejected.
|
|
89
119
|
const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
|
|
90
120
|
const threshold = Object.hasOwn(compactionRecord, "threshold")
|
|
91
121
|
? compactionRecord.threshold
|
|
@@ -107,10 +137,11 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
return {
|
|
110
|
-
|
|
111
|
-
automaticTurns,
|
|
140
|
+
maxTurns,
|
|
112
141
|
noProgressTurns,
|
|
113
142
|
maxLoopDuration,
|
|
143
|
+
inlineInvocation,
|
|
144
|
+
defaultInterval,
|
|
114
145
|
compaction: { enabled, threshold, instructions },
|
|
115
146
|
};
|
|
116
147
|
}
|
|
@@ -122,6 +153,32 @@ function normalizeCap(value: unknown, fallback: number | null): number | null |
|
|
|
122
153
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : false;
|
|
123
154
|
}
|
|
124
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
|
+
|
|
125
182
|
function readBoolean(record: Record<string, unknown>, key: string, fallback: boolean): unknown {
|
|
126
183
|
return Object.hasOwn(record, key) ? record[key] : fallback;
|
|
127
184
|
}
|
|
@@ -192,13 +249,18 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
|
|
|
192
249
|
}
|
|
193
250
|
|
|
194
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];
|
|
195
256
|
const document = `${JSON.stringify(
|
|
196
257
|
{
|
|
197
258
|
...raw,
|
|
198
|
-
|
|
199
|
-
automaticTurns: normalized.automaticTurns,
|
|
259
|
+
maxTurns: normalized.maxTurns,
|
|
200
260
|
noProgressTurns: normalized.noProgressTurns,
|
|
201
261
|
maxLoopDuration: normalized.maxLoopDuration,
|
|
262
|
+
inlineInvocation: normalized.inlineInvocation,
|
|
263
|
+
defaultInterval: normalized.defaultInterval,
|
|
202
264
|
compaction: { ...compaction, ...normalized.compaction },
|
|
203
265
|
},
|
|
204
266
|
null,
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop_start`: the model-invoked way into a loop.
|
|
3
|
+
*
|
|
4
|
+
* Pi only dispatches `/loop` when it starts the message, so a mid-prompt
|
|
5
|
+
* `quick check /loop 10m get CI green` arrives as ordinary prose. The
|
|
6
|
+
* inline-invocation hooks append a one-turn reminder to the system prompt for
|
|
7
|
+
* exactly that message, and this tool is what the reminder points at. It
|
|
8
|
+
* reuses `LoopController.startLoop`, the same path the `/loop` command takes,
|
|
9
|
+
* so replacement rules, the loop_complete availability guard, the ledger, the
|
|
10
|
+
* kickoff anchor, and persistence all behave identically.
|
|
11
|
+
*
|
|
12
|
+
* The hard armed-gate is the deliberate divergence from pi-goal's equivalent
|
|
13
|
+
* tool, which relied on prompt guidelines alone. A loop is *self-continuing*:
|
|
14
|
+
* a spurious start does not produce one unwanted answer, it produces turns
|
|
15
|
+
* until a cap. So the tool refuses outright unless the inline hint armed for
|
|
16
|
+
* the turn that is calling it.
|
|
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
|
+
*
|
|
26
|
+
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
27
|
+
* of the cached request prefix, so it never changes with loop state.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { Type } from "typebox";
|
|
32
|
+
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
33
|
+
import { formatDuration, parseDuration, parseInterval } from "./interval.js";
|
|
34
|
+
import { MAX_CRITERIA, MAX_DESCRIPTION_LENGTH } from "./ledger.js";
|
|
35
|
+
import type { InlineInvocationState } from "./inline-invocation.js";
|
|
36
|
+
import type { LoopController } from "./loop.js";
|
|
37
|
+
|
|
38
|
+
export const LOOP_START_TOOL = "loop_start";
|
|
39
|
+
|
|
40
|
+
/** Long enough for a real objective, short enough to reject a pasted file. */
|
|
41
|
+
const MAX_OBJECTIVE_LENGTH = 4_000;
|
|
42
|
+
|
|
43
|
+
export function registerLoopStartTool(
|
|
44
|
+
pi: ExtensionAPI,
|
|
45
|
+
controller: LoopController,
|
|
46
|
+
invocation: InlineInvocationState,
|
|
47
|
+
) {
|
|
48
|
+
pi.registerTool(
|
|
49
|
+
defineTool({
|
|
50
|
+
name: LOOP_START_TOOL,
|
|
51
|
+
label: "Loop Start",
|
|
52
|
+
description:
|
|
53
|
+
"Start a /loop for an objective the user explicitly invoked with an inline /loop or loop: token in their message. Only for explicit invocations: never start a loop from general conversation, your own initiative, or an instruction that merely sounds loop-like. The objective is the text following the token.",
|
|
54
|
+
promptSnippet:
|
|
55
|
+
"Start a /loop when the user's message contains an explicit inline /loop or loop: invocation",
|
|
56
|
+
promptGuidelines: [
|
|
57
|
+
"Call loop_start only when the user's message contains an explicit `/loop <objective>` or `loop: <objective>` token. Never start a loop without that token, no matter how loop-like the request sounds; the tool refuses when the turn carries no inline invocation.",
|
|
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.",
|
|
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.",
|
|
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.",
|
|
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.",
|
|
66
|
+
],
|
|
67
|
+
parameters: Type.Object({
|
|
68
|
+
objective: Type.String({
|
|
69
|
+
minLength: 1,
|
|
70
|
+
maxLength: MAX_OBJECTIVE_LENGTH,
|
|
71
|
+
description:
|
|
72
|
+
"The loop objective, including how the loop knows it is done: the user's text following the /loop or loop: token, verbatim, without the token, the interval, or flags.",
|
|
73
|
+
}),
|
|
74
|
+
interval: Type.Optional(
|
|
75
|
+
Type.String({
|
|
76
|
+
description:
|
|
77
|
+
"Fallback wake interval from the invocation, e.g. '10m' or '2h'. Omit when the user named none.",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
max: Type.Optional(
|
|
81
|
+
Type.Integer({
|
|
82
|
+
minimum: 1,
|
|
83
|
+
description:
|
|
84
|
+
"Cap on the turns the loop causes (continuations and pokes), from a --max flag in the invocation.",
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
expires: Type.Optional(
|
|
88
|
+
Type.String({
|
|
89
|
+
description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
|
|
90
|
+
}),
|
|
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
|
+
),
|
|
103
|
+
}),
|
|
104
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
105
|
+
// The gate. Everything below is ordinary validation; this is the
|
|
106
|
+
// one check that makes a self-continuing tool safe to expose.
|
|
107
|
+
if (!invocation.invokedThisTurn) {
|
|
108
|
+
return refusal(
|
|
109
|
+
`${LOOP_START_TOOL} is only available on a turn whose user message contains an explicit inline /loop or loop: invocation. This turn has none, so no loop was started. If the user wants one, they can type /loop <interval> <objective>.`,
|
|
110
|
+
{},
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const objective = params.objective.trim();
|
|
114
|
+
if (!objective) {
|
|
115
|
+
return refusal("Loop not started: the objective is empty.", {});
|
|
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
|
+
}
|
|
125
|
+
const existing = controller.state;
|
|
126
|
+
if (existing && existing.status !== "stopped") {
|
|
127
|
+
return refusal(
|
|
128
|
+
`Loop not started: a loop already exists in this session (${existing.status}). The user can replace it with /loop, or stop it with /loop stop.`,
|
|
129
|
+
{ existingLoopId: existing.id },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const intervalToken = params.interval?.trim() || controller.settings.defaultInterval;
|
|
133
|
+
const interval = parseInterval(intervalToken);
|
|
134
|
+
if (!interval) {
|
|
135
|
+
return refusal(
|
|
136
|
+
`Loop not started: invalid interval ${intervalToken}. Use <number><unit> with unit s, m, h, or d, e.g. 10m.`,
|
|
137
|
+
{ objective },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
let expiresInMs: number | undefined;
|
|
141
|
+
if (params.expires !== undefined) {
|
|
142
|
+
expiresInMs = parseDuration(params.expires.trim());
|
|
143
|
+
if (expiresInMs === undefined) {
|
|
144
|
+
return refusal(
|
|
145
|
+
`Loop not started: invalid expiry ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
|
|
146
|
+
{ objective },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const result = controller.startLoop(ctx, {
|
|
151
|
+
kind: "start",
|
|
152
|
+
requestedMs: interval.requestedMs,
|
|
153
|
+
intervalMs: interval.effectiveMs,
|
|
154
|
+
clamped: interval.clamped,
|
|
155
|
+
...(params.max === undefined ? {} : { maxTurns: params.max }),
|
|
156
|
+
...(expiresInMs === undefined ? {} : { expiresInMs }),
|
|
157
|
+
...(criteria === undefined ? {} : { criteria }),
|
|
158
|
+
prompt: objective,
|
|
159
|
+
});
|
|
160
|
+
if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
|
|
161
|
+
const loop = result.loop;
|
|
162
|
+
return {
|
|
163
|
+
content: toolContent(
|
|
164
|
+
`Loop started (loop_id ${loop.id}): ${objective}. Fallback wake every ${formatDuration(loop.intervalMs)}. Keep working the objective this turn; the loop continues at every idle boundary until you call ${LOOP_COMPLETE_TOOL} with this loop_id and cited evidence for every criterion, a cap is reached, or the user stops it. Do not call ${LOOP_COMPLETE_TOOL} in this turn.`,
|
|
165
|
+
),
|
|
166
|
+
details: { loopId: loop.id, objective, intervalMs: loop.intervalMs },
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
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
|
+
|
|
193
|
+
function refusal(text: string, details: Record<string, unknown>) {
|
|
194
|
+
return { content: toolContent(text), details, isError: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toolContent(text: string) {
|
|
198
|
+
return [{ type: "text" as const, text }];
|
|
199
|
+
}
|