pi-warden 0.1.2 → 0.2.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.
@@ -0,0 +1,88 @@
1
+ import type { StuckGuardConfig } from "./config.js";
2
+ import type { Judge } from "./guard.js";
3
+ /** One remembered tool result. `key` identifies the exact call; `call` is the redacted view that may leave the machine. */
4
+ export interface Attempt {
5
+ tool: string;
6
+ key: string;
7
+ /** Hash of the normalised output, so identical failures can be told from a changed error. */
8
+ outputKey: string;
9
+ call: string;
10
+ failed: boolean;
11
+ /** Tail of the tool output, redacted, where the error usually is. */
12
+ output: string;
13
+ }
14
+ export interface StuckJudgment {
15
+ sameStrategy: number;
16
+ /** 0 identical … 2 meaningfully different. */
17
+ approachChange: number;
18
+ progress: number;
19
+ model: string;
20
+ elapsedMs: number;
21
+ }
22
+ export interface StuckVerdict {
23
+ stuck: boolean;
24
+ source: "repeat" | "typesafe" | "error";
25
+ failures: number;
26
+ reasons: string[];
27
+ judgment?: StuckJudgment;
28
+ error?: string;
29
+ }
30
+ /** Text content of a tool result, without images. */
31
+ export declare function resultText(content: ReadonlyArray<{
32
+ type: string;
33
+ text?: string;
34
+ }>): string;
35
+ /** Non-zero exit codes count as failures even when the tool did not flag an error. */
36
+ export declare function resultFailed(isError: boolean, details: unknown): boolean;
37
+ export declare function makeAttempt(tool: string, input: Record<string, unknown>, content: ReadonlyArray<{
38
+ type: string;
39
+ text?: string;
40
+ }>, failed: boolean): Attempt;
41
+ /** Rolling memory of tool results for the current user prompt. */
42
+ export declare class AttemptWindow {
43
+ private readonly limit;
44
+ readonly attempts: Attempt[];
45
+ private sinceJudgment;
46
+ constructor(limit: number);
47
+ push(attempt: Attempt): void;
48
+ reset(): void;
49
+ markJudged(): void;
50
+ failures(): number;
51
+ /** How many failed attempts repeat the latest attempt's exact call with the same output. A changed error is progress, not a repeat. */
52
+ exactRepeats(): number;
53
+ /** Latest result failed, enough failures accumulated, and the cool-down since the last check has passed. */
54
+ shouldJudge(config: StuckGuardConfig): boolean;
55
+ }
56
+ export declare const stuckQuestions: {
57
+ same_strategy: import("pi-typesafe").NoulQuestion;
58
+ approach_change: import("pi-typesafe").ScoreQuestion<readonly ["Identical or near-identical retries", "Cosmetic changes: different flags, paths, or wording, same underlying idea", "Meaningfully different: a new hypothesis, tool, or information-gathering step"]>;
59
+ progress: import("pi-typesafe").NoulQuestion;
60
+ };
61
+ export declare function buildStuckRequest(attempts: readonly Attempt[], task: string | undefined): {
62
+ state: {
63
+ task: string;
64
+ attempts: {
65
+ n: number;
66
+ tool: string;
67
+ call: string;
68
+ outcome: string;
69
+ output: string;
70
+ }[];
71
+ };
72
+ questions: {
73
+ same_strategy: import("pi-typesafe").NoulQuestion;
74
+ approach_change: import("pi-typesafe").ScoreQuestion<readonly ["Identical or near-identical retries", "Cosmetic changes: different flags, paths, or wording, same underlying idea", "Meaningfully different: a new hypothesis, tool, or information-gathering step"]>;
75
+ progress: import("pi-typesafe").NoulQuestion;
76
+ };
77
+ };
78
+ export interface StuckOptions {
79
+ config: StuckGuardConfig;
80
+ judge?: Judge | undefined;
81
+ timeoutMs: number;
82
+ signal?: AbortSignal | undefined;
83
+ }
84
+ /** Exact repeats are decided in code; otherwise one Jev request judges the sequence. */
85
+ export declare function evaluateStuck(window: AttemptWindow, task: string | undefined, options: StuckOptions): Promise<StuckVerdict>;
86
+ /** Steering text for the agent. Names the pattern and asks for a change of method, not another retry. */
87
+ export declare function stuckNudge(verdict: StuckVerdict): string;
88
+ export declare function formatStuck(verdict: StuckVerdict): string;
package/dist/stuck.js ADDED
@@ -0,0 +1,148 @@
1
+ import { createHash } from "node:crypto";
2
+ import { noul, score, TypeSafeIntegrationError } from "pi-typesafe";
3
+ import { redact } from "./redact.js";
4
+ const CALL_LIMIT = 300;
5
+ const OUTPUT_LIMIT = 400;
6
+ function head(text, limit) {
7
+ return text.length <= limit ? text : `${text.slice(0, limit)}… [${text.length - limit} more chars]`;
8
+ }
9
+ function tail(text, limit) {
10
+ return text.length <= limit ? text : `[${text.length - limit} earlier chars] …${text.slice(-limit)}`;
11
+ }
12
+ /** Text content of a tool result, without images. */
13
+ export function resultText(content) {
14
+ return content.filter(part => part.type === "text" && typeof part.text === "string").map(part => part.text).join("\n");
15
+ }
16
+ /** Non-zero exit codes count as failures even when the tool did not flag an error. */
17
+ export function resultFailed(isError, details) {
18
+ if (isError)
19
+ return true;
20
+ const exitCode = details && typeof details === "object" ? details.exitCode : undefined;
21
+ return typeof exitCode === "number" && exitCode !== 0;
22
+ }
23
+ /** Durations, timestamps, PIDs, and addresses change between identical runs; counts and line numbers stay. */
24
+ function normaliseOutput(text) {
25
+ return text
26
+ .replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|m|h|µs|us|ns)\b/g, "#t")
27
+ .replace(/0x[0-9a-fA-F]+/g, "0x#")
28
+ .replace(/\d{5,}/g, "#")
29
+ .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?Z?/g, "#date");
30
+ }
31
+ export function makeAttempt(tool, input, content, failed) {
32
+ const call = typeof input.command === "string" ? input.command
33
+ : typeof input.path === "string" ? `${tool} ${input.path}`
34
+ : JSON.stringify(input);
35
+ const text = resultText(content).trim();
36
+ return {
37
+ tool,
38
+ key: createHash("sha1").update(tool).update("\0").update(JSON.stringify(input)).digest("hex"),
39
+ outputKey: createHash("sha1").update(normaliseOutput(text)).digest("hex"),
40
+ call: redact(head(call, CALL_LIMIT)),
41
+ failed,
42
+ output: redact(tail(text, OUTPUT_LIMIT)),
43
+ };
44
+ }
45
+ /** Rolling memory of tool results for the current user prompt. */
46
+ export class AttemptWindow {
47
+ limit;
48
+ attempts = [];
49
+ sinceJudgment = Number.MAX_SAFE_INTEGER;
50
+ constructor(limit) {
51
+ this.limit = limit;
52
+ }
53
+ push(attempt) {
54
+ this.attempts.push(attempt);
55
+ if (this.attempts.length > this.limit)
56
+ this.attempts.splice(0, this.attempts.length - this.limit);
57
+ if (this.sinceJudgment !== Number.MAX_SAFE_INTEGER)
58
+ this.sinceJudgment++;
59
+ }
60
+ reset() {
61
+ this.attempts.length = 0;
62
+ this.sinceJudgment = Number.MAX_SAFE_INTEGER;
63
+ }
64
+ markJudged() {
65
+ this.sinceJudgment = 0;
66
+ }
67
+ failures() {
68
+ return this.attempts.filter(attempt => attempt.failed).length;
69
+ }
70
+ /** How many failed attempts repeat the latest attempt's exact call with the same output. A changed error is progress, not a repeat. */
71
+ exactRepeats() {
72
+ const latest = this.attempts.at(-1);
73
+ if (!latest?.failed)
74
+ return 0;
75
+ return this.attempts.filter(attempt => attempt.failed && attempt.key === latest.key && attempt.outputKey === latest.outputKey).length;
76
+ }
77
+ /** Latest result failed, enough failures accumulated, and the cool-down since the last check has passed. */
78
+ shouldJudge(config) {
79
+ const latest = this.attempts.at(-1);
80
+ return Boolean(latest?.failed) && this.failures() >= config.minFailures && this.sinceJudgment >= config.cooldown;
81
+ }
82
+ }
83
+ export const stuckQuestions = {
84
+ same_strategy: noul("Do the failed entries in `attempts` repeat the same strategy with only superficial variation, instead of trying a different approach to the failure?", {
85
+ true: "Yes: the same command or edit is retried, or only flags, paths, names, or wording change while the underlying idea stays the same.",
86
+ false: "No: later attempts use a different tool, test a new hypothesis, gather new information first, or act on what the earlier output said.",
87
+ }),
88
+ approach_change: score("How much did the approach change across `attempts`?", [
89
+ "Identical or near-identical retries",
90
+ "Cosmetic changes: different flags, paths, or wording, same underlying idea",
91
+ "Meaningfully different: a new hypothesis, tool, or information-gathering step",
92
+ ]),
93
+ progress: noul("Do the later entries in `attempts` show progress toward resolving the failure seen in the earlier ones, such as a different error, a partial success, or new information?"),
94
+ };
95
+ export function buildStuckRequest(attempts, task) {
96
+ return {
97
+ state: {
98
+ task: task?.trim() ? head(task.trim(), 1500) : "(no user request recorded in this session)",
99
+ attempts: attempts.map((attempt, index) => ({ n: index + 1, tool: attempt.tool, call: attempt.call, outcome: attempt.failed ? "failed" : "ok", output: attempt.output })),
100
+ },
101
+ questions: stuckQuestions,
102
+ };
103
+ }
104
+ /** Exact repeats are decided in code; otherwise one Jev request judges the sequence. */
105
+ export async function evaluateStuck(window, task, options) {
106
+ const failures = window.failures();
107
+ const repeats = window.exactRepeats();
108
+ if (repeats >= options.config.minFailures) {
109
+ return { stuck: true, source: "repeat", failures, reasons: [`the same call failed ${repeats} times with the same output`] };
110
+ }
111
+ if (!options.judge)
112
+ return { stuck: false, source: "repeat", failures, reasons: [] };
113
+ window.markJudged();
114
+ try {
115
+ const timeout = AbortSignal.timeout(options.timeoutMs);
116
+ const result = await options.judge.evaluate(buildStuckRequest(window.attempts, task), { signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout });
117
+ const judgment = {
118
+ sameStrategy: result.answers.same_strategy.noul,
119
+ approachChange: result.answers.approach_change.score,
120
+ progress: result.answers.progress.noul,
121
+ model: result.model,
122
+ elapsedMs: result.elapsedMs,
123
+ };
124
+ const stuck = judgment.sameStrategy >= options.config.sameStrategy;
125
+ const reasons = stuck
126
+ ? [`${failures} failures with the same strategy (${judgment.sameStrategy.toFixed(2)}), approach change ${judgment.approachChange.toFixed(1)}/2, progress ${judgment.progress.toFixed(2)}`]
127
+ : [];
128
+ return { stuck, source: "typesafe", failures, reasons, judgment };
129
+ }
130
+ catch (error) {
131
+ return { stuck: false, source: "error", failures, reasons: [], error: error instanceof TypeSafeIntegrationError ? error.message : "TypeSafe request failed." };
132
+ }
133
+ }
134
+ /** Steering text for the agent. Names the pattern and asks for a change of method, not another retry. */
135
+ export function stuckNudge(verdict) {
136
+ 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.`;
137
+ }
138
+ export function formatStuck(verdict) {
139
+ const parts = [`warden · stuck · ${verdict.failures} failures`];
140
+ if (verdict.judgment)
141
+ parts.push(`same strategy ${verdict.judgment.sameStrategy.toFixed(2)}`, `change ${verdict.judgment.approachChange.toFixed(1)}/2`, `progress ${verdict.judgment.progress.toFixed(2)}`);
142
+ if (verdict.source === "repeat" && verdict.stuck)
143
+ parts.push("exact repeat");
144
+ if (verdict.source === "error")
145
+ parts.push("typesafe error");
146
+ parts.push(verdict.stuck ? "stuck" : "ok");
147
+ return parts.join(" · ");
148
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.1.2",
4
- "description": "Harness guardrails for Pi built on pi-typesafe: pattern checks plus Jev judgments flag irreversible or off-task bash, write, and edit calls before they run.",
3
+ "version": "0.2.0",
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",
7
7
  "author": "Ryan Gapac",