pi-goal-list-loop-audit 0.15.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/LICENSE +21 -0
- package/README.md +252 -0
- package/docs/DESIGN.md +214 -0
- package/extensions/goal-loop-auditor.ts +391 -0
- package/extensions/goal-loop-backoff.ts +99 -0
- package/extensions/goal-loop-core.ts +506 -0
- package/extensions/goal-loop-display.ts +160 -0
- package/extensions/goal-loop-forever.ts +230 -0
- package/extensions/goal-loop-shield.ts +59 -0
- package/extensions/loops/goal.ts +2323 -0
- package/package.json +66 -0
- package/prompts/goal-loop-continuation.md +83 -0
- package/prompts/goal-loop-draft.md +39 -0
- package/prompts/goal-loop-forever-draft.md +50 -0
- package/prompts/goal-loop-forever.md +46 -0
- package/schemas/goal.schema.json +98 -0
- package/scripts/smoke.sh +210 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal-list-loop-audit — v0.1.0
|
|
3
|
+
* extensions/goal-loop-auditor.ts
|
|
4
|
+
*
|
|
5
|
+
* Isolated completion auditor. Runs in a fresh pi agent session with no
|
|
6
|
+
* extensions, no skills, no prompts, no themes, no editor. Only read tools.
|
|
7
|
+
*
|
|
8
|
+
* In v0.1.0 the auditor behaves the same as pi-goal-x's auditor: it must
|
|
9
|
+
* call at least one read tool before <approved/>.
|
|
10
|
+
*
|
|
11
|
+
* In v0.2.0 (NOT YET IMPLEMENTED) we will add regression_shield: the
|
|
12
|
+
* auditor's report must include raw output (cat / grep / bash) for every
|
|
13
|
+
* must-verify item in the verification contract. The orchestrator will
|
|
14
|
+
* reject <approved/> without that evidence.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
19
|
+
import {
|
|
20
|
+
createAgentSession,
|
|
21
|
+
createExtensionRuntime,
|
|
22
|
+
SessionManager,
|
|
23
|
+
SettingsManager,
|
|
24
|
+
type ExtensionContext,
|
|
25
|
+
type ResourceLoader,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
|
|
28
|
+
import type { Goal } from "./goal-loop-core.js";
|
|
29
|
+
import { renderGoalMarkdown } from "./goal-loop-core.js";
|
|
30
|
+
|
|
31
|
+
// =================================================================
|
|
32
|
+
// Result type
|
|
33
|
+
// =================================================================
|
|
34
|
+
|
|
35
|
+
export interface GoalAuditorResult {
|
|
36
|
+
approved: boolean;
|
|
37
|
+
disapproved: boolean;
|
|
38
|
+
output: string;
|
|
39
|
+
model: string;
|
|
40
|
+
thinkingLevel?: ThinkingLevel;
|
|
41
|
+
error?: string;
|
|
42
|
+
/** regression_shield outcome when the goal has a verification contract. */
|
|
43
|
+
regressionShieldPassed?: boolean;
|
|
44
|
+
regressionShieldMissing?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// =================================================================
|
|
48
|
+
// Audit log: every tool call the auditor made, with first ~120 chars of args.
|
|
49
|
+
// We use this to enforce "must call at least one tool" and (in v0.2.0)
|
|
50
|
+
// to enforce "must include raw evidence".
|
|
51
|
+
// =================================================================
|
|
52
|
+
|
|
53
|
+
export interface AuditProgress {
|
|
54
|
+
recentOutput: string[];
|
|
55
|
+
phase: "starting" | "running" | "thinking" | "tool_executing" | "producing_report" | "complete";
|
|
56
|
+
elapsedMs: number;
|
|
57
|
+
label?: string;
|
|
58
|
+
percentage?: number;
|
|
59
|
+
currentTool?: string;
|
|
60
|
+
currentToolArgs?: string;
|
|
61
|
+
currentToolStartedAt?: number;
|
|
62
|
+
// Tool-call history for regression_shield:
|
|
63
|
+
toolCalls: Array<{ name: string; argsPrefix: string; finishedAt: number }>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type AuditorProgressCallback = (progress: AuditProgress) => void;
|
|
67
|
+
|
|
68
|
+
// =================================================================
|
|
69
|
+
// Auditor resource loader — zero extensions, zero skills, zero prompts
|
|
70
|
+
// =================================================================
|
|
71
|
+
|
|
72
|
+
function makeAuditorResourceLoader(): ResourceLoader {
|
|
73
|
+
return {
|
|
74
|
+
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
75
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
76
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
77
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
78
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
79
|
+
getSystemPrompt: () => [
|
|
80
|
+
"You are a read-only completion auditor running in an isolated pi agent session.",
|
|
81
|
+
"Inspect the repository and decide whether the claimed goal completion is genuinely satisfied.",
|
|
82
|
+
"Never modify files. Never approve unless the actual user objective is complete.",
|
|
83
|
+
].join("\n"),
|
|
84
|
+
getAppendSystemPrompt: () => [],
|
|
85
|
+
extendResources: () => {},
|
|
86
|
+
reload: async () => {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// =================================================================
|
|
91
|
+
// Auditor prompt
|
|
92
|
+
// =================================================================
|
|
93
|
+
|
|
94
|
+
function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | null | undefined, verificationSummary: string | null | undefined): string {
|
|
95
|
+
const goalMd = renderGoalMarkdown(goal);
|
|
96
|
+
return [
|
|
97
|
+
"You are the independent completion auditor for pi-goal-list-loop-audit.",
|
|
98
|
+
"The executor claims the goal is complete. Your job is to decide whether the user's objective is actually satisfied.",
|
|
99
|
+
"Be skeptical and semantic. Do not approve from paperwork, intent, file count, word count, build success, or a plausible summary alone.",
|
|
100
|
+
"Use read/grep/find/ls/bash as needed to inspect real artifacts. Do not mutate files or run destructive commands.",
|
|
101
|
+
"If the work is only an alpha scaffold, generated template, shallow draft, proxy milestone, or lacks the user-facing value requested, disapprove.",
|
|
102
|
+
"If any explicit requirement is missing, weakly verified, contradicted, or not inspectable with the available evidence, disapprove.",
|
|
103
|
+
"Return a concise audit report. The final line MUST be exactly one of:",
|
|
104
|
+
"<approved/>",
|
|
105
|
+
"<disapproved/>",
|
|
106
|
+
"",
|
|
107
|
+
"Goal markdown (full state):",
|
|
108
|
+
"<goal>",
|
|
109
|
+
goalMd,
|
|
110
|
+
"</goal>",
|
|
111
|
+
"",
|
|
112
|
+
"Executor completion claim:",
|
|
113
|
+
"<completion_summary>",
|
|
114
|
+
(completionSummary?.trim() || "(none provided)"),
|
|
115
|
+
"</completion_summary>",
|
|
116
|
+
...(verificationSummary?.trim() ? [
|
|
117
|
+
"",
|
|
118
|
+
"Executor verification summary:",
|
|
119
|
+
"<verification_summary>",
|
|
120
|
+
verificationSummary.trim(),
|
|
121
|
+
"</verification_summary>",
|
|
122
|
+
] : []),
|
|
123
|
+
...(goal.verificationContract?.trim() ? [
|
|
124
|
+
"",
|
|
125
|
+
"Goal verification contract (what the executor was required to verify):",
|
|
126
|
+
"<verification_contract>",
|
|
127
|
+
goal.verificationContract.trim(),
|
|
128
|
+
"</verification_contract>",
|
|
129
|
+
] : []),
|
|
130
|
+
"",
|
|
131
|
+
"Audit checklist:",
|
|
132
|
+
"1. Extract the real success criteria from the objective, including quality/reader outcomes.",
|
|
133
|
+
"2. Inspect artifacts or command output that can prove or disprove those criteria.",
|
|
134
|
+
...(verificationSummary?.trim()
|
|
135
|
+
? ["3. Check the <verification_summary> against real artifacts. If the executor claims to have run tests or searched for references, verify those claims with actual file/shell evidence. The summary is a claim, not proof — cross-check it."]
|
|
136
|
+
: []),
|
|
137
|
+
...(goal.verificationContract?.trim()
|
|
138
|
+
? ["4. Verify that the executor has satisfied every item in the <verification_contract>. If any item is missing or weakly addressed, disapprove."]
|
|
139
|
+
: []),
|
|
140
|
+
"5. Explain missing or weak evidence, especially scaffold-vs-final quality gaps.",
|
|
141
|
+
"6. End with exactly <approved/> only if the objective is truly complete; otherwise end with exactly <disapproved/>.",
|
|
142
|
+
...(goal.verificationContract?.trim()
|
|
143
|
+
? [
|
|
144
|
+
"",
|
|
145
|
+
"REGRESSION SHIELD (mandatory because this goal has a verification contract):",
|
|
146
|
+
"Your report MUST contain an <evidence> section. For EACH item in the verification contract,",
|
|
147
|
+
"quote the item, then paste the RAW tool output that proves it (real bash/grep/read output,",
|
|
148
|
+
"copied verbatim — not a paraphrase, not a description of what you saw). Format:",
|
|
149
|
+
"",
|
|
150
|
+
"<evidence>",
|
|
151
|
+
"Item: <contract item 1>",
|
|
152
|
+
"Output:",
|
|
153
|
+
"<raw command output here>",
|
|
154
|
+
"Item: <contract item 2>",
|
|
155
|
+
"Output:",
|
|
156
|
+
"<raw command output here>",
|
|
157
|
+
"</evidence>",
|
|
158
|
+
"",
|
|
159
|
+
"An approval without a complete <evidence> section will be rejected automatically.",
|
|
160
|
+
]
|
|
161
|
+
: []),
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// regression_shield lives in goal-loop-shield.ts (dependency-free, so unit
|
|
166
|
+
// tests can import it without pulling in pi). Re-exported for callers.
|
|
167
|
+
export { checkRegressionShield, contractItems, type RegressionShieldResult } from "./goal-loop-shield.js";
|
|
168
|
+
import { checkRegressionShield } from "./goal-loop-shield.js";
|
|
169
|
+
|
|
170
|
+
// =================================================================
|
|
171
|
+
// Auditor entry point
|
|
172
|
+
// =================================================================
|
|
173
|
+
|
|
174
|
+
export async function runGoalCompletionAuditor(args: {
|
|
175
|
+
ctx: ExtensionContext;
|
|
176
|
+
goal: Goal;
|
|
177
|
+
completionSummary?: string | null;
|
|
178
|
+
verificationSummary?: string | null;
|
|
179
|
+
model?: Model<any>;
|
|
180
|
+
thinkingLevel?: ThinkingLevel;
|
|
181
|
+
signal?: AbortSignal;
|
|
182
|
+
onProgress?: AuditorProgressCallback;
|
|
183
|
+
}): Promise<GoalAuditorResult> {
|
|
184
|
+
const ctx = args.ctx;
|
|
185
|
+
// Default to the session's current model when no dedicated auditor model is
|
|
186
|
+
// configured (pi-goal-x's resolveAuditorModel does the same). A missing
|
|
187
|
+
// auditor model must never be a silent audit failure.
|
|
188
|
+
const model = args.model ?? ctx.model;
|
|
189
|
+
const thinkingLevel = args.thinkingLevel ?? "medium";
|
|
190
|
+
const outputParts: string[] = [];
|
|
191
|
+
if (!model) {
|
|
192
|
+
return { approved: false, disapproved: false, output: "", model: "(unset)", thinkingLevel, error: "no model (session model also unset)" };
|
|
193
|
+
}
|
|
194
|
+
const toolCalls: AuditProgress["toolCalls"] = [];
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const startedAt = Date.now();
|
|
198
|
+
const progress: AuditProgress = {
|
|
199
|
+
recentOutput: [],
|
|
200
|
+
phase: "running",
|
|
201
|
+
elapsedMs: 0,
|
|
202
|
+
toolCalls,
|
|
203
|
+
};
|
|
204
|
+
function emitProgress(): void {
|
|
205
|
+
progress.elapsedMs = Date.now() - startedAt;
|
|
206
|
+
args.onProgress?.({ ...progress });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { session } = await createAgentSession({
|
|
210
|
+
cwd: ctx.cwd,
|
|
211
|
+
model,
|
|
212
|
+
thinkingLevel,
|
|
213
|
+
modelRegistry: ctx.modelRegistry,
|
|
214
|
+
resourceLoader: makeAuditorResourceLoader(),
|
|
215
|
+
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
216
|
+
// Compaction ENABLED (v0.4.0, closes pi-goal-x flaw #3: context exhaustion
|
|
217
|
+
// mid-audit). Safety: regression_shield is orchestrator-side — if compaction
|
|
218
|
+
// degrades the auditor's memory, its <evidence> block gets weaker and the
|
|
219
|
+
// orchestrator disapproves. Compaction can never cause a false approval.
|
|
220
|
+
settingsManager: SettingsManager.inMemory({ compaction: { enabled: true } }),
|
|
221
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
222
|
+
});
|
|
223
|
+
let streamError: string | undefined;
|
|
224
|
+
const unsub = session.subscribe((event) => {
|
|
225
|
+
// Capture provider/stream errors (401/403/429/credits) — these often
|
|
226
|
+
// arrive as events rather than thrown exceptions, and without this they
|
|
227
|
+
// surface as a silent empty report that looks like a disapproval.
|
|
228
|
+
const anyEvent = event as any;
|
|
229
|
+
if (anyEvent.type === "error" || anyEvent.error || anyEvent.type === "auto_retry_start") {
|
|
230
|
+
const msg = anyEvent.error?.message ?? anyEvent.message ?? anyEvent.errorMessage;
|
|
231
|
+
if (typeof msg === "string") streamError = msg.slice(0, 300);
|
|
232
|
+
}
|
|
233
|
+
if (event.type === "tool_execution_start") {
|
|
234
|
+
progress.currentTool = event.toolName;
|
|
235
|
+
progress.currentToolArgs = typeof event.args === "object" && event.args !== null
|
|
236
|
+
? JSON.stringify(event.args).slice(0, 120)
|
|
237
|
+
: String(event.args ?? "").slice(0, 120);
|
|
238
|
+
progress.currentToolStartedAt = Date.now();
|
|
239
|
+
progress.phase = "tool_executing";
|
|
240
|
+
emitProgress();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (event.type === "tool_execution_end") {
|
|
244
|
+
if (progress.currentTool) {
|
|
245
|
+
toolCalls.push({
|
|
246
|
+
name: progress.currentTool,
|
|
247
|
+
argsPrefix: progress.currentToolArgs ?? "",
|
|
248
|
+
finishedAt: Date.now(),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
progress.currentTool = undefined;
|
|
252
|
+
progress.currentToolArgs = undefined;
|
|
253
|
+
progress.currentToolStartedAt = undefined;
|
|
254
|
+
progress.phase = "running";
|
|
255
|
+
emitProgress();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (event.type === "message_end") {
|
|
259
|
+
const message = event.message as any;
|
|
260
|
+
if (message?.role !== "assistant") return;
|
|
261
|
+
for (const part of message.content ?? []) {
|
|
262
|
+
if (part.type === "text" && typeof part.text === "string") outputParts.push(part.text);
|
|
263
|
+
}
|
|
264
|
+
const fullText = outputParts.join("\n\n");
|
|
265
|
+
const lines = fullText.split("\n").filter((l: string) => l.trim());
|
|
266
|
+
progress.recentOutput = lines.slice(-8);
|
|
267
|
+
emitProgress();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const abort = () => { session.abort(); };
|
|
272
|
+
args.signal?.addEventListener("abort", abort, { once: true });
|
|
273
|
+
|
|
274
|
+
progress.label = "Starting audit...";
|
|
275
|
+
progress.percentage = 0;
|
|
276
|
+
emitProgress();
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
if (args.signal?.aborted) {
|
|
280
|
+
return { approved: false, disapproved: false, output: "", model: modelLabel(model), thinkingLevel, error: "Auditor aborted." };
|
|
281
|
+
}
|
|
282
|
+
await session.prompt(buildGoalAuditorPrompt(args.goal, args.completionSummary, args.verificationSummary));
|
|
283
|
+
} finally {
|
|
284
|
+
unsub();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const output = outputParts.join("\n\n");
|
|
288
|
+
|
|
289
|
+
// SILENT-FAILURE GUARD (v0.9.9, wild-caught): an auditor that produced
|
|
290
|
+
// nothing — or produced text with no verdict marker — did not VERDICT.
|
|
291
|
+
// That is an infrastructure result (dead model, quota, stream error),
|
|
292
|
+
// not a disapproval, and must never be recorded as one.
|
|
293
|
+
if (!output.trim()) {
|
|
294
|
+
return {
|
|
295
|
+
approved: false,
|
|
296
|
+
disapproved: false,
|
|
297
|
+
output,
|
|
298
|
+
model: modelLabel(model),
|
|
299
|
+
thinkingLevel,
|
|
300
|
+
error: `Auditor produced no output${streamError ? `: ${streamError}` : " — the auditor session likely failed (check the model's auth/quota, or set a working one with /gla model=provider/id)"}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const lastAssistant = [...outputParts].reverse().find((t) => /<\/?(approved|disapproved)\/>/i.test(t)) ?? output;
|
|
305
|
+
const approved = /<approved\/>/i.test(lastAssistant);
|
|
306
|
+
const disapproved = /<disapproved\/>/i.test(lastAssistant);
|
|
307
|
+
|
|
308
|
+
if (!approved && !disapproved) {
|
|
309
|
+
return {
|
|
310
|
+
approved: false,
|
|
311
|
+
disapproved: false,
|
|
312
|
+
output,
|
|
313
|
+
model: modelLabel(model),
|
|
314
|
+
thinkingLevel,
|
|
315
|
+
error: `Auditor produced no verdict marker (<approved/>/<disapproved/>)${streamError ? ` — stream error: ${streamError}` : ""}. Treating as an error, not a verdict.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// v0.1.0 honesty: must call at least one read tool, otherwise it didn't really audit.
|
|
320
|
+
const usedReadTool = toolCalls.some((c) => ["read", "grep", "find", "ls", "bash"].includes(c.name));
|
|
321
|
+
|
|
322
|
+
if (approved && !usedReadTool) {
|
|
323
|
+
return {
|
|
324
|
+
approved: false,
|
|
325
|
+
disapproved: true,
|
|
326
|
+
output,
|
|
327
|
+
model: modelLabel(model),
|
|
328
|
+
thinkingLevel,
|
|
329
|
+
error: "Auditor approved without calling any read tool; treated as disapproved.",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// regression_shield: an approval against a verification contract must
|
|
334
|
+
// carry per-item raw evidence, or it is converted to a disapproval.
|
|
335
|
+
if (approved && args.goal.verificationContract?.trim()) {
|
|
336
|
+
const shield = checkRegressionShield(output, args.goal.verificationContract);
|
|
337
|
+
if (!shield.passed) {
|
|
338
|
+
const why = !shield.hasEvidenceBlock
|
|
339
|
+
? "report has no <evidence> block"
|
|
340
|
+
: `report's evidence does not address: ${shield.missingItems.join("; ")}`;
|
|
341
|
+
return {
|
|
342
|
+
approved: false,
|
|
343
|
+
disapproved: true,
|
|
344
|
+
output,
|
|
345
|
+
model: modelLabel(model),
|
|
346
|
+
thinkingLevel,
|
|
347
|
+
error: `regression_shield: approved but ${why}`,
|
|
348
|
+
regressionShieldPassed: false,
|
|
349
|
+
regressionShieldMissing: shield.missingItems,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
progress.phase = "complete";
|
|
353
|
+
emitProgress();
|
|
354
|
+
return {
|
|
355
|
+
approved,
|
|
356
|
+
disapproved,
|
|
357
|
+
output,
|
|
358
|
+
model: modelLabel(model),
|
|
359
|
+
thinkingLevel,
|
|
360
|
+
regressionShieldPassed: true,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
progress.phase = "complete";
|
|
365
|
+
emitProgress();
|
|
366
|
+
return { approved, disapproved, output, model: modelLabel(model), thinkingLevel };
|
|
367
|
+
} catch (err) {
|
|
368
|
+
// v0.11.1 (audit critical): a runtime exception is INFRASTRUCTURE, never
|
|
369
|
+
// a verdict. The three-way split identifies infra by `error &&
|
|
370
|
+
// !disapproved` — setting disapproved here would silently route this to
|
|
371
|
+
// the semantic-disapproval branch (the bug v0.9.9 was built to kill).
|
|
372
|
+
return {
|
|
373
|
+
approved: false,
|
|
374
|
+
disapproved: false,
|
|
375
|
+
output: "",
|
|
376
|
+
model: modelLabel(model),
|
|
377
|
+
thinkingLevel,
|
|
378
|
+
error: err instanceof Error ? err.message : String(err),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// =================================================================
|
|
384
|
+
// Model label helper
|
|
385
|
+
// =================================================================
|
|
386
|
+
|
|
387
|
+
function modelLabel(model: Model<any>): string {
|
|
388
|
+
if (typeof model === "string") return model;
|
|
389
|
+
if (model && typeof model === "object" && "id" in model) return (model as { id: string }).id;
|
|
390
|
+
return "(unknown model)";
|
|
391
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal-list-loop-audit — v0.1.0
|
|
3
|
+
* extensions/goal-loop-backoff.ts
|
|
4
|
+
*
|
|
5
|
+
* Hard 5-minute ceiling on backoff. Anything beyond the ceiling pauses the
|
|
6
|
+
* loop and notifies the user (TUI badge + optional push).
|
|
7
|
+
*
|
|
8
|
+
* Design: see docs/DESIGN.md, decision #3.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const BACKOFF_HARD_CAP_MS = 5 * 60 * 1000;
|
|
12
|
+
export const BACKOFF_IDLE_RETRY_MS = 50; // when adding another iter to queue
|
|
13
|
+
export const BACKOFF_ERROR_BASE_MS = 5_000; // first error retry
|
|
14
|
+
export const BACKOFF_ERROR_MAX_MS = 60_000; // max error retry (separate from stuck cap)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return the backoff (ms) before scheduling the next iteration, based on
|
|
18
|
+
* consecutive iterations that produced no meaningful progress.
|
|
19
|
+
*
|
|
20
|
+
* Caps at BACKOFF_HARD_CAP_MS (5 min). Beyond that, the orchestrator should
|
|
21
|
+
* pause and notify the user.
|
|
22
|
+
*/
|
|
23
|
+
export function backoffMs(stuckCount: number, mode: "stuck" | "error" | "context" = "stuck"): number {
|
|
24
|
+
if (mode === "error") {
|
|
25
|
+
return Math.min(BACKOFF_ERROR_BASE_MS * 2 ** Math.max(0, stuckCount - 1), BACKOFF_ERROR_MAX_MS);
|
|
26
|
+
}
|
|
27
|
+
if (mode === "context") {
|
|
28
|
+
return Math.min(30_000 * Math.max(1, stuckCount), BACKOFF_HARD_CAP_MS);
|
|
29
|
+
}
|
|
30
|
+
// "stuck" — the main case the user complained about.
|
|
31
|
+
const schedule = [0, 30_000, 60_000, 120_000, 240_000, BACKOFF_HARD_CAP_MS];
|
|
32
|
+
const idx = Math.max(0, Math.min(schedule.length - 1, stuckCount));
|
|
33
|
+
return schedule[idx] ?? BACKOFF_HARD_CAP_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Determine whether the orchestrator should pause (vs. reschedule).
|
|
38
|
+
*
|
|
39
|
+
* Pause conditions:
|
|
40
|
+
* - stuck for >= 5 minutes
|
|
41
|
+
* - any single iteration has been silent (no tool call) for > N seconds
|
|
42
|
+
*/
|
|
43
|
+
export function shouldPauseAfterBackoff(stuckElapsedMs: number, idleIterCount: number): boolean {
|
|
44
|
+
if (stuckElapsedMs >= BACKOFF_HARD_CAP_MS) return true;
|
|
45
|
+
if (idleIterCount >= 3) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Human-readable label, e.g. "5m", "30s", "1m".
|
|
51
|
+
*/
|
|
52
|
+
export function humanMs(ms: number): string {
|
|
53
|
+
if (ms < 1000) return `${ms}ms`;
|
|
54
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
55
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =================================================================
|
|
59
|
+
// Heartbeat self-watchdog (v0.5.0)
|
|
60
|
+
//
|
|
61
|
+
// Replaces the external pi-compaction-continue plugin FOR OUR LOOPS. A goal
|
|
62
|
+
// loop that dies silently (compaction-eaten turn, dropped message, stale ctx)
|
|
63
|
+
// is a hole in this plugin, not something to outsource. One precise check
|
|
64
|
+
// covers every stall cause: supervising + idle + nothing scheduled + quiet
|
|
65
|
+
// for too long → re-fire the continuation ourselves.
|
|
66
|
+
// =================================================================
|
|
67
|
+
|
|
68
|
+
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
69
|
+
export const HEARTBEAT_STALL_MS = 60_000;
|
|
70
|
+
export const HEARTBEAT_MAX_NUDGES = 3;
|
|
71
|
+
|
|
72
|
+
export interface HeartbeatInput {
|
|
73
|
+
/** A goal is active (autoContinue) or a loop is running. */
|
|
74
|
+
supervising: boolean;
|
|
75
|
+
/** ctx.isIdle() && !ctx.hasPendingMessages() */
|
|
76
|
+
sessionIdle: boolean;
|
|
77
|
+
/** A continuation or loop timer is already scheduled. */
|
|
78
|
+
timerPending: boolean;
|
|
79
|
+
/** Milliseconds since the last observed agent activity. */
|
|
80
|
+
msSinceActivity: number;
|
|
81
|
+
stallMs?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Should the heartbeat re-fire the continuation right now? */
|
|
85
|
+
export function shouldHeartbeatRefire(input: HeartbeatInput): boolean {
|
|
86
|
+
if (!input.supervising) return false;
|
|
87
|
+
if (!input.sessionIdle) return false;
|
|
88
|
+
if (input.timerPending) return false;
|
|
89
|
+
return input.msSinceActivity >= (input.stallMs ?? HEARTBEAT_STALL_MS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Judge a finished turn for nudge accounting. A supervising turn with zero
|
|
94
|
+
* tool calls produced no real progress — that is a nudge. Anything with a
|
|
95
|
+
* tool call resets the counter. Returns the new consecutive-nudge count.
|
|
96
|
+
*/
|
|
97
|
+
export function accountTurnForNudges(toolCalls: number, currentNudges: number): number {
|
|
98
|
+
return toolCalls > 0 ? 0 : currentNudges + 1;
|
|
99
|
+
}
|