@mjasnikovs/pi-task 0.13.21 → 0.13.22
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/task/loop-detector.d.ts +30 -7
- package/dist/task/loop-detector.js +80 -13
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.22",
|
|
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",
|