@crewhaus/tool-loop-detection 0.1.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/package.json +41 -0
- package/src/index.test.ts +131 -0
- package/src/index.ts +114 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-loop-detection",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Detect repeated tool calls via sliding-window canonical-JSON signatures",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/turn-state-machine": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/tool-loop-detection"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-loop-detection#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ToolUseBlock } from "@crewhaus/turn-state-machine";
|
|
3
|
+
import { detectLoop, toolCallSignature } from "./index";
|
|
4
|
+
|
|
5
|
+
function call(name: string, input: unknown = {}, id = `tu_${Math.random()}`): ToolUseBlock {
|
|
6
|
+
return { id, name, input };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("toolCallSignature", () => {
|
|
10
|
+
test("primitives, arrays, and nested objects are canonical", () => {
|
|
11
|
+
expect(toolCallSignature("X", 1)).toBe("X:1");
|
|
12
|
+
expect(toolCallSignature("X", "y")).toBe('X:"y"');
|
|
13
|
+
expect(toolCallSignature("X", true)).toBe("X:true");
|
|
14
|
+
expect(toolCallSignature("X", null)).toBe("X:null");
|
|
15
|
+
expect(toolCallSignature("X", [1, 2, 3])).toBe("X:[1,2,3]");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("object key order does not affect signature", () => {
|
|
19
|
+
expect(toolCallSignature("X", { a: 1, b: 2 })).toBe(toolCallSignature("X", { b: 2, a: 1 }));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("nested object key order does not affect signature", () => {
|
|
23
|
+
const a = { outer: { a: 1, b: 2 }, list: [{ x: 1, y: 2 }] };
|
|
24
|
+
const b = { list: [{ y: 2, x: 1 }], outer: { b: 2, a: 1 } };
|
|
25
|
+
expect(toolCallSignature("X", a)).toBe(toolCallSignature("X", b));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("escaping handles quotes and newlines", () => {
|
|
29
|
+
const sig = toolCallSignature("X", { msg: 'he said "hi"\nthen left' });
|
|
30
|
+
expect(sig).toContain("X:");
|
|
31
|
+
// Round-trip the JSON portion to ensure it parses cleanly.
|
|
32
|
+
const json = sig.slice(2);
|
|
33
|
+
expect(JSON.parse(json)).toEqual({ msg: 'he said "hi"\nthen left' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("name vs input separation", () => {
|
|
37
|
+
expect(toolCallSignature("Foo", { a: 1 })).not.toBe(toolCallSignature("Bar", { a: 1 }));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("undefined values collapse to null in canonical form", () => {
|
|
41
|
+
expect(toolCallSignature("X", undefined)).toBe("X:null");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("detectLoop — basic shapes", () => {
|
|
46
|
+
test("empty history returns null", () => {
|
|
47
|
+
expect(detectLoop([])).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("single occurrence with threshold 3 returns null", () => {
|
|
51
|
+
expect(detectLoop([call("A")])).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("threshold 3, three identical calls in window → detected", () => {
|
|
55
|
+
const out = detectLoop([
|
|
56
|
+
call("Bash", { command: "date" }),
|
|
57
|
+
call("Bash", { command: "date" }),
|
|
58
|
+
call("Bash", { command: "date" }),
|
|
59
|
+
]);
|
|
60
|
+
expect(out).not.toBeNull();
|
|
61
|
+
expect(out?.toolName).toBe("Bash");
|
|
62
|
+
expect(out?.count).toBe(3);
|
|
63
|
+
expect(out?.signature).toBe('Bash:{"command":"date"}');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("threshold 3, two identical and one different → null", () => {
|
|
67
|
+
const out = detectLoop([call("A"), call("B"), call("A")]);
|
|
68
|
+
expect(out).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("interleaved A/B/A/B/A with threshold 3 → detects A", () => {
|
|
72
|
+
const out = detectLoop([call("A"), call("B"), call("A"), call("B"), call("A")]);
|
|
73
|
+
expect(out?.toolName).toBe("A");
|
|
74
|
+
expect(out?.count).toBe(3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("different inputs do not collapse", () => {
|
|
78
|
+
const out = detectLoop([
|
|
79
|
+
call("Read", { path: "a" }),
|
|
80
|
+
call("Read", { path: "b" }),
|
|
81
|
+
call("Read", { path: "c" }),
|
|
82
|
+
]);
|
|
83
|
+
expect(out).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("same call but different object key order → still collapses", () => {
|
|
87
|
+
const out = detectLoop([
|
|
88
|
+
call("Read", { path: "a", limit: 10 }),
|
|
89
|
+
call("Read", { limit: 10, path: "a" }),
|
|
90
|
+
call("Read", { path: "a", limit: 10 }),
|
|
91
|
+
]);
|
|
92
|
+
expect(out?.count).toBe(3);
|
|
93
|
+
expect(out?.toolName).toBe("Read");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("detectLoop — window slicing", () => {
|
|
98
|
+
test("only the last windowSize entries count", () => {
|
|
99
|
+
// Three As at the head, then 10 Bs. windowSize 10 should NOT see the As.
|
|
100
|
+
const head = [call("A"), call("A"), call("A")];
|
|
101
|
+
const tail = Array.from({ length: 10 }, () => call("B"));
|
|
102
|
+
const out = detectLoop([...head, ...tail], 10, 3);
|
|
103
|
+
expect(out?.toolName).toBe("B");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("threshold can be tuned", () => {
|
|
107
|
+
expect(detectLoop([call("A"), call("A")], 10, 2)?.count).toBe(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("threshold larger than window length → null", () => {
|
|
111
|
+
expect(detectLoop([call("A"), call("A")], 10, 5)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("threshold 0 or negative is a no-op", () => {
|
|
115
|
+
expect(detectLoop([call("A")], 10, 0)).toBeNull();
|
|
116
|
+
expect(detectLoop([call("A")], 10, -1)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("detectLoop — early-exit on first hit", () => {
|
|
121
|
+
test("returns the FIRST signature to hit threshold while scanning the window", () => {
|
|
122
|
+
// Two competing loops in window: A and B. A's third occurrence should
|
|
123
|
+
// arrive first and be reported.
|
|
124
|
+
const out = detectLoop(
|
|
125
|
+
[call("A"), call("B"), call("A"), call("B"), call("A"), call("B"), call("B")],
|
|
126
|
+
10,
|
|
127
|
+
3,
|
|
128
|
+
);
|
|
129
|
+
expect(out?.toolName).toBe("A");
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R3 `tool-loop-detection` — flag when the model gets stuck
|
|
3
|
+
* making the same `(toolName, input)` call over and over. The runtime
|
|
4
|
+
* runs `detectLoop()` after every batch of tool executions; on a hit
|
|
5
|
+
* it injects a synthetic warning message so the model can self-correct.
|
|
6
|
+
*
|
|
7
|
+
* Detection is a simple sliding-window count. Take the last
|
|
8
|
+
* `windowSize` tool calls, compute a canonical signature for each
|
|
9
|
+
* (`toolName + ":" + canonicalJson(input)`), and return the first
|
|
10
|
+
* signature whose count meets `threshold`. Canonical JSON sorts object
|
|
11
|
+
* keys recursively so `{a:1,b:2}` and `{b:2,a:1}` collapse to the same
|
|
12
|
+
* signature.
|
|
13
|
+
*
|
|
14
|
+
* No hashing — plain string signatures keep memory tiny (a single
|
|
15
|
+
* detection map per check) and make debug output readable. Cross-turn:
|
|
16
|
+
* the runtime feeds its full per-run tool-use history, so loops that
|
|
17
|
+
* span turns get caught.
|
|
18
|
+
*
|
|
19
|
+
* Reference: `openclaw/agents/tool-loop-detection.ts` — uses SHA-256
|
|
20
|
+
* over a stable-stringified params object plus a 30-call window with
|
|
21
|
+
* tiered thresholds (10 warning / 20 critical). We collapse to one
|
|
22
|
+
* threshold and skip the hash.
|
|
23
|
+
*/
|
|
24
|
+
import type { ToolUseBlock } from "@crewhaus/turn-state-machine";
|
|
25
|
+
|
|
26
|
+
export type LoopDetection = {
|
|
27
|
+
readonly signature: string;
|
|
28
|
+
readonly toolName: string;
|
|
29
|
+
/** How many times the signature appeared inside the window. */
|
|
30
|
+
readonly count: number;
|
|
31
|
+
readonly windowSize: number;
|
|
32
|
+
readonly threshold: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_WINDOW_SIZE = 10;
|
|
36
|
+
export const DEFAULT_THRESHOLD = 3;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Look for a `(toolName, input)` pair that occurs at least `threshold`
|
|
40
|
+
* times in the last `windowSize` entries of `history`. Returns the
|
|
41
|
+
* first hit found while scanning the window left-to-right, or `null`
|
|
42
|
+
* if no signature reaches the threshold.
|
|
43
|
+
*/
|
|
44
|
+
export function detectLoop(
|
|
45
|
+
history: ReadonlyArray<ToolUseBlock>,
|
|
46
|
+
windowSize: number = DEFAULT_WINDOW_SIZE,
|
|
47
|
+
threshold: number = DEFAULT_THRESHOLD,
|
|
48
|
+
): LoopDetection | null {
|
|
49
|
+
if (history.length === 0 || threshold <= 0) return null;
|
|
50
|
+
|
|
51
|
+
const window = history.length > windowSize ? history.slice(history.length - windowSize) : history;
|
|
52
|
+
|
|
53
|
+
const counts = new Map<string, { count: number; toolName: string }>();
|
|
54
|
+
for (const call of window) {
|
|
55
|
+
const sig = toolCallSignature(call.name, call.input);
|
|
56
|
+
const entry = counts.get(sig);
|
|
57
|
+
if (entry) {
|
|
58
|
+
entry.count += 1;
|
|
59
|
+
if (entry.count >= threshold) {
|
|
60
|
+
return {
|
|
61
|
+
signature: sig,
|
|
62
|
+
toolName: entry.toolName,
|
|
63
|
+
count: entry.count,
|
|
64
|
+
windowSize,
|
|
65
|
+
threshold,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
counts.set(sig, { count: 1, toolName: call.name });
|
|
70
|
+
if (threshold === 1) {
|
|
71
|
+
return {
|
|
72
|
+
signature: sig,
|
|
73
|
+
toolName: call.name,
|
|
74
|
+
count: 1,
|
|
75
|
+
windowSize,
|
|
76
|
+
threshold,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Canonical signature for a tool call. The JSON encoding sorts object
|
|
86
|
+
* keys recursively so the same logical input always produces the same
|
|
87
|
+
* string regardless of property order. Arrays preserve order; primitive
|
|
88
|
+
* leaves use `JSON.stringify` (which already escapes quotes/newlines/
|
|
89
|
+
* unicode safely).
|
|
90
|
+
*/
|
|
91
|
+
export function toolCallSignature(name: string, input: unknown): string {
|
|
92
|
+
return `${name}:${canonicalJson(input)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function canonicalJson(value: unknown): string {
|
|
96
|
+
if (value === null) return "null";
|
|
97
|
+
if (value === undefined) return "null";
|
|
98
|
+
const t = typeof value;
|
|
99
|
+
if (t === "string" || t === "number" || t === "boolean") {
|
|
100
|
+
return JSON.stringify(value);
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
104
|
+
}
|
|
105
|
+
if (t === "object") {
|
|
106
|
+
const obj = value as Record<string, unknown>;
|
|
107
|
+
const keys = Object.keys(obj).sort();
|
|
108
|
+
const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`);
|
|
109
|
+
return `{${parts.join(",")}}`;
|
|
110
|
+
}
|
|
111
|
+
// bigint, symbol, function — fall through to a stable but obviously-tagged
|
|
112
|
+
// representation so detection still works.
|
|
113
|
+
return JSON.stringify(String(value));
|
|
114
|
+
}
|