@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 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
+ }