pi-warden 0.5.4 → 0.5.5
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/dist/extension.d.ts +10 -1
- package/dist/extension.js +72 -13
- package/package.json +1 -1
package/dist/extension.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SlopSymptom, Verdict } from "./guard.js";
|
|
3
3
|
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and tail compression. Compression stores an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
4
|
+
/**
|
|
5
|
+
* Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
|
|
6
|
+
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
7
|
+
*/
|
|
8
|
+
export declare function siblingToolCalls(ctx: ExtensionContext): Array<{
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
input: Record<string, unknown>;
|
|
12
|
+
}>;
|
|
4
13
|
export declare function confirmMessage(verdict: Verdict): string;
|
|
5
14
|
/** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
|
|
6
15
|
export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
|
package/dist/extension.js
CHANGED
|
@@ -53,6 +53,27 @@ function recentTaskContext(ctx) {
|
|
|
53
53
|
}
|
|
54
54
|
return messages.reverse();
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
|
|
58
|
+
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
59
|
+
*/
|
|
60
|
+
export function siblingToolCalls(ctx) {
|
|
61
|
+
const entries = ctx.sessionManager.getBranch();
|
|
62
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
63
|
+
const entry = entries[index];
|
|
64
|
+
if (entry?.type !== "message")
|
|
65
|
+
continue;
|
|
66
|
+
if (entry.message.role !== "assistant")
|
|
67
|
+
return [];
|
|
68
|
+
const content = entry.message.content;
|
|
69
|
+
if (!Array.isArray(content))
|
|
70
|
+
return [];
|
|
71
|
+
return content.flatMap(part => part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string"
|
|
72
|
+
? [{ id: part.id, name: part.name, input: (part.arguments ?? {}) }]
|
|
73
|
+
: []);
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
56
77
|
function activeMode(config, hasUI) {
|
|
57
78
|
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
58
79
|
const mode = isMode(env) ? env : config.mode;
|
|
@@ -98,6 +119,8 @@ export default function wardenExtension(pi) {
|
|
|
98
119
|
const trace = new Trace();
|
|
99
120
|
let panel;
|
|
100
121
|
let lastUi;
|
|
122
|
+
/** Judgments for tool calls of the current assistant message, keyed by tool call id; `used` marks preflighted ones. */
|
|
123
|
+
const prejudged = new Map();
|
|
101
124
|
/**
|
|
102
125
|
* Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
|
|
103
126
|
* prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
|
|
@@ -199,6 +222,7 @@ export default function wardenExtension(pi) {
|
|
|
199
222
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
200
223
|
attempts = new AttemptWindow(configFor(ctx).stuck.window);
|
|
201
224
|
doneNudged = false;
|
|
225
|
+
prejudged.clear();
|
|
202
226
|
});
|
|
203
227
|
// Each low-level run collects its own evidence of changes and checks.
|
|
204
228
|
pi.on("agent_start", async () => {
|
|
@@ -207,6 +231,8 @@ export default function wardenExtension(pi) {
|
|
|
207
231
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
208
232
|
pi.on("turn_end", async () => {
|
|
209
233
|
ledger.turnEnd();
|
|
234
|
+
// Siblings that were never preflighted (an earlier one terminated the batch, or Esc) do not outlive their turn.
|
|
235
|
+
prejudged.clear();
|
|
210
236
|
});
|
|
211
237
|
pi.on("tool_call", async (event, ctx) => {
|
|
212
238
|
const config = configFor(ctx);
|
|
@@ -223,7 +249,26 @@ export default function wardenExtension(pi) {
|
|
|
223
249
|
// A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
|
|
224
250
|
const retryAfterHold = holdPending && lastHoldPrompt !== task;
|
|
225
251
|
const judge = judgeFor(config);
|
|
226
|
-
const
|
|
252
|
+
const context = recentTaskContext(ctx);
|
|
253
|
+
const options = { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold };
|
|
254
|
+
const judgeCall = (tool, input) => evaluateAction({ tool, input, cwd: ctx.cwd, task, context }, options);
|
|
255
|
+
// Sibling calls in the same assistant message are preflighted one after another; their requests go out together.
|
|
256
|
+
// A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
|
|
257
|
+
if (judge && !retryAfterHold) {
|
|
258
|
+
for (const sibling of siblingToolCalls(ctx)) {
|
|
259
|
+
if (sibling.id === event.toolCallId || prejudged.has(sibling.id) || !config.action.tools.includes(sibling.name))
|
|
260
|
+
continue;
|
|
261
|
+
const verdict = judgeCall(sibling.name, sibling.input);
|
|
262
|
+
verdict.catch(() => undefined);
|
|
263
|
+
prejudged.set(sibling.id, { key: JSON.stringify(sibling.input), verdict, used: false });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// An earlier hook may have changed this call's input; a stale judgment is discarded, not reused.
|
|
267
|
+
const key = JSON.stringify(event.input);
|
|
268
|
+
const ready = prejudged.get(event.toolCallId);
|
|
269
|
+
const pending = ready && !ready.used && ready.key === key && !retryAfterHold ? ready.verdict : judgeCall(event.toolName, event.input);
|
|
270
|
+
prejudged.set(event.toolCallId, { key, verdict: pending, used: true });
|
|
271
|
+
const verdict = await pending;
|
|
227
272
|
if (verdict.source === "skipped")
|
|
228
273
|
return;
|
|
229
274
|
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
@@ -293,6 +338,17 @@ export default function wardenExtension(pi) {
|
|
|
293
338
|
return;
|
|
294
339
|
const textBlocks = event.content.filter(part => part.type === "text");
|
|
295
340
|
const text = textBlocks.map(part => part.text).join("\n");
|
|
341
|
+
// Repeat detection uses the original result, so its request goes out together with the output check.
|
|
342
|
+
const failed = resultFailed(event.isError, event.details, event.content);
|
|
343
|
+
const stuckCheck = (() => {
|
|
344
|
+
if (!config.stuck.enabled)
|
|
345
|
+
return undefined;
|
|
346
|
+
attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
|
|
347
|
+
if (!attempts.shouldJudge(config.stuck))
|
|
348
|
+
return undefined;
|
|
349
|
+
stats.stuckChecks++;
|
|
350
|
+
return evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
351
|
+
})();
|
|
296
352
|
const output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
|
|
297
353
|
security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
|
|
298
354
|
signal: ctx.signal, compressible: textBlocks.length === 1, taskContext: recentTaskContext(ctx),
|
|
@@ -343,17 +399,12 @@ export default function wardenExtension(pi) {
|
|
|
343
399
|
]);
|
|
344
400
|
}
|
|
345
401
|
const patch = content === event.content ? undefined : { content };
|
|
346
|
-
// Checks
|
|
347
|
-
const failed = resultFailed(event.isError, event.details, event.content);
|
|
402
|
+
// Checks use the original result, not the excerpts or security banner.
|
|
348
403
|
if (config.done.enabled)
|
|
349
404
|
recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
|
|
350
|
-
|
|
405
|
+
const verdict = await stuckCheck;
|
|
406
|
+
if (!verdict)
|
|
351
407
|
return patch;
|
|
352
|
-
attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
|
|
353
|
-
if (!attempts.shouldJudge(config.stuck))
|
|
354
|
-
return patch;
|
|
355
|
-
stats.stuckChecks++;
|
|
356
|
-
const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
357
408
|
if (verdict.error)
|
|
358
409
|
noteError(ctx, verdict.error, undefined);
|
|
359
410
|
if (verdict.source === "repeat" && !verdict.stuck)
|
|
@@ -377,9 +428,17 @@ export default function wardenExtension(pi) {
|
|
|
377
428
|
const judge = judgeFor(config);
|
|
378
429
|
if (!finalMessage || !judge)
|
|
379
430
|
return;
|
|
380
|
-
|
|
431
|
+
// Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
|
|
432
|
+
const task = latestUserPrompt(ctx);
|
|
433
|
+
const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
|
|
434
|
+
? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
435
|
+
: undefined;
|
|
436
|
+
const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
|
|
437
|
+
? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
438
|
+
: undefined;
|
|
439
|
+
if (proseCheck) {
|
|
381
440
|
stats.proseChecks++;
|
|
382
|
-
const verdict = await
|
|
441
|
+
const verdict = await proseCheck;
|
|
383
442
|
if (verdict.error)
|
|
384
443
|
noteError(ctx, verdict.error, undefined);
|
|
385
444
|
else
|
|
@@ -398,10 +457,10 @@ export default function wardenExtension(pi) {
|
|
|
398
457
|
steer(config, nudge, { deliverAs: "nextTurn" });
|
|
399
458
|
}
|
|
400
459
|
}
|
|
401
|
-
if (!
|
|
460
|
+
if (!doneCheck)
|
|
402
461
|
return;
|
|
403
462
|
stats.doneChecks++;
|
|
404
|
-
const verdict = await
|
|
463
|
+
const verdict = await doneCheck;
|
|
405
464
|
if (verdict.error)
|
|
406
465
|
noteError(ctx, verdict.error, undefined);
|
|
407
466
|
const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, detects stuck loops, checks unverified done claims, and flags slop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|