@mjasnikovs/pi-task 0.13.21 → 0.13.23

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.
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Pure-logic loop detector for pi-task sub-agent phases.
3
3
  *
4
- * Watches a ring buffer of recent tool-call keys. When the same
5
- * `(toolName, stable-stringified args)` key appears `threshold` times
6
- * within the last `window` events, returns a LoopHit so the caller can
7
- * kill the child and re-spawn with a hint.
4
+ * Watches a ring buffer of recent tool calls and trips on two thrash patterns:
8
5
  *
9
- * No I/O. No imports from index.ts. Trivially unit-testable.
6
+ * 1. EXACT repeats the same `(toolName, stable-stringified args)` key appears
7
+ * `threshold` times within the last `window` events.
8
+ * 2. PATH revisits — the same primary file path is re-targeted `pathThreshold`
9
+ * times within the window WITHOUT the read offset advancing, which the exact
10
+ * key misses when `offset/limit` vary call-to-call. Strictly-forward paging
11
+ * never trips (see countRevisits).
12
+ *
13
+ * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
+ * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
10
15
  */
11
16
  export interface ToolCall {
12
17
  name: string;
@@ -22,11 +27,29 @@ export interface LoopHit {
22
27
  * Arrays preserve their order (positional). undefined / primitives passthrough.
23
28
  */
24
29
  export declare function stableStringify(value: unknown): string;
30
+ /**
31
+ * The primary file path a tool call targets, mirroring summarizeToolArgs' field
32
+ * precedence. Returns null when the call names no path (e.g. bash, or a grep
33
+ * with only a pattern) so such calls never participate in path detection.
34
+ */
35
+ export declare function primaryPath(args: unknown): string | null;
25
36
  export declare class LoopDetector {
26
37
  private readonly window;
27
38
  private readonly threshold;
39
+ /** Revisits of one path needed to trip; defaults to the exact threshold. */
40
+ private readonly pathThreshold;
28
41
  private readonly buf;
29
- constructor(window?: number, threshold?: number);
30
- /** Record a tool call. Returns LoopHit if the threshold is breached, else null. */
42
+ constructor(window?: number, threshold?: number,
43
+ /** Revisits of one path needed to trip; defaults to the exact threshold. */
44
+ pathThreshold?: number);
45
+ /** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
31
46
  record(call: ToolCall): LoopHit | null;
47
+ /**
48
+ * Count same-path calls in the window that are "revisits" — accesses whose
49
+ * offset does not advance past the furthest offset already seen for that
50
+ * path. Linear paging (strictly increasing offsets) yields zero revisits and
51
+ * never trips; repeated whole-file re-reads (offset 0 each time), backward
52
+ * jumps, and path-targeting greps (offset 0) accumulate.
53
+ */
54
+ private countRevisits;
32
55
  }
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Pure-logic loop detector for pi-task sub-agent phases.
3
3
  *
4
- * Watches a ring buffer of recent tool-call keys. When the same
5
- * `(toolName, stable-stringified args)` key appears `threshold` times
6
- * within the last `window` events, returns a LoopHit so the caller can
7
- * kill the child and re-spawn with a hint.
4
+ * Watches a ring buffer of recent tool calls and trips on two thrash patterns:
8
5
  *
9
- * No I/O. No imports from index.ts. Trivially unit-testable.
6
+ * 1. EXACT repeats the same `(toolName, stable-stringified args)` key appears
7
+ * `threshold` times within the last `window` events.
8
+ * 2. PATH revisits — the same primary file path is re-targeted `pathThreshold`
9
+ * times within the window WITHOUT the read offset advancing, which the exact
10
+ * key misses when `offset/limit` vary call-to-call. Strictly-forward paging
11
+ * never trips (see countRevisits).
12
+ *
13
+ * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
+ * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
10
15
  */
11
16
  /**
12
17
  * JSON.stringify with sorted object keys so {a:1,b:2} and {b:2,a:1} hash equal.
@@ -23,24 +28,86 @@ export function stableStringify(value) {
23
28
  return sorted;
24
29
  });
25
30
  }
31
+ /**
32
+ * The primary file path a tool call targets, mirroring summarizeToolArgs' field
33
+ * precedence. Returns null when the call names no path (e.g. bash, or a grep
34
+ * with only a pattern) so such calls never participate in path detection.
35
+ */
36
+ export function primaryPath(args) {
37
+ if (!args || typeof args !== 'object')
38
+ return null;
39
+ const a = args;
40
+ if (typeof a.file_path === 'string')
41
+ return a.file_path;
42
+ if (typeof a.path === 'string')
43
+ return a.path;
44
+ if (typeof a.filePath === 'string')
45
+ return a.filePath;
46
+ return null;
47
+ }
48
+ /** Numeric read offset (0 when absent/non-numeric) — drives progress vs revisit. */
49
+ function readOffset(args) {
50
+ if (!args || typeof args !== 'object')
51
+ return 0;
52
+ const o = args.offset;
53
+ return typeof o === 'number' && Number.isFinite(o) ? o : 0;
54
+ }
26
55
  export class LoopDetector {
27
56
  window;
28
57
  threshold;
58
+ pathThreshold;
29
59
  buf = [];
30
- constructor(window = 20, threshold = 5) {
60
+ constructor(window = 20, threshold = 5,
61
+ /** Revisits of one path needed to trip; defaults to the exact threshold. */
62
+ pathThreshold = threshold) {
31
63
  this.window = window;
32
64
  this.threshold = threshold;
65
+ this.pathThreshold = pathThreshold;
33
66
  }
34
- /** Record a tool call. Returns LoopHit if the threshold is breached, else null. */
67
+ /** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
35
68
  record(call) {
36
69
  const key = `${call.name}\x00${stableStringify(call.args)}`;
37
- this.buf.push(key);
70
+ this.buf.push({ key, path: primaryPath(call.args), offset: readOffset(call.args) });
38
71
  if (this.buf.length > this.window)
39
72
  this.buf.shift();
40
- let count = 0;
41
- for (const k of this.buf)
42
- if (k === key)
43
- count++;
44
- return count >= this.threshold ? { call, count, windowSize: this.buf.length } : null;
73
+ // 1. Exact-match loop: identical (name, args) repeated past threshold.
74
+ let exact = 0;
75
+ for (const e of this.buf)
76
+ if (e.key === key)
77
+ exact++;
78
+ if (exact >= this.threshold)
79
+ return { call, count: exact, windowSize: this.buf.length };
80
+ // 2. Path-aware loop: the same file re-targeted without forward progress.
81
+ // Caught here precisely because varied offset/limit make the exact key
82
+ // miss it (TASK_0017: auth.ts read/grepped ~12× with changing args until
83
+ // the wall-clock timeout, never tripping the exact detector).
84
+ const path = this.buf[this.buf.length - 1].path;
85
+ if (path !== null) {
86
+ const revisits = this.countRevisits(path);
87
+ if (revisits >= this.pathThreshold) {
88
+ return { call, count: revisits, windowSize: this.buf.length };
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Count same-path calls in the window that are "revisits" — accesses whose
95
+ * offset does not advance past the furthest offset already seen for that
96
+ * path. Linear paging (strictly increasing offsets) yields zero revisits and
97
+ * never trips; repeated whole-file re-reads (offset 0 each time), backward
98
+ * jumps, and path-targeting greps (offset 0) accumulate.
99
+ */
100
+ countRevisits(path) {
101
+ let maxOffset = -1;
102
+ let revisits = 0;
103
+ for (const e of this.buf) {
104
+ if (e.path !== path)
105
+ continue;
106
+ if (e.offset > maxOffset)
107
+ maxOffset = e.offset; // progress: new ground
108
+ else
109
+ revisits++; // revisit: already-covered ground
110
+ }
111
+ return revisits;
45
112
  }
46
113
  }
@@ -79,6 +79,15 @@ export async function phaseVerifyTooling(deps, research) {
79
79
  return replaceToolingWithVerified(research, parsed.verified);
80
80
  }
81
81
  const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
82
+ /**
83
+ * In-process guard loaded into the TOOLING worker only: blocks a second read of
84
+ * any file it already read, feeding the model "you already read this, answer
85
+ * now" instead of letting it re-read. TOOLING reads each file exactly once in
86
+ * every healthy recorded run; re-reading is purely the thrash signature, so a
87
+ * read-once rule has no legitimate false positive here. See single-read-guard.ts.
88
+ */
89
+ const SINGLE_READ_EXTENSION_PATH = new URL('../workers/single-read-extension.js', import.meta.url)
90
+ .pathname;
82
91
  /**
83
92
  * Task-file heading under which a research worker's validated output is cached.
84
93
  * A resumed research phase reads these to skip workers that already succeeded,
@@ -216,7 +225,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
216
225
  label: 'worker:tooling',
217
226
  // Scope to the goal prose only — not the per-file edit checklist that
218
227
  // makes a weak model spelunk source and loop. See scopedToolingGoal.
219
- prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(scopedToolingGoal(refined)))
228
+ prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(scopedToolingGoal(refined))),
229
+ // Block re-reads in-process so a weak model that wants to re-read the
230
+ // same file is told to answer from what it has instead of looping.
231
+ extensions: [SINGLE_READ_EXTENSION_PATH]
220
232
  }
221
233
  ];
222
234
  // Run workers one at a time, persisting each worker's validated output the
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /**
3
+ * Loaded via `-e` into the TOOLING research worker only. Blocks a second read of
4
+ * any file the worker has already read this run, returning a reason the model
5
+ * receives as an error tool result (agent-loop: block → createErrorToolResult).
6
+ * The worker then continues from what it has instead of re-reading. See
7
+ * SingleReadGuard for why this is scoped to TOOLING.
8
+ */
9
+ export default function (pi: ExtensionAPI): void;
@@ -0,0 +1,20 @@
1
+ import { resolve } from 'node:path';
2
+ import { SingleReadGuard } from './single-read-guard.js';
3
+ /**
4
+ * Loaded via `-e` into the TOOLING research worker only. Blocks a second read of
5
+ * any file the worker has already read this run, returning a reason the model
6
+ * receives as an error tool result (agent-loop: block → createErrorToolResult).
7
+ * The worker then continues from what it has instead of re-reading. See
8
+ * SingleReadGuard for why this is scoped to TOOLING.
9
+ */
10
+ export default function (pi) {
11
+ const guard = new SingleReadGuard();
12
+ pi.on('tool_call', event => {
13
+ if (event.toolName !== 'read')
14
+ return;
15
+ const path = event.input.path;
16
+ if (typeof path !== 'string')
17
+ return;
18
+ return guard.check(resolve(process.cwd(), path)) ?? undefined;
19
+ });
20
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * In-process "read each file once" guard for the TOOLING research worker.
3
+ *
4
+ * Validated against every recorded mx5 run: a healthy TOOLING worker reads each
5
+ * file exactly once (max same-file reads = 1 across 7 tasks). The failure case
6
+ * (TASK_0017) re-read the same file up to 50× — the model already had the answer
7
+ * (package.json) but kept oscillating read→write-fragment→read until it looped.
8
+ *
9
+ * Rather than detect-and-kill (which only restarts a model that re-thrashes), we
10
+ * deny the re-read *inside the run*: the extension wiring returns this guard's
11
+ * block result from a `tool_call` handler, so pi feeds `reason` back to the model
12
+ * as an error tool result and the worker continues without the re-read. No kill,
13
+ * no restart. Pure logic, no I/O — the extension does path resolution.
14
+ */
15
+ export interface ReadBlock {
16
+ block: true;
17
+ reason: string;
18
+ }
19
+ /** The error text the model receives in place of the re-read's contents. */
20
+ export declare function singleReadReason(path: string): string;
21
+ export declare class SingleReadGuard {
22
+ private readonly seen;
23
+ /**
24
+ * Record a read of `resolvedPath`. Returns a ReadBlock the first time a path
25
+ * is seen a second time (and every time after), else null on the first read.
26
+ * Callers pass an already-resolved/normalized path so `a.ts` and `./a.ts`
27
+ * dedupe to one entry.
28
+ */
29
+ check(resolvedPath: string): ReadBlock | null;
30
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * In-process "read each file once" guard for the TOOLING research worker.
3
+ *
4
+ * Validated against every recorded mx5 run: a healthy TOOLING worker reads each
5
+ * file exactly once (max same-file reads = 1 across 7 tasks). The failure case
6
+ * (TASK_0017) re-read the same file up to 50× — the model already had the answer
7
+ * (package.json) but kept oscillating read→write-fragment→read until it looped.
8
+ *
9
+ * Rather than detect-and-kill (which only restarts a model that re-thrashes), we
10
+ * deny the re-read *inside the run*: the extension wiring returns this guard's
11
+ * block result from a `tool_call` handler, so pi feeds `reason` back to the model
12
+ * as an error tool result and the worker continues without the re-read. No kill,
13
+ * no restart. Pure logic, no I/O — the extension does path resolution.
14
+ */
15
+ /** The error text the model receives in place of the re-read's contents. */
16
+ export function singleReadReason(path) {
17
+ return (`You already read ${path} earlier in this run — its contents are in your context. `
18
+ + `Re-reading the same file is blocked. Do not read it again: use what you have already `
19
+ + `gathered and write your final answer now.`);
20
+ }
21
+ export class SingleReadGuard {
22
+ seen = new Set();
23
+ /**
24
+ * Record a read of `resolvedPath`. Returns a ReadBlock the first time a path
25
+ * is seen a second time (and every time after), else null on the first read.
26
+ * Callers pass an already-resolved/normalized path so `a.ts` and `./a.ts`
27
+ * dedupe to one entry.
28
+ */
29
+ check(resolvedPath) {
30
+ if (this.seen.has(resolvedPath)) {
31
+ return { block: true, reason: singleReadReason(resolvedPath) };
32
+ }
33
+ this.seen.add(resolvedPath);
34
+ return null;
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.21",
3
+ "version": "0.13.23",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",