@hank-warren/pi-loop 0.4.1 → 0.6.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 +33 -0
- package/README.md +170 -32
- package/package.json +4 -2
- package/src/ack.ts +67 -0
- package/src/command.ts +92 -33
- package/src/complete-tool.ts +122 -15
- package/src/decide.ts +92 -51
- package/src/errors.ts +131 -0
- package/src/index.ts +117 -15
- package/src/inline-command.ts +159 -0
- package/src/inline-invocation.ts +109 -0
- package/src/ledger.ts +230 -0
- package/src/loop.ts +844 -129
- package/src/manager.ts +67 -46
- package/src/markers.ts +24 -3
- package/src/messages.ts +167 -35
- package/src/objective.ts +34 -9
- package/src/render.ts +28 -9
- 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 +62 -5
- package/src/start-tool.ts +145 -0
- package/src/state.ts +96 -97
- package/src/wait-tool.ts +114 -0
- package/src/wait.ts +95 -0
package/src/loop.ts
CHANGED
|
@@ -3,33 +3,54 @@
|
|
|
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: an agent_end records a
|
|
7
|
+
* continuation *intent*, and the next fully settled boundary dispatches it.
|
|
8
|
+
* The interval is a fallback heartbeat, re-armed from the last settle, that
|
|
9
|
+
* fires only when the session has been idle a whole interval with the
|
|
10
|
+
* objective unfinished (a lost continuation, or an external wait).
|
|
11
|
+
* - Timers are armed in session_start, a settle, or a command handler, never
|
|
12
|
+
* the factory, and cleared in an idempotent session_shutdown.
|
|
8
13
|
* - Pokes deliver only at a fully idle boundary; a tick that lands while the
|
|
9
14
|
* agent is busy coalesces into a single pending wake delivered at the next
|
|
10
|
-
* agent_settled. Missed ticks never stack
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - Terminal decisions (expiry,
|
|
16
|
-
*
|
|
17
|
-
* ever pokes.
|
|
15
|
+
* agent_settled. Missed ticks never stack, and a continuation supersedes a
|
|
16
|
+
* coalesced wake rather than delivering both.
|
|
17
|
+
* - A loop owns "whether the work is done" itself: it ends through
|
|
18
|
+
* `loop_complete`, a cap, its expiry, or the user, and reads no other
|
|
19
|
+
* extension's state to decide that.
|
|
20
|
+
* - Terminal decisions (expiry, caps) also land at a settled boundary, so the
|
|
21
|
+
* loop settles as soon as the work does; only the timer ever pokes.
|
|
18
22
|
* - The loop's proactive compaction is the normal compaction path; Pi's
|
|
19
|
-
* reserve-token auto-compaction is the fault handler.
|
|
20
|
-
* post-compaction re-
|
|
23
|
+
* reserve-token auto-compaction is the fault handler. The loop owns the
|
|
24
|
+
* post-compaction re-anchor.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
import { randomUUID } from "node:crypto";
|
|
24
|
-
import type {
|
|
25
|
-
ExtensionAPI,
|
|
26
|
-
ExtensionCommandContext,
|
|
27
|
-
ExtensionContext,
|
|
28
|
-
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
29
29
|
import type { LoopStartArguments } from "./command.js";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
import {
|
|
31
|
+
type ContinuationDecision,
|
|
32
|
+
decideContinuation,
|
|
33
|
+
decideTick,
|
|
34
|
+
type TickDecision,
|
|
35
|
+
type TickEnvironment,
|
|
36
|
+
} from "./decide.js";
|
|
37
|
+
import { formatClock, formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
38
|
+
import {
|
|
39
|
+
createLedger,
|
|
40
|
+
deriveCriteria,
|
|
41
|
+
type LedgerPaths,
|
|
42
|
+
ledgerPaths,
|
|
43
|
+
readCriteria,
|
|
44
|
+
} from "./ledger.js";
|
|
45
|
+
import {
|
|
46
|
+
buildCompactionInstructions,
|
|
47
|
+
buildContinuation,
|
|
48
|
+
buildExpiryWake,
|
|
49
|
+
buildKickoffAnchor,
|
|
50
|
+
buildObjectivePoke,
|
|
51
|
+
type ContinuationKind,
|
|
52
|
+
extractNextActions,
|
|
53
|
+
} from "./messages.js";
|
|
33
54
|
import {
|
|
34
55
|
DEFAULT_LOOP_SETTINGS,
|
|
35
56
|
type LoopSettings,
|
|
@@ -37,20 +58,66 @@ import {
|
|
|
37
58
|
readLoopSettings,
|
|
38
59
|
} from "./settings.js";
|
|
39
60
|
import {
|
|
40
|
-
isStandaloneLoop,
|
|
41
61
|
LOOP_STATE_ENTRY_TYPE,
|
|
42
62
|
type LoopState,
|
|
43
|
-
readGoalSnapshot,
|
|
44
63
|
readPlanModeEnabled,
|
|
45
64
|
restoreLoopState,
|
|
46
65
|
} from "./state.js";
|
|
66
|
+
import { isLoopOkAck } from "./ack.js";
|
|
67
|
+
import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
|
|
68
|
+
import { classifyInterruption } from "./errors.js";
|
|
69
|
+
import { LOOP_WAIT_TOOL } from "./wait-tool.js";
|
|
70
|
+
import {
|
|
71
|
+
createLoopWait,
|
|
72
|
+
type LoopWait,
|
|
73
|
+
LoopWaitTimer,
|
|
74
|
+
resolveWaitDelay,
|
|
75
|
+
type ResolvedWaitDelay,
|
|
76
|
+
} from "./wait.js";
|
|
77
|
+
import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
|
|
47
78
|
import { clearLoopWidget, updateLoopWidget } from "./widget.js";
|
|
48
79
|
|
|
49
80
|
export const LOOP_STATUS_KEY = "loop";
|
|
50
81
|
|
|
82
|
+
/** Custom message type of the kickoff anchor. */
|
|
83
|
+
export const LOOP_ANCHOR_MESSAGE_TYPE = "loop-objective";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A fallback wake that produces a no-op turn doubles the next fallback delay,
|
|
87
|
+
* capped here. Deterministic and small: the point is to stop re-waking a loop
|
|
88
|
+
* that has nothing to do, not to invent an adaptive scheduler.
|
|
89
|
+
*/
|
|
90
|
+
export const MAX_FALLBACK_BACKOFF = 4;
|
|
91
|
+
|
|
92
|
+
/** Consecutive loop deliveries that produce no run before the loop pauses. */
|
|
93
|
+
export const MAX_DEAD_DELIVERIES = 3;
|
|
94
|
+
|
|
95
|
+
/** Why the loop caused the run that is currently in flight. */
|
|
96
|
+
type RunOrigin = "continuation" | "fallback";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The outcome of a start attempt.
|
|
100
|
+
*
|
|
101
|
+
* `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
|
|
102
|
+
* which tied the only start path to a UI. Two callers now share that path —
|
|
103
|
+
* the `/loop` command and the `loop_start` tool — and the tool has to turn
|
|
104
|
+
* the same refusal into tool content rather than a toast, so the decision is
|
|
105
|
+
* returned and each caller renders it.
|
|
106
|
+
*/
|
|
107
|
+
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
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,57 +181,505 @@ 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 (this.state.objective === undefined && this.adoptLegacyObjective(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 loop is the signal that paces it:
|
|
215
|
+
* record the *intent* to continue here and let the settled boundary decide
|
|
216
|
+
* whether it may be delivered. Recording at agent_end (not at settle) is
|
|
217
|
+
* what makes the intent survive Pi's own retries and auto-compaction, which
|
|
218
|
+
* 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") 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.
|
|
386
|
+
this.armFallback();
|
|
119
387
|
}
|
|
120
388
|
|
|
121
389
|
/**
|
|
122
390
|
* A settled boundary with no wake pending still evaluates the terminal
|
|
123
|
-
* decisions — expiry
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
391
|
+
* decisions — expiry and the caps — so the loop settles the moment the work
|
|
392
|
+
* does instead of up to one interval later. Poke and skip decisions are
|
|
393
|
+
* deliberately ignored here: only the timer pokes, and settling is not a
|
|
394
|
+
* schedule.
|
|
127
395
|
*/
|
|
128
|
-
private settleTerminalState(ctx: ExtensionContext):
|
|
396
|
+
private settleTerminalState(ctx: ExtensionContext): boolean {
|
|
129
397
|
const loop = this.state;
|
|
130
|
-
if (!loop) return;
|
|
398
|
+
if (!loop) return false;
|
|
131
399
|
const env = this.gatherEnvironment(ctx);
|
|
132
400
|
const decision = decideTick(loop, env);
|
|
133
|
-
if (decision.action !== "expire" && decision.action !== "stop"
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
401
|
+
if (decision.action !== "expire" && decision.action !== "stop") return false;
|
|
136
402
|
this.lastDecision = { ...decision, at: env.now };
|
|
137
403
|
this.applyTerminalDecision(loop, decision);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The restore shim for a loop persisted before 0.6.0.
|
|
409
|
+
*
|
|
410
|
+
* Such a loop may carry no objective of its own: it delegated "is the work
|
|
411
|
+
* done" to a goal in another extension that no longer exists here. The only
|
|
412
|
+
* case that can still reach this code is a session persisted before 0.6.0
|
|
413
|
+
* and resumed after it, having never been restored under 0.5.0 — where it
|
|
414
|
+
* would already have been converted.
|
|
415
|
+
*
|
|
416
|
+
* Its focus text, when it has one, is the closest thing to an objective it
|
|
417
|
+
* has, so adopt that. With nothing to adopt there is no honest way to run
|
|
418
|
+
* it, so it pauses and says so.
|
|
419
|
+
*
|
|
420
|
+
* Returns true when the loop was paused and needs no timer.
|
|
421
|
+
*/
|
|
422
|
+
private adoptLegacyObjective(ctx: ExtensionContext): boolean {
|
|
423
|
+
const loop = this.state;
|
|
424
|
+
if (!loop) return true;
|
|
425
|
+
const objective = loop.prompt;
|
|
426
|
+
if (!objective) {
|
|
427
|
+
this.transition(
|
|
428
|
+
"paused",
|
|
429
|
+
"it was bound to a goal that is gone and has no objective of its own; start a new loop with /loop <interval> <objective>",
|
|
430
|
+
"loop with no objective",
|
|
431
|
+
);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
const { prompt: _prompt, ...rest } = loop;
|
|
435
|
+
this.state = { ...rest, objective };
|
|
436
|
+
this.persist();
|
|
437
|
+
ctx.ui.notify(
|
|
438
|
+
`This loop predates pi-loop owning its own objective; it now works its focus text directly: ${objective}`,
|
|
439
|
+
"info",
|
|
440
|
+
);
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* A loop that cannot call `loop_complete` cannot end itself: it will work,
|
|
446
|
+
* finish, and then be told to keep working until it hits a cap.
|
|
447
|
+
* That happens whenever the tool set is restricted (`--tools`, `--no-tools`,
|
|
448
|
+
* a policy that drops extension tools), and it is invisible from inside the
|
|
449
|
+
* loop — so check the live tool set and pause instead of spinning.
|
|
450
|
+
*
|
|
451
|
+
* Returns true when the loop was paused.
|
|
452
|
+
*/
|
|
453
|
+
private enforceToolAvailability(ctx: ExtensionContext): boolean {
|
|
454
|
+
const loop = this.state;
|
|
455
|
+
if (!loop || loop.status !== "active") return false;
|
|
456
|
+
if (this.completeToolAvailable()) return false;
|
|
457
|
+
this.transition(
|
|
458
|
+
"paused",
|
|
459
|
+
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop resume`,
|
|
460
|
+
"loop_complete unavailable",
|
|
461
|
+
);
|
|
462
|
+
void ctx;
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private completeToolAvailable(): boolean {
|
|
467
|
+
const getActiveTools = (this.pi as { getActiveTools?: () => string[] }).getActiveTools;
|
|
468
|
+
if (typeof getActiveTools !== "function") return true;
|
|
469
|
+
try {
|
|
470
|
+
// Fail open: a host that cannot report its tools is not evidence that
|
|
471
|
+
// the tool is missing.
|
|
472
|
+
return getActiveTools.call(this.pi).includes(LOOP_COMPLETE_TOOL);
|
|
473
|
+
} catch {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Count a delivery that never became a run, and pause once the loop is
|
|
480
|
+
* plainly shouting into a void. Without this a session whose provider
|
|
481
|
+
* refuses every request (no API key, a revoked token) keeps waking on the
|
|
482
|
+
* heartbeat until it exhausts its wake cap, because a run that fails before
|
|
483
|
+
* the first token never reaches agent_end and so is never classified.
|
|
484
|
+
*
|
|
485
|
+
* Returns true when the loop was paused.
|
|
486
|
+
*/
|
|
487
|
+
private noteDelivery(): boolean {
|
|
488
|
+
if (this.awaitingRun) this.deadDeliveries += 1;
|
|
489
|
+
this.awaitingRun = true;
|
|
490
|
+
if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
|
|
491
|
+
this.transition(
|
|
492
|
+
"paused",
|
|
493
|
+
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop resume`,
|
|
494
|
+
"deliveries produce no turns",
|
|
495
|
+
);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- loop_wait ---
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Enter an external wait. The pacemaker is deliberately left armed: a wait
|
|
503
|
+
* supersedes the *next* fallback wake, it does not disable the heartbeat,
|
|
504
|
+
* so a wait whose event never arrives still ends in a wake rather than in
|
|
505
|
+
* silence.
|
|
506
|
+
*/
|
|
507
|
+
enterWait(reason: string, resumeAfterMs: number | undefined): ResolvedWaitDelay | undefined {
|
|
508
|
+
const loop = this.state;
|
|
509
|
+
if (!loop || loop.status !== "active") return undefined;
|
|
510
|
+
const resolved = resolveWaitDelay(resumeAfterMs);
|
|
511
|
+
const waiting = createLoopWait(reason, resumeAfterMs, this.now());
|
|
512
|
+
// The wait replaces any continuation already recorded for this turn.
|
|
513
|
+
this.continuationIntent = undefined;
|
|
514
|
+
const { cancelledWaitReason: _cancelled, ...rest } = loop;
|
|
515
|
+
this.state = { ...rest, waiting };
|
|
516
|
+
this.persist();
|
|
517
|
+
this.restoreWaitTimer();
|
|
518
|
+
this.updateWidget();
|
|
519
|
+
return resolved;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Drop a wait that something other than its own deadline ended, keeping its
|
|
524
|
+
* reason as a one-shot hint for the next loop message. There is no cancel
|
|
525
|
+
* tool: the events that legitimately cancel a wait are not the model's to
|
|
526
|
+
* report.
|
|
527
|
+
*/
|
|
528
|
+
private cancelWait(_why: string): void {
|
|
529
|
+
const loop = this.state;
|
|
530
|
+
if (!loop?.waiting) return;
|
|
531
|
+
const { waiting, ...rest } = loop;
|
|
532
|
+
this.waitTimer.clear();
|
|
533
|
+
this.state = { ...rest, cancelledWaitReason: waiting.reason };
|
|
534
|
+
this.persist();
|
|
535
|
+
this.updateWidget();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Re-arm the wait deadline, including after a restore or a compaction. */
|
|
539
|
+
private restoreWaitTimer(): void {
|
|
540
|
+
this.waitTimer.clear();
|
|
541
|
+
const loop = this.state;
|
|
542
|
+
const resumeAt = loop?.status === "active" ? loop.waiting?.resumeAt : undefined;
|
|
543
|
+
if (resumeAt === undefined) return;
|
|
544
|
+
const loopId = loop?.id;
|
|
545
|
+
this.waitTimer.schedule(
|
|
546
|
+
resumeAt,
|
|
547
|
+
() => {
|
|
548
|
+
const ctx = this.sessionCtx;
|
|
549
|
+
if (!ctx || this.state?.id !== loopId) return;
|
|
550
|
+
try {
|
|
551
|
+
this.runTick(ctx);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
ctx.ui.notify(`pi-loop wait deadline failed: ${formatError(error)}`, "warning");
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
this.now(),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The wait a delivered wake consumed, cleared as the wake goes out. */
|
|
561
|
+
private consumeWait(loop: LoopState): LoopState {
|
|
562
|
+
const { waiting: _waiting, cancelledWaitReason: _cancelled, ...rest } = loop;
|
|
563
|
+
this.waitTimer.clear();
|
|
564
|
+
return rest;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// --- ledger ---
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Create (or adopt) the loop's ledger. Best-effort by design: a loop with
|
|
571
|
+
* no writable ledger still runs, it just loses the durable record, so the
|
|
572
|
+
* failure is warned once and never repeated.
|
|
573
|
+
*/
|
|
574
|
+
private openLedger(loop: LoopState): void {
|
|
575
|
+
if (loop.objective === undefined) {
|
|
576
|
+
this.ledger = undefined;
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const paths = ledgerPaths(loop.id, this.agentDir);
|
|
580
|
+
const failure = createLedger(paths, loop.objective, deriveCriteria(loop.objective));
|
|
581
|
+
if (failure) {
|
|
582
|
+
this.ledger = undefined;
|
|
583
|
+
if (!this.ledgerWarned) {
|
|
584
|
+
this.ledgerWarned = true;
|
|
585
|
+
this.sessionCtx?.ui.notify(
|
|
586
|
+
`pi-loop could not write its ledger (${failure}). The loop runs without one.`,
|
|
587
|
+
"warning",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.ledger = paths;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The loop's criteria as last written to disk, fail-open. */
|
|
596
|
+
criteria() {
|
|
597
|
+
return this.ledger ? readCriteria(this.ledger) : undefined;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- settle-driven continuation ---
|
|
601
|
+
|
|
602
|
+
/** Record the intent to continue; the settled boundary decides delivery. */
|
|
603
|
+
private requestContinuation(
|
|
604
|
+
loop: LoopState,
|
|
605
|
+
kind: ContinuationKind = "continue",
|
|
606
|
+
nextActions?: string,
|
|
607
|
+
): void {
|
|
608
|
+
// A re-anchor outranks an ordinary continuation already queued: after a
|
|
609
|
+
// compaction, "re-read the ledger" is strictly the better instruction.
|
|
610
|
+
if (this.continuationIntent?.loopId === loop.id && kind !== "reanchor") return;
|
|
611
|
+
this.continuationIntent = { loopId: loop.id, kind, ...(nextActions ? { nextActions } : {}) };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Deliver a recorded continuation, but only at a boundary where Pi will
|
|
616
|
+
* actually accept it. A skip leaves the intent in place for the next
|
|
617
|
+
* settle; a terminal decision consumes it.
|
|
618
|
+
*/
|
|
619
|
+
dispatchContinuationIfSettled(ctx: ExtensionContext): boolean {
|
|
620
|
+
const intent = this.continuationIntent;
|
|
621
|
+
if (!intent) return false;
|
|
622
|
+
const loop = this.state;
|
|
623
|
+
if (!loop || loop.id !== intent.loopId) {
|
|
624
|
+
this.continuationIntent = undefined;
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
const env = this.gatherEnvironment(ctx);
|
|
628
|
+
const decision = decideContinuation(loop, env);
|
|
629
|
+
this.lastContinuation = { ...decision, at: env.now };
|
|
630
|
+
if (decision.action === "none") {
|
|
631
|
+
this.continuationIntent = undefined;
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
if (decision.action === "skip") return false;
|
|
635
|
+
if (decision.action !== "continue") {
|
|
636
|
+
this.continuationIntent = undefined;
|
|
637
|
+
this.lastDecision = { ...decision, at: env.now };
|
|
638
|
+
this.applyTerminalDecision(loop, decision);
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
this.pi.sendUserMessage(buildContinuation(loop, intent.kind, intent.nextActions));
|
|
643
|
+
} catch (error) {
|
|
644
|
+
// Keep the intent: the next settle retries it, and the fallback
|
|
645
|
+
// heartbeat covers the case where no further settle arrives.
|
|
646
|
+
this.sessionCtx?.ui.notify(
|
|
647
|
+
`pi-loop could not continue the loop: ${formatError(error)}. Retrying at the next idle boundary.`,
|
|
648
|
+
"warning",
|
|
649
|
+
);
|
|
650
|
+
this.armFallback();
|
|
651
|
+
this.updateWidget();
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
this.continuationIntent = undefined;
|
|
655
|
+
this.runOrigin = "continuation";
|
|
656
|
+
if (this.noteDelivery()) return true;
|
|
657
|
+
// A continuation is the work the coalesced wake would have asked for.
|
|
658
|
+
this.wakePending = false;
|
|
659
|
+
this.state = { ...loop, automaticTurns: loop.automaticTurns + 1 };
|
|
660
|
+
this.persist();
|
|
661
|
+
this.armFallback();
|
|
662
|
+
this.updateWidget();
|
|
663
|
+
return true;
|
|
138
664
|
}
|
|
139
665
|
|
|
140
666
|
// --- tick machinery ---
|
|
141
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Arm the fallback heartbeat, backing off while consecutive fallback wakes
|
|
670
|
+
* keep producing no-op turns.
|
|
671
|
+
*/
|
|
672
|
+
private armFallback(): void {
|
|
673
|
+
const loop = this.state;
|
|
674
|
+
if (!loop || loop.status !== "active") return;
|
|
675
|
+
this.scheduleTick(this.fallbackDelayMs(loop));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
fallbackDelayMs(loop: LoopState): number {
|
|
679
|
+
const multiplier = Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak);
|
|
680
|
+
return Math.min(MAX_INTERVAL_MS, loop.intervalMs * multiplier);
|
|
681
|
+
}
|
|
682
|
+
|
|
142
683
|
private scheduleTick(delayMs: number): void {
|
|
143
684
|
this.clearTimer();
|
|
144
685
|
this.nextWakeAt = this.now() + delayMs;
|
|
@@ -171,7 +712,6 @@ export class LoopController {
|
|
|
171
712
|
busy: !ctx.isIdle() || ctx.hasPendingMessages(),
|
|
172
713
|
compacting: this.compacting,
|
|
173
714
|
planModeEnabled: readPlanModeEnabled(branch),
|
|
174
|
-
goal: readGoalSnapshot(branch),
|
|
175
715
|
};
|
|
176
716
|
}
|
|
177
717
|
|
|
@@ -185,10 +725,12 @@ export class LoopController {
|
|
|
185
725
|
case "none":
|
|
186
726
|
return;
|
|
187
727
|
case "skip":
|
|
188
|
-
if (decision.reason === "plan-mode-active") {
|
|
189
|
-
// Plan mode may end without an agent_settled we can use,
|
|
190
|
-
//
|
|
191
|
-
|
|
728
|
+
if (decision.reason === "plan-mode-active" || decision.reason === "loop-waiting") {
|
|
729
|
+
// Plan mode may end without an agent_settled we can use, and a
|
|
730
|
+
// wait supersedes only this one fallback wake — in both cases
|
|
731
|
+
// keep the heartbeat armed rather than coalescing a wake that
|
|
732
|
+
// would fire the moment the hold ends.
|
|
733
|
+
this.armFallback();
|
|
192
734
|
} else {
|
|
193
735
|
// Busy or compacting: coalesce into one pending wake that
|
|
194
736
|
// the next agent_settled (or compaction onComplete) delivers.
|
|
@@ -197,7 +739,7 @@ export class LoopController {
|
|
|
197
739
|
this.updateWidget();
|
|
198
740
|
return;
|
|
199
741
|
case "poke":
|
|
200
|
-
this.deliverPoke(env, decision.reason);
|
|
742
|
+
this.deliverPoke(env.now, decision.reason);
|
|
201
743
|
return;
|
|
202
744
|
default:
|
|
203
745
|
this.applyTerminalDecision(loop, decision);
|
|
@@ -207,62 +749,96 @@ export class LoopController {
|
|
|
207
749
|
|
|
208
750
|
private applyTerminalDecision(
|
|
209
751
|
loop: LoopState,
|
|
210
|
-
decision: Extract<TickDecision, { action: "expire" | "stop"
|
|
752
|
+
decision: Extract<TickDecision, { action: "expire" | "stop" }>,
|
|
211
753
|
): void {
|
|
212
754
|
switch (decision.action) {
|
|
213
755
|
case "expire":
|
|
214
|
-
|
|
756
|
+
if (decision.reason === "expiry-final-wake" && this.deliverExpiryWake(loop)) return;
|
|
757
|
+
this.transition("stopped", "loop expired (the expiry was reached)");
|
|
215
758
|
return;
|
|
216
759
|
case "stop":
|
|
217
760
|
this.transition(
|
|
218
761
|
"stopped",
|
|
219
|
-
decision.reason === "
|
|
220
|
-
?
|
|
762
|
+
decision.reason === "max-automatic-turns"
|
|
763
|
+
? `the ${loop.maxAutomaticTurns}-automatic-turn cap was reached`
|
|
221
764
|
: `the ${loop.maxIterations}-iteration cap was reached`,
|
|
222
765
|
);
|
|
223
766
|
return;
|
|
224
|
-
case "pause":
|
|
225
|
-
this.transition(
|
|
226
|
-
"paused",
|
|
227
|
-
decision.reason === "goal-missing"
|
|
228
|
-
? "loops require an active goal; start one with /goal <objective>, then /loop resume"
|
|
229
|
-
: `pi-goal reports the goal is ${decision.cause}; resolve it, then /loop resume`,
|
|
230
|
-
);
|
|
231
|
-
return;
|
|
232
767
|
}
|
|
233
768
|
}
|
|
234
769
|
|
|
770
|
+
/**
|
|
771
|
+
* One last turn at expiry, so the loop's most recent state lands in the
|
|
772
|
+
* ledger instead of only in a conversation that is about to be closed. The
|
|
773
|
+
* loop stays active for exactly that turn — the objective append has to be
|
|
774
|
+
* present while it writes — and `expiring` makes the next settle stop it.
|
|
775
|
+
*
|
|
776
|
+
* Returns false when the wake could not be delivered, in which case the
|
|
777
|
+
* caller stops the loop immediately rather than leaving it alive past its
|
|
778
|
+
* expiry waiting for a turn that will not happen.
|
|
779
|
+
*/
|
|
780
|
+
private deliverExpiryWake(loop: LoopState): boolean {
|
|
781
|
+
if (loop.expiring) return false;
|
|
782
|
+
try {
|
|
783
|
+
this.pi.sendUserMessage(buildExpiryWake(loop, this.ledger));
|
|
784
|
+
} catch (error) {
|
|
785
|
+
this.sessionCtx?.ui.notify(
|
|
786
|
+
`pi-loop could not deliver the expiry wake: ${formatError(error)}. Stopping the loop.`,
|
|
787
|
+
"warning",
|
|
788
|
+
);
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
this.runOrigin = "fallback";
|
|
792
|
+
this.continuationIntent = undefined;
|
|
793
|
+
this.state = {
|
|
794
|
+
...this.consumeWait(loop),
|
|
795
|
+
iteration: loop.iteration + 1,
|
|
796
|
+
automaticTurns: loop.automaticTurns + 1,
|
|
797
|
+
lastWakeAt: this.now(),
|
|
798
|
+
expiring: true,
|
|
799
|
+
};
|
|
800
|
+
this.clearTimer();
|
|
801
|
+
this.persist();
|
|
802
|
+
this.updateWidget();
|
|
803
|
+
this.sessionCtx?.ui.notify(
|
|
804
|
+
"Loop expired: one final turn to write the current state down, then it stops.",
|
|
805
|
+
"info",
|
|
806
|
+
);
|
|
807
|
+
return true;
|
|
808
|
+
}
|
|
809
|
+
|
|
235
810
|
/**
|
|
236
811
|
* Send first, then account. Pi can refuse the delivery (a busy or compacting
|
|
237
812
|
* session), and an iteration persisted before the send would burn the
|
|
238
813
|
* maxIterations cap on a poke that never arrived; on a throw the loop re-arms
|
|
239
814
|
* on the same cadence and retries at the next wake.
|
|
240
815
|
*/
|
|
241
|
-
private deliverPoke(
|
|
242
|
-
env: TickEnvironment,
|
|
243
|
-
reason: "goal-stalled" | "goal-waiting" | "objective-stalled",
|
|
244
|
-
): void {
|
|
816
|
+
private deliverPoke(now: number, reason: "objective-stalled" | "wait-elapsed"): void {
|
|
245
817
|
const loop = this.state;
|
|
246
818
|
if (!loop) return;
|
|
247
|
-
// A goal-bound poke restates nothing, so it is only meaningful while the
|
|
248
|
-
// goal it points at is readable; a standalone poke needs no goal at all.
|
|
249
|
-
if (reason !== "objective-stalled" && !env.goal) return;
|
|
250
819
|
try {
|
|
251
|
-
this.pi.sendUserMessage(
|
|
252
|
-
reason === "objective-stalled" ? buildObjectivePoke(loop) : buildGoalPoke(loop, reason),
|
|
253
|
-
);
|
|
820
|
+
this.pi.sendUserMessage(buildObjectivePoke(loop, reason));
|
|
254
821
|
} catch (error) {
|
|
255
822
|
this.sessionCtx?.ui.notify(
|
|
256
823
|
`pi-loop could not deliver a wake: ${formatError(error)}. Retrying at the next interval.`,
|
|
257
824
|
"warning",
|
|
258
825
|
);
|
|
259
|
-
this.
|
|
826
|
+
this.armFallback();
|
|
260
827
|
this.updateWidget();
|
|
261
828
|
return;
|
|
262
829
|
}
|
|
263
|
-
this.
|
|
830
|
+
this.runOrigin = "fallback";
|
|
831
|
+
if (this.noteDelivery()) return;
|
|
832
|
+
// The wake consumed the wait it was arranged for, and any one-shot
|
|
833
|
+
// cancelled-wait hint it just carried.
|
|
834
|
+
this.state = {
|
|
835
|
+
...this.consumeWait(loop),
|
|
836
|
+
iteration: loop.iteration + 1,
|
|
837
|
+
automaticTurns: loop.automaticTurns + 1,
|
|
838
|
+
lastWakeAt: now,
|
|
839
|
+
};
|
|
264
840
|
this.persist();
|
|
265
|
-
this.
|
|
841
|
+
this.armFallback();
|
|
266
842
|
this.updateWidget();
|
|
267
843
|
}
|
|
268
844
|
|
|
@@ -271,22 +847,27 @@ export class LoopController {
|
|
|
271
847
|
if (!loop || loop.status !== "active" || loop.compactAt === null) return false;
|
|
272
848
|
if (!this.settings.compaction.enabled || this.compacting) return false;
|
|
273
849
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
850
|
+
// A turn that overflowed the context window compacts regardless of the
|
|
851
|
+
// threshold: the usage reading that would gate it is exactly the reading
|
|
852
|
+
// the failed request just disproved.
|
|
853
|
+
const requested = this.compactionRequested;
|
|
854
|
+
this.compactionRequested = false;
|
|
855
|
+
if (!requested) {
|
|
856
|
+
const usage = ctx.getContextUsage();
|
|
857
|
+
if (!usage || typeof usage.tokens !== "number" || !usage.contextWindow) return false;
|
|
858
|
+
if (usage.tokens / usage.contextWindow < loop.compactAt) return false;
|
|
859
|
+
}
|
|
278
860
|
this.compacting = true;
|
|
279
861
|
try {
|
|
280
862
|
ctx.compact({
|
|
281
863
|
customInstructions: buildCompactionInstructions(
|
|
282
864
|
loop,
|
|
283
|
-
// A completed or otherwise finished goal is no longer the
|
|
284
|
-
// objective the summary must preserve.
|
|
285
|
-
goal?.status === "active" ? goal : undefined,
|
|
286
865
|
this.settings.compaction.instructions,
|
|
866
|
+
this.ledger,
|
|
287
867
|
),
|
|
288
|
-
onComplete: () => {
|
|
868
|
+
onComplete: (result) => {
|
|
289
869
|
this.compacting = false;
|
|
870
|
+
this.requestReAnchor(result);
|
|
290
871
|
this.nudgeHeldWake();
|
|
291
872
|
},
|
|
292
873
|
onError: (error) => {
|
|
@@ -307,21 +888,41 @@ export class LoopController {
|
|
|
307
888
|
}
|
|
308
889
|
|
|
309
890
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
891
|
+
* Own the post-compaction re-anchor instead of leaving the loop silent
|
|
892
|
+
* until the next fallback wake. One pointer-sized continuation at the next
|
|
893
|
+
* settle: the objective is in the system append, the record is in the
|
|
894
|
+
* ledger, and the next actions ride out of the summary that just replaced
|
|
895
|
+
* the conversation.
|
|
896
|
+
*/
|
|
897
|
+
private requestReAnchor(result: unknown): void {
|
|
898
|
+
const loop = this.state;
|
|
899
|
+
if (!loop || loop.status !== "active") return;
|
|
900
|
+
const summary =
|
|
901
|
+
isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
|
|
902
|
+
this.requestContinuation(loop, "reanchor", summary ? extractNextActions(summary) : undefined);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* A wake or continuation held during compaction delivers at the next
|
|
907
|
+
* settled boundary; nudge in case that boundary already passed while
|
|
908
|
+
* compaction ran.
|
|
312
909
|
*/
|
|
313
910
|
private nudgeHeldWake(): void {
|
|
314
911
|
const ctx = this.sessionCtx;
|
|
315
|
-
if (ctx && this.wakePending) this.onAgentSettled(ctx);
|
|
912
|
+
if (ctx && (this.wakePending || this.continuationIntent)) this.onAgentSettled(ctx);
|
|
316
913
|
}
|
|
317
914
|
|
|
318
915
|
// --- state transitions & presentation ---
|
|
319
916
|
|
|
320
|
-
private transition(status: "paused" | "stopped", why: string): void {
|
|
917
|
+
private transition(status: "paused" | "stopped", why: string, cause?: string): void {
|
|
321
918
|
if (!this.state) return;
|
|
322
|
-
|
|
919
|
+
const { waiting: _waiting, pauseCause: _pauseCause, ...rest } = this.state;
|
|
920
|
+
this.state = { ...rest, status, ...(cause ? { pauseCause: cause } : {}) };
|
|
323
921
|
this.clearTimer();
|
|
922
|
+
this.waitTimer.clear();
|
|
324
923
|
this.wakePending = false;
|
|
924
|
+
this.continuationIntent = undefined;
|
|
925
|
+
this.runOrigin = undefined;
|
|
325
926
|
this.persist();
|
|
326
927
|
this.sessionCtx?.ui.notify(`Loop ${status}: ${why}.`, "info");
|
|
327
928
|
this.updateWidget();
|
|
@@ -345,7 +946,19 @@ export class LoopController {
|
|
|
345
946
|
return;
|
|
346
947
|
}
|
|
347
948
|
if (loop.status === "paused") {
|
|
348
|
-
ui.setStatus(
|
|
949
|
+
ui.setStatus(
|
|
950
|
+
LOOP_STATUS_KEY,
|
|
951
|
+
loop.pauseCause ? `loop paused · ${loop.pauseCause}` : "loop paused",
|
|
952
|
+
);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (loop.waiting) {
|
|
956
|
+
ui.setStatus(
|
|
957
|
+
LOOP_STATUS_KEY,
|
|
958
|
+
`loop waiting · ${loop.waiting.reason}${
|
|
959
|
+
loop.waiting.resumeAt ? ` · until ${formatClock(loop.waiting.resumeAt)}` : ""
|
|
960
|
+
}`,
|
|
961
|
+
);
|
|
349
962
|
return;
|
|
350
963
|
}
|
|
351
964
|
const cap = loop.maxIterations === null ? "∞" : `${loop.maxIterations}`;
|
|
@@ -364,26 +977,59 @@ export class LoopController {
|
|
|
364
977
|
const loop = this.state;
|
|
365
978
|
if (!loop) return ["No loop in this session. Start one with /loop <interval> [prompt]."];
|
|
366
979
|
const lines = [
|
|
367
|
-
`Status: ${loop.status}`,
|
|
980
|
+
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
|
|
981
|
+
...(loop.waiting
|
|
982
|
+
? [
|
|
983
|
+
`Waiting: ${loop.waiting.reason}${
|
|
984
|
+
loop.waiting.resumeAt
|
|
985
|
+
? ` (wakes ${formatClock(loop.waiting.resumeAt)})`
|
|
986
|
+
: " (no deadline)"
|
|
987
|
+
}`,
|
|
988
|
+
]
|
|
989
|
+
: []),
|
|
990
|
+
...(loop.cancelledWaitReason
|
|
991
|
+
? [`Cancelled wait (reported on the next wake): ${loop.cancelledWaitReason}`]
|
|
992
|
+
: []),
|
|
368
993
|
`Interval: every ${formatDuration(loop.intervalMs)}`,
|
|
369
|
-
`
|
|
994
|
+
`Wakes: ${loop.iteration}${loop.maxIterations === null ? " (unlimited)" : ` of ${loop.maxIterations}`}`,
|
|
995
|
+
`Automatic turns: ${loop.automaticTurns}${loop.maxAutomaticTurns === null ? " (unlimited)" : ` of ${loop.maxAutomaticTurns}`}`,
|
|
370
996
|
`Started: ${new Date(loop.startedAt).toLocaleString()}`,
|
|
371
997
|
`Expires: ${new Date(loop.expiresAt).toLocaleString()}`,
|
|
372
998
|
`Proactive compaction: ${loop.compactAt === null ? "off" : `at ${Math.round(loop.compactAt * 100)}% of context`}`,
|
|
373
999
|
];
|
|
374
1000
|
if (loop.objective) {
|
|
375
|
-
lines.push("Mode: standalone (this loop owns its completion criteria)");
|
|
376
1001
|
lines.push(`Objective: ${loop.objective}`);
|
|
377
|
-
|
|
378
|
-
|
|
1002
|
+
if (this.ledger) {
|
|
1003
|
+
const criteria = this.criteria();
|
|
1004
|
+
lines.push(`Ledger: ${this.ledger.dir}`);
|
|
1005
|
+
if (criteria) {
|
|
1006
|
+
const met = criteria.filter((criterion) => criterion.passes).length;
|
|
1007
|
+
lines.push(`Criteria: ${met}/${criteria.length} marked passing`);
|
|
1008
|
+
lines.push(
|
|
1009
|
+
...criteria.map(
|
|
1010
|
+
(criterion) =>
|
|
1011
|
+
` [${criterion.passes ? "x" : " "}] ${criterion.id}. ${criterion.description}`,
|
|
1012
|
+
),
|
|
1013
|
+
);
|
|
1014
|
+
} else {
|
|
1015
|
+
lines.push("Criteria: unreadable (the loop runs without them)");
|
|
1016
|
+
}
|
|
1017
|
+
} else {
|
|
1018
|
+
lines.push("Ledger: unavailable (the loop runs without one)");
|
|
1019
|
+
}
|
|
379
1020
|
}
|
|
380
1021
|
if (loop.prompt) lines.push(`Focus: ${loop.prompt}`);
|
|
381
|
-
const goal = isStandaloneLoop(loop)
|
|
382
|
-
? undefined
|
|
383
|
-
: readGoalSnapshot(ctx.sessionManager.getBranch());
|
|
384
|
-
if (goal) lines.push(`Goal (pi-goal): ${goal.status} — ${goal.text}`);
|
|
385
1022
|
if (this.nextWakeAt && loop.status === "active") {
|
|
386
|
-
lines.push(
|
|
1023
|
+
lines.push(
|
|
1024
|
+
`Next fallback wake: ${formatClock(this.nextWakeAt)}${
|
|
1025
|
+
this.noOpStreak > 0
|
|
1026
|
+
? ` (backed off ×${Math.min(MAX_FALLBACK_BACKOFF, 2 ** this.noOpStreak)} after ${this.noOpStreak} no-op wake${this.noOpStreak === 1 ? "" : "s"})`
|
|
1027
|
+
: ""
|
|
1028
|
+
}`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (this.continuationIntent) {
|
|
1032
|
+
lines.push("A continuation is queued for the next idle boundary.");
|
|
387
1033
|
}
|
|
388
1034
|
if (this.wakePending) lines.push("A wake is pending delivery at the next idle boundary.");
|
|
389
1035
|
if (this.lastDecision) {
|
|
@@ -396,50 +1042,58 @@ export class LoopController {
|
|
|
396
1042
|
// --- command actions ---
|
|
397
1043
|
|
|
398
1044
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
* behaves exactly as it always has, and the trailing text stays a per-wake
|
|
403
|
-
* focus. With no active goal the trailing text becomes this loop's own
|
|
404
|
-
* objective and the loop is standalone. With neither, there is nothing to
|
|
405
|
-
* work on, and the caller is told what to supply.
|
|
1045
|
+
* Start a loop on its own objective, the only mode there is: the trailing
|
|
1046
|
+
* text *is* what the loop works on and what `loop_complete` answers for.
|
|
1047
|
+
* With no text there is nothing to work on, and the caller is told so.
|
|
406
1048
|
*/
|
|
407
|
-
startLoop(ctx:
|
|
1049
|
+
startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
|
|
408
1050
|
this.sessionCtx = ctx;
|
|
409
1051
|
const now = this.now();
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
418
|
-
return;
|
|
1052
|
+
const objective = start.prompt?.trim();
|
|
1053
|
+
if (!objective) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: false,
|
|
1056
|
+
message:
|
|
1057
|
+
"A loop needs something to work on. Give it an objective: /loop <interval> <objective with completion criteria>.",
|
|
1058
|
+
};
|
|
419
1059
|
}
|
|
420
|
-
|
|
1060
|
+
// A loop with no way to call loop_complete would work, finish, and then be
|
|
1061
|
+
// told to keep working until it hit a cap. Refuse at the door rather than
|
|
1062
|
+
// after the first turn.
|
|
1063
|
+
if (!this.completeToolAvailable()) {
|
|
1064
|
+
return {
|
|
1065
|
+
ok: false,
|
|
1066
|
+
message: `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.`,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
const expiryMs =
|
|
1070
|
+
start.expiresInMs ?? parseDuration(this.settings.maxLoopDuration) ?? 604_800_000;
|
|
421
1071
|
const compactAt =
|
|
422
1072
|
start.compactAt !== undefined
|
|
423
1073
|
? start.compactAt
|
|
424
1074
|
: this.settings.compaction.enabled
|
|
425
1075
|
? this.settings.compaction.threshold
|
|
426
1076
|
: null;
|
|
427
|
-
|
|
1077
|
+
const started: LoopState = {
|
|
428
1078
|
id: randomUUID().slice(0, 8),
|
|
429
1079
|
status: "active",
|
|
430
|
-
|
|
431
|
-
// the authoritative objective for a standalone one; never both.
|
|
432
|
-
...(goalBound && start.prompt ? { prompt: start.prompt } : {}),
|
|
433
|
-
...(objective ? { objective } : {}),
|
|
1080
|
+
objective,
|
|
434
1081
|
intervalMs: start.intervalMs,
|
|
435
1082
|
maxIterations:
|
|
436
1083
|
start.maxIterations !== undefined ? start.maxIterations : this.settings.maxIterations,
|
|
1084
|
+
maxAutomaticTurns: this.settings.automaticTurns,
|
|
437
1085
|
compactAt,
|
|
438
1086
|
iteration: 0,
|
|
1087
|
+
automaticTurns: 0,
|
|
439
1088
|
startedAt: now,
|
|
440
1089
|
expiresAt: now + expiryMs,
|
|
441
1090
|
};
|
|
1091
|
+
this.state = started;
|
|
442
1092
|
this.wakePending = false;
|
|
1093
|
+
this.continuationIntent = undefined;
|
|
1094
|
+
this.noOpStreak = 0;
|
|
1095
|
+
this.ledgerWarned = false;
|
|
1096
|
+
this.openLedger(this.state);
|
|
443
1097
|
this.persist();
|
|
444
1098
|
this.scheduleTick(start.intervalMs);
|
|
445
1099
|
this.updateWidget();
|
|
@@ -447,11 +1101,57 @@ export class LoopController {
|
|
|
447
1101
|
? ` (requested ${formatDuration(start.requestedMs)}, clamped to the ${formatDuration(start.intervalMs)} minimum)`
|
|
448
1102
|
: "";
|
|
449
1103
|
ctx.ui.notify(
|
|
450
|
-
|
|
451
|
-
? `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: every ${formatDuration(start.intervalMs)}${clampNote}, first wake at ${formatClock(now + start.intervalMs)}, working its own objective until the criteria are met (loop_complete), the cap is reached, or you run /loop stop.`,
|
|
1104
|
+
`Loop started: working its 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
1105
|
"info",
|
|
454
1106
|
);
|
|
1107
|
+
if (this.ledger) {
|
|
1108
|
+
const criteria = this.criteria() ?? [];
|
|
1109
|
+
ctx.ui.notify(
|
|
1110
|
+
[
|
|
1111
|
+
`Loop ledger: ${this.ledger.dir}`,
|
|
1112
|
+
`Completion criteria (${criteria.length}) — loop_complete answers for these:`,
|
|
1113
|
+
...criteria.map((criterion) => ` ${criterion.id}. ${criterion.description}`),
|
|
1114
|
+
].join("\n"),
|
|
1115
|
+
"info",
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
// The kickoff anchor: one stored message per loop holding the objective
|
|
1119
|
+
// data, because the system append exists only while the loop is active.
|
|
1120
|
+
this.sendKickoffAnchor(ctx);
|
|
1121
|
+
// Immediate kickoff: the loop starts working now instead of burning its
|
|
1122
|
+
// first interval idle. A busy session keeps the intent and delivers it at
|
|
1123
|
+
// the settle.
|
|
1124
|
+
this.requestContinuation(started, "kickoff");
|
|
1125
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
1126
|
+
return { ok: true, loop: started };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Store the objective as an ordinary message so it outlives the loop.
|
|
1131
|
+
*
|
|
1132
|
+
* At an idle boundary `sendMessage` appends the message with no turn, which
|
|
1133
|
+
* is exactly an anchor. While the agent streams the same call would *steer*
|
|
1134
|
+
* the running turn, so a busy session gets `nextTurn` instead: queued as
|
|
1135
|
+
* context alongside the next prompt, interrupting nothing.
|
|
1136
|
+
*/
|
|
1137
|
+
private sendKickoffAnchor(ctx: ExtensionContext): void {
|
|
1138
|
+
const loop = this.state;
|
|
1139
|
+
if (!loop || loop.objective === undefined || !this.ledger) return;
|
|
1140
|
+
const idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
1141
|
+
try {
|
|
1142
|
+
this.pi.sendMessage(
|
|
1143
|
+
{
|
|
1144
|
+
customType: LOOP_ANCHOR_MESSAGE_TYPE,
|
|
1145
|
+
content: buildKickoffAnchor(loop, this.ledger),
|
|
1146
|
+
display: true,
|
|
1147
|
+
details: { loopId: loop.id },
|
|
1148
|
+
},
|
|
1149
|
+
idle ? {} : { deliverAs: "nextTurn" },
|
|
1150
|
+
);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
// The anchor is a durability nicety; the loop runs without it.
|
|
1153
|
+
ctx.ui.notify(`pi-loop could not anchor the objective: ${formatError(error)}.`, "warning");
|
|
1154
|
+
}
|
|
455
1155
|
}
|
|
456
1156
|
|
|
457
1157
|
pauseLoop(ctx: ExtensionContext): void {
|
|
@@ -475,27 +1175,23 @@ export class LoopController {
|
|
|
475
1175
|
this.transition("stopped", "loop expired (maxLoopDuration reached)");
|
|
476
1176
|
return;
|
|
477
1177
|
}
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
readGoalSnapshot(ctx.sessionManager.getBranch())?.status !== "active"
|
|
484
|
-
) {
|
|
485
|
-
ctx.ui.notify(
|
|
486
|
-
"This loop is bound to a goal, which is no longer active. Start one with /goal <objective>, then /loop resume.",
|
|
487
|
-
"error",
|
|
488
|
-
);
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
this.state = { ...loop, status: "active" };
|
|
1178
|
+
// Resuming starts a fresh safety epoch: the user has seen why it paused
|
|
1179
|
+
// and chosen to continue, so the breaker must not trip on stale counters.
|
|
1180
|
+
const { pauseCause: _cause, lastFingerprint: _fingerprint, ...rest } = loop;
|
|
1181
|
+
this.state = { ...rest, status: "active", toolFreeRepeatCount: 0 };
|
|
1182
|
+
this.noOpStreak = 0;
|
|
492
1183
|
this.persist();
|
|
493
1184
|
this.scheduleTick(loop.intervalMs);
|
|
494
1185
|
this.updateWidget();
|
|
495
1186
|
ctx.ui.notify(
|
|
496
|
-
`Loop resumed:
|
|
1187
|
+
`Loop resumed: continuing now, with a fallback wake every ${formatDuration(loop.intervalMs)}.`,
|
|
497
1188
|
"info",
|
|
498
1189
|
);
|
|
1190
|
+
// Resuming resumes the work, not just the heartbeat.
|
|
1191
|
+
if (this.state) {
|
|
1192
|
+
this.requestContinuation(this.state);
|
|
1193
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
1194
|
+
}
|
|
499
1195
|
}
|
|
500
1196
|
|
|
501
1197
|
/** Re-arm the timer after an interval edit while active. */
|
|
@@ -525,3 +1221,22 @@ export class LoopController {
|
|
|
525
1221
|
function formatError(error: unknown): string {
|
|
526
1222
|
return error instanceof Error ? error.message : String(error);
|
|
527
1223
|
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* A run that called no tool did nothing to the world. It is the signal the
|
|
1227
|
+
* fallback backoff needs today; Stage 6's `LOOP_OK` acknowledgement refines
|
|
1228
|
+
* the same counter rather than replacing it.
|
|
1229
|
+
*/
|
|
1230
|
+
function isNoOpRun(messages: readonly unknown[]): boolean {
|
|
1231
|
+
for (const message of messages) {
|
|
1232
|
+
if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (message.content.some((block) => isRecord(block) && block.type === "toolCall")) return false;
|
|
1236
|
+
}
|
|
1237
|
+
return true;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1241
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1242
|
+
}
|