@hank-warren/pi-loop 0.4.0 → 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 +21 -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 +842 -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/complete-tool.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `loop_complete`: the standalone loop's terminal tool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* It used to be deliberately thin, on the argument that stopping a pacemaker
|
|
5
|
+
* has a small blast radius: the user just restarts it. That argument does not
|
|
6
|
+
* survive the loop becoming the *only* long-work mechanism. A premature
|
|
7
|
+
* completion now abandons autonomous work outright, and the model doing the
|
|
8
|
+
* abandoning is the same one that decided the work was done.
|
|
9
|
+
*
|
|
10
|
+
* So completion is gated on the loop's own `criteria.json`: every criterion
|
|
11
|
+
* must be answered with cited evidence, and a criterion the file still
|
|
12
|
+
* records as unmet must be addressed by name. The gate is deliberately
|
|
13
|
+
* mechanical — it cannot judge whether the evidence is *good*, only that the
|
|
14
|
+
* model was made to look at every requirement and say something specific
|
|
15
|
+
* about each. The audit rules that make the evidence worth citing live in the
|
|
16
|
+
* tool description and the system append.
|
|
11
17
|
*
|
|
12
18
|
* Registered unconditionally, never added or removed with loop state: tools
|
|
13
19
|
* are part of the cached request prefix, so mutating the tool set mid-session
|
|
@@ -19,10 +25,29 @@
|
|
|
19
25
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
26
|
import { Type } from "typebox";
|
|
21
27
|
import type { LoopController } from "./loop.js";
|
|
28
|
+
import type { LoopCriterion } from "./ledger.js";
|
|
22
29
|
|
|
23
30
|
export const LOOP_COMPLETE_TOOL = "loop_complete";
|
|
24
31
|
|
|
25
32
|
const MAX_SUMMARY_LENGTH = 4_000;
|
|
33
|
+
const MAX_EVIDENCE_LENGTH = 4_000;
|
|
34
|
+
/** Shorter than this is an assertion, not a citation. */
|
|
35
|
+
const MIN_EVIDENCE_LENGTH = 12;
|
|
36
|
+
const EMPTY_EVIDENCE = new Set([
|
|
37
|
+
"done",
|
|
38
|
+
"ok",
|
|
39
|
+
"okay",
|
|
40
|
+
"yes",
|
|
41
|
+
"true",
|
|
42
|
+
"passes",
|
|
43
|
+
"passed",
|
|
44
|
+
"complete",
|
|
45
|
+
"completed",
|
|
46
|
+
"verified",
|
|
47
|
+
"confirmed",
|
|
48
|
+
"n/a",
|
|
49
|
+
"none",
|
|
50
|
+
]);
|
|
26
51
|
|
|
27
52
|
export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopController) {
|
|
28
53
|
pi.registerTool(
|
|
@@ -30,13 +55,18 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
30
55
|
name: LOOP_COMPLETE_TOOL,
|
|
31
56
|
label: "Loop Complete",
|
|
32
57
|
description:
|
|
33
|
-
"Stop the active /loop when
|
|
34
|
-
promptSnippet: "Stop the active standalone /loop once
|
|
58
|
+
"Stop the active /loop when every completion criterion in the loop's criteria.json is proven by authoritative evidence. Requires one cited piece of evidence per criterion id. It stops the scheduled wakeups and does not assert that unrelated work is finished.",
|
|
59
|
+
promptSnippet: "Stop the active standalone /loop once every criterion is proven",
|
|
35
60
|
promptGuidelines: [
|
|
36
|
-
"Call loop_complete only when
|
|
61
|
+
"Call loop_complete only when every criterion in the loop's criteria.json is demonstrably met, verified against authoritative current state.",
|
|
62
|
+
"Before calling it, treat completion as unproven and audit requirement by requirement: for every criterion, inspect authoritative evidence and match the verification scope to the requirement scope.",
|
|
63
|
+
"Authoritative means the current worktree, command output, test results, runtime behaviour, PR state, or external state. Previous conversation, plans, and summaries are context, not proof.",
|
|
64
|
+
"Weak, indirect, missing, or merely consistent evidence is not enough; gather stronger evidence and keep working.",
|
|
65
|
+
"Effort exhaustion is not completion. Running long, running out of ideas, or reaching a cap is never a reason to call loop_complete.",
|
|
66
|
+
"Pass evidence as a map of criterion id to a specific citation (the command you ran and what it printed, the file and what it now contains, the URL and its state). Every criterion id must appear.",
|
|
37
67
|
"Pass the exact loop_id from the active /loop objective in the system prompt. A mismatched id means the loop changed and the call is refused.",
|
|
38
68
|
"loop_complete stops scheduled wakeups only. It does not mean an unrelated goal or task is complete.",
|
|
39
|
-
"If the criteria are not met, do not call it: keep working and expect another
|
|
69
|
+
"If the criteria are not met, do not call it: keep working and expect another continuation.",
|
|
40
70
|
],
|
|
41
71
|
parameters: Type.Object({
|
|
42
72
|
loop_id: Type.String({
|
|
@@ -44,6 +74,14 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
44
74
|
maxLength: 200,
|
|
45
75
|
description: "The exact loop_id from the active /loop objective in the system prompt.",
|
|
46
76
|
}),
|
|
77
|
+
evidence: Type.Record(
|
|
78
|
+
Type.String(),
|
|
79
|
+
Type.String({ maxLength: MAX_EVIDENCE_LENGTH }),
|
|
80
|
+
{
|
|
81
|
+
description:
|
|
82
|
+
"Map of criterion id (c1, c2, …, from the loop's criteria.json) to the authoritative evidence proving it: the command run and its output, the file and its current contents, the external state and how it was checked.",
|
|
83
|
+
},
|
|
84
|
+
),
|
|
47
85
|
summary: Type.Optional(
|
|
48
86
|
Type.String({
|
|
49
87
|
maxLength: MAX_SUMMARY_LENGTH,
|
|
@@ -78,19 +116,88 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
78
116
|
isError: true,
|
|
79
117
|
};
|
|
80
118
|
}
|
|
119
|
+
const evidence = normalizeEvidence(params.evidence);
|
|
120
|
+
const refusal = auditEvidence(controller.criteria(), evidence);
|
|
121
|
+
if (refusal) {
|
|
122
|
+
return {
|
|
123
|
+
content: toolContent(refusal),
|
|
124
|
+
details: { loopId: loop.id, criteria: controller.criteria() ?? [] },
|
|
125
|
+
isError: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
81
128
|
const summary = params.summary?.trim();
|
|
82
129
|
controller.completeLoop(summary);
|
|
83
130
|
return {
|
|
84
131
|
content: toolContent(
|
|
85
|
-
`Loop stopped:
|
|
132
|
+
`Loop stopped: every criterion answered with evidence.${summary ? ` ${summary}` : ""}`,
|
|
86
133
|
),
|
|
87
|
-
details: { loopId: loop.id, ...(summary ? { summary } : {}) },
|
|
134
|
+
details: { loopId: loop.id, evidence, ...(summary ? { summary } : {}) },
|
|
88
135
|
};
|
|
89
136
|
},
|
|
90
137
|
}),
|
|
91
138
|
);
|
|
92
139
|
}
|
|
93
140
|
|
|
141
|
+
function normalizeEvidence(evidence: Record<string, string>): Record<string, string> {
|
|
142
|
+
const normalized: Record<string, string> = {};
|
|
143
|
+
for (const [id, value] of Object.entries(evidence)) {
|
|
144
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
145
|
+
if (!trimmed) continue;
|
|
146
|
+
normalized[id.trim()] = trimmed;
|
|
147
|
+
}
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The mechanical half of the completion gate. Returns the refusal text, or
|
|
153
|
+
* undefined when the call may proceed.
|
|
154
|
+
*
|
|
155
|
+
* With no readable criteria the loop cannot say what completion means, so the
|
|
156
|
+
* gate degrades to "cite something specific" rather than blocking a loop
|
|
157
|
+
* whose ledger is missing — the ledger is fail-open everywhere else too.
|
|
158
|
+
*/
|
|
159
|
+
export function auditEvidence(
|
|
160
|
+
criteria: LoopCriterion[] | undefined,
|
|
161
|
+
evidence: Record<string, string>,
|
|
162
|
+
): string | undefined {
|
|
163
|
+
const answeredIds = new Set(Object.keys(evidence));
|
|
164
|
+
if (!criteria) {
|
|
165
|
+
return Object.values(evidence).some(isSubstantive)
|
|
166
|
+
? undefined
|
|
167
|
+
: "This loop has no readable criteria.json, so loop_complete still needs at least one specific citation: the command you ran and what it printed, or the state you inspected and what it showed.";
|
|
168
|
+
}
|
|
169
|
+
const missing = criteria.filter((criterion) => !answeredIds.has(criterion.id));
|
|
170
|
+
if (missing.length > 0) {
|
|
171
|
+
return [
|
|
172
|
+
`loop_complete refused: ${missing.length} of ${criteria.length} criteria have no cited evidence.`,
|
|
173
|
+
...missing.map(
|
|
174
|
+
(criterion) =>
|
|
175
|
+
`- ${criterion.id}${criterion.passes ? "" : " (still recorded as unmet)"}: ${criterion.description}`,
|
|
176
|
+
),
|
|
177
|
+
"Audit each one against authoritative current state — command output, file contents, external state — and pass the evidence keyed by criterion id. Weak or merely consistent evidence is not enough, and effort exhaustion is not completion.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
const unknown = [...answeredIds].filter(
|
|
181
|
+
(id) => !criteria.some((criterion) => criterion.id === id),
|
|
182
|
+
);
|
|
183
|
+
if (unknown.length > 0) {
|
|
184
|
+
return `loop_complete refused: evidence cites unknown criterion id(s) ${unknown.join(", ")}. Use the ids from the loop's criteria.json (${criteria.map((criterion) => criterion.id).join(", ")}).`;
|
|
185
|
+
}
|
|
186
|
+
const weak = Object.entries(evidence)
|
|
187
|
+
.filter(([id]) => criteria.some((criterion) => criterion.id === id))
|
|
188
|
+
.filter(([, value]) => !isSubstantive(value));
|
|
189
|
+
if (weak.length > 0) {
|
|
190
|
+
return `loop_complete refused: the evidence for ${weak.map(([id]) => id).join(", ")} asserts completion instead of citing it. Give the command and its output, the file and its contents, or the external state and how it was checked.`;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isSubstantive(value: string): boolean {
|
|
196
|
+
const trimmed = value.trim();
|
|
197
|
+
if (trimmed.length < MIN_EVIDENCE_LENGTH) return false;
|
|
198
|
+
return !EMPTY_EVIDENCE.has(trimmed.toLowerCase().replace(/[.!]+$/u, ""));
|
|
199
|
+
}
|
|
200
|
+
|
|
94
201
|
function toolContent(text: string) {
|
|
95
202
|
return [{ type: "text" as const, text }];
|
|
96
203
|
}
|
package/src/decide.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure
|
|
3
|
-
*
|
|
4
|
-
* without timers or a Pi runtime.
|
|
2
|
+
* Pure decisions for the two things that can move a loop forward, so the full
|
|
3
|
+
* matrix is unit-testable without timers or a Pi runtime:
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* - `decideContinuation` runs at every settled idle boundary and is the
|
|
6
|
+
* primary driver of a standalone loop. The session going idle with the
|
|
7
|
+
* objective unfinished *is* the signal to continue; no clock is involved.
|
|
8
|
+
* - `decideTick` runs when the fallback heartbeat fires. It is the fault
|
|
9
|
+
* handler for a lost continuation or an external wait, not the pacemaker.
|
|
10
|
+
*
|
|
11
|
+
* Both share one precedence prefix: loop liveness → expiry → plan mode →
|
|
12
|
+
* compaction → busy → mode-specific stop criteria → caps → act.
|
|
8
13
|
*
|
|
9
14
|
* The mode-specific step is the whole difference between the two kinds of
|
|
10
15
|
* loop. A goal-bound loop delegates "is the work done" to pi-goal and reads
|
|
11
|
-
* its `goal-state`, so a missing goal pauses it and a safety state holds it
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
16
|
+
* its `goal-state`, so a missing goal pauses it and a safety state holds it;
|
|
17
|
+
* pi-goal also owns its settle continuations, so `decideContinuation` never
|
|
18
|
+
* acts for one. A standalone loop owns its own objective, reads no goal state
|
|
19
|
+
* at all, and ends only through `loop_complete`, a cap, or the user.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { GOAL_SAFETY_STATUSES, type GoalSnapshot, isStandaloneLoop, type LoopState } from "./state.js";
|
|
@@ -26,30 +31,115 @@ export interface TickEnvironment {
|
|
|
26
31
|
goal: GoalSnapshot | undefined;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
/** A `loop_wait` whose deadline has passed is due, not waiting. */
|
|
35
|
+
function isWaiting(loop: LoopState, now: number): boolean {
|
|
36
|
+
const waiting = loop.waiting;
|
|
37
|
+
if (!waiting) return false;
|
|
38
|
+
return waiting.resumeAt === undefined || now < waiting.resumeAt;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An expiring standalone loop gets one last turn to write its state into the
|
|
43
|
+
* ledger before it stops; every other expiry stops immediately.
|
|
44
|
+
*/
|
|
45
|
+
export type ExpiryReason = "loop-expired" | "expiry-final-wake";
|
|
46
|
+
|
|
47
|
+
export type SkipReason =
|
|
48
|
+
| "plan-mode-active"
|
|
49
|
+
| "agent-busy"
|
|
50
|
+
| "compaction-in-flight"
|
|
51
|
+
| "loop-waiting";
|
|
52
|
+
|
|
29
53
|
export type TickDecision =
|
|
30
54
|
| { action: "none"; reason: "loop-not-active" }
|
|
31
|
-
| { action: "expire"; reason:
|
|
32
|
-
| { action: "skip"; reason:
|
|
33
|
-
| { action: "stop"; reason: "goal-complete" | "max-iterations" }
|
|
55
|
+
| { action: "expire"; reason: ExpiryReason }
|
|
56
|
+
| { action: "skip"; reason: SkipReason }
|
|
57
|
+
| { action: "stop"; reason: "goal-complete" | "max-iterations" | "max-automatic-turns" }
|
|
34
58
|
| { action: "pause"; reason: "goal-safety"; cause: string }
|
|
35
59
|
| { action: "pause"; reason: "goal-missing" }
|
|
36
|
-
| {
|
|
60
|
+
| {
|
|
61
|
+
action: "poke";
|
|
62
|
+
reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed";
|
|
63
|
+
};
|
|
37
64
|
|
|
38
|
-
export
|
|
65
|
+
export type ContinuationDecision =
|
|
66
|
+
| { action: "none"; reason: "loop-not-active" | "goal-bound" }
|
|
67
|
+
| { action: "expire"; reason: ExpiryReason }
|
|
68
|
+
| { action: "skip"; reason: SkipReason }
|
|
69
|
+
| { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
|
|
70
|
+
| { action: "continue"; reason: "settled-idle" };
|
|
71
|
+
|
|
72
|
+
/** Shared prefix: everything that holds or ends a loop before mode matters. */
|
|
73
|
+
function decideCommonPrefix(
|
|
74
|
+
loop: LoopState,
|
|
75
|
+
env: TickEnvironment,
|
|
76
|
+
): Extract<TickDecision, { action: "none" | "expire" | "skip" }> | undefined {
|
|
39
77
|
if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
|
|
40
|
-
if (env.now >= loop.expiresAt)
|
|
78
|
+
if (env.now >= loop.expiresAt) {
|
|
79
|
+
// The final wake is a standalone loop's own summarise-and-stop turn; a
|
|
80
|
+
// goal-bound loop has no ledger of its own to write, and pi-goal owns
|
|
81
|
+
// that conversation.
|
|
82
|
+
return {
|
|
83
|
+
action: "expire",
|
|
84
|
+
reason:
|
|
85
|
+
isStandaloneLoop(loop) && !loop.expiring ? "expiry-final-wake" : "loop-expired",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
41
88
|
if (env.planModeEnabled) return { action: "skip", reason: "plan-mode-active" };
|
|
42
89
|
if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
|
|
43
90
|
if (env.busy) return { action: "skip", reason: "agent-busy" };
|
|
91
|
+
// A declared external wait holds both drivers: the loop is not stalled, it
|
|
92
|
+
// is waiting on the world, and its own deadline is the next thing to speak.
|
|
93
|
+
if (isWaiting(loop, env.now)) return { action: "skip", reason: "loop-waiting" };
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Caps are checked in a fixed order so a loop that trips both reports the
|
|
99
|
+
* wake cap first — it is the one the user configured with `--max`.
|
|
100
|
+
*/
|
|
101
|
+
function decideCaps(
|
|
102
|
+
loop: LoopState,
|
|
103
|
+
): { action: "stop"; reason: "max-iterations" | "max-automatic-turns" } | undefined {
|
|
104
|
+
if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
|
|
105
|
+
return { action: "stop", reason: "max-iterations" };
|
|
106
|
+
}
|
|
107
|
+
if (loop.maxAutomaticTurns !== null && loop.automaticTurns >= loop.maxAutomaticTurns) {
|
|
108
|
+
return { action: "stop", reason: "max-automatic-turns" };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The settled-idle boundary of a standalone loop: the session finished a turn
|
|
115
|
+
* with the objective unfinished, so the loop continues immediately instead of
|
|
116
|
+
* waiting out an interval of idle wall time.
|
|
117
|
+
*/
|
|
118
|
+
export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
|
|
119
|
+
const prefix = decideCommonPrefix(loop, env);
|
|
120
|
+
if (prefix) return prefix;
|
|
121
|
+
// pi-goal drives its own settle continuations for a goal-bound loop; two
|
|
122
|
+
// extensions continuing the same session would double every turn.
|
|
123
|
+
if (!isStandaloneLoop(loop)) return { action: "none", reason: "goal-bound" };
|
|
124
|
+
const capped = decideCaps(loop);
|
|
125
|
+
if (capped) return capped;
|
|
126
|
+
return { action: "continue", reason: "settled-idle" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
|
|
130
|
+
const prefix = decideCommonPrefix(loop, env);
|
|
131
|
+
if (prefix) return prefix;
|
|
44
132
|
|
|
45
133
|
// A standalone loop carries its own objective, so it never consults
|
|
46
134
|
// pi-goal: it runs until loop_complete stops it, a cap is reached, or the
|
|
47
135
|
// user intervenes.
|
|
48
136
|
if (isStandaloneLoop(loop)) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
137
|
+
const capped = decideCaps(loop);
|
|
138
|
+
if (capped) return capped;
|
|
139
|
+
// The prefix already let a still-waiting loop skip, so a wait surviving
|
|
140
|
+
// to here is one whose deadline has come due: this wake is the wake it
|
|
141
|
+
// asked for, and it counts against the wake cap like any other.
|
|
142
|
+
return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
|
|
53
143
|
}
|
|
54
144
|
|
|
55
145
|
const goal = env.goal;
|
|
@@ -67,9 +157,8 @@ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision
|
|
|
67
157
|
return { action: "pause", reason: "goal-safety", cause: goal.status };
|
|
68
158
|
}
|
|
69
159
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
160
|
+
const capped = decideCaps(loop);
|
|
161
|
+
if (capped) return capped;
|
|
73
162
|
|
|
74
163
|
// An idle session with an active goal is exactly the stall/wait case:
|
|
75
164
|
// pi-goal continues on its own at every idle boundary, so idleness at
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of an interrupted loop turn.
|
|
3
|
+
*
|
|
4
|
+
* Without this every provider hiccup looks the same to a loop, and the loop
|
|
5
|
+
* answers all of them identically: continue. That is wrong in both
|
|
6
|
+
* directions. Retrying into an exhausted usage quota burns the loop's caps
|
|
7
|
+
* against a window that has not reset, and pausing on a transient 503 strands
|
|
8
|
+
* a multi-day loop on a blip. Context overflow is a third answer again: the
|
|
9
|
+
* turn failed because the conversation is too big, so the fix is a
|
|
10
|
+
* compaction, not a retry of the same oversized request.
|
|
11
|
+
*
|
|
12
|
+
* Ported from pi-goal's classifier, trimmed to what a loop acts on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
isContextOverflow,
|
|
17
|
+
isRetryableAssistantError,
|
|
18
|
+
type AssistantMessage,
|
|
19
|
+
type Usage,
|
|
20
|
+
} from "@earendil-works/pi-ai";
|
|
21
|
+
|
|
22
|
+
export type LoopInterruption =
|
|
23
|
+
/** The turn finished normally. */
|
|
24
|
+
| "none"
|
|
25
|
+
/** The user (or another extension) aborted the turn. */
|
|
26
|
+
| "aborted"
|
|
27
|
+
/** Provider quota or billing exhaustion: retrying cannot help. */
|
|
28
|
+
| "usage-limited"
|
|
29
|
+
/** The request no longer fits: compact, then continue. */
|
|
30
|
+
| "context-overflow"
|
|
31
|
+
/** Transient: continue as usual. */
|
|
32
|
+
| "retryable"
|
|
33
|
+
/** Auth or another error a retry cannot fix. */
|
|
34
|
+
| "fatal";
|
|
35
|
+
|
|
36
|
+
const USAGE_LIMIT_PATTERNS = [
|
|
37
|
+
/usage[_\s-]*(?:limit|cap)|chatgpt.{0,32}usage/i,
|
|
38
|
+
// Exhaustion phrased as spent allowance rather than a named limit. These
|
|
39
|
+
// matter most when the provider reports them as a 429, because the
|
|
40
|
+
// retryable patterns match the status code and the loop would otherwise
|
|
41
|
+
// retry against a quota window that has not reset. Deliberately narrow: a
|
|
42
|
+
// plain "rate limit, try again later" 429 must stay retryable.
|
|
43
|
+
/used all (?:the |your )?(?:included |available |free |remaining )*usage/i,
|
|
44
|
+
/draw from your extra usage/i,
|
|
45
|
+
/quota.{0,32}(?:reached|exceeded|exhausted|depleted)|(?:reached|exceeded|exhausted|depleted).{0,32}quota/i,
|
|
46
|
+
/insufficient[_\s-]*(?:quota|credits?)|out of credits|out of budget|available balance|payment required/i,
|
|
47
|
+
/(?:credit|balance).{0,32}(?:low|exhausted|depleted)|billing/i,
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
const NON_RETRYABLE_PATTERN =
|
|
51
|
+
/multi-auth rotation failed|credentials tried|unauthori[sz]ed|invalid api key/i;
|
|
52
|
+
|
|
53
|
+
const RETRYABLE_PATTERNS = [
|
|
54
|
+
/overloaded|rate.?limit|too many requests|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error/i,
|
|
55
|
+
/provider.?returned.?error|you can retry your request|try your request again|please retry your request/i,
|
|
56
|
+
/network.?error|connection.?(?:error|refused|lost)|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up/i,
|
|
57
|
+
/timed? out|timeout|terminated|websocket.?(?:closed|error)|ended without|stream ended before message_stop|http2 request did not get a response|retry delay/i,
|
|
58
|
+
/context[_\s-]*length[_\s-]*exceeded|input exceeds the context window/i,
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
interface AssistantLike {
|
|
62
|
+
stopReason?: string;
|
|
63
|
+
errorMessage?: string;
|
|
64
|
+
content?: unknown;
|
|
65
|
+
api?: string;
|
|
66
|
+
provider?: string;
|
|
67
|
+
model?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function classifyInterruption(messages: readonly unknown[]): LoopInterruption {
|
|
71
|
+
const assistant = findFinalAssistantMessage(messages);
|
|
72
|
+
if (!assistant) return "none";
|
|
73
|
+
if (assistant.stopReason === "aborted") return "aborted";
|
|
74
|
+
if (assistant.stopReason !== "error") return "none";
|
|
75
|
+
const errorMessage = assistant.errorMessage ?? "";
|
|
76
|
+
// Usage limits are checked first on purpose: providers report them as 429,
|
|
77
|
+
// which the retryable patterns also match.
|
|
78
|
+
if (USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(errorMessage))) return "usage-limited";
|
|
79
|
+
if (NON_RETRYABLE_PATTERN.test(errorMessage)) return "fatal";
|
|
80
|
+
if (isContextOverflow(toPiAssistantMessage(assistant))) return "context-overflow";
|
|
81
|
+
if (
|
|
82
|
+
isRetryableAssistantError(toPiAssistantMessage(assistant)) ||
|
|
83
|
+
RETRYABLE_PATTERNS.some((pattern) => pattern.test(errorMessage))
|
|
84
|
+
) {
|
|
85
|
+
return "retryable";
|
|
86
|
+
}
|
|
87
|
+
return "fatal";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findFinalAssistantMessage(messages: readonly unknown[]): AssistantLike | undefined {
|
|
91
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
92
|
+
const message = messages[index];
|
|
93
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
94
|
+
const record = message as Record<string, unknown>;
|
|
95
|
+
if (record.role !== "assistant") continue;
|
|
96
|
+
return {
|
|
97
|
+
...(typeof record.stopReason === "string" ? { stopReason: record.stopReason } : {}),
|
|
98
|
+
...(typeof record.errorMessage === "string" ? { errorMessage: record.errorMessage } : {}),
|
|
99
|
+
...(Array.isArray(record.content) ? { content: record.content } : {}),
|
|
100
|
+
...(typeof record.api === "string" ? { api: record.api } : {}),
|
|
101
|
+
...(typeof record.provider === "string" ? { provider: record.provider } : {}),
|
|
102
|
+
...(typeof record.model === "string" ? { model: record.model } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toPiAssistantMessage(assistant: AssistantLike): AssistantMessage {
|
|
109
|
+
return {
|
|
110
|
+
role: "assistant",
|
|
111
|
+
content: (assistant.content ?? []) as AssistantMessage["content"],
|
|
112
|
+
api: (assistant.api ?? "openai-responses") as AssistantMessage["api"],
|
|
113
|
+
provider: assistant.provider ?? "unknown",
|
|
114
|
+
model: assistant.model ?? "unknown",
|
|
115
|
+
usage: zeroUsage(),
|
|
116
|
+
stopReason: "error",
|
|
117
|
+
errorMessage: assistant.errorMessage,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function zeroUsage(): Usage {
|
|
123
|
+
return {
|
|
124
|
+
input: 0,
|
|
125
|
+
output: 0,
|
|
126
|
+
cacheRead: 0,
|
|
127
|
+
cacheWrite: 0,
|
|
128
|
+
totalTokens: 0,
|
|
129
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,25 +10,37 @@
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
|
+
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
13
14
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
14
15
|
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
15
16
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
16
17
|
import { registerLoopMessageRendering } from "./render.js";
|
|
18
|
+
import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
|
|
19
|
+
import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
|
|
20
|
+
import { describeSchedule } from "./schedule/model.js";
|
|
21
|
+
import { Scheduler } from "./schedule/runner.js";
|
|
17
22
|
|
|
18
23
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
19
24
|
const controller = new LoopController(pi, options);
|
|
25
|
+
const scheduler = new Scheduler(pi, {
|
|
26
|
+
...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
|
|
27
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
28
|
+
});
|
|
20
29
|
// Registered unconditionally and never toggled with loop state: tools are
|
|
21
30
|
// part of the cached request prefix, so mutating the tool set mid-session
|
|
22
31
|
// would invalidate the whole conversation cache. It refuses when no
|
|
23
32
|
// standalone loop is active.
|
|
24
33
|
registerLoopCompleteTool(pi, controller);
|
|
34
|
+
// Registered on the same terms and for the same reason: the tool set is
|
|
35
|
+
// part of the cached prefix, so it never changes with loop state.
|
|
36
|
+
registerLoopWaitTool(pi, controller);
|
|
25
37
|
// Collapse loop pokes into one-line transcript chips (display-only; the
|
|
26
38
|
// stored message and model context are untouched).
|
|
27
39
|
registerLoopMessageRendering(pi);
|
|
28
40
|
|
|
29
41
|
pi.registerCommand("loop", {
|
|
30
42
|
description:
|
|
31
|
-
"
|
|
43
|
+
"Work an objective across many turns, waking the session if it goes quiet: /loop [--max N] [--compact-at 60%] [--expires 3d] <interval> [objective]",
|
|
32
44
|
getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
|
|
33
45
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
34
46
|
const command = parseLoopCommand(args);
|
|
@@ -79,14 +91,95 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
79
91
|
},
|
|
80
92
|
});
|
|
81
93
|
|
|
94
|
+
// The scheduler is user-typed only, exactly like /loop: the model gets no
|
|
95
|
+
// scheduling tools, because a model that can schedule its own future turns
|
|
96
|
+
// can schedule its way around every limit the loop imposes.
|
|
97
|
+
pi.registerCommand("schedule", {
|
|
98
|
+
description:
|
|
99
|
+
'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
|
|
100
|
+
getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
|
|
101
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
102
|
+
const command = parseScheduleCommand(args, { cwd: ctx.cwd });
|
|
103
|
+
switch (command.kind) {
|
|
104
|
+
case "show":
|
|
105
|
+
await showScheduleManager(scheduler, ctx);
|
|
106
|
+
return;
|
|
107
|
+
case "list":
|
|
108
|
+
ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
|
|
109
|
+
return;
|
|
110
|
+
case "error":
|
|
111
|
+
ctx.ui.notify(command.message, "error");
|
|
112
|
+
return;
|
|
113
|
+
case "create": {
|
|
114
|
+
const { task, warning } = scheduler.create(command);
|
|
115
|
+
if (warning) {
|
|
116
|
+
ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
|
|
117
|
+
}
|
|
118
|
+
if (command.clampedFrom !== undefined) {
|
|
119
|
+
ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
|
|
120
|
+
}
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
[
|
|
123
|
+
`Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
|
|
124
|
+
task.task.kind === "run"
|
|
125
|
+
? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
|
|
126
|
+
: "Injects a prompt into this session at an idle boundary; it dies with the session.",
|
|
127
|
+
`Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
|
|
128
|
+
].join("\n"),
|
|
129
|
+
"info",
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
default: {
|
|
134
|
+
const task = scheduler.find(command.id);
|
|
135
|
+
if (!task) {
|
|
136
|
+
ctx.ui.notify(
|
|
137
|
+
`No scheduled task matches ${command.id}. Run /schedule list to see them.`,
|
|
138
|
+
"error",
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (command.kind === "status") {
|
|
143
|
+
ctx.ui.notify(describeTask(task).join("\n"), "info");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (command.kind === "pause" || command.kind === "resume") {
|
|
147
|
+
const status = command.kind === "pause" ? "paused" : "active";
|
|
148
|
+
scheduler.update({ ...task, status });
|
|
149
|
+
ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (command.kind === "run") {
|
|
153
|
+
scheduler.fireNow(task);
|
|
154
|
+
ctx.ui.notify(`Running "${task.name}" now.`, "info");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
scheduler.remove(task.id);
|
|
158
|
+
ctx.ui.notify(`Deleted "${task.name}".`, "info");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
82
164
|
pi.on("session_start", async (_event, ctx) => {
|
|
83
165
|
controller.onSessionStart(ctx);
|
|
166
|
+
scheduler.onSessionStart(ctx);
|
|
84
167
|
});
|
|
85
168
|
pi.on("session_shutdown", async () => {
|
|
86
169
|
controller.onSessionShutdown();
|
|
170
|
+
scheduler.onSessionShutdown();
|
|
171
|
+
});
|
|
172
|
+
// The pacemaker of a standalone loop: agent_end records the intent to
|
|
173
|
+
// continue, agent_settled delivers it once Pi will accept a message.
|
|
174
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
175
|
+
controller.onAgentStart(ctx);
|
|
176
|
+
});
|
|
177
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
178
|
+
controller.onAgentEnd(ctx, event.messages ?? []);
|
|
87
179
|
});
|
|
88
180
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
89
181
|
controller.onAgentSettled(ctx);
|
|
182
|
+
scheduler.onAgentSettled(ctx);
|
|
90
183
|
});
|
|
91
184
|
// A standalone loop carries its own objective, so it injects it the way
|
|
92
185
|
// pi-goal does for a goal-bound one: a byte-stable system append, which is
|
|
@@ -95,7 +188,7 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
95
188
|
pi.on("before_agent_start", (event) => {
|
|
96
189
|
const loop = controller.state;
|
|
97
190
|
if (!loop || loop.status !== "active") return;
|
|
98
|
-
const objectivePrompt = buildLoopObjectivePrompt(loop);
|
|
191
|
+
const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
|
|
99
192
|
if (objectivePrompt === undefined) return;
|
|
100
193
|
return { systemPrompt: `${event.systemPrompt}\n\n${objectivePrompt}` };
|
|
101
194
|
});
|