pi-warden 0.34.1 → 0.35.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 +4 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +3 -1
- package/dist/extension.js +48 -3
- package/dist/shape.js +1 -1
- package/dist/stuck.d.ts +9 -0
- package/dist/stuck.js +101 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,10 @@ How to keep this current: add the entry in the same pull request as the change,
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## 0.35.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Stuck-loop diff: when the stuck detector marks a repeated failed attempt, the agent sees a short unified line diff against the previous output instead of the full repeated output again. Byte-identical outputs get a one-line note. The diff note never grows the result. Config keys `stuck.diffLimit` (3000) and `stuck.tailLimit` (1000) control the diff and tail caps.
|
|
10
13
|
|
|
11
14
|
## 0.34.1
|
|
12
15
|
|
package/dist/config.d.ts
CHANGED
|
@@ -127,6 +127,10 @@ export interface StuckGuardConfig {
|
|
|
127
127
|
churnThreshold: number;
|
|
128
128
|
/** Also steer the agent with a short message, not only the user. */
|
|
129
129
|
nudge: boolean;
|
|
130
|
+
/** Maximum characters of the unified line diff in a stuck-loop diff note. */
|
|
131
|
+
diffLimit: number;
|
|
132
|
+
/** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
|
|
133
|
+
tailLimit: number;
|
|
130
134
|
}
|
|
131
135
|
export interface DoneGuardConfig {
|
|
132
136
|
enabled: boolean;
|
package/dist/config.js
CHANGED
|
@@ -39,7 +39,7 @@ export function defaultConfig() {
|
|
|
39
39
|
escalationThreshold: 0.85,
|
|
40
40
|
floor: "evidence",
|
|
41
41
|
},
|
|
42
|
-
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
|
|
42
|
+
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, diffLimit: 3000, tailLimit: 1000 },
|
|
43
43
|
done: { enabled: true, claimsDone: 0.7, nudge: true },
|
|
44
44
|
slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
|
|
45
45
|
security: { enabled: true, threshold: 0.7 },
|
|
@@ -284,6 +284,8 @@ function applyStuck(base, raw) {
|
|
|
284
284
|
sameStrategy: probability(raw.sameStrategy, base.sameStrategy),
|
|
285
285
|
churnThreshold: Math.min(window, positiveInteger(raw.churnThreshold, base.churnThreshold)),
|
|
286
286
|
nudge: boolean(raw.nudge, base.nudge),
|
|
287
|
+
diffLimit: positiveInteger(raw.diffLimit, base.diffLimit),
|
|
288
|
+
tailLimit: positiveInteger(raw.tailLimit, base.tailLimit),
|
|
287
289
|
};
|
|
288
290
|
}
|
|
289
291
|
function applyRunaway(base, raw) {
|
package/dist/extension.js
CHANGED
|
@@ -25,7 +25,7 @@ import { writeStarterRules, buildInitPrompt } from "./init.js";
|
|
|
25
25
|
import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
|
|
26
26
|
import { detectNotifier, sendNotification } from "./notify.js";
|
|
27
27
|
import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
|
|
28
|
-
import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
|
|
28
|
+
import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckDiff, stuckNudge } from "./stuck.js";
|
|
29
29
|
import { openConfigPanel, openTracePanel } from "./panel.js";
|
|
30
30
|
import { completeConfig, shapeWarning } from "./shape.js";
|
|
31
31
|
import { ContextLedger, formatLedger } from "./saver.js";
|
|
@@ -250,6 +250,8 @@ export default function wardenExtension(pi) {
|
|
|
250
250
|
// Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
|
|
251
251
|
let regretCandidates = [];
|
|
252
252
|
let attempts = new AttemptWindow(defaultConfig().stuck.window);
|
|
253
|
+
/** Full output text and saved path keyed by attempt call key, for stuck-loop diffs. */
|
|
254
|
+
let fullOutputs = new Map();
|
|
253
255
|
let evidence = emptyEvidence();
|
|
254
256
|
let doneNudged = false;
|
|
255
257
|
let warnedFallback = false;
|
|
@@ -595,6 +597,7 @@ export default function wardenExtension(pi) {
|
|
|
595
597
|
lastUi = ctx.ui;
|
|
596
598
|
const config = configFor(ctx);
|
|
597
599
|
attempts = new AttemptWindow(config.stuck.window);
|
|
600
|
+
fullOutputs = new Map();
|
|
598
601
|
doneNudged = false;
|
|
599
602
|
steersThisRun = 0;
|
|
600
603
|
finals.reset();
|
|
@@ -1129,11 +1132,53 @@ export default function wardenExtension(pi) {
|
|
|
1129
1132
|
`output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
|
|
1130
1133
|
]);
|
|
1131
1134
|
}
|
|
1135
|
+
// Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
|
|
1136
|
+
// for the same call is available, replace the tool result with a short diff note. Compute
|
|
1137
|
+
// bytesSaved against the current content (after duplicate detection and compression), so the
|
|
1138
|
+
// diff never grows the result.
|
|
1139
|
+
const verdict = await stuckCheck;
|
|
1140
|
+
if (verdict && verdict.stuck && failed && !verdict.successRepeat && !verdict.churn && text.length > 0) {
|
|
1141
|
+
const currentKey = attempts.attempts.at(-1)?.key;
|
|
1142
|
+
const prevEntry = currentKey ? fullOutputs.get(currentKey) : undefined;
|
|
1143
|
+
if (currentKey && prevEntry && prevEntry.text !== text) {
|
|
1144
|
+
// Differing outputs: save the current output and diff against the previous one.
|
|
1145
|
+
try {
|
|
1146
|
+
const saved = await saveOutput(text);
|
|
1147
|
+
const diffNote = stuckDiff(prevEntry.text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: saved });
|
|
1148
|
+
const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
|
|
1149
|
+
if (Buffer.byteLength(diffNote) < currentLen) {
|
|
1150
|
+
content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
|
|
1151
|
+
}
|
|
1152
|
+
fullOutputs.set(currentKey, { text, path: saved });
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
noteError(ctx, "Could not store full output for stuck diff; keeping it unchanged.", undefined);
|
|
1156
|
+
fullOutputs.set(currentKey, { text });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
else if (currentKey && prevEntry && prevEntry.text === text && prevEntry.path) {
|
|
1160
|
+
// Byte-identical outputs: one-line note reusing the existing saved path.
|
|
1161
|
+
const diffNote = stuckDiff(text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: prevEntry.path });
|
|
1162
|
+
const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
|
|
1163
|
+
if (Buffer.byteLength(diffNote) < currentLen) {
|
|
1164
|
+
content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
else if (currentKey) {
|
|
1168
|
+
// First failed output for this call, or no previous entry yet: store for future diffs.
|
|
1169
|
+
fullOutputs.set(currentKey, { text });
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
else if (failed && text.length > 0) {
|
|
1173
|
+
// Not stuck (or no verdict yet): store the output so a future repeat can diff against it.
|
|
1174
|
+
const currentKey = attempts.attempts.at(-1)?.key;
|
|
1175
|
+
if (currentKey)
|
|
1176
|
+
fullOutputs.set(currentKey, { text });
|
|
1177
|
+
}
|
|
1132
1178
|
const patch = content === event.content ? undefined : { content };
|
|
1133
1179
|
// Checks use the original result, not the excerpts or security banner.
|
|
1134
1180
|
if (config.done.enabled)
|
|
1135
1181
|
recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
|
|
1136
|
-
const verdict = await stuckCheck;
|
|
1137
1182
|
if (!verdict)
|
|
1138
1183
|
return patch;
|
|
1139
1184
|
if (verdict.error)
|
|
@@ -1146,7 +1191,7 @@ export default function wardenExtension(pi) {
|
|
|
1146
1191
|
return patch;
|
|
1147
1192
|
stats.stuck++;
|
|
1148
1193
|
if (ctx.hasUI && config.notices)
|
|
1149
|
-
ctx.ui.notify(`warden
|
|
1194
|
+
ctx.ui.notify(`warden \u00b7 stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
|
|
1150
1195
|
if (nudge)
|
|
1151
1196
|
steer(config, "stuck", nudge);
|
|
1152
1197
|
return patch;
|
package/dist/shape.js
CHANGED
|
@@ -31,7 +31,7 @@ export function completeConfig(loaded) {
|
|
|
31
31
|
notices: source.notices ?? false,
|
|
32
32
|
steerBudget: typeof source.steerBudget === "number" && source.steerBudget >= 0 ? source.steerBudget : 3,
|
|
33
33
|
action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, steer: 1 }, intentMismatch: 1, visibleMismatch: 1, shouldProceed: { hold: 0.6, steer: false }, feedbackLog: false, commandRules: [], commandDenyRules: [], exemptRules: [], pathRules: [], armingRules: [], escalationThreshold: 0.85, floor: "evidence" }),
|
|
34
|
-
stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false }),
|
|
34
|
+
stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false, diffLimit: 3000, tailLimit: 1000 }),
|
|
35
35
|
done: section("done", { ...off, claimsDone: 1, nudge: false }),
|
|
36
36
|
slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
|
|
37
37
|
security: section("security", { ...off, threshold: 1 }),
|
package/dist/stuck.d.ts
CHANGED
|
@@ -102,4 +102,13 @@ export declare function evaluateStuck(window: AttemptWindow, task: string | unde
|
|
|
102
102
|
/** Steering text for the agent. Names the pattern and asks for a change of method, not another retry. A successful
|
|
103
103
|
* repeat is a different disease than a failure loop: the model already has the answer, so it should use it. */
|
|
104
104
|
export declare function stuckNudge(verdict: StuckVerdict): string;
|
|
105
|
+
/**
|
|
106
|
+
* Short note for a stuck-loop repeated failure: header, capped unified diff against the previous output,
|
|
107
|
+
* tail of the current output, and the path of the full copy. All inputs must be pre-redacted.
|
|
108
|
+
*/
|
|
109
|
+
export declare function stuckDiff(previous: string, current: string, options: {
|
|
110
|
+
diffLimit: number;
|
|
111
|
+
tailLimit: number;
|
|
112
|
+
fullPath: string;
|
|
113
|
+
}): string;
|
|
105
114
|
export declare function formatStuck(verdict: StuckVerdict, template?: string): string;
|
package/dist/stuck.js
CHANGED
|
@@ -177,6 +177,107 @@ export function stuckNudge(verdict) {
|
|
|
177
177
|
}
|
|
178
178
|
return `pi-warden: ${verdict.reasons.join("; ")}. Stop retrying. Re-read the last error output carefully, state a new hypothesis about the cause, and either gather the missing information (read the relevant file, check versions or paths) or try a different method. If two different methods have failed, report the blocker to the user with the exact error instead of trying again.`;
|
|
179
179
|
}
|
|
180
|
+
/** LCS-based line diff between two outputs, capped at diffLimit characters. Both inputs must be pre-redacted. */
|
|
181
|
+
function lineDiff(previous, current, diffLimit) {
|
|
182
|
+
const prevLines = previous.split("\n");
|
|
183
|
+
const currLines = current.split("\n");
|
|
184
|
+
const m = prevLines.length;
|
|
185
|
+
const n = currLines.length;
|
|
186
|
+
// Longest common subsequence.
|
|
187
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
188
|
+
for (let i = 1; i <= m; i++) {
|
|
189
|
+
for (let j = 1; j <= n; j++) {
|
|
190
|
+
dp[i][j] = prevLines[i - 1] === currLines[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Backtrack to build unified diff.
|
|
194
|
+
let i = m;
|
|
195
|
+
let j = n;
|
|
196
|
+
const ops = [];
|
|
197
|
+
while (i > 0 || j > 0) {
|
|
198
|
+
if (i > 0 && j > 0 && prevLines[i - 1] === currLines[j - 1]) {
|
|
199
|
+
ops.push([" ", prevLines[i - 1]]);
|
|
200
|
+
i--;
|
|
201
|
+
j--;
|
|
202
|
+
}
|
|
203
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
204
|
+
ops.push(["+", currLines[j - 1]]);
|
|
205
|
+
j--;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
ops.push(["-", prevLines[i - 1]]);
|
|
209
|
+
i--;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
ops.reverse();
|
|
213
|
+
// Identify indices of changed ops (+ or -).
|
|
214
|
+
const changedIndices = [];
|
|
215
|
+
for (let k = 0; k < ops.length; k++) {
|
|
216
|
+
if (ops[k][0] !== " ")
|
|
217
|
+
changedIndices.push(k);
|
|
218
|
+
}
|
|
219
|
+
if (changedIndices.length === 0)
|
|
220
|
+
return "";
|
|
221
|
+
// Merge nearby changes: expand each changed index by 3 lines of context on each side,
|
|
222
|
+
// then merge overlapping ranges.
|
|
223
|
+
const CONTEXT = 3;
|
|
224
|
+
const ranges = [];
|
|
225
|
+
for (const idx of changedIndices) {
|
|
226
|
+
const start = Math.max(0, idx - CONTEXT);
|
|
227
|
+
const end = Math.min(ops.length - 1, idx + CONTEXT);
|
|
228
|
+
if (ranges.length > 0 && start <= ranges[ranges.length - 1][1] + 1) {
|
|
229
|
+
ranges[ranges.length - 1][1] = end;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
ranges.push([start, end]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Emit hunks.
|
|
236
|
+
const hunks = [];
|
|
237
|
+
for (const [rangeStart, rangeEnd] of ranges) {
|
|
238
|
+
let prevPos = 0;
|
|
239
|
+
for (let k = 0; k < rangeStart; k++) {
|
|
240
|
+
const op = ops[k][0];
|
|
241
|
+
if (op === " " || op === "-")
|
|
242
|
+
prevPos++;
|
|
243
|
+
}
|
|
244
|
+
// Count lines in the range for each file.
|
|
245
|
+
let prevCount = 0;
|
|
246
|
+
let currCount = 0;
|
|
247
|
+
const lines = [];
|
|
248
|
+
for (let k = rangeStart; k <= rangeEnd; k++) {
|
|
249
|
+
const [op, line] = ops[k];
|
|
250
|
+
lines.push(`${op} ${line}`);
|
|
251
|
+
if (op === " ") {
|
|
252
|
+
prevCount++;
|
|
253
|
+
currCount++;
|
|
254
|
+
}
|
|
255
|
+
else if (op === "-") {
|
|
256
|
+
prevCount++;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
currCount++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
hunks.push(`@@ -${prevPos + 1},${prevCount} +${prevPos + 1},${currCount} @@`);
|
|
263
|
+
hunks.push(...lines);
|
|
264
|
+
}
|
|
265
|
+
const text = hunks.join("\n");
|
|
266
|
+
return text.length <= diffLimit ? text : `${text.slice(0, diffLimit)}\n… [diff truncated]`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Short note for a stuck-loop repeated failure: header, capped unified diff against the previous output,
|
|
270
|
+
* tail of the current output, and the path of the full copy. All inputs must be pre-redacted.
|
|
271
|
+
*/
|
|
272
|
+
export function stuckDiff(previous, current, options) {
|
|
273
|
+
const header = `pi-warden: stuck-loop diff; this output repeated a failure (previous: ${previous.length} chars, current: ${current.length} chars).`;
|
|
274
|
+
if (previous === current) {
|
|
275
|
+
return `${header}\nOutputs are byte-identical; see the full output at ${options.fullPath}.`;
|
|
276
|
+
}
|
|
277
|
+
const diff = lineDiff(previous, current, options.diffLimit);
|
|
278
|
+
const tail = current.length <= options.tailLimit ? current : `… [${current.length - options.tailLimit} earlier chars omitted]\n${current.slice(-options.tailLimit)}`;
|
|
279
|
+
return `${header}\n${diff}\n${tail}\n\nFull output: ${options.fullPath}`;
|
|
280
|
+
}
|
|
180
281
|
export function formatStuck(verdict, template = DEFAULT_TEMPLATES.stuck) {
|
|
181
282
|
return renderTemplate(template, stuckTokens(verdict));
|
|
182
283
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|