loopforge 2.6.0 → 2.11.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/README.md +103 -166
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +44 -6
- package/dist/canonical-state.js.map +1 -1
- package/dist/enforcement-gate.d.ts +34 -7
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +445 -51
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +21 -3
- package/dist/engine.js.map +1 -1
- package/dist/evidence-provider.d.ts +3 -5
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +24 -10
- package/dist/evidence-provider.js.map +1 -1
- package/dist/index.d.ts +2 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -11
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +2 -2
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +135 -42
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +3 -5
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +42 -8
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +27 -2
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +145 -1
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +26 -4
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +8 -0
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +11 -0
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +52 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +12 -2
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +3 -1
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +427 -29
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/protocol.d.ts +54 -8
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +7 -1
- package/dist/protocol.js.map +1 -1
- package/dist/round-coordinator.d.ts +28 -4
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +92 -8
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +4 -0
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +2 -0
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +4 -0
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +4 -5
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +30 -0
- package/dist/self-eval.js.map +1 -1
- package/dist/token-utils.d.ts +3 -0
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +6 -0
- package/dist/token-utils.js.map +1 -1
- package/dist/verification-gate.d.ts +6 -2
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +41 -5
- package/dist/verification-gate.js.map +1 -1
- package/package.json +2 -2
- package/skills/perception/SKILL.md +105 -30
- package/dist/interop.d.ts +0 -35
- package/dist/interop.d.ts.map +0 -1
- package/dist/interop.js +0 -34
- package/dist/interop.js.map +0 -1
- package/dist/run.d.ts +0 -51
- package/dist/run.d.ts.map +0 -1
- package/dist/run.js +0 -200
- package/dist/run.js.map +0 -1
package/dist/enforcement-gate.js
CHANGED
|
@@ -31,6 +31,149 @@ function entryProgress(entry) {
|
|
|
31
31
|
const pe = evidence.progress_estimate;
|
|
32
32
|
return typeof pe === "number" ? pe : null;
|
|
33
33
|
}
|
|
34
|
+
/** Collect progress estimates from vault entries into a round→estimate map.
|
|
35
|
+
* Shared by R4 (progress_stall) and R5 (progress_stall_terminal). */
|
|
36
|
+
function collectProgressByRound(vaultEntries, currentRound) {
|
|
37
|
+
const progressByRound = new Map();
|
|
38
|
+
for (const entry of vaultEntries) {
|
|
39
|
+
const rnd = entryRound(entry);
|
|
40
|
+
if (rnd < 1 || rnd >= currentRound)
|
|
41
|
+
continue;
|
|
42
|
+
const pe = entryProgress(entry);
|
|
43
|
+
if (pe !== null)
|
|
44
|
+
progressByRound.set(rnd, pe);
|
|
45
|
+
}
|
|
46
|
+
return progressByRound;
|
|
47
|
+
}
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
// v2.10: Backtrack helpers
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
+
/** Check whether a vault entry represents a "clean" round that can serve
|
|
52
|
+
* as a safe restore point. A clean round was accepted (not rejected)
|
|
53
|
+
* and had no error-level verification flags. */
|
|
54
|
+
function isCleanRound(entry) {
|
|
55
|
+
const lineage = entry.loop_lineage;
|
|
56
|
+
// Rejected rounds are not clean
|
|
57
|
+
const success = typeof entry.success === "boolean"
|
|
58
|
+
? entry.success
|
|
59
|
+
: typeof lineage?.success === "boolean" ? lineage.success : undefined;
|
|
60
|
+
if (success === false)
|
|
61
|
+
return false;
|
|
62
|
+
// Rounds with error-level verification flags are not clean
|
|
63
|
+
const flags = entry.verification_flags;
|
|
64
|
+
if (Array.isArray(flags)) {
|
|
65
|
+
for (const f of flags) {
|
|
66
|
+
if (f !== null && typeof f === "object" &&
|
|
67
|
+
f.severity === "error")
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
/** Scan backwards from currentRound to find the most recent clean round.
|
|
74
|
+
* Collects discovered_constraints from skipped rounds along the way.
|
|
75
|
+
* Returns null when no clean round is found within maxDepth. */
|
|
76
|
+
export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
77
|
+
const completed = vaultEntries
|
|
78
|
+
.filter((e) => {
|
|
79
|
+
const rnd = entryRound(e);
|
|
80
|
+
return rnd >= 1 && rnd < currentRound;
|
|
81
|
+
})
|
|
82
|
+
.sort((a, b) => entryRound(b) - entryRound(a)); // newest first
|
|
83
|
+
const skippedDiscoveries = [];
|
|
84
|
+
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
85
|
+
const targetRound = currentRound - depth;
|
|
86
|
+
if (targetRound < 1)
|
|
87
|
+
break;
|
|
88
|
+
const entry = completed.find((e) => entryRound(e) === targetRound);
|
|
89
|
+
if (!entry)
|
|
90
|
+
continue;
|
|
91
|
+
// Collect discoveries from rounds we're about to skip (depth > 1)
|
|
92
|
+
if (depth > 1) {
|
|
93
|
+
for (let d = 1; d < depth; d++) {
|
|
94
|
+
const skippedRound = currentRound - d;
|
|
95
|
+
const skippedEntry = completed.find((e) => entryRound(e) === skippedRound);
|
|
96
|
+
if (skippedEntry) {
|
|
97
|
+
const discovered = Array.isArray(skippedEntry.discovered_constraints)
|
|
98
|
+
? skippedEntry.discovered_constraints.filter((v) => typeof v === "string")
|
|
99
|
+
: [];
|
|
100
|
+
for (const item of discovered) {
|
|
101
|
+
if (!skippedDiscoveries.includes(item))
|
|
102
|
+
skippedDiscoveries.push(item);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (isCleanRound(entry)) {
|
|
108
|
+
return { round: targetRound, skippedDiscoveries };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/** Build the backtrack prompt injected at the top of the restored round.
|
|
114
|
+
*
|
|
115
|
+
* v2.13: Includes concrete workspace restore instructions with affected
|
|
116
|
+
* file lists from skipped rounds. The agent must restore the working tree
|
|
117
|
+
* to the clean round's state before proceeding. */
|
|
118
|
+
export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
|
|
119
|
+
/** v2.13: Files changed in the skipped rounds (from evidence snapshots).
|
|
120
|
+
* Used to show the agent exactly what needs to be reverted. */
|
|
121
|
+
skippedFiles = [],
|
|
122
|
+
/** v2.13: Git HEAD commit hash at the backtrack point (current HEAD).
|
|
123
|
+
* The agent must discard work back to the clean round's state. */
|
|
124
|
+
_gitHead) {
|
|
125
|
+
const lines = [
|
|
126
|
+
`## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
|
|
127
|
+
"",
|
|
128
|
+
];
|
|
129
|
+
// ── Why This Happened ────────────────────────────────────────────────
|
|
130
|
+
if (triggerRule === "progress_stall") {
|
|
131
|
+
lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
|
|
132
|
+
`The work done in those rounds produced no verifiable progress — ` +
|
|
133
|
+
`no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
|
|
134
|
+
}
|
|
135
|
+
else if (triggerRule === "progress_stall_terminal") {
|
|
136
|
+
lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
|
|
137
|
+
`The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
|
|
138
|
+
}
|
|
139
|
+
// ── v2.13: Workspace Restore ──────────────────────────────────────────
|
|
140
|
+
lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
|
|
141
|
+
`rounds (${toRound + 1}–${fromRound}). You MUST discard those changes ` +
|
|
142
|
+
`before starting Round ${toRound + 1}. Working on top of stale changes ` +
|
|
143
|
+
`will cause the verification gate to reject your next submission.`, "");
|
|
144
|
+
if (skippedFiles.length > 0) {
|
|
145
|
+
lines.push("**Files modified in skipped rounds (must be reverted):**", "");
|
|
146
|
+
for (const f of skippedFiles.slice(0, 15)) {
|
|
147
|
+
lines.push(`- \`${f}\``);
|
|
148
|
+
}
|
|
149
|
+
if (skippedFiles.length > 15) {
|
|
150
|
+
lines.push(`- … and ${skippedFiles.length - 15} more files`);
|
|
151
|
+
}
|
|
152
|
+
lines.push("");
|
|
153
|
+
}
|
|
154
|
+
lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Discard all uncommitted changes (recommended)", "git checkout -- .", "git clean -fd", "", "# Option B: If you have unrelated work to keep", `git stash push -m "backtrack-safety-net-round-${fromRound}"`, "git checkout -- .", "git clean -fd", "```", "", `**If git is unavailable**, manually revert the files listed above ` +
|
|
155
|
+
`to their state at Round ${toRound}. Re-read the state file below — ` +
|
|
156
|
+
`it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
|
|
157
|
+
"> your workspace no longer contains the failed changes. Submissions " +
|
|
158
|
+
"> with unrestored files will be **rejected**.", "");
|
|
159
|
+
// ── What Must Change ──────────────────────────────────────────────────
|
|
160
|
+
lines.push("### What Must Change", "", "- Do **NOT** repeat the approach used in the skipped rounds.", "- Try a **different** task decomposition or technique.", "- If the current sub-goal is stuck, consider canceling it " +
|
|
161
|
+
"(`canceled_subtasks`) and working on a different one.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
|
|
162
|
+
"to flag anything you don't understand about the task or the blockage.", "");
|
|
163
|
+
// ── Preserved Discoveries ─────────────────────────────────────────────
|
|
164
|
+
if (skippedDiscoveries.length > 0) {
|
|
165
|
+
lines.push("### Preserved Discoveries", "", "These were discovered in the skipped rounds and remain valid:", "");
|
|
166
|
+
for (const d of skippedDiscoveries) {
|
|
167
|
+
lines.push(`- ${d}`);
|
|
168
|
+
}
|
|
169
|
+
lines.push("");
|
|
170
|
+
}
|
|
171
|
+
// ── Instructions ──────────────────────────────────────────────────────
|
|
172
|
+
lines.push("### Instructions", "", `You have been rolled back to Round ${toRound}. ` +
|
|
173
|
+
`You are now starting **Round ${toRound + 1}** with the lessons above.`, "", "1. **First**: restore your workspace using the commands above.", "2. Read the restored state below — it reflects Round " + toRound + ".", "3. The discoveries from skipped rounds (if any) have been merged " +
|
|
174
|
+
"into the active constraints.", "4. Do NOT retrace the steps that led to the stall. Choose a new path.", "");
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
34
177
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
35
178
|
// Individual enforcement rules — each returns EnforcementResult | null
|
|
36
179
|
// Rules are ordered by priority. The first non-null result wins.
|
|
@@ -122,31 +265,14 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
|
|
|
122
265
|
// Need at least 3 rounds of history
|
|
123
266
|
if (currentRound < 3)
|
|
124
267
|
return null;
|
|
125
|
-
|
|
126
|
-
const progressByRound = new Map();
|
|
127
|
-
for (const entry of vaultEntries) {
|
|
128
|
-
const rnd = entryRound(entry);
|
|
129
|
-
// Only consider rounds before the current one
|
|
130
|
-
if (rnd < 1 || rnd >= currentRound)
|
|
131
|
-
continue;
|
|
132
|
-
const pe = entryProgress(entry);
|
|
133
|
-
if (pe !== null)
|
|
134
|
-
progressByRound.set(rnd, pe);
|
|
135
|
-
}
|
|
136
|
-
// Need at least 3 data points
|
|
268
|
+
const progressByRound = collectProgressByRound(vaultEntries, currentRound);
|
|
137
269
|
if (progressByRound.size < 3)
|
|
138
270
|
return null;
|
|
139
|
-
// Get the three most recent rounds with progress data
|
|
140
271
|
const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
|
|
141
272
|
const last3 = sortedRounds.slice(-3);
|
|
142
|
-
|
|
143
|
-
return null;
|
|
144
|
-
// Verify these are the actual last 3 rounds (contiguous with current)
|
|
145
|
-
// Accept rounds that are within [currentRound-3, currentRound-1]
|
|
273
|
+
// Require all three data points to be within [currentRound-3, currentRound-1].
|
|
146
274
|
const expectedMin = currentRound - 3;
|
|
147
|
-
|
|
148
|
-
// Allow up to 1 round gap (some rounds may not have progress data)
|
|
149
|
-
if (actualMin < expectedMin - 1 || last3[2] > currentRound - 1)
|
|
275
|
+
if (last3[0] < expectedMin || last3[2] > currentRound - 1)
|
|
150
276
|
return null;
|
|
151
277
|
const p1 = progressByRound.get(last3[0]);
|
|
152
278
|
const p2 = progressByRound.get(last3[1]);
|
|
@@ -160,7 +286,49 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
|
|
|
160
286
|
p3 < 0.95;
|
|
161
287
|
if (!isStalling)
|
|
162
288
|
return null;
|
|
163
|
-
|
|
289
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
290
|
+
// v2.7: When escalation is enabled, give one extra rejection round with a
|
|
291
|
+
// "Seek Human Guidance" notice before terminating. This lets the agent
|
|
292
|
+
// course-correct with human input rather than being killed immediately.
|
|
293
|
+
if (consecutiveRejections >= 2) {
|
|
294
|
+
return makeEnforcementResult({
|
|
295
|
+
action: "terminate",
|
|
296
|
+
reason: `Progress stalled for 3+ rounds ` +
|
|
297
|
+
`(${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%) ` +
|
|
298
|
+
`and agent did not resolve after escalation. Terminating loop.`,
|
|
299
|
+
check: "progress_stall",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (consecutiveRejections >= 1 && escalation) {
|
|
303
|
+
// v2.10: When backtrack is enabled, escalate to backtrack instead of
|
|
304
|
+
// reject. The agent is rolled back to the last clean round with lessons
|
|
305
|
+
// injected, rather than redoing the same round with an escalation notice.
|
|
306
|
+
if (getPolicy().engine.backtrack_enabled) {
|
|
307
|
+
return makeEnforcementResult({
|
|
308
|
+
action: "backtrack",
|
|
309
|
+
reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
|
|
310
|
+
`${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
|
|
311
|
+
`last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
|
|
312
|
+
fix_instructions: "Your progress has been flat for 3 rounds. You are being rolled back " +
|
|
313
|
+
"to the last clean round. Do NOT repeat the approach that led to the stall. " +
|
|
314
|
+
"Choose a different technique or task decomposition.",
|
|
315
|
+
check: "progress_stall",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return makeEnforcementResult({
|
|
319
|
+
action: "reject",
|
|
320
|
+
reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
|
|
321
|
+
`${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
|
|
322
|
+
`last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
|
|
323
|
+
fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
|
|
324
|
+
"(a) explain what is blocking progress, " +
|
|
325
|
+
"(b) propose a DIFFERENT technique or task decomposition, and " +
|
|
326
|
+
"(c) set a concrete, verifiable goal for the redo of this round. " +
|
|
327
|
+
"Do NOT repeat the same approach — it has not moved progress forward." +
|
|
328
|
+
buildEscalationNotice(),
|
|
329
|
+
check: "progress_stall",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
164
332
|
if (consecutiveRejections >= 1) {
|
|
165
333
|
return makeEnforcementResult({
|
|
166
334
|
action: "terminate",
|
|
@@ -183,34 +351,27 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
|
|
|
183
351
|
check: "progress_stall",
|
|
184
352
|
});
|
|
185
353
|
}
|
|
186
|
-
/** R5: Progress completely stalled (delta = 0) for N consecutive rounds
|
|
354
|
+
/** R5: Progress completely stalled (delta = 0) for N consecutive rounds.
|
|
187
355
|
* Stricter than R4 (which triggers on delta < 5%). R4 gives the agent a chance
|
|
188
356
|
* to change approach via reject; this rule fires when progress has been exactly
|
|
189
|
-
* flat — the agent is making zero forward motion
|
|
357
|
+
* flat — the agent is making zero forward motion.
|
|
358
|
+
*
|
|
359
|
+
* v2.7: When enforcement_escalation_enabled is true, the first occurrence
|
|
360
|
+
* issues a rejection with an escalation notice instead of terminating.
|
|
361
|
+
* Termination only happens on the second consecutive flatline detection.
|
|
190
362
|
*
|
|
191
363
|
* Uses the engine.max_circuit_breaker policy value as the lookback window. */
|
|
192
|
-
function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntries, skipEvidenceRules = false) {
|
|
364
|
+
function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
|
|
193
365
|
if (skipEvidenceRules)
|
|
194
366
|
return null; // Requires execution_evidence
|
|
195
367
|
const breakerSize = getPolicy().engine.max_circuit_breaker;
|
|
196
368
|
if (currentRound < breakerSize || vaultEntries.length < breakerSize)
|
|
197
369
|
return null;
|
|
198
|
-
|
|
199
|
-
const progressByRound = new Map();
|
|
200
|
-
for (const entry of vaultEntries) {
|
|
201
|
-
const rnd = entryRound(entry);
|
|
202
|
-
if (rnd < 1 || rnd >= currentRound)
|
|
203
|
-
continue;
|
|
204
|
-
const pe = entryProgress(entry);
|
|
205
|
-
if (pe !== null)
|
|
206
|
-
progressByRound.set(rnd, pe);
|
|
207
|
-
}
|
|
370
|
+
const progressByRound = collectProgressByRound(vaultEntries, currentRound);
|
|
208
371
|
if (progressByRound.size < breakerSize)
|
|
209
372
|
return null;
|
|
210
373
|
const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
|
|
211
374
|
const recent = sortedRounds.slice(-breakerSize);
|
|
212
|
-
if (recent.length < breakerSize)
|
|
213
|
-
return null;
|
|
214
375
|
// Check that progress values are ALL identical (delta = 0).
|
|
215
376
|
// Use epsilon comparison — JSON round-trips can produce float drift
|
|
216
377
|
// (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
|
|
@@ -219,6 +380,48 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
|
|
|
219
380
|
const allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
|
|
220
381
|
if (!allFlat || first >= 0.95)
|
|
221
382
|
return null;
|
|
383
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
384
|
+
// v2.7: When escalation is enabled, give one rejection round with a
|
|
385
|
+
// "Seek Human Guidance" notice before terminating.
|
|
386
|
+
if (consecutiveRejections >= 1) {
|
|
387
|
+
return makeEnforcementResult({
|
|
388
|
+
action: "terminate",
|
|
389
|
+
reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
|
|
390
|
+
`for ${breakerSize} consecutive rounds and agent did not resolve ` +
|
|
391
|
+
`after escalation. The agent is making zero forward motion.`,
|
|
392
|
+
check: "progress_stall_terminal",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (escalation) {
|
|
396
|
+
// v2.10: When backtrack is enabled, escalate to backtrack instead of
|
|
397
|
+
// reject. The agent is rolled back to the last clean round with a
|
|
398
|
+
// "radically different strategy" lesson injected.
|
|
399
|
+
if (getPolicy().engine.backtrack_enabled) {
|
|
400
|
+
return makeEnforcementResult({
|
|
401
|
+
action: "backtrack",
|
|
402
|
+
reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
|
|
403
|
+
`for ${breakerSize} consecutive rounds. The agent is making zero ` +
|
|
404
|
+
`forward motion.`,
|
|
405
|
+
fix_instructions: "Your progress has been exactly flat — zero forward motion. " +
|
|
406
|
+
"You are being rolled back to the last clean round. " +
|
|
407
|
+
"Choose a RADICALLY different approach. The previous one produced nothing.",
|
|
408
|
+
check: "progress_stall_terminal",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return makeEnforcementResult({
|
|
412
|
+
action: "reject",
|
|
413
|
+
reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
|
|
414
|
+
`for ${breakerSize} consecutive rounds. The agent is making zero ` +
|
|
415
|
+
`forward motion.`,
|
|
416
|
+
fix_instructions: "Your progress has been exactly flat — you are making zero forward " +
|
|
417
|
+
"motion. You must: (a) explain what is fundamentally blocking you, " +
|
|
418
|
+
"(b) propose a radically different approach, and " +
|
|
419
|
+
"(c) set a concrete verifiable goal. " +
|
|
420
|
+
"Do NOT resubmit the same output." +
|
|
421
|
+
buildEscalationNotice(),
|
|
422
|
+
check: "progress_stall_terminal",
|
|
423
|
+
});
|
|
424
|
+
}
|
|
222
425
|
return makeEnforcementResult({
|
|
223
426
|
action: "terminate",
|
|
224
427
|
reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
|
|
@@ -227,10 +430,40 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
|
|
|
227
430
|
check: "progress_stall_terminal",
|
|
228
431
|
});
|
|
229
432
|
}
|
|
230
|
-
/** R6: Two consecutive rejections → terminate.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
433
|
+
/** R6: Two consecutive rejections → escalate (v2.7) or terminate.
|
|
434
|
+
*
|
|
435
|
+
* v2.7: When enforcement_escalation_enabled is true, the second consecutive
|
|
436
|
+
* rejection issues an escalated rejection with a "Seek Human Guidance" notice
|
|
437
|
+
* instead of terminating immediately. Termination occurs on the third
|
|
438
|
+
* consecutive rejection. When the flag is disabled, behavior is unchanged
|
|
439
|
+
* (terminate at ≥2 rejections). */
|
|
233
440
|
function enforceMaxRejections(consecutiveRejections) {
|
|
441
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
442
|
+
if (escalation) {
|
|
443
|
+
if (consecutiveRejections >= 3) {
|
|
444
|
+
return makeEnforcementResult({
|
|
445
|
+
action: "terminate",
|
|
446
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
447
|
+
`same issue without resolution. The agent has been unable to correct ` +
|
|
448
|
+
`the identified issue even after escalation. Terminating loop.`,
|
|
449
|
+
check: "max_rejections",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (consecutiveRejections >= 2) {
|
|
453
|
+
return makeEnforcementResult({
|
|
454
|
+
action: "reject",
|
|
455
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
456
|
+
`same issue without resolution.`,
|
|
457
|
+
fix_instructions: "You have been rejected multiple times for the same issue. " +
|
|
458
|
+
"You must address ALL items in the Evidence Gap section above. " +
|
|
459
|
+
"If you do not understand why you are being rejected, explain " +
|
|
460
|
+
"your confusion explicitly and ask for clarification." +
|
|
461
|
+
buildEscalationNotice(),
|
|
462
|
+
check: "max_rejections",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
234
467
|
if (consecutiveRejections < 2)
|
|
235
468
|
return null;
|
|
236
469
|
return makeEnforcementResult({
|
|
@@ -240,19 +473,114 @@ function enforceMaxRejections(consecutiveRejections) {
|
|
|
240
473
|
check: "max_rejections",
|
|
241
474
|
});
|
|
242
475
|
}
|
|
476
|
+
/** v2.12: Check if a drift_clarification contains at least one semantic
|
|
477
|
+
* anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
|
|
478
|
+
* reference. This distinguishes genuine explanations from filler text that
|
|
479
|
+
* just happens to be ≥ 20 characters. */
|
|
480
|
+
function hasSemanticAnchor(clarification) {
|
|
481
|
+
// v2.11: Constraint / criterion / sub-goal ID patterns
|
|
482
|
+
if (/[c]r?-[a-f0-9]{8}/.test(clarification))
|
|
483
|
+
return true;
|
|
484
|
+
if (/sg-[a-f0-9]{8}/.test(clarification))
|
|
485
|
+
return true;
|
|
486
|
+
// File path references (e.g. "src/auth/login.ts", "lib/utils.go")
|
|
487
|
+
if (/[\w./-]+\.[a-z]{2,6}\b/i.test(clarification))
|
|
488
|
+
return true;
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
/** Build a stronger rejection notice as the clarification streak grows. */
|
|
492
|
+
function buildClarificationRejectionNotice(streak) {
|
|
493
|
+
const base = "Your declared next_action from the previous round does not match " +
|
|
494
|
+
"what you actually did this round. You must either: " +
|
|
495
|
+
"(a) explain why you pivoted in the drift_clarification field, " +
|
|
496
|
+
"mentioning specific constraint/criterion/sub-goal IDs or file paths " +
|
|
497
|
+
"that prompted the change, or " +
|
|
498
|
+
"(b) redo this round and do what you said you would do.";
|
|
499
|
+
if (streak >= 2) {
|
|
500
|
+
return (base +
|
|
501
|
+
"\n\n### ⚠️ Repeated Weak Clarification\n\n" +
|
|
502
|
+
`You have submitted drift_clarification without concrete references ` +
|
|
503
|
+
`for ${streak} consecutive rounds. Your explanation MUST include at ` +
|
|
504
|
+
`least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
|
|
505
|
+
`sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
|
|
506
|
+
`🚨 If the next clarification also lacks concrete anchors, the loop ` +
|
|
507
|
+
`will be terminated.`);
|
|
508
|
+
}
|
|
509
|
+
return base;
|
|
510
|
+
}
|
|
243
511
|
/** R7: Agent declared a next_action but did something unrelated.
|
|
512
|
+
*
|
|
513
|
+
* v2.8: When the agent provides a substantive drift_clarification (≥ 20
|
|
514
|
+
* characters), the rejection is waived — the agent has acknowledged and
|
|
515
|
+
* explained the pivot. An empty or absent clarification still triggers
|
|
516
|
+
* the normal reject/terminate path.
|
|
517
|
+
*
|
|
518
|
+
* v2.12: Clarification now requires a semantic anchor (constraint/criterion/
|
|
519
|
+
* sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
|
|
520
|
+
* clarifications (no anchors) increment a streak counter; 3 consecutive
|
|
521
|
+
* weak clarifications terminate the loop.
|
|
522
|
+
*
|
|
244
523
|
* First occurrence → reject (agent must explain the gap or realign).
|
|
245
524
|
* Second consecutive → terminate (agent cannot/will not follow its own plan). */
|
|
246
|
-
function enforceIntentDrift(flags, consecutiveRejections) {
|
|
525
|
+
function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0) {
|
|
247
526
|
const flag = flags.find((f) => f.check === "intent_drift");
|
|
248
527
|
if (!flag)
|
|
249
528
|
return null;
|
|
529
|
+
const clarification = selfEval.drift_clarification?.trim();
|
|
530
|
+
// v2.12: Semantic anchor detection — requires concrete references
|
|
531
|
+
const hasAnchor = clarification ? hasSemanticAnchor(clarification) : false;
|
|
532
|
+
// v2.12: Substantive clarification = length check + semantic anchor
|
|
533
|
+
const isSubstantive = clarification
|
|
534
|
+
&& clarification.length >= 20
|
|
535
|
+
&& hasAnchor;
|
|
536
|
+
if (isSubstantive) {
|
|
537
|
+
// Genuine explanation with concrete references → accept the pivot
|
|
538
|
+
return makeEnforcementResult({
|
|
539
|
+
action: "accept",
|
|
540
|
+
reason: "",
|
|
541
|
+
fix_instructions: "",
|
|
542
|
+
check: "intent_drift",
|
|
543
|
+
clarification_accepted: true,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
// v2.12: Clarification present but weak (no semantic anchors)
|
|
547
|
+
if (clarification && clarification.length >= 20 && !hasAnchor) {
|
|
548
|
+
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
549
|
+
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
550
|
+
if (maxStreak <= 0) {
|
|
551
|
+
return makeEnforcementResult({
|
|
552
|
+
action: "accept",
|
|
553
|
+
reason: "",
|
|
554
|
+
fix_instructions: "",
|
|
555
|
+
check: "intent_drift",
|
|
556
|
+
clarification_accepted: true,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
const newStreak = driftClarificationStreak + 1;
|
|
560
|
+
if (newStreak >= maxStreak) {
|
|
561
|
+
return makeEnforcementResult({
|
|
562
|
+
action: "terminate",
|
|
563
|
+
reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
|
|
564
|
+
`file references) for ${newStreak} consecutive rounds. The agent ` +
|
|
565
|
+
`systematically avoids following its own plan without substantive ` +
|
|
566
|
+
`explanation.`,
|
|
567
|
+
check: "intent_drift",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return makeEnforcementResult({
|
|
571
|
+
action: "reject",
|
|
572
|
+
reason: flag.detail,
|
|
573
|
+
fix_instructions: buildClarificationRejectionNotice(newStreak),
|
|
574
|
+
check: "intent_drift",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// No clarification or too short — original behavior
|
|
250
578
|
if (consecutiveRejections >= 1) {
|
|
251
579
|
return makeEnforcementResult({
|
|
252
580
|
action: "terminate",
|
|
253
581
|
reason: `Agent was warned about intent-action drift but continues to do ` +
|
|
254
|
-
`work unrelated to its declared next_action
|
|
255
|
-
`will not follow its own plan — terminating loop.`,
|
|
582
|
+
`work unrelated to its declared next_action without explanation. ` +
|
|
583
|
+
`The agent cannot or will not follow its own plan — terminating loop.`,
|
|
256
584
|
check: "intent_drift",
|
|
257
585
|
});
|
|
258
586
|
}
|
|
@@ -261,7 +589,8 @@ function enforceIntentDrift(flags, consecutiveRejections) {
|
|
|
261
589
|
reason: flag.detail,
|
|
262
590
|
fix_instructions: "Your declared next_action from the previous round does not match " +
|
|
263
591
|
"what you actually did this round. You must either: " +
|
|
264
|
-
"(a) explain why you pivoted and what you learned
|
|
592
|
+
"(a) explain why you pivoted and what you learned in the " +
|
|
593
|
+
"drift_clarification field, or " +
|
|
265
594
|
"(b) redo this round and do what you said you would do. " +
|
|
266
595
|
"If you intentionally changed direction, update your objective_refinement " +
|
|
267
596
|
"and set a new next_action that reflects the new plan.",
|
|
@@ -269,6 +598,58 @@ function enforceIntentDrift(flags, consecutiveRejections) {
|
|
|
269
598
|
});
|
|
270
599
|
}
|
|
271
600
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
601
|
+
// Escalation notice builder (v2.7)
|
|
602
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
603
|
+
/** Build an escalation notice urging the agent to seek human guidance.
|
|
604
|
+
*
|
|
605
|
+
* Appended to fix_instructions when a rule would otherwise terminate.
|
|
606
|
+
* Gives the agent one final chance to correct course with human input
|
|
607
|
+
* before the loop is irreversibly stopped. */
|
|
608
|
+
function buildEscalationNotice() {
|
|
609
|
+
return [
|
|
610
|
+
"",
|
|
611
|
+
"### 🆘 Escalation — Seek Human Guidance",
|
|
612
|
+
"",
|
|
613
|
+
"You have been rejected multiple times for the same issue. " +
|
|
614
|
+
"Before retrying, pause and ask a human for help:",
|
|
615
|
+
"",
|
|
616
|
+
"1. Explain what you've tried so far and why each approach failed.",
|
|
617
|
+
"2. Ask the human for specific guidance on what to try next.",
|
|
618
|
+
"3. Incorporate their feedback before re-submitting your self-evaluation.",
|
|
619
|
+
"",
|
|
620
|
+
"⚠️ If this round is rejected again, the loop will be **terminated**. " +
|
|
621
|
+
"Do NOT resubmit without meaningful changes.",
|
|
622
|
+
].join("\n");
|
|
623
|
+
}
|
|
624
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
625
|
+
// Diagnostic gap builder (v2.7)
|
|
626
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
627
|
+
/** Build a diagnostic gap section from verification flags.
|
|
628
|
+
*
|
|
629
|
+
* Translates each error/warn-level VerificationFlag into a concrete
|
|
630
|
+
* claim-vs-evidence mismatch statement. Info-level flags are excluded
|
|
631
|
+
* because they are purely informational and do not represent gaps.
|
|
632
|
+
*
|
|
633
|
+
* Returns an empty string when there are no diagnostic flags to report. */
|
|
634
|
+
function buildDiagnosticGap(flags) {
|
|
635
|
+
const diagnosticFlags = flags.filter((f) => f.severity === "error" || f.severity === "warn");
|
|
636
|
+
if (diagnosticFlags.length === 0)
|
|
637
|
+
return "";
|
|
638
|
+
const lines = [
|
|
639
|
+
"### Evidence Gap",
|
|
640
|
+
"",
|
|
641
|
+
"The enforcement gate detected mismatches between your claims and the evidence. " +
|
|
642
|
+
"Each item below shows a specific discrepancy — address every one before re-submitting.",
|
|
643
|
+
"",
|
|
644
|
+
];
|
|
645
|
+
for (const flag of diagnosticFlags) {
|
|
646
|
+
const icon = flag.severity === "error" ? "🚫" : "⚠️";
|
|
647
|
+
lines.push(`- ${icon} **[${flag.check}]** \`${flag.field}\` — ${flag.detail}`);
|
|
648
|
+
}
|
|
649
|
+
lines.push("");
|
|
650
|
+
return lines.join("\n");
|
|
651
|
+
}
|
|
652
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
272
653
|
// Main entry point
|
|
273
654
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
274
655
|
/** Enforce round-boundary rules based on the verification gate's findings
|
|
@@ -283,7 +664,9 @@ function enforceIntentDrift(flags, consecutiveRejections) {
|
|
|
283
664
|
* @param consecutiveRejections How many consecutive rounds have already been rejected.
|
|
284
665
|
* Starts at 0; increments on each reject; resets on accept.
|
|
285
666
|
*/
|
|
286
|
-
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false
|
|
667
|
+
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false,
|
|
668
|
+
/** v2.12: Current clarification streak for R7 escalation. */
|
|
669
|
+
driftClarificationStreak = 0) {
|
|
287
670
|
const { flags } = verifyResult;
|
|
288
671
|
// Run enforcement rules in priority order.
|
|
289
672
|
// Earlier rules take higher precedence.
|
|
@@ -292,9 +675,9 @@ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries,
|
|
|
292
675
|
() => enforceRecurringViolation(flags),
|
|
293
676
|
() => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
|
|
294
677
|
() => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
|
|
295
|
-
() => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, skipEvidenceRules),
|
|
678
|
+
() => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
|
|
296
679
|
() => enforceMaxRejections(consecutiveRejections),
|
|
297
|
-
() => enforceIntentDrift(flags, consecutiveRejections),
|
|
680
|
+
() => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak),
|
|
298
681
|
];
|
|
299
682
|
for (const rule of rules) {
|
|
300
683
|
const result = rule();
|
|
@@ -313,11 +696,17 @@ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries,
|
|
|
313
696
|
* The prompt clearly states the round was rejected, why, what the agent
|
|
314
697
|
* must fix, and that the agent must redo the SAME round (not advance).
|
|
315
698
|
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
|
|
320
|
-
|
|
699
|
+
* v2.7: Accepts verificationFlags to render a diagnostic "Evidence Gap"
|
|
700
|
+
* section. Each error/warn flag becomes a concrete claim-vs-evidence
|
|
701
|
+
* mismatch statement so the agent knows exactly what to correct rather
|
|
702
|
+
* than retrying blindly.
|
|
703
|
+
*
|
|
704
|
+
* @param currentRound The round number that was rejected (NOT incremented).
|
|
705
|
+
* @param task The original loop task description.
|
|
706
|
+
* @param enforceResult The enforcement decision with reason and fix instructions.
|
|
707
|
+
* @param verificationFlags The verification gate's findings for this round.
|
|
708
|
+
* Used to build the Evidence Gap section. */
|
|
709
|
+
export function buildRejectionPrompt(currentRound, task, enforceResult, verificationFlags = []) {
|
|
321
710
|
const lines = [
|
|
322
711
|
"## ⛔ Round " + currentRound + " — REJECTED",
|
|
323
712
|
"",
|
|
@@ -329,6 +718,11 @@ export function buildRejectionPrompt(currentRound, task, enforceResult) {
|
|
|
329
718
|
enforceResult.reason,
|
|
330
719
|
"",
|
|
331
720
|
];
|
|
721
|
+
// v2.7: Diagnostic gap — show the agent exactly what doesn't match.
|
|
722
|
+
const gap = buildDiagnosticGap(verificationFlags);
|
|
723
|
+
if (gap) {
|
|
724
|
+
lines.push(gap);
|
|
725
|
+
}
|
|
332
726
|
if (enforceResult.fix_instructions) {
|
|
333
727
|
lines.push("### Required Fix");
|
|
334
728
|
lines.push("");
|