@hank-warren/pi-loop 0.4.1 → 0.5.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 +15 -0
- package/README.md +155 -19
- package/package.json +4 -2
- package/src/ack.ts +67 -0
- package/src/command.ts +92 -33
- package/src/complete-tool.ts +120 -13
- package/src/decide.ts +111 -22
- package/src/errors.ts +131 -0
- package/src/index.ts +95 -2
- package/src/ledger.ts +230 -0
- package/src/loop.ts +836 -40
- package/src/manager.ts +55 -29
- package/src/markers.ts +24 -3
- package/src/messages.ts +164 -8
- package/src/objective.ts +29 -2
- package/src/render.ts +25 -2
- package/src/safety.ts +98 -0
- package/src/schedule/command.ts +255 -0
- package/src/schedule/cron.ts +182 -0
- package/src/schedule/manager.ts +129 -0
- package/src/schedule/model.ts +237 -0
- package/src/schedule/runner.ts +351 -0
- package/src/schedule/store.ts +183 -0
- package/src/settings.ts +27 -1
- package/src/state.ts +80 -1
- package/src/wait-tool.ts +114 -0
- package/src/wait.ts +95 -0
package/src/loop.ts
CHANGED
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
* loop-aware compaction, wired to Pi's extension events.
|
|
4
4
|
*
|
|
5
5
|
* Design invariants (approved plan):
|
|
6
|
-
* -
|
|
7
|
-
* and
|
|
6
|
+
* - The settled idle boundary is the pacemaker for a standalone loop: an
|
|
7
|
+
* agent_end records a continuation *intent*, and the next fully settled
|
|
8
|
+
* boundary dispatches it. The interval is a fallback heartbeat, re-armed
|
|
9
|
+
* from the last settle, that fires only when the session has been idle a
|
|
10
|
+
* whole interval with the objective unfinished (a lost continuation, or an
|
|
11
|
+
* external wait). A goal-bound loop is unchanged: pi-goal owns its settle
|
|
12
|
+
* continuations, and the interval is still that loop's only driver.
|
|
13
|
+
* - Timers are armed in session_start, a settle, or a command handler, never
|
|
14
|
+
* the factory, and cleared in an idempotent session_shutdown.
|
|
8
15
|
* - Pokes deliver only at a fully idle boundary; a tick that lands while the
|
|
9
16
|
* agent is busy coalesces into a single pending wake delivered at the next
|
|
10
|
-
* agent_settled. Missed ticks never stack
|
|
17
|
+
* agent_settled. Missed ticks never stack, and a continuation supersedes a
|
|
18
|
+
* coalesced wake rather than delivering both.
|
|
11
19
|
* - Loops require an active pi-goal goal to operate: pi-goal owns "whether
|
|
12
20
|
* the work is done". Its safety states pause the loop, its completion stops
|
|
13
21
|
* it, and a missing goal pauses the loop. Coupling is read-only session
|
|
@@ -27,9 +35,31 @@ import type {
|
|
|
27
35
|
ExtensionContext,
|
|
28
36
|
} from "@earendil-works/pi-coding-agent";
|
|
29
37
|
import type { LoopStartArguments } from "./command.js";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
import {
|
|
39
|
+
type ContinuationDecision,
|
|
40
|
+
decideContinuation,
|
|
41
|
+
decideTick,
|
|
42
|
+
type TickDecision,
|
|
43
|
+
type TickEnvironment,
|
|
44
|
+
} from "./decide.js";
|
|
45
|
+
import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
46
|
+
import {
|
|
47
|
+
createLedger,
|
|
48
|
+
deriveCriteria,
|
|
49
|
+
type LedgerPaths,
|
|
50
|
+
ledgerPaths,
|
|
51
|
+
readCriteria,
|
|
52
|
+
} from "./ledger.js";
|
|
53
|
+
import {
|
|
54
|
+
buildCompactionInstructions,
|
|
55
|
+
buildContinuation,
|
|
56
|
+
buildExpiryWake,
|
|
57
|
+
buildGoalPoke,
|
|
58
|
+
buildKickoffAnchor,
|
|
59
|
+
buildObjectivePoke,
|
|
60
|
+
type ContinuationKind,
|
|
61
|
+
extractNextActions,
|
|
62
|
+
} from "./messages.js";
|
|
33
63
|
import {
|
|
34
64
|
DEFAULT_LOOP_SETTINGS,
|
|
35
65
|
type LoopSettings,
|
|
@@ -44,13 +74,50 @@ import {
|
|
|
44
74
|
readPlanModeEnabled,
|
|
45
75
|
restoreLoopState,
|
|
46
76
|
} from "./state.js";
|
|
77
|
+
import { isLoopOkAck } from "./ack.js";
|
|
78
|
+
import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
|
|
79
|
+
import { classifyInterruption } from "./errors.js";
|
|
80
|
+
import { LOOP_WAIT_TOOL } from "./wait-tool.js";
|
|
81
|
+
import {
|
|
82
|
+
createLoopWait,
|
|
83
|
+
type LoopWait,
|
|
84
|
+
LoopWaitTimer,
|
|
85
|
+
resolveWaitDelay,
|
|
86
|
+
type ResolvedWaitDelay,
|
|
87
|
+
} from "./wait.js";
|
|
88
|
+
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
47
89
|
import { clearLoopWidget, updateLoopWidget } from "./widget.js";
|
|
48
90
|
|
|
49
91
|
export const LOOP_STATUS_KEY = "loop";
|
|
50
92
|
|
|
93
|
+
/** Custom message type of the kickoff anchor. */
|
|
94
|
+
export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A fallback wake that produces a no-op turn doubles the next fallback delay,
|
|
98
|
+
* capped here. Deterministic and small: the point is to stop re-waking a loop
|
|
99
|
+
* that has nothing to do, not to invent an adaptive scheduler.
|
|
100
|
+
*/
|
|
101
|
+
export const MAX_FALLBACK_BACKOFF = 4;
|
|
102
|
+
|
|
103
|
+
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
104
|
+
export const MAX_DEAD_DELIVERIES = 3;
|
|
105
|
+
|
|
106
|
+
/** Why the loop caused the run that is currently in flight. */
|
|
107
|
+
type RunOrigin = "continuation" | "fallback";
|
|
108
|
+
|
|
109
|
+
interface ContinuationIntent {
|
|
110
|
+
loopId: string;
|
|
111
|
+
kind: ContinuationKind;
|
|
112
|
+
/** Next actions carried out of a compaction summary, for a re-anchor. */
|
|
113
|
+
nextActions?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
51
116
|
export interface LoopControllerOptions {
|
|
52
117
|
settingsPath?: string;
|
|
53
118
|
now?: () => number;
|
|
119
|
+
/** Root for the loop ledger; defaults to Pi's agent dir. Tests override it. */
|
|
120
|
+
agentDir?: string;
|
|
54
121
|
}
|
|
55
122
|
|
|
56
123
|
export class LoopController {
|
|
@@ -66,11 +133,31 @@ export class LoopController {
|
|
|
66
133
|
private nextWakeAt: number | undefined;
|
|
67
134
|
private wakePending = false;
|
|
68
135
|
private sessionCtx: ExtensionContext | undefined;
|
|
136
|
+
private continuationIntent: ContinuationIntent | undefined;
|
|
137
|
+
private runOrigin: RunOrigin | undefined;
|
|
138
|
+
/** The active loop's ledger, or undefined when it could not be created. */
|
|
139
|
+
ledger: LedgerPaths | undefined;
|
|
140
|
+
private ledgerWarned = false;
|
|
141
|
+
private readonly agentDir: string | undefined;
|
|
142
|
+
/** Consecutive fallback wakes that produced a no-op turn. */
|
|
143
|
+
noOpStreak = 0;
|
|
144
|
+
lastContinuation: (ContinuationDecision & { at: number }) | undefined;
|
|
145
|
+
private readonly waitTimer = new LoopWaitTimer();
|
|
146
|
+
/**
|
|
147
|
+
* Loop-caused deliveries that never produced an agent run. A provider that
|
|
148
|
+
* refuses before the first token — no API key, a torn-down runner — never
|
|
149
|
+
* reaches agent_end, so no classifier ever sees it.
|
|
150
|
+
*/
|
|
151
|
+
private deadDeliveries = 0;
|
|
152
|
+
private awaitingRun = false;
|
|
153
|
+
/** Set when an interrupted turn needs a compaction before the loop continues. */
|
|
154
|
+
private compactionRequested = false;
|
|
69
155
|
|
|
70
156
|
constructor(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
71
157
|
this.pi = pi;
|
|
72
158
|
this.now = options.now ?? Date.now;
|
|
73
159
|
this.settingsPath = options.settingsPath ?? loopSettingsPath();
|
|
160
|
+
this.agentDir = options.agentDir;
|
|
74
161
|
}
|
|
75
162
|
|
|
76
163
|
// --- lifecycle ---
|
|
@@ -80,6 +167,12 @@ export class LoopController {
|
|
|
80
167
|
this.wakePending = false;
|
|
81
168
|
this.compacting = false;
|
|
82
169
|
this.lastDecision = undefined;
|
|
170
|
+
this.lastContinuation = undefined;
|
|
171
|
+
this.continuationIntent = undefined;
|
|
172
|
+
this.runOrigin = undefined;
|
|
173
|
+
this.noOpStreak = 0;
|
|
174
|
+
this.deadDeliveries = 0;
|
|
175
|
+
this.awaitingRun = false;
|
|
83
176
|
this.sessionCtx = ctx;
|
|
84
177
|
|
|
85
178
|
const loaded = readLoopSettings(this.settingsPath);
|
|
@@ -88,34 +181,210 @@ export class LoopController {
|
|
|
88
181
|
ctx.ui.notify(`pi-loop settings ignored: ${loaded.reason}. Using defaults.`, "warning");
|
|
89
182
|
}
|
|
90
183
|
|
|
184
|
+
this.ledger = undefined;
|
|
185
|
+
this.ledgerWarned = false;
|
|
91
186
|
this.state = restoreLoopState(ctx.sessionManager.getBranch());
|
|
92
187
|
if (this.state && this.state.status === "active") {
|
|
93
188
|
if (this.now() >= this.state.expiresAt) {
|
|
94
189
|
this.transition("stopped", "loop expired while the session was away");
|
|
95
190
|
return;
|
|
96
191
|
}
|
|
97
|
-
|
|
192
|
+
if (!isStandaloneLoop(this.state) && this.migrateGoalBoundLoop(ctx)) return;
|
|
193
|
+
// A restored loop keeps its ledger: createLedger only ever creates
|
|
194
|
+
// PROGRESS.md, so days of agent-written state survive a restart.
|
|
195
|
+
this.openLedger(this.state);
|
|
196
|
+
// A wait whose deadline passed while the session was away is due now.
|
|
197
|
+
this.restoreWaitTimer();
|
|
198
|
+
this.armFallback();
|
|
98
199
|
}
|
|
99
200
|
this.updateWidget();
|
|
100
201
|
}
|
|
101
202
|
|
|
102
203
|
onSessionShutdown(): void {
|
|
103
204
|
this.clearTimer();
|
|
205
|
+
this.waitTimer.clear();
|
|
104
206
|
this.wakePending = false;
|
|
207
|
+
this.continuationIntent = undefined;
|
|
208
|
+
this.runOrigin = undefined;
|
|
105
209
|
if (this.sessionCtx) clearLoopWidget(this.sessionCtx.ui);
|
|
106
210
|
this.sessionCtx = undefined;
|
|
107
211
|
}
|
|
108
212
|
|
|
213
|
+
/**
|
|
214
|
+
* A finished agent run with an active standalone loop is the signal that
|
|
215
|
+
* paces the loop: record the *intent* to continue here and let the settled
|
|
216
|
+
* boundary decide whether it may be delivered. Recording at agent_end (not
|
|
217
|
+
* at settle) is what makes the intent survive Pi's own retries and
|
|
218
|
+
* auto-compaction, which run between the two events.
|
|
219
|
+
*/
|
|
220
|
+
/** A run started, so the delivery that caused it was not a dead one. */
|
|
221
|
+
onAgentStart(ctx: ExtensionContext): void {
|
|
222
|
+
this.sessionCtx = ctx;
|
|
223
|
+
this.awaitingRun = false;
|
|
224
|
+
this.deadDeliveries = 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
onAgentEnd(ctx: ExtensionContext, messages: readonly unknown[] = []): void {
|
|
228
|
+
this.sessionCtx = ctx;
|
|
229
|
+
this.awaitingRun = false;
|
|
230
|
+
const origin = this.runOrigin;
|
|
231
|
+
this.runOrigin = undefined;
|
|
232
|
+
const loop = this.state;
|
|
233
|
+
if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
|
|
234
|
+
if (this.enforceToolAvailability(ctx)) return;
|
|
235
|
+
// The expiry's final turn is the last one: never queue a continuation
|
|
236
|
+
// behind it. The settle that follows stops the loop.
|
|
237
|
+
if (loop.expiring) return;
|
|
238
|
+
// A user turn cancels a wait: whatever they just said outranks it. The
|
|
239
|
+
// reason survives as a hint on the next loop message.
|
|
240
|
+
if (origin === undefined && loop.waiting) this.cancelWait("a user message arrived");
|
|
241
|
+
this.recordProgress(origin, messages);
|
|
242
|
+
if (this.classifyAndHandleInterruption(ctx, messages, origin)) return;
|
|
243
|
+
if (this.enforceNoProgress(ctx, messages, origin)) return;
|
|
244
|
+
// A turn that ended in loop_wait asked not to be continued.
|
|
245
|
+
if (this.state?.waiting) return;
|
|
246
|
+
if (this.state) this.requestContinuation(this.state);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Act on how the turn ended. A loop that answers every provider failure
|
|
251
|
+
* with "continue" retries into exhausted quotas and re-sends requests that
|
|
252
|
+
* are too large to succeed; each class needs its own answer. Returns true
|
|
253
|
+
* when the interruption was handled and no continuation should be recorded.
|
|
254
|
+
*/
|
|
255
|
+
private classifyAndHandleInterruption(
|
|
256
|
+
ctx: ExtensionContext,
|
|
257
|
+
messages: readonly unknown[],
|
|
258
|
+
origin: RunOrigin | undefined,
|
|
259
|
+
): boolean {
|
|
260
|
+
const loop = this.state;
|
|
261
|
+
if (!loop) return true;
|
|
262
|
+
switch (classifyInterruption(messages)) {
|
|
263
|
+
case "usage-limited":
|
|
264
|
+
this.transition(
|
|
265
|
+
"paused",
|
|
266
|
+
"the provider reports the usage limit is reached; resume with /loop resume once it resets",
|
|
267
|
+
"usage limit reached",
|
|
268
|
+
);
|
|
269
|
+
return true;
|
|
270
|
+
case "fatal":
|
|
271
|
+
this.transition(
|
|
272
|
+
"paused",
|
|
273
|
+
"the turn failed with an error a retry cannot fix; resolve it, then /loop resume",
|
|
274
|
+
"unrecoverable provider error",
|
|
275
|
+
);
|
|
276
|
+
return true;
|
|
277
|
+
case "aborted":
|
|
278
|
+
// Esc, or another extension stopping the turn. A loop-caused run
|
|
279
|
+
// that the user interrupted must not be immediately re-sent.
|
|
280
|
+
if (origin === undefined) return false;
|
|
281
|
+
this.transition("paused", "the turn was interrupted; resume with /loop resume", "interrupted");
|
|
282
|
+
return true;
|
|
283
|
+
case "context-overflow":
|
|
284
|
+
// The request no longer fits: compact first, then continue. The
|
|
285
|
+
// re-anchor that follows the compaction is the continuation.
|
|
286
|
+
this.compactionRequested = true;
|
|
287
|
+
ctx.ui.notify(
|
|
288
|
+
"pi-loop: the turn overflowed the context window; compacting before continuing.",
|
|
289
|
+
"warning",
|
|
290
|
+
);
|
|
291
|
+
return true;
|
|
292
|
+
default:
|
|
293
|
+
// "none" and "retryable" both continue: a transient provider error
|
|
294
|
+
// is exactly what the next continuation retries.
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The no-progress breaker: consecutive tool-free loop turns with identical
|
|
301
|
+
* visible output pause the loop instead of waking it again forever. It
|
|
302
|
+
* pauses rather than stops, so the loop stays configured and one
|
|
303
|
+
* `/loop resume` (or the next user prompt) puts it back to work.
|
|
304
|
+
*/
|
|
305
|
+
private enforceNoProgress(
|
|
306
|
+
ctx: ExtensionContext,
|
|
307
|
+
messages: readonly unknown[],
|
|
308
|
+
origin: RunOrigin | undefined,
|
|
309
|
+
): boolean {
|
|
310
|
+
const loop = this.state;
|
|
311
|
+
if (!loop) return true;
|
|
312
|
+
const limit = this.settings.noProgressTurns;
|
|
313
|
+
if (origin === undefined) {
|
|
314
|
+
// Any user input resets the safety epoch: the user has seen the
|
|
315
|
+
// output and chosen to keep going.
|
|
316
|
+
if (loop.toolFreeRepeatCount !== undefined || loop.lastFingerprint !== undefined) {
|
|
317
|
+
this.state = { ...loop, toolFreeRepeatCount: 0, lastFingerprint: undefined };
|
|
318
|
+
this.persist();
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (limit === null) return false;
|
|
323
|
+
// A turn that called loop_wait declared a wait; that is a decision, not a
|
|
324
|
+
// stall, and counting it is the classic false positive.
|
|
325
|
+
const toolAttempted = hasAssistantToolCall(messages) || calledTool(messages, LOOP_WAIT_TOOL);
|
|
326
|
+
const next = nextNoProgressState(
|
|
327
|
+
{
|
|
328
|
+
toolFreeRepeatCount: loop.toolFreeRepeatCount ?? 0,
|
|
329
|
+
...(loop.lastFingerprint === undefined ? {} : { lastFingerprint: loop.lastFingerprint }),
|
|
330
|
+
},
|
|
331
|
+
messages,
|
|
332
|
+
toolAttempted,
|
|
333
|
+
);
|
|
334
|
+
this.state = {
|
|
335
|
+
...loop,
|
|
336
|
+
toolFreeRepeatCount: next.toolFreeRepeatCount,
|
|
337
|
+
...(next.lastFingerprint === undefined ? {} : { lastFingerprint: next.lastFingerprint }),
|
|
338
|
+
};
|
|
339
|
+
if (next.toolFreeRepeatCount < limit) {
|
|
340
|
+
this.persist();
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
void ctx;
|
|
344
|
+
this.transition(
|
|
345
|
+
"paused",
|
|
346
|
+
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so /loop resume (or your next message) continues it`,
|
|
347
|
+
"no progress",
|
|
348
|
+
);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Fallback backoff bookkeeping. A user-driven turn, or any loop-caused turn
|
|
354
|
+
* that actually did something, resets the streak; only a fallback wake that
|
|
355
|
+
* produced a no-op turn grows the next fallback delay.
|
|
356
|
+
*/
|
|
357
|
+
private recordProgress(origin: RunOrigin | undefined, messages: readonly unknown[]): void {
|
|
358
|
+
if (origin === undefined) {
|
|
359
|
+
// A turn the user drove: the loop is not the thing spinning.
|
|
360
|
+
this.noOpStreak = 0;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// An explicit LOOP_OK is the deterministic version of the same signal:
|
|
364
|
+
// the model looked and there was nothing to do. It counts even when the
|
|
365
|
+
// turn used a tool to look.
|
|
366
|
+
if (!isNoOpRun(messages) && !isLoopOkAck(messages)) {
|
|
367
|
+
this.noOpStreak = 0;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (origin === "fallback") this.noOpStreak += 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
109
373
|
onAgentSettled(ctx: ExtensionContext): void {
|
|
110
374
|
this.sessionCtx = ctx;
|
|
111
375
|
if (!this.state || this.state.status !== "active") return;
|
|
112
376
|
if (this.maybeStartCompaction(ctx)) return;
|
|
377
|
+
if (this.settleTerminalState(ctx)) return;
|
|
378
|
+
if (this.dispatchContinuationIfSettled(ctx)) return;
|
|
113
379
|
if (this.wakePending) {
|
|
114
380
|
this.wakePending = false;
|
|
115
381
|
this.runTick(ctx);
|
|
116
382
|
return;
|
|
117
383
|
}
|
|
118
|
-
this
|
|
384
|
+
// Re-arm the heartbeat from this settle, so it can only fire after a full
|
|
385
|
+
// interval of genuine idleness. A goal-bound loop keeps the old cadence:
|
|
386
|
+
// its timer is the only driver it has.
|
|
387
|
+
if (isStandaloneLoop(this.state)) this.armFallback();
|
|
119
388
|
}
|
|
120
389
|
|
|
121
390
|
/**
|
|
@@ -125,20 +394,309 @@ export class LoopController {
|
|
|
125
394
|
* later. Poke and skip decisions are deliberately ignored here: only the
|
|
126
395
|
* timer pokes, and settling is not a schedule.
|
|
127
396
|
*/
|
|
128
|
-
private settleTerminalState(ctx: ExtensionContext):
|
|
397
|
+
private settleTerminalState(ctx: ExtensionContext): boolean {
|
|
129
398
|
const loop = this.state;
|
|
130
|
-
if (!loop) return;
|
|
399
|
+
if (!loop) return false;
|
|
131
400
|
const env = this.gatherEnvironment(ctx);
|
|
132
401
|
const decision = decideTick(loop, env);
|
|
133
402
|
if (decision.action !== "expire" && decision.action !== "stop" && decision.action !== "pause") {
|
|
134
|
-
return;
|
|
403
|
+
return false;
|
|
135
404
|
}
|
|
136
405
|
this.lastDecision = { ...decision, at: env.now };
|
|
137
406
|
this.applyTerminalDecision(loop, decision);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Migration for goal-bound loops, which are deprecated.
|
|
412
|
+
*
|
|
413
|
+
* A goal-bound loop delegated "is the work done" to pi-goal. Now that a
|
|
414
|
+
* standalone loop owns completion itself, that delegation is the deprecated
|
|
415
|
+
* path, and this converts one in place at restore rather than leaving the
|
|
416
|
+
* user with a loop that pauses forever the moment its goal is gone.
|
|
417
|
+
*
|
|
418
|
+
* The one case it must *not* convert is a still-active goal: pi-goal is
|
|
419
|
+
* driving that session's continuations, and a standalone loop driving them
|
|
420
|
+
* too would send two messages at every settle. That loop keeps its old
|
|
421
|
+
* behaviour and gets the notice instead.
|
|
422
|
+
*
|
|
423
|
+
* Returns true when the loop was stopped or paused and needs no timer.
|
|
424
|
+
*/
|
|
425
|
+
private migrateGoalBoundLoop(ctx: ExtensionContext): boolean {
|
|
426
|
+
const loop = this.state;
|
|
427
|
+
if (!loop) return true;
|
|
428
|
+
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
429
|
+
if (goal?.status === "active") {
|
|
430
|
+
ctx.ui.notify(
|
|
431
|
+
"This loop is bound to a /goal, which is deprecated: pi-loop now owns long-running work on its own. It keeps working as before for now — start future loops with /loop <interval> <objective>.",
|
|
432
|
+
"warning",
|
|
433
|
+
);
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
if (goal?.status === "complete") {
|
|
437
|
+
this.transition("stopped", "the goal completed");
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
// The goal is gone or held: nothing is driving this loop any more, so
|
|
441
|
+
// adopt whatever objective text is still readable and carry on.
|
|
442
|
+
const objective = goal?.text ?? loop.prompt;
|
|
443
|
+
if (!objective) {
|
|
444
|
+
this.transition(
|
|
445
|
+
"paused",
|
|
446
|
+
"it was bound to a goal that is gone, and it has no objective text of its own to adopt; start a new loop with /loop <interval> <objective>",
|
|
447
|
+
"goal-bound loop with no objective",
|
|
448
|
+
);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
const { prompt: _prompt, ...rest } = loop;
|
|
452
|
+
this.state = { ...rest, objective };
|
|
453
|
+
this.persist();
|
|
454
|
+
ctx.ui.notify(
|
|
455
|
+
`Goal-bound loops are deprecated; this one now owns its objective directly: ${objective}`,
|
|
456
|
+
"info",
|
|
457
|
+
);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A standalone loop that cannot call `loop_complete` cannot end itself: it
|
|
463
|
+
* will work, finish, and then be told to keep working until it hits a cap.
|
|
464
|
+
* That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
|
|
465
|
+
* a policy that drops extension tools), and it is invisible from inside the
|
|
466
|
+
* loop — so check the live tool set and pause instead of spinning.
|
|
467
|
+
*
|
|
468
|
+
* Returns true when the loop was paused.
|
|
469
|
+
*/
|
|
470
|
+
private enforceToolAvailability(ctx: ExtensionContext): boolean {
|
|
471
|
+
const loop = this.state;
|
|
472
|
+
if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return false;
|
|
473
|
+
if (this.completeToolAvailable()) return false;
|
|
474
|
+
this.transition(
|
|
475
|
+
"paused",
|
|
476
|
+
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
|
|
477
|
+
"loop_complete unavailable",
|
|
478
|
+
);
|
|
479
|
+
void ctx;
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private completeToolAvailable(): boolean {
|
|
484
|
+
const getActiveTools = (this.pi as { getActiveTools?: () => string[] }).getActiveTools;
|
|
485
|
+
if (typeof getActiveTools !== "function") return true;
|
|
486
|
+
try {
|
|
487
|
+
// Fail open: a host that cannot report its tools is not evidence that
|
|
488
|
+
// the tool is missing.
|
|
489
|
+
return getActiveTools.call(this.pi).includes(LOOP_COMPLETE_TOOL);
|
|
490
|
+
} catch {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Count a delivery that never became a run, and pause once the loop is
|
|
497
|
+
* plainly shouting into a void. Without this a session whose provider
|
|
498
|
+
* refuses every request (no API key, a revoked token) keeps waking on the
|
|
499
|
+
* heartbeat until it exhausts its wake cap, because a run that fails before
|
|
500
|
+
* the first token never reaches agent_end and so is never classified.
|
|
501
|
+
*
|
|
502
|
+
* Returns true when the loop was paused.
|
|
503
|
+
*/
|
|
504
|
+
private noteDelivery(): boolean {
|
|
505
|
+
if (this.awaitingRun) this.deadDeliveries += 1;
|
|
506
|
+
this.awaitingRun = true;
|
|
507
|
+
if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
|
|
508
|
+
this.transition(
|
|
509
|
+
"paused",
|
|
510
|
+
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
|
|
511
|
+
"deliveries produce no turns",
|
|
512
|
+
);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// --- loop_wait ---
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Enter an external wait. The pacemaker is deliberately left armed: a wait
|
|
520
|
+
* supersedes the *next* fallback wake, it does not disable the heartbeat,
|
|
521
|
+
* so a wait whose event never arrives still ends in a wake rather than in
|
|
522
|
+
* silence.
|
|
523
|
+
*/
|
|
524
|
+
enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
|
|
525
|
+
const loop = this.state;
|
|
526
|
+
if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return undefined;
|
|
527
|
+
const resolved = resolveWaitDelay(resumeAfterMs);
|
|
528
|
+
const waiting = createLoopWait(reason, resumeAfterMs, this.now());
|
|
529
|
+
// The wait replaces any continuation already recorded for this turn.
|
|
530
|
+
this.continuationIntent = undefined;
|
|
531
|
+
const { cancelledWaitReason: _cancelled, ...rest } = loop;
|
|
532
|
+
this.state = { ...rest, waiting };
|
|
533
|
+
this.persist();
|
|
534
|
+
this.restoreWaitTimer();
|
|
535
|
+
this.updateWidget();
|
|
536
|
+
return resolved;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Drop a wait that something other than its own deadline ended, keeping its
|
|
541
|
+
* reason as a one-shot hint for the next loop message. There is no cancel
|
|
542
|
+
* tool: the events that legitimately cancel a wait are not the model's to
|
|
543
|
+
* report.
|
|
544
|
+
*/
|
|
545
|
+
private cancelWait(_why: string): void {
|
|
546
|
+
const loop = this.state;
|
|
547
|
+
if (!loop?.waiting) return;
|
|
548
|
+
const { waiting, ...rest } = loop;
|
|
549
|
+
this.waitTimer.clear();
|
|
550
|
+
this.state = { ...rest, cancelledWaitReason: waiting.reason };
|
|
551
|
+
this.persist();
|
|
552
|
+
this.updateWidget();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Re-arm the wait deadline, including after a restore or a compaction. */
|
|
556
|
+
private restoreWaitTimer(): void {
|
|
557
|
+
this.waitTimer.clear();
|
|
558
|
+
const loop = this.state;
|
|
559
|
+
const resumeAt = loop?.status === "active" ? loop.waiting?.resumeAt : undefined;
|
|
560
|
+
if (resumeAt === undefined) return;
|
|
561
|
+
const loopId = loop?.id;
|
|
562
|
+
this.waitTimer.schedule(
|
|
563
|
+
resumeAt,
|
|
564
|
+
() => {
|
|
565
|
+
const ctx = this.sessionCtx;
|
|
566
|
+
if (!ctx || this.state?.id !== loopId) return;
|
|
567
|
+
try {
|
|
568
|
+
this.runTick(ctx);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
ctx.ui.notify(`pi-loop wait deadline failed: ${formatError(error)}`, "warning");
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
this.now(),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The wait a delivered wake consumed, cleared as the wake goes out. */
|
|
578
|
+
private consumeWait(loop: LoopState): LoopState {
|
|
579
|
+
const { waiting: _waiting, cancelledWaitReason: _cancelled, ...rest } = loop;
|
|
580
|
+
this.waitTimer.clear();
|
|
581
|
+
return rest;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// --- ledger ---
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Create (or adopt) the ledger for a standalone loop. Best-effort by
|
|
588
|
+
* design: a loop with no writable ledger still runs, it just loses the
|
|
589
|
+
* durable record, so the failure is warned once and never repeated.
|
|
590
|
+
*/
|
|
591
|
+
private openLedger(loop: LoopState): void {
|
|
592
|
+
if (loop.objective === undefined) {
|
|
593
|
+
this.ledger = undefined;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const paths = ledgerPaths(loop.id, this.agentDir);
|
|
597
|
+
const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
|
|
598
|
+
if (failure) {
|
|
599
|
+
this.ledger = undefined;
|
|
600
|
+
if (!this.ledgerWarned) {
|
|
601
|
+
this.ledgerWarned = true;
|
|
602
|
+
this.sessionCtx?.ui.notify(
|
|
603
|
+
`pi-loop could not write its ledger (${failure}). The loop runs without one.`,
|
|
604
|
+
"warning",
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
this.ledger = paths;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The loop's criteria as last written to disk, fail-open. */
|
|
613
|
+
criteria() {
|
|
614
|
+
return this.ledger ? readCriteria(this.ledger) : undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// --- settle-driven continuation ---
|
|
618
|
+
|
|
619
|
+
/** Record the intent to continue; the settled boundary decides delivery. */
|
|
620
|
+
private requestContinuation(
|
|
621
|
+
loop: LoopState,
|
|
622
|
+
kind: ContinuationKind = "continue",
|
|
623
|
+
nextActions?: string,
|
|
624
|
+
): void {
|
|
625
|
+
// A re-anchor outranks an ordinary continuation already queued: after a
|
|
626
|
+
// compaction, "re-read the ledger" is strictly the better instruction.
|
|
627
|
+
if (this.continuationIntent?.loopId === loop.id && kind !== "reanchor") return;
|
|
628
|
+
this.continuationIntent = { loopId: loop.id, kind, ...(nextActions ? { nextActions } : {}) };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Deliver a recorded continuation, but only at a boundary where Pi will
|
|
633
|
+
* actually accept it. A skip leaves the intent in place for the next
|
|
634
|
+
* settle; a terminal decision consumes it.
|
|
635
|
+
*/
|
|
636
|
+
dispatchContinuationIfSettled(ctx: ExtensionContext): boolean {
|
|
637
|
+
const intent = this.continuationIntent;
|
|
638
|
+
if (!intent) return false;
|
|
639
|
+
const loop = this.state;
|
|
640
|
+
if (!loop || loop.id !== intent.loopId) {
|
|
641
|
+
this.continuationIntent = undefined;
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
const env = this.gatherEnvironment(ctx);
|
|
645
|
+
const decision = decideContinuation(loop, env);
|
|
646
|
+
this.lastContinuation = { ...decision, at: env.now };
|
|
647
|
+
if (decision.action === "none") {
|
|
648
|
+
this.continuationIntent = undefined;
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
if (decision.action === "skip") return false;
|
|
652
|
+
if (decision.action !== "continue") {
|
|
653
|
+
this.continuationIntent = undefined;
|
|
654
|
+
this.lastDecision = { ...decision, at: env.now };
|
|
655
|
+
this.applyTerminalDecision(loop, decision);
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
this.pi.sendUserMessage(buildContinuation(loop, intent.kind, intent.nextActions));
|
|
660
|
+
} catch (error) {
|
|
661
|
+
// Keep the intent: the next settle retries it, and the fallback
|
|
662
|
+
// heartbeat covers the case where no further settle arrives.
|
|
663
|
+
this.sessionCtx?.ui.notify(
|
|
664
|
+
`pi-loop could not continue the loop: ${formatError(error)}. Retrying at the next idle boundary.`,
|
|
665
|
+
"warning",
|
|
666
|
+
);
|
|
667
|
+
this.armFallback();
|
|
668
|
+
this.updateWidget();
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
this.continuationIntent = undefined;
|
|
672
|
+
this.runOrigin = "continuation";
|
|
673
|
+
if (this.noteDelivery()) return true;
|
|
674
|
+
// A continuation is the work the coalesced wake would have asked for.
|
|
675
|
+
this.wakePending = false;
|
|
676
|
+
this.state = { ...loop, automaticTurns: loop.automaticTurns + 1 };
|
|
677
|
+
this.persist();
|
|
678
|
+
this.armFallback();
|
|
679
|
+
this.updateWidget();
|
|
680
|
+
return true;
|
|
138
681
|
}
|
|
139
682
|
|
|
140
683
|
// --- tick machinery ---
|
|
141
684
|
|
|
685
|
+
/**
|
|
686
|
+
* Arm the fallback heartbeat, backing off while consecutive fallback wakes
|
|
687
|
+
* keep producing no-op turns.
|
|
688
|
+
*/
|
|
689
|
+
private armFallback(): void {
|
|
690
|
+
const loop = this.state;
|
|
691
|
+
if (!loop || loop.status !== "active") return;
|
|
692
|
+
this.scheduleTick(this.fallbackDelayMs(loop));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
fallbackDelayMs(loop: LoopState): number {
|
|
696
|
+
const multiplier = Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak);
|
|
697
|
+
return Math.min(MAX_INTERVAL_MS, loop.intervalMs * multiplier);
|
|
698
|
+
}
|
|
699
|
+
|
|
142
700
|
private scheduleTick(delayMs: number): void {
|
|
143
701
|
this.clearTimer();
|
|
144
702
|
this.nextWakeAt = this.now() + delayMs;
|
|
@@ -185,10 +743,12 @@ export class LoopController {
|
|
|
185
743
|
case "none":
|
|
186
744
|
return;
|
|
187
745
|
case "skip":
|
|
188
|
-
if (decision.reason === "plan-mode-active") {
|
|
189
|
-
// Plan mode may end without an agent_settled we can use,
|
|
190
|
-
//
|
|
191
|
-
|
|
746
|
+
if (decision.reason === "plan-mode-active" || decision.reason === "loop-waiting") {
|
|
747
|
+
// Plan mode may end without an agent_settled we can use, and a
|
|
748
|
+
// wait supersedes only this one fallback wake — in both cases
|
|
749
|
+
// keep the heartbeat armed rather than coalescing a wake that
|
|
750
|
+
// would fire the moment the hold ends.
|
|
751
|
+
this.armFallback();
|
|
192
752
|
} else {
|
|
193
753
|
// Busy or compacting: coalesce into one pending wake that
|
|
194
754
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
@@ -211,14 +771,17 @@ export class LoopController {
|
|
|
211
771
|
): void {
|
|
212
772
|
switch (decision.action) {
|
|
213
773
|
case "expire":
|
|
214
|
-
|
|
774
|
+
if (decision.reason === "expiry-final-wake" && this.deliverExpiryWake(loop)) return;
|
|
775
|
+
this.transition("stopped", "loop expired (the expiry was reached)");
|
|
215
776
|
return;
|
|
216
777
|
case "stop":
|
|
217
778
|
this.transition(
|
|
218
779
|
"stopped",
|
|
219
780
|
decision.reason === "goal-complete"
|
|
220
781
|
? "the goal completed"
|
|
221
|
-
:
|
|
782
|
+
: decision.reason === "max-automatic-turns"
|
|
783
|
+
? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
|
|
784
|
+
: `the ${loop.maxIterations}-iteration cap was reached`,
|
|
222
785
|
);
|
|
223
786
|
return;
|
|
224
787
|
case "pause":
|
|
@@ -232,6 +795,46 @@ export class LoopController {
|
|
|
232
795
|
}
|
|
233
796
|
}
|
|
234
797
|
|
|
798
|
+
/**
|
|
799
|
+
* One last turn at expiry, so the loop's most recent state lands in the
|
|
800
|
+
* ledger instead of only in a conversation that is about to be closed. The
|
|
801
|
+
* loop stays active for exactly that turn — the objective append has to be
|
|
802
|
+
* present while it writes — and `expiring` makes the next settle stop it.
|
|
803
|
+
*
|
|
804
|
+
* Returns false when the wake could not be delivered, in which case the
|
|
805
|
+
* caller stops the loop immediately rather than leaving it alive past its
|
|
806
|
+
* expiry waiting for a turn that will not happen.
|
|
807
|
+
*/
|
|
808
|
+
private deliverExpiryWake(loop: LoopState): boolean {
|
|
809
|
+
if (loop.expiring) return false;
|
|
810
|
+
try {
|
|
811
|
+
this.pi.sendUserMessage(buildExpiryWake(loop, this.ledger));
|
|
812
|
+
} catch (error) {
|
|
813
|
+
this.sessionCtx?.ui.notify(
|
|
814
|
+
`pi-loop could not deliver the expiry wake: ${formatError(error)}. Stopping the loop.`,
|
|
815
|
+
"warning",
|
|
816
|
+
);
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
this.runOrigin = "fallback";
|
|
820
|
+
this.continuationIntent = undefined;
|
|
821
|
+
this.state = {
|
|
822
|
+
...this.consumeWait(loop),
|
|
823
|
+
iteration: loop.iteration + 1,
|
|
824
|
+
automaticTurns: loop.automaticTurns + 1,
|
|
825
|
+
lastWakeAt: this.now(),
|
|
826
|
+
expiring: true,
|
|
827
|
+
};
|
|
828
|
+
this.clearTimer();
|
|
829
|
+
this.persist();
|
|
830
|
+
this.updateWidget();
|
|
831
|
+
this.sessionCtx?.ui.notify(
|
|
832
|
+
"Loop expired: one final turn to write the current state down, then it stops.",
|
|
833
|
+
"info",
|
|
834
|
+
);
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
|
|
235
838
|
/**
|
|
236
839
|
* Send first, then account. Pi can refuse the delivery (a busy or compacting
|
|
237
840
|
* session), and an iteration persisted before the send would burn the
|
|
@@ -240,29 +843,39 @@ export class LoopController {
|
|
|
240
843
|
*/
|
|
241
844
|
private deliverPoke(
|
|
242
845
|
env: TickEnvironment,
|
|
243
|
-
reason: "goal-stalled" | "goal-waiting" | "objective-stalled",
|
|
846
|
+
reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed",
|
|
244
847
|
): void {
|
|
245
848
|
const loop = this.state;
|
|
246
849
|
if (!loop) return;
|
|
850
|
+
const standalone = reason === "objective-stalled" || reason === "wait-elapsed";
|
|
247
851
|
// A goal-bound poke restates nothing, so it is only meaningful while the
|
|
248
852
|
// goal it points at is readable; a standalone poke needs no goal at all.
|
|
249
|
-
if (
|
|
853
|
+
if (!standalone && !env.goal) return;
|
|
250
854
|
try {
|
|
251
855
|
this.pi.sendUserMessage(
|
|
252
|
-
|
|
856
|
+
standalone ? buildObjectivePoke(loop, reason) : buildGoalPoke(loop, reason),
|
|
253
857
|
);
|
|
254
858
|
} catch (error) {
|
|
255
859
|
this.sessionCtx?.ui.notify(
|
|
256
860
|
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
257
861
|
"warning",
|
|
258
862
|
);
|
|
259
|
-
this.
|
|
863
|
+
this.armFallback();
|
|
260
864
|
this.updateWidget();
|
|
261
865
|
return;
|
|
262
866
|
}
|
|
263
|
-
this.
|
|
867
|
+
this.runOrigin = "fallback";
|
|
868
|
+
if (this.noteDelivery()) return;
|
|
869
|
+
// The wake consumed the wait it was arranged for, and any one-shot
|
|
870
|
+
// cancelled-wait hint it just carried.
|
|
871
|
+
this.state = {
|
|
872
|
+
...this.consumeWait(loop),
|
|
873
|
+
iteration: loop.iteration + 1,
|
|
874
|
+
automaticTurns: loop.automaticTurns + 1,
|
|
875
|
+
lastWakeAt: env.now,
|
|
876
|
+
};
|
|
264
877
|
this.persist();
|
|
265
|
-
this.
|
|
878
|
+
this.armFallback();
|
|
266
879
|
this.updateWidget();
|
|
267
880
|
}
|
|
268
881
|
|
|
@@ -271,9 +884,16 @@ export class LoopController {
|
|
|
271
884
|
if (!loop || loop.status !== "active" || loop.compactAt === null) return false;
|
|
272
885
|
if (!this.settings.compaction.enabled || this.compacting) return false;
|
|
273
886
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
887
|
+
// A turn that overflowed the context window compacts regardless of the
|
|
888
|
+
// threshold: the usage reading that would gate it is exactly the reading
|
|
889
|
+
// the failed request just disproved.
|
|
890
|
+
const requested = this.compactionRequested;
|
|
891
|
+
this.compactionRequested = false;
|
|
892
|
+
if (!requested) {
|
|
893
|
+
const usage = ctx.getContextUsage();
|
|
894
|
+
if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
|
|
895
|
+
if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
|
|
896
|
+
}
|
|
277
897
|
const goal = readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
278
898
|
this.compacting = true;
|
|
279
899
|
try {
|
|
@@ -284,9 +904,11 @@ export class LoopController {
|
|
|
284
904
|
// objective the summary must preserve.
|
|
285
905
|
goal?.status === "active" ? goal : undefined,
|
|
286
906
|
this.settings.compaction.instructions,
|
|
907
|
+
this.ledger,
|
|
287
908
|
),
|
|
288
|
-
onComplete: () => {
|
|
909
|
+
onComplete: (result) => {
|
|
289
910
|
this.compacting = false;
|
|
911
|
+
this.requestReAnchor(result);
|
|
290
912
|
this.nudgeHeldWake();
|
|
291
913
|
},
|
|
292
914
|
onError: (error) => {
|
|
@@ -307,21 +929,41 @@ export class LoopController {
|
|
|
307
929
|
}
|
|
308
930
|
|
|
309
931
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
932
|
+
* Own the post-compaction re-anchor instead of leaving the loop silent
|
|
933
|
+
* until the next fallback wake. One pointer-sized continuation at the next
|
|
934
|
+
* settle: the objective is in the system append, the record is in the
|
|
935
|
+
* ledger, and the next actions ride out of the summary that just replaced
|
|
936
|
+
* the conversation.
|
|
937
|
+
*/
|
|
938
|
+
private requestReAnchor(result: unknown): void {
|
|
939
|
+
const loop = this.state;
|
|
940
|
+
if (!loop || loop.status !== "active" || !isStandaloneLoop(loop)) return;
|
|
941
|
+
const summary =
|
|
942
|
+
isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
|
|
943
|
+
this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* A wake or continuation held during compaction delivers at the next
|
|
948
|
+
* settled boundary; nudge in case that boundary already passed while
|
|
949
|
+
* compaction ran.
|
|
312
950
|
*/
|
|
313
951
|
private nudgeHeldWake(): void {
|
|
314
952
|
const ctx = this.sessionCtx;
|
|
315
|
-
if (ctx && this.wakePending) this.onAgentSettled(ctx);
|
|
953
|
+
if (ctx && (this.wakePending || this.continuationIntent)) this.onAgentSettled(ctx);
|
|
316
954
|
}
|
|
317
955
|
|
|
318
956
|
// --- state transitions & presentation ---
|
|
319
957
|
|
|
320
|
-
private transition(status: "paused" | "stopped", why: string): void {
|
|
958
|
+
private transition(status: "paused" | "stopped", why: string, cause?: string): void {
|
|
321
959
|
if (!this.state) return;
|
|
322
|
-
|
|
960
|
+
const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
|
|
961
|
+
this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
|
|
323
962
|
this.clearTimer();
|
|
963
|
+
this.waitTimer.clear();
|
|
324
964
|
this.wakePending = false;
|
|
965
|
+
this.continuationIntent = undefined;
|
|
966
|
+
this.runOrigin = undefined;
|
|
325
967
|
this.persist();
|
|
326
968
|
this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
|
|
327
969
|
this.updateWidget();
|
|
@@ -345,7 +987,19 @@ export class LoopController {
|
|
|
345
987
|
return;
|
|
346
988
|
}
|
|
347
989
|
if (loop.status === "paused") {
|
|
348
|
-
ui.setStatus(
|
|
990
|
+
ui.setStatus(
|
|
991
|
+
LOOP_STATUS_KEY,
|
|
992
|
+
loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
|
|
993
|
+
);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (loop.waiting) {
|
|
997
|
+
ui.setStatus(
|
|
998
|
+
LOOP_STATUS_KEY,
|
|
999
|
+
`loop waiting · ${loop.waiting.reason}${
|
|
1000
|
+
loop.waiting.resumeAt ? ` · until ${formatClock(loop.waiting.resumeAt)}` : ""
|
|
1001
|
+
}`,
|
|
1002
|
+
);
|
|
349
1003
|
return;
|
|
350
1004
|
}
|
|
351
1005
|
const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
|
|
@@ -364,9 +1018,22 @@ export class LoopController {
|
|
|
364
1018
|
const loop = this.state;
|
|
365
1019
|
if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
|
|
366
1020
|
const lines = [
|
|
367
|
-
`Status: ${loop.status}`,
|
|
1021
|
+
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
|
|
1022
|
+
...(loop.waiting
|
|
1023
|
+
? [
|
|
1024
|
+
`Waiting: ${loop.waiting.reason}${
|
|
1025
|
+
loop.waiting.resumeAt
|
|
1026
|
+
? ` (wakes ${formatClock(loop.waiting.resumeAt)})`
|
|
1027
|
+
: " (no deadline)"
|
|
1028
|
+
}`,
|
|
1029
|
+
]
|
|
1030
|
+
: []),
|
|
1031
|
+
...(loop.cancelledWaitReason
|
|
1032
|
+
? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
|
|
1033
|
+
: []),
|
|
368
1034
|
`Interval: every ${formatDuration(loop.intervalMs)}`,
|
|
369
|
-
`
|
|
1035
|
+
`Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
|
|
1036
|
+
`Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
|
|
370
1037
|
`Started: ${new Date(loop.startedAt).toLocaleString()}`,
|
|
371
1038
|
`Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
|
|
372
1039
|
`Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
|
|
@@ -374,6 +1041,24 @@ export class LoopController {
|
|
|
374
1041
|
if (loop.objective) {
|
|
375
1042
|
lines.push("Mode: standalone (this loop owns its completion criteria)");
|
|
376
1043
|
lines.push(`Objective: ${loop.objective}`);
|
|
1044
|
+
if (this.ledger) {
|
|
1045
|
+
const criteria = this.criteria();
|
|
1046
|
+
lines.push(`Ledger: ${this.ledger.dir}`);
|
|
1047
|
+
if (criteria) {
|
|
1048
|
+
const met = criteria.filter((criterion) => criterion.passes).length;
|
|
1049
|
+
lines.push(`Criteria: ${met}/${criteria.length} marked passing`);
|
|
1050
|
+
lines.push(
|
|
1051
|
+
...criteria.map(
|
|
1052
|
+
(criterion) =>
|
|
1053
|
+
` [${criterion.passes ? "x" : " "}] ${criterion.id}. ${criterion.description}`,
|
|
1054
|
+
),
|
|
1055
|
+
);
|
|
1056
|
+
} else {
|
|
1057
|
+
lines.push("Criteria: unreadable (the loop runs without them)");
|
|
1058
|
+
}
|
|
1059
|
+
} else {
|
|
1060
|
+
lines.push("Ledger: unavailable (the loop runs without one)");
|
|
1061
|
+
}
|
|
377
1062
|
} else {
|
|
378
1063
|
lines.push("Mode: goal-bound (pi-goal owns completion)");
|
|
379
1064
|
}
|
|
@@ -383,7 +1068,16 @@ export class LoopController {
|
|
|
383
1068
|
: readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
384
1069
|
if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
|
|
385
1070
|
if (this.nextWakeAt && loop.status === "active") {
|
|
386
|
-
lines.push(
|
|
1071
|
+
lines.push(
|
|
1072
|
+
`${isStandaloneLoop(loop) ? "Next fallback wake" : "Next wake"}: ${formatClock(this.nextWakeAt)}${
|
|
1073
|
+
this.noOpStreak > 0
|
|
1074
|
+
? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
|
|
1075
|
+
: ""
|
|
1076
|
+
}`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (this.continuationIntent) {
|
|
1080
|
+
lines.push("A continuation is queued for the next idle boundary.");
|
|
387
1081
|
}
|
|
388
1082
|
if (this.wakePending) lines.push("A wake is pending delivery at the next idle boundary.");
|
|
389
1083
|
if (this.lastDecision) {
|
|
@@ -417,7 +1111,18 @@ export class LoopController {
|
|
|
417
1111
|
);
|
|
418
1112
|
return;
|
|
419
1113
|
}
|
|
420
|
-
|
|
1114
|
+
// A standalone loop with no way to call loop_complete would work, finish,
|
|
1115
|
+
// and then be told to keep working until it hit a cap. Refuse at the door
|
|
1116
|
+
// rather than after the first turn.
|
|
1117
|
+
if (objective && !this.completeToolAvailable()) {
|
|
1118
|
+
ctx.ui.notify(
|
|
1119
|
+
`This session has no ${LOOP_COMPLETE_TOOL} tool, so a loop could never end itself. Re-enable it (it is excluded by --tools/--no-tools or a tool policy) and start the loop again.`,
|
|
1120
|
+
"error",
|
|
1121
|
+
);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const expiryMs =
|
|
1125
|
+
start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
|
|
421
1126
|
const compactAt =
|
|
422
1127
|
start.compactAt !== undefined
|
|
423
1128
|
? start.compactAt
|
|
@@ -434,24 +1139,85 @@ export class LoopController {
|
|
|
434
1139
|
intervalMs: start.intervalMs,
|
|
435
1140
|
maxIterations:
|
|
436
1141
|
start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
|
|
1142
|
+
maxAutomaticTurns: this.settings.automaticTurns,
|
|
437
1143
|
compactAt,
|
|
438
1144
|
iteration: 0,
|
|
1145
|
+
automaticTurns: 0,
|
|
439
1146
|
startedAt: now,
|
|
440
1147
|
expiresAt: now + expiryMs,
|
|
441
1148
|
};
|
|
442
1149
|
this.wakePending = false;
|
|
1150
|
+
this.continuationIntent = undefined;
|
|
1151
|
+
this.noOpStreak = 0;
|
|
1152
|
+
this.ledgerWarned = false;
|
|
1153
|
+
this.openLedger(this.state);
|
|
443
1154
|
this.persist();
|
|
444
1155
|
this.scheduleTick(start.intervalMs);
|
|
445
1156
|
this.updateWidget();
|
|
446
1157
|
const clampNote = start.clamped
|
|
447
1158
|
? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
|
|
448
1159
|
: "";
|
|
1160
|
+
if (goalBound) {
|
|
1161
|
+
ctx.ui.notify(
|
|
1162
|
+
"Binding a loop to an active /goal is deprecated and will be removed. pi-loop now owns long-running work on its own: stop the goal and run /loop <interval> <objective> instead.",
|
|
1163
|
+
"warning",
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
449
1166
|
ctx.ui.notify(
|
|
450
1167
|
goalBound
|
|
451
1168
|
? `Loop started: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, poking the active goal${start.prompt ? " with the loop focus" : ""}. Stop with /loop stop.`
|
|
452
|
-
: `Loop started:
|
|
1169
|
+
: `Loop started: working its own objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you run /loop stop. Fallback wake every ${formatDuration(start.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(expiryMs)} (one final turn to write its state down, then it stops).`,
|
|
453
1170
|
"info",
|
|
454
1171
|
);
|
|
1172
|
+
if (this.ledger) {
|
|
1173
|
+
const criteria = this.criteria() ?? [];
|
|
1174
|
+
ctx.ui.notify(
|
|
1175
|
+
[
|
|
1176
|
+
`Loop ledger: ${this.ledger.dir}`,
|
|
1177
|
+
`Completion criteria (${criteria.length}) — loop_complete answers for these:`,
|
|
1178
|
+
...criteria.map((criterion) => ` ${criterion.id}. ${criterion.description}`),
|
|
1179
|
+
].join("\n"),
|
|
1180
|
+
"info",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
// The kickoff anchor: one stored message per loop holding the objective
|
|
1184
|
+
// data, because the system append exists only while the loop is active.
|
|
1185
|
+
this.sendKickoffAnchor(ctx);
|
|
1186
|
+
// Immediate kickoff: a standalone loop starts working now instead of
|
|
1187
|
+
// burning its first interval idle. A busy session keeps the intent and
|
|
1188
|
+
// delivers it at the settle.
|
|
1189
|
+
if (this.state && isStandaloneLoop(this.state)) {
|
|
1190
|
+
this.requestContinuation(this.state, "kickoff");
|
|
1191
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Store the objective as an ordinary message so it outlives the loop.
|
|
1197
|
+
*
|
|
1198
|
+
* At an idle boundary `sendMessage` appends the message with no turn, which
|
|
1199
|
+
* is exactly an anchor. While the agent streams the same call would *steer*
|
|
1200
|
+
* the running turn, so a busy session gets `nextTurn` instead: queued as
|
|
1201
|
+
* context alongside the next prompt, interrupting nothing.
|
|
1202
|
+
*/
|
|
1203
|
+
private sendKickoffAnchor(ctx: ExtensionContext): void {
|
|
1204
|
+
const loop = this.state;
|
|
1205
|
+
if (!loop || !isStandaloneLoop(loop) || !this.ledger) return;
|
|
1206
|
+
const idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
1207
|
+
try {
|
|
1208
|
+
this.pi.sendMessage(
|
|
1209
|
+
{
|
|
1210
|
+
customType: LOOP_ANCHOR_MESSAGE_TYPE,
|
|
1211
|
+
content: buildKickoffAnchor(loop, this.ledger),
|
|
1212
|
+
display: true,
|
|
1213
|
+
details: { loopId: loop.id },
|
|
1214
|
+
},
|
|
1215
|
+
idle ? {} : { deliverAs: "nextTurn" },
|
|
1216
|
+
);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
// The anchor is a durability nicety; the loop runs without it.
|
|
1219
|
+
ctx.ui.notify(`pi-loop could not anchor the objective: ${formatError(error)}.`, "warning");
|
|
1220
|
+
}
|
|
455
1221
|
}
|
|
456
1222
|
|
|
457
1223
|
pauseLoop(ctx: ExtensionContext): void {
|
|
@@ -488,14 +1254,25 @@ export class LoopController {
|
|
|
488
1254
|
);
|
|
489
1255
|
return;
|
|
490
1256
|
}
|
|
491
|
-
|
|
1257
|
+
// Resuming starts a fresh safety epoch: the user has seen why it paused
|
|
1258
|
+
// and chosen to continue, so the breaker must not trip on stale counters.
|
|
1259
|
+
const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
|
|
1260
|
+
this.state = { ...rest, status: "active", toolFreeRepeatCount: 0 };
|
|
1261
|
+
this.noOpStreak = 0;
|
|
492
1262
|
this.persist();
|
|
493
1263
|
this.scheduleTick(loop.intervalMs);
|
|
494
1264
|
this.updateWidget();
|
|
495
1265
|
ctx.ui.notify(
|
|
496
|
-
|
|
1266
|
+
isStandaloneLoop(loop)
|
|
1267
|
+
? `Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`
|
|
1268
|
+
: `Loop resumed: next wake at ${formatClock(this.now() + loop.intervalMs)}.`,
|
|
497
1269
|
"info",
|
|
498
1270
|
);
|
|
1271
|
+
// Resuming a standalone loop resumes the work, not just the heartbeat.
|
|
1272
|
+
if (this.state && isStandaloneLoop(this.state)) {
|
|
1273
|
+
this.requestContinuation(this.state);
|
|
1274
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
1275
|
+
}
|
|
499
1276
|
}
|
|
500
1277
|
|
|
501
1278
|
/** Re-arm the timer after an interval edit while active. */
|
|
@@ -525,3 +1302,22 @@ export class LoopController {
|
|
|
525
1302
|
function formatError(error: unknown): string {
|
|
526
1303
|
return error instanceof Error ? error.message : String(error);
|
|
527
1304
|
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* A run that called no tool did nothing to the world. It is the signal the
|
|
1308
|
+
* fallback backoff needs today; Stage 6's `LOOP_OK` acknowledgement refines
|
|
1309
|
+
* the same counter rather than replacing it.
|
|
1310
|
+
*/
|
|
1311
|
+
function isNoOpRun(messages: readonly unknown[]): boolean {
|
|
1312
|
+
for (const message of messages) {
|
|
1313
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
if (message.content.some((block) => isRecord(block) && block.type === "toolCall")) return false;
|
|
1317
|
+
}
|
|
1318
|
+
return true;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1322
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1323
|
+
}
|