Files
openclaw/src/agents/tool-loop-detection.test.ts
T
2026-08-02 11:42:57 -07:00

1975 lines
64 KiB
TypeScript

// Tool loop detection tests cover repeated-call hashing, ping-pong detection,
// unknown-tool thresholds, and circuit-breaker escalation.
import { describe, expect, it, vi } from "vitest";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import type { SessionState } from "../logging/diagnostic-session-state.js";
// Recognize a provider-docked send tool by name (only "telegram" here) so the
// volatility strip applies to it without pulling in the channel-plugin registry; the
// real detector is covered by embedded-agent-messaging's own tests.
const isMessagingToolSendActionMock = vi.hoisted(() =>
vi.fn((toolName: string): boolean => toolName === "telegram"),
);
vi.mock("./embedded-agent-messaging.js", () => ({
isMessagingToolSendAction: isMessagingToolSendActionMock,
}));
import { reconcileToolCallExecutionParams } from "./tool-loop-call-reconciliation.js";
import {
UNKNOWN_TOOL_THRESHOLD,
detectToolCallLoop,
recordToolCall,
recordToolCallOutcome,
} from "./tool-loop-detection.js";
const TOOL_CALL_HISTORY_SIZE = 30;
const WARNING_THRESHOLD = 10;
const CRITICAL_THRESHOLD = 20;
const GLOBAL_CIRCUIT_BREAKER_THRESHOLD = 30;
function createState(): SessionState {
return {
lastActivity: Date.now(),
state: "processing",
queueDepth: 0,
};
}
function recordArgsHash(toolName: string, params: unknown): string {
const state = createState();
recordToolCall(state, toolName, params, "hash-test");
const hash = state.toolCallHistory?.[0]?.argsHash;
if (!hash) {
throw new Error("recordToolCall did not record an argument hash");
}
return hash;
}
const enabledLoopDetectionConfig: ToolLoopDetectionConfig = { enabled: true };
function recordSuccessfulCall(
state: SessionState,
toolName: string,
params: unknown,
result: unknown,
index: number,
): void {
const toolCallId = `${toolName}-${index}`;
recordToolCall(state, toolName, params, toolCallId);
recordToolCallOutcome(state, {
toolName,
toolParams: params,
toolCallId,
result,
});
}
function recordFailedCall(
state: SessionState,
toolName: string,
params: unknown,
error: unknown,
index: number,
): void {
const toolCallId = `${toolName}-error-${index}`;
recordToolCall(state, toolName, params, toolCallId);
recordToolCallOutcome(state, {
toolName,
toolParams: params,
toolCallId,
error,
});
}
function createExecLoopResult(params: {
status: "completed" | "failed";
exitCode: number | null;
output: string;
aggregated?: string;
timedOut?: boolean;
}) {
return {
content: [{ type: "text", text: params.output }],
details: {
status: params.status,
exitCode: params.exitCode,
aggregated: params.aggregated ?? params.output,
...(params.timedOut === undefined ? {} : { timedOut: params.timedOut }),
},
};
}
function recordRepeatedSuccessfulCalls(params: {
state: SessionState;
toolName: string;
toolParams: unknown;
result: unknown;
count: number;
startIndex?: number;
}) {
const startIndex = params.startIndex ?? 0;
for (let i = 0; i < params.count; i += 1) {
recordSuccessfulCall(
params.state,
params.toolName,
params.toolParams,
params.result,
startIndex + i,
);
}
}
function createNoProgressPollFixture(sessionId: string) {
return {
params: { action: "poll", sessionId },
result: {
content: [{ type: "text", text: "(no new output)\n\nProcess still running." }],
details: { status: "running", aggregated: "steady" },
},
};
}
function createReadNoProgressFixture() {
return {
toolName: "read",
params: { path: "/same.txt" },
result: {
content: [{ type: "text", text: "same output" }],
details: { ok: true },
},
} as const;
}
function createPingPongFixture() {
return {
state: createState(),
readParams: { path: "/a.txt" },
listParams: { dir: "/workspace" },
};
}
function detectLoopAfterRepeatedCalls(params: {
toolName: string;
toolParams: unknown;
result: unknown;
count: number;
config?: ToolLoopDetectionConfig;
}) {
const state = createState();
recordRepeatedSuccessfulCalls({
state,
toolName: params.toolName,
toolParams: params.toolParams,
result: params.result,
count: params.count,
});
return detectToolCallLoop(
state,
params.toolName,
params.toolParams,
params.config ?? enabledLoopDetectionConfig,
);
}
function recordSuccessfulPingPongCalls(params: {
state: SessionState;
readParams: { path: string };
listParams: { dir: string };
count: number;
textAtIndex: (toolName: "read" | "list", index: number) => string;
}) {
// Alternating successful calls with unchanged output exercise the ping-pong
// detector independently from same-tool repetition.
for (let i = 0; i < params.count; i += 1) {
if (i % 2 === 0) {
recordSuccessfulCall(
params.state,
"read",
params.readParams,
{ content: [{ type: "text", text: params.textAtIndex("read", i) }], details: { ok: true } },
i,
);
} else {
recordSuccessfulCall(
params.state,
"list",
params.listParams,
{ content: [{ type: "text", text: params.textAtIndex("list", i) }], details: { ok: true } },
i,
);
}
}
}
function expectPingPongLoop(
loopResult: ReturnType<typeof detectToolCallLoop>,
expected: { level: "warning" | "critical"; count: number; expectCriticalText?: boolean },
) {
expect(loopResult.stuck).toBe(true);
if (!loopResult.stuck) {
return;
}
expect(loopResult.level).toBe(expected.level);
expect(loopResult.detector).toBe("ping_pong");
expect(loopResult.count).toBe(expected.count);
if (expected.expectCriticalText) {
expect(loopResult.message).toContain("CRITICAL");
}
}
describe("tool-loop-detection", () => {
describe("recordToolCall argument hashing", () => {
it("creates consistent hash for same tool and params", () => {
const hash1 = recordArgsHash("read", { path: "/file.txt" });
const hash2 = recordArgsHash("read", { path: "/file.txt" });
expect(hash1).toBe(hash2);
});
it("creates different hashes for different params", () => {
const hash1 = recordArgsHash("read", { path: "/file1.txt" });
const hash2 = recordArgsHash("read", { path: "/file2.txt" });
expect(hash1).not.toBe(hash2);
});
it("creates different hashes for different tools", () => {
const hash1 = recordArgsHash("read", { path: "/file.txt" });
const hash2 = recordArgsHash("write", { path: "/file.txt" });
expect(hash1).not.toBe(hash2);
});
it("hashes non-object params with the same digest shape", () => {
const hashes = [
recordArgsHash("tool", "string-param"),
recordArgsHash("tool", 123),
recordArgsHash("tool", null),
];
expect(hashes).toHaveLength(3);
for (const hash of hashes) {
expect(hash.startsWith("tool:")).toBe(true);
expect(hash.length).toBe("tool:".length + 64);
expect(/^[a-f0-9]+$/.test(hash.slice("tool:".length))).toBe(true);
}
});
it("produces deterministic hashes regardless of key order", () => {
const hash1 = recordArgsHash("tool", { a: 1, b: 2 });
const hash2 = recordArgsHash("tool", { b: 2, a: 1 });
expect(hash1).toBe(hash2);
});
it("keeps hashes fixed-size even for large params", () => {
const payload = { data: "x".repeat(20_000) };
const hash = recordArgsHash("read", payload);
expect(hash.startsWith("read:")).toBe(true);
expect(hash.length).toBe("read:".length + 64);
});
it("hashes circular params without collapsing repeated references", () => {
const shared = { id: "shared" };
const payload: Record<string, unknown> = { first: shared, second: shared };
payload.self = payload;
const equivalentShared = { id: "shared" };
const equivalentPayload: Record<string, unknown> = {
second: equivalentShared,
first: equivalentShared,
};
equivalentPayload.self = equivalentPayload;
expect(recordArgsHash("tool", payload)).toBe(recordArgsHash("tool", equivalentPayload));
expect(recordArgsHash("tool", payload)).toEqual(expect.stringMatching(/^tool:[a-f0-9]{64}$/));
});
});
describe("recordToolCall", () => {
it("adds tool call to empty history", () => {
const state = createState();
recordToolCall(state, "read", { path: "/file.txt" }, "call-1");
expect(state.toolCallHistory).toHaveLength(1);
expect(state.toolCallHistory?.[0]?.toolName).toBe("read");
expect(state.toolCallHistory?.[0]?.toolCallId).toBe("call-1");
});
it("maintains sliding window of last N calls", () => {
const state = createState();
for (let i = 0; i < TOOL_CALL_HISTORY_SIZE + 10; i += 1) {
recordToolCall(state, "tool", { iteration: i }, `call-${i}`);
}
expect(state.toolCallHistory).toHaveLength(TOOL_CALL_HISTORY_SIZE);
const oldestCall = state.toolCallHistory?.[0];
expect(oldestCall?.argsHash).toBe(recordArgsHash("tool", { iteration: 10 }));
});
it("records timestamp for each call", () => {
const state = createState();
const before = Date.now();
recordToolCall(state, "tool", { arg: 1 }, "call-ts");
const after = Date.now();
const timestamp = state.toolCallHistory?.[0]?.timestamp ?? 0;
expect(timestamp).toBeGreaterThanOrEqual(before);
expect(timestamp).toBeLessThanOrEqual(after);
});
it("records run id when provided", () => {
const state = createState();
recordToolCall(state, "tool", { arg: 1 }, "call-run", enabledLoopDetectionConfig, {
runId: "run-1",
});
expect(state.toolCallHistory?.[0]?.runId).toBe("run-1");
});
});
describe("detectToolCallLoop", () => {
it("is disabled by default", () => {
const state = createState();
for (let i = 0; i < 20; i += 1) {
recordToolCall(state, "read", { path: "/same.txt" }, `default-${i}`);
}
const loopResult = detectToolCallLoop(state, "read", { path: "/same.txt" });
expect(loopResult.stuck).toBe(false);
});
it("does not flag unique tool calls", () => {
const state = createState();
for (let i = 0; i < 15; i += 1) {
recordToolCall(state, "read", { path: `/file${i}.txt` }, `call-${i}`);
}
const result = detectToolCallLoop(
state,
"read",
{ path: "/new-file.txt" },
enabledLoopDetectionConfig,
);
expect(result.stuck).toBe(false);
});
it("ignores repeated history from other runs", () => {
const state = createState();
const params = { path: "/same.txt" };
for (let i = 0; i < WARNING_THRESHOLD; i += 1) {
recordToolCall(state, "read", params, `old-run-${i}`, enabledLoopDetectionConfig, {
runId: "heartbeat-1",
});
}
const result = detectToolCallLoop(state, "read", params, enabledLoopDetectionConfig, {
runId: "heartbeat-2",
});
expect(result.stuck).toBe(false);
});
it("detects repeated history within the same run", () => {
const state = createState();
const params = { path: "/same.txt" };
for (let i = 0; i < WARNING_THRESHOLD; i += 1) {
recordToolCall(state, "read", params, `same-run-${i}`, enabledLoopDetectionConfig, {
runId: "run-1",
});
}
const result = detectToolCallLoop(state, "read", params, enabledLoopDetectionConfig, {
runId: "run-1",
});
expect(result.stuck).toBe(true);
if (result.stuck) {
expect(result.detector).toBe("generic_repeat");
expect(result.count).toBe(WARNING_THRESHOLD);
}
});
it("keeps scoped and unscoped history isolated", () => {
const state = createState();
const params = { path: "/same.txt" };
for (let i = 0; i < WARNING_THRESHOLD; i += 1) {
recordToolCall(state, "read", params, `scoped-${i}`, enabledLoopDetectionConfig, {
runId: "run-1",
});
}
const result = detectToolCallLoop(state, "read", params, enabledLoopDetectionConfig);
expect(result.stuck).toBe(false);
});
it("warns on generic repeated tool+args calls", () => {
const state = createState();
for (let i = 0; i < WARNING_THRESHOLD; i += 1) {
recordToolCall(state, "read", { path: "/same.txt" }, `warn-${i}`);
}
const result = detectToolCallLoop(
state,
"read",
{ path: "/same.txt" },
enabledLoopDetectionConfig,
);
expect(result.stuck).toBe(true);
if (result.stuck) {
expect(result.level).toBe("warning");
expect(result.detector).toBe("generic_repeat");
expect(result.count).toBe(WARNING_THRESHOLD);
expect(result.message).toContain("WARNING");
expect(result.message).toContain(`${WARNING_THRESHOLD} times`);
}
});
it("blocks generic no-progress loops at critical threshold", () => {
const fixture = createReadNoProgressFixture();
const loopResult = detectLoopAfterRepeatedCalls({
toolName: fixture.toolName,
toolParams: fixture.params,
result: fixture.result,
count: CRITICAL_THRESHOLD,
});
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
expect(loopResult.detector).toBe("generic_repeat");
expect(loopResult.message).toContain("identical outcomes");
}
});
it("warns for known polling no-progress loops", () => {
const { params, result } = createNoProgressPollFixture("sess-1");
const loopResult = detectLoopAfterRepeatedCalls({
toolName: "process",
toolParams: params,
result,
count: WARNING_THRESHOLD,
});
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("warning");
expect(loopResult.detector).toBe("known_poll_no_progress");
expect(loopResult.message).toContain("no progress");
}
});
it("blocks known polling no-progress loops at critical threshold", () => {
const { params, result } = createNoProgressPollFixture("sess-1");
const loopResult = detectLoopAfterRepeatedCalls({
toolName: "process",
toolParams: params,
result,
count: CRITICAL_THRESHOLD,
});
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
expect(loopResult.detector).toBe("known_poll_no_progress");
expect(loopResult.message).toContain("CRITICAL");
}
});
it("does not block known polling when output progresses", () => {
const state = createState();
const params = { action: "poll", sessionId: "sess-1" };
for (let i = 0; i < CRITICAL_THRESHOLD + 5; i += 1) {
const result = {
content: [{ type: "text", text: `line ${i}` }],
details: { status: "running", aggregated: `line ${i}` },
};
recordSuccessfulCall(state, "process", params, result, i);
}
const loopResult = detectToolCallLoop(state, "process", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(false);
});
it("blocks any tool with global no-progress breaker at 30", () => {
const fixture = createReadNoProgressFixture();
const loopResult = detectLoopAfterRepeatedCalls({
toolName: fixture.toolName,
toolParams: fixture.params,
result: fixture.result,
count: GLOBAL_CIRCUIT_BREAKER_THRESHOLD,
config: enabledLoopDetectionConfig,
});
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
expect(loopResult.detector).toBe("global_circuit_breaker");
expect(loopResult.message).toContain("global circuit breaker");
}
});
it("warns on repeated stable argument churn without vetoing the next call", () => {
const state = createState();
const paths = ["/tmp/a.md", "/tmp/b.md", "/tmp/a.md", "/tmp/a.md", "/tmp/b.md"];
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
const targetPath = paths[index % paths.length]!;
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content: "same content" },
{
content: [{ type: "text", text: "write made no changes" }],
details: { ok: true, changed: false },
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/a.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("warning");
expect(loopResult.detector).toBe("argument_churn");
expect(loopResult.livenessSignal).toBe("argument_churn");
expect(loopResult.count).toBe(GLOBAL_CIRCUIT_BREAKER_THRESHOLD);
expect(loopResult.message).toContain("tool call remains allowed");
}
const escapeResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/c.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(escapeResult.stuck).toBe(false);
});
it("normalizes built-in write no-ops that only differ by echoed path", () => {
const state = createState();
const content = "same content";
const paths = ["/tmp/a.md", "/tmp/b.md"];
for (const [index, targetPath] of paths.entries()) {
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content },
{
content: [
{
type: "text",
text: `No changes made to ${targetPath}. The file already has identical content.`,
},
],
details: { changed: false },
},
index,
);
}
const hashes = state.toolCallHistory?.map((record) => record.resultHash);
expect(hashes?.[0]).toBeTypeOf("string");
expect(hashes?.[0]).toBe(hashes?.[1]);
expect(state.toolCallHistory?.every((record) => record.noProgress === true)).toBe(true);
});
it("preserves target identity for successful write outcomes", () => {
const state = createState();
const content = "same content";
const paths = ["/tmp/a.md", "/tmp/b.md"];
for (const [index, targetPath] of paths.entries()) {
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content },
{
content: [
{
type: "text",
text: `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to ${targetPath}`,
},
],
details: {
changed: true,
created: true,
diff: "+same content",
patch: `--- ${targetPath}\n+++ ${targetPath}\n+same content`,
},
},
index,
);
}
const history = state.toolCallHistory ?? [];
expect(history[0]?.resultHash).not.toBe(history[1]?.resultHash);
expect(history.every((record) => record.noProgress === undefined)).toBe(true);
});
it("uses the supplied warning threshold when reconciling rewritten calls", () => {
const state = createState();
const paths = ["/tmp/a.md", "/tmp/b.md"];
for (let index = 0; index < 6; index += 1) {
const targetPath = paths[index % paths.length]!;
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content: "same content" },
{
content: [{ type: "text", text: "write made no changes" }],
details: { changed: false },
},
index,
);
}
recordToolCall(
state,
"write",
{ path: "/tmp/original.md", content: "same content" },
"rewritten-call",
);
const reconciled = reconcileToolCallExecutionParams(state, {
toolName: "write",
toolParams: { path: "/tmp/a.md", content: "same content" },
toolCallId: "rewritten-call",
warningThreshold: 6,
});
expect(reconciled).toEqual({ active: true, count: 6, variantCount: 2 });
});
it("does not reconcile a completed loop veto as a pending call", () => {
const state = createState();
state.toolCallHistory = [
{
toolName: "write",
argsHash: "pending-args",
timestamp: 1,
},
{
toolName: "write",
argsHash: "vetoed-args",
outcomeKind: "tool-loop-veto",
timestamp: 2,
},
];
expect(
reconcileToolCallExecutionParams(state, {
toolName: "write",
toolParams: { path: "/tmp/rewritten.md", content: "same content" },
warningThreshold: 6,
}),
).toEqual({ active: false, count: 0, variantCount: 0 });
expect(state.toolCallHistory[0]?.argsHash).not.toBe("pending-args");
expect(state.toolCallHistory[1]?.argsHash).toBe("vetoed-args");
});
it("keeps completed churn evidence across a pending same-tool sibling", () => {
const state = createState();
const paths = ["/tmp/a.md", "/tmp/b.md", "/tmp/a.md", "/tmp/a.md", "/tmp/b.md"];
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
const targetPath = paths[index % paths.length]!;
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content: "same content" },
{
content: [{ type: "text", text: "write made no changes" }],
details: { ok: true, changed: false },
},
index,
);
}
recordToolCall(
state,
"write",
{ path: "/tmp/a.md", content: "same content" },
"pending-sibling",
);
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/b.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult).toMatchObject({
stuck: true,
level: "warning",
detector: "argument_churn",
});
});
it("does not treat generic stable successes as semantic no-progress", () => {
const state = createState();
const paths = ["/tmp/a.md", "/tmp/b.md", "/tmp/a.md", "/tmp/a.md", "/tmp/b.md"];
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
const targetPath = paths[index % paths.length]!;
recordSuccessfulCall(
state,
"side_effect",
{ path: targetPath },
{
content: [{ type: "text", text: "done" }],
details: { ok: true },
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"side_effect",
{ path: "/tmp/a.md" },
enabledLoopDetectionConfig,
);
expect(loopResult).toMatchObject({
stuck: true,
level: "warning",
detector: "generic_repeat",
});
if (loopResult.stuck) {
expect(loopResult.livenessSignal).toBeUndefined();
}
});
it("keeps repeated stable errors eligible for argument-churn liveness", () => {
const state = createState();
const paths = ["/tmp/a.md", "/tmp/b.md", "/tmp/a.md", "/tmp/a.md", "/tmp/b.md"];
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
const targetPath = paths[index % paths.length]!;
const toolCallId = `failed-${index}`;
const params = { path: targetPath };
recordToolCall(state, "side_effect", params, toolCallId, enabledLoopDetectionConfig);
recordToolCallOutcome(state, {
toolName: "side_effect",
toolParams: params,
toolCallId,
error: new Error("permission denied"),
config: enabledLoopDetectionConfig,
});
}
const loopResult = detectToolCallLoop(
state,
"side_effect",
{ path: "/tmp/a.md" },
enabledLoopDetectionConfig,
);
expect(loopResult).toMatchObject({
stuck: true,
level: "warning",
detector: "argument_churn",
livenessSignal: "argument_churn",
});
});
it("keeps generic critical repeats ahead of warning-only argument churn", () => {
const state = createState();
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"write",
{ path: "/tmp/a.md", content: "same content" },
{
content: [{ type: "text", text: "wrote /tmp/a.md" }],
details: { ok: true, path: "/tmp/a.md" },
},
index,
);
}
for (let index = 0; index < WARNING_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"write",
{ path: "/tmp/b.md", content: "same content" },
{
content: [{ type: "text", text: "wrote /tmp/b.md" }],
details: { ok: true, path: "/tmp/b.md" },
},
CRITICAL_THRESHOLD + index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/a.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult).toMatchObject({
stuck: true,
level: "critical",
detector: "generic_repeat",
});
});
it("preserves churn liveness when strict alternation owns the primary warning", () => {
const state = createState();
for (let index = 0; index < WARNING_THRESHOLD; index += 1) {
const targetPath = index % 2 === 0 ? "/tmp/a.md" : "/tmp/b.md";
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content: "same content" },
{
content: [{ type: "text", text: "write made no changes" }],
details: { ok: true, changed: false },
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/a.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("warning");
expect(loopResult.detector).toBe("ping_pong");
expect(loopResult.livenessSignal).toBe("argument_churn");
}
});
it("does not carry argument-churn liveness across singleton probes", () => {
const state = createState();
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
const targetPath =
index === 8 || index === 19 ? `/tmp/probe-${index}.md` : `/tmp/${index % 2}.md`;
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content: "same content" },
{
content: [{ type: "text", text: `wrote ${targetPath}` }],
details: { ok: true, path: targetPath },
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/a.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it("does not block a one-shot batch of distinct arguments", () => {
const state = createState();
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"write",
{ path: `/tmp/file-${index}.md`, content: "same content" },
{
content: [{ type: "text", text: "write complete" }],
details: { ok: true },
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/next.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it("does not block a legitimate two-pass batch", () => {
const state = createState();
const paths = Array.from({ length: 15 }, (_, index) => `/tmp/batch-${index}.md`);
const content = "same content";
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
const targetPath = paths[index % paths.length]!;
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content },
{
content: [
{
type: "text",
text: `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to ${targetPath}`,
},
],
details: {
changed: true,
created: true,
diff: "+same content",
patch: `--- ${targetPath}\n+++ ${targetPath}\n+same content`,
},
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/next.md", content },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it("does not block argument churn when a repeated variant makes progress", () => {
const state = createState();
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
const targetPath = index % 2 === 0 ? "/tmp/a.md" : "/tmp/b.md";
recordSuccessfulCall(
state,
"write",
{ path: targetPath, content: "same content" },
{
content: [{ type: "text", text: `write ${targetPath} revision ${index}` }],
details: { ok: true, revision: index },
},
index,
);
}
const loopResult = detectToolCallLoop(
state,
"write",
{ path: "/tmp/c.md", content: "same content" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it.each([
{
label: "completed normal process failures",
status: "completed",
exitCode: 1,
output: "Traceback: missing package\n\n(Command exited with code 1)",
},
{
label: "failed non-executable commands",
status: "failed",
exitCode: 126,
output: "Command not executable (permission denied)",
aggregated: "",
},
{
label: "failed missing commands",
status: "failed",
exitCode: 127,
output: "Command not found",
aggregated: "",
},
] as const)("blocks repeated $label across changing exec arguments", (testCase) => {
const state = createState();
const result = createExecLoopResult(testCase);
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
recordSuccessfulCall(state, "exec", { command: `python job-${index}.py` }, result, index);
}
expect(
state.toolCallHistory?.every((record) => record.outcomeKind === "terminal-exec-failure"),
).toBe(true);
expect(
detectToolCallLoop(
state,
"exec",
{ command: "python next-job.py" },
enabledLoopDetectionConfig,
),
).toMatchObject({
stuck: true,
level: "critical",
detector: "generic_repeat",
count: CRITICAL_THRESHOLD,
});
});
it("anchors changing-argument exec vetoes until the global circuit breaker", () => {
const state = createState();
const result = createExecLoopResult({
status: "completed",
exitCode: 1,
output: "Traceback: missing package\n\n(Command exited with code 1)",
});
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
recordSuccessfulCall(state, "exec", { command: `python job-${index}.py` }, result, index);
}
for (let index = CRITICAL_THRESHOLD; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
const params = { command: `python job-${index}.py` };
expect(detectToolCallLoop(state, "exec", params, enabledLoopDetectionConfig)).toMatchObject(
{
stuck: true,
level: "critical",
detector: "generic_repeat",
count: index,
},
);
expect(
recordToolCallOutcome(state, {
toolName: "exec",
toolParams: params,
toolCallId: `exec-veto-${index}`,
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "tool-loop" },
},
config: enabledLoopDetectionConfig,
}),
).toMatchObject({ outcomeKind: "tool-loop-veto", resultHash: undefined });
}
expect(
detectToolCallLoop(
state,
"exec",
{ command: "python final-job.py" },
enabledLoopDetectionConfig,
),
).toMatchObject({
stuck: true,
level: "critical",
detector: "global_circuit_breaker",
count: GLOBAL_CIRCUIT_BREAKER_THRESHOLD,
});
});
it.each([
{
label: "synthetic exit-code-only output",
result: createExecLoopResult({
status: "completed",
exitCode: 1,
output: "\n\n(Command exited with code 1)",
}),
},
{
label: "successful command batches",
result: createExecLoopResult({ status: "completed", exitCode: 0, output: "done" }),
},
{
label: "timed-out executions",
result: createExecLoopResult({
status: "failed",
exitCode: 1,
output: "Command timed out",
timedOut: true,
}),
},
{
label: "non-finite exit codes",
result: createExecLoopResult({
status: "failed",
exitCode: Number.POSITIVE_INFINITY,
output: "process failed",
}),
},
{
label: "failures without an exit code",
result: createExecLoopResult({
status: "failed",
exitCode: null,
output: "process failed before spawning",
}),
},
])("does not semantically block $label", ({ result }) => {
const state = createState();
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
recordSuccessfulCall(state, "exec", { command: `grep target-${index}` }, result, index);
}
expect(state.toolCallHistory?.every((record) => record.outcomeKind === undefined)).toBe(true);
expect(
detectToolCallLoop(
state,
"exec",
{ command: "grep next-target" },
enabledLoopDetectionConfig,
),
).toEqual({ stuck: false });
});
it.each([
{
label: "a distinct terminal failure",
toolName: "exec",
result: createExecLoopResult({
status: "completed",
exitCode: 1,
output: "Traceback: different package\n\n(Command exited with code 1)",
}),
},
{
label: "a successful execution",
toolName: "exec",
result: createExecLoopResult({ status: "completed", exitCode: 0, output: "done" }),
},
{
label: "a timed-out execution",
toolName: "exec",
result: createExecLoopResult({
status: "failed",
exitCode: 1,
output: "Command timed out",
timedOut: true,
}),
},
{
label: "another tool",
toolName: "read",
result: { content: [{ type: "text", text: "read complete" }], details: { ok: true } },
},
])("resets the semantic exec failure tail after $label", ({ toolName, result }) => {
const state = createState();
const failure = createExecLoopResult({
status: "completed",
exitCode: 1,
output: "Traceback: missing package\n\n(Command exited with code 1)",
});
for (let index = 0; index < CRITICAL_THRESHOLD - 1; index += 1) {
recordSuccessfulCall(state, "exec", { command: `python job-${index}.py` }, failure, index);
}
recordSuccessfulCall(state, toolName, { command: "interruption" }, result, 19);
recordSuccessfulCall(state, "exec", { command: "python latest.py" }, failure, 20);
expect(
detectToolCallLoop(
state,
"exec",
{ command: "python next.py" },
enabledLoopDetectionConfig,
),
).toEqual({ stuck: false });
});
it("does not carry semantic exec failures into another run", () => {
const state = createState();
const result = createExecLoopResult({
status: "completed",
exitCode: 1,
output: "Traceback: missing package\n\n(Command exited with code 1)",
});
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
const params = { command: `python job-${index}.py` };
const toolCallId = `exec-old-run-${index}`;
recordToolCall(state, "exec", params, toolCallId, enabledLoopDetectionConfig, {
runId: "old-run",
});
recordToolCallOutcome(state, {
toolName: "exec",
toolParams: params,
toolCallId,
result,
config: enabledLoopDetectionConfig,
runId: "old-run",
});
}
expect(
detectToolCallLoop(
state,
"exec",
{ command: "python next.py" },
enabledLoopDetectionConfig,
{ runId: "new-run" },
),
).toEqual({ stuck: false });
});
it("blocks repeated completed exec calls despite volatile runtime details", () => {
const state = createState();
const params = { command: "grafana-api.sh datasources" };
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"exec",
params,
{
content: [{ type: "text", text: "Loki\nPrometheus" }],
details: {
status: "completed",
exitCode: 0,
durationMs: 100 + index,
cwd: `/tmp/run-${index}`,
aggregated: "Loki\nPrometheus",
},
},
index,
);
}
const loopResult = detectToolCallLoop(state, "exec", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
expect(loopResult.detector).toBe("generic_repeat");
}
});
it("blocks repeated running exec calls despite volatile session details and text", () => {
const state = createState();
const params = { command: "tail -f /var/log/app.log", yieldMs: 1000 };
for (let index = 0; index < CRITICAL_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"exec",
params,
{
content: [
{
type: "text",
text: `Command still running (session sess-${index}, pid ${1000 + index})`,
},
],
details: {
status: "running",
sessionId: `sess-${index}`,
pid: 1000 + index,
startedAt: Date.now() + index,
cwd: `/tmp/run-${index}`,
tail: "(no new output)",
},
},
index,
);
}
const loopResult = detectToolCallLoop(state, "exec", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
expect(loopResult.detector).toBe("generic_repeat");
}
});
it("keeps changing exec output below the global no-progress breaker", () => {
const state = createState();
const params = { command: "date" };
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"exec",
params,
{
content: [{ type: "text", text: `tick ${index}` }],
details: {
status: "completed",
exitCode: 0,
durationMs: 100 + index,
aggregated: `tick ${index}`,
},
},
index,
);
}
const loopResult = detectToolCallLoop(state, "exec", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("warning");
expect(loopResult.detector).toBe("generic_repeat");
}
});
it("keeps changing empty-output exec failures below the global no-progress breaker", () => {
const state = createState();
const params = { command: "openclaw flaky-helper" };
for (let index = 0; index < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; index += 1) {
recordSuccessfulCall(
state,
"exec",
params,
{
content: [{ type: "text", text: `Runtime failed before spawn: attempt ${index}` }],
details: {
status: "failed",
exitCode: null,
durationMs: 100 + index,
aggregated: "",
},
},
index,
);
}
const loopResult = detectToolCallLoop(state, "exec", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("warning");
expect(loopResult.detector).toBe("generic_repeat");
}
});
it("does not block repeated unknown-tool failures before the unknown-tool threshold", () => {
const state = createState();
const toolName = "exec";
const unknownToolError = new Error("Tool exec not found");
for (let index = 0; index < UNKNOWN_TOOL_THRESHOLD - 1; index += 1) {
recordFailedCall(state, toolName, { command: `echo ${index}` }, unknownToolError, index);
}
const loopResult = detectToolCallLoop(
state,
toolName,
{ command: "echo still allowed" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it("blocks repeated unknown-tool failures even when the args keep changing", () => {
const state = createState();
const toolName = "exec";
const unknownToolError = new Error("Tool exec not found");
const attempts = [
{ command: "ls" },
{ command: "pwd" },
{ input: "whoami" },
{ cmd: "env" },
{ shell: "bash -lc ls" },
{ command: "printf ok" },
{ cwd: "/tmp", command: "ls" },
{ args: ["ls", "/tmp"] },
{ command: "find . -maxdepth 1" },
{ text: "run ls" },
{ command: "uname -a" },
{ command: "id" },
{ command: "date" },
{ command: "ps" },
{ command: "df -h" },
{ command: "free -m" },
{ command: "ls /tmp" },
{ command: "ls -la" },
{ command: "cat /etc/hostname" },
{ command: "echo done" },
];
for (const [index, params] of attempts.entries()) {
recordFailedCall(state, toolName, params, unknownToolError, index);
}
const loopResult = detectToolCallLoop(
state,
toolName,
{ command: "echo still looping" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.detector).toBe("unknown_tool_repeat");
expect(loopResult.level).toBe("critical");
}
});
it("warns on ping-pong alternating patterns", () => {
const state = createState();
const readParams = { path: "/a.txt" };
const listParams = { dir: "/workspace" };
for (let i = 0; i < WARNING_THRESHOLD - 1; i += 1) {
if (i % 2 === 0) {
recordToolCall(state, "read", readParams, `read-${i}`);
} else {
recordToolCall(state, "list", listParams, `list-${i}`);
}
}
const loopResult = detectToolCallLoop(state, "list", listParams, enabledLoopDetectionConfig);
expectPingPongLoop(loopResult, { level: "warning", count: WARNING_THRESHOLD });
if (loopResult.stuck) {
expect(loopResult.message).toContain("ping-pong loop");
}
});
it("blocks ping-pong alternating patterns at critical threshold", () => {
const { state, readParams, listParams } = createPingPongFixture();
recordSuccessfulPingPongCalls({
state,
readParams,
listParams,
count: CRITICAL_THRESHOLD - 1,
textAtIndex: (toolName) => (toolName === "read" ? "read stable" : "list stable"),
});
const loopResult = detectToolCallLoop(state, "list", listParams, enabledLoopDetectionConfig);
expectPingPongLoop(loopResult, {
level: "critical",
count: CRITICAL_THRESHOLD,
expectCriticalText: true,
});
if (loopResult.stuck) {
expect(loopResult.message).toContain("ping-pong loop");
}
});
it("does not block ping-pong at critical threshold when outcomes are progressing", () => {
const { state, readParams, listParams } = createPingPongFixture();
recordSuccessfulPingPongCalls({
state,
readParams,
listParams,
count: CRITICAL_THRESHOLD - 1,
textAtIndex: (toolName, index) => `${toolName} ${index}`,
});
const loopResult = detectToolCallLoop(state, "list", listParams, enabledLoopDetectionConfig);
expectPingPongLoop(loopResult, { level: "warning", count: CRITICAL_THRESHOLD });
});
it("does not flag ping-pong when alternation is broken", () => {
const state = createState();
recordToolCall(state, "read", { path: "/a.txt" }, "a1");
recordToolCall(state, "list", { dir: "/workspace" }, "b1");
recordToolCall(state, "read", { path: "/a.txt" }, "a2");
recordToolCall(state, "write", { path: "/tmp/out.txt" }, "c1"); // breaks alternation
const loopResult = detectToolCallLoop(
state,
"list",
{ dir: "/workspace" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it("records fixed-size result hashes for large tool outputs", () => {
const state = createState();
const params = { action: "log", sessionId: "sess-big" };
const toolCallId = "log-big";
recordToolCall(state, "process", params, toolCallId);
recordToolCallOutcome(state, {
toolName: "process",
toolParams: params,
toolCallId,
result: {
content: [{ type: "text", text: "y".repeat(40_000) }],
details: { status: "running", totalLines: 1, totalChars: 40_000 },
},
});
const entry = state.toolCallHistory?.find((call) => call.toolCallId === toolCallId);
expect(typeof entry?.resultHash).toBe("string");
expect(entry?.resultHash?.length).toBe(64);
});
it("returns the recorded call when a pre-recorded tool call receives its result", () => {
const state = createState();
const params = { action: "lookup", path: "cron.enabled" };
recordToolCall(state, "gateway", params, "call-1");
const recorded = recordToolCallOutcome(state, {
toolName: "gateway",
toolParams: params,
toolCallId: "call-1",
result: { content: [{ type: "text", text: "same schema" }] },
});
expect(recorded?.toolCallId).toBe("call-1");
expect(state.toolCallHistory).toHaveLength(1);
expect(state.toolCallHistory?.[0]?.resultHash).toBeTypeOf("string");
});
it("returns the recorded call while trimming production call/outcome records", () => {
const state = createState();
let lastRecordedToolCallId: string | undefined;
for (let i = 0; i < TOOL_CALL_HISTORY_SIZE + 3; i += 1) {
const params = { action: "lookup", path: `config.${i}` };
const toolCallId = `call-${i}`;
recordToolCall(state, "gateway", params, toolCallId);
const recorded = recordToolCallOutcome(state, {
toolName: "gateway",
toolParams: params,
toolCallId,
result: { content: [{ type: "text", text: `schema-${i}` }] },
});
lastRecordedToolCallId = recorded?.toolCallId;
}
expect(lastRecordedToolCallId).toBe(`call-${TOOL_CALL_HISTORY_SIZE + 2}`);
expect(state.toolCallHistory).toHaveLength(TOOL_CALL_HISTORY_SIZE);
expect(state.toolCallHistory?.[0]?.toolCallId).toBe("call-3");
});
it("does not attach outcomes to matching calls from other runs", () => {
const state = createState();
const params = { path: "/same.txt" };
recordToolCall(state, "read", params, "call-1", enabledLoopDetectionConfig, {
runId: "run-1",
});
recordToolCallOutcome(state, {
toolName: "read",
toolParams: params,
toolCallId: "call-1",
result: { content: [{ type: "text", text: "same output" }] },
config: enabledLoopDetectionConfig,
runId: "run-2",
});
expect(state.toolCallHistory).toHaveLength(2);
expect(state.toolCallHistory?.[0]?.resultHash).toBeUndefined();
expect(state.toolCallHistory?.[1]?.runId).toBe("run-2");
expect(state.toolCallHistory?.[1]?.resultHash).toBeTypeOf("string");
});
it("handles empty history", () => {
const state = createState();
const result = detectToolCallLoop(state, "tool", { arg: 1 }, enabledLoopDetectionConfig);
expect(result.stuck).toBe(false);
});
});
describe("message send loop detection (#89090)", () => {
// Mirror jsonResult(payload): text is the stringified payload (so it carries the
// volatile id too) and details is the payload — the shape a real send returns.
function sendResult(payload: Record<string, unknown>) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
};
}
function recordSend(
state: SessionState,
toolName: string,
params: unknown,
payload: Record<string, unknown>,
index: number,
): void {
const toolCallId = `${toolName}-${index}`;
recordToolCall(state, toolName, params, toolCallId, enabledLoopDetectionConfig);
recordToolCallOutcome(state, {
toolName,
toolParams: params,
toolCallId,
result: sendResult(payload),
config: enabledLoopDetectionConfig,
});
}
function sendPayload(index: number): Record<string, unknown> {
return {
ok: true,
channel: "feishu",
chatId: "oc_chat",
runId: `run_${index}`,
messageId: `om_${index}`,
receipt: { platformMessageId: `p_${index}` },
};
}
it("gives duplicate sends a stable result hash despite per-call ids in details and text", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
recordSend(state, "message", params, sendPayload(0), 0);
recordSend(state, "message", params, sendPayload(1), 1);
const hashes = state.toolCallHistory
?.filter((call) => call.toolName === "message")
.map((call) => call.resultHash);
expect(hashes?.[0]).toBeTypeOf("string");
expect(hashes?.[0]).toBe(hashes?.[1]);
});
it("strips nested ids so broadcast results with per-call ids share a result hash", () => {
const state = createState();
const params = { action: "broadcast", text: "ping" };
const broadcast = (index: number) => ({
results: [
{ channel: "feishu", ok: true, result: { messageId: `om_${index}`, receipt: index } },
],
});
recordSend(state, "message", params, broadcast(0), 0);
recordSend(state, "message", params, broadcast(1), 1);
const hashes = state.toolCallHistory
?.filter((call) => call.toolName === "message")
.map((call) => call.resultHash);
expect(hashes?.[0]).toBe(hashes?.[1]);
});
it("escalates identical-arg send loops to critical even though every id differs", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("also blocks sessions_send loops whose result carries a fresh runId", () => {
const state = createState();
const params = { sessionKey: "agent:main:peer", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "sessions_send", params, { ok: true, runId: `run_${i}` }, i);
}
const loopResult = detectToolCallLoop(
state,
"sessions_send",
params,
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("escalates provider-docked send-tool loops (e.g. telegram) whose result carries fresh ids", () => {
const state = createState();
const params = { to: "telegram:123", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "telegram", params, sendPayload(i), i);
}
const loopResult = detectToolCallLoop(state, "telegram", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("escalates sibling delivery actions (reply) the same as send", () => {
const state = createState();
const params = { action: "reply", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("blocks upload-file loops whose result carries a fresh file id", () => {
const state = createState();
const params = { action: "upload-file", target: "feishu:oc_chat", media: "img.png" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(
state,
"message",
params,
{ ok: true, channel: "feishu", fileId: `f_${i}`, messageId: `om_${i}` },
i,
);
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("blocks poll loops whose result carries a fresh poll id", () => {
const state = createState();
const params = { action: "poll", target: "tg:chat-1", question: "?" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(
state,
"message",
params,
{ ok: true, channel: "telegram", messageId: `om_${i}`, chatId: "c1", pollId: `pl_${i}` },
i,
);
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("does not flag distinct messages (different args) as a no-progress loop", () => {
const state = createState();
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(
state,
"message",
{ action: "send", target: "feishu:oc_chat", text: `line ${i}` },
sendPayload(i),
i,
);
}
const loopResult = detectToolCallLoop(
state,
"message",
{ action: "send", target: "feishu:oc_chat", text: "line final" },
enabledLoopDetectionConfig,
);
expect(loopResult.stuck).toBe(false);
});
it("keeps full result hashing for non-send message actions so id/timestamp progress is not masked", () => {
const state = createState();
const params = { action: "read", target: "feishu:oc_chat" };
// Results differ only in id/timestamp: a non-send action must still treat that as
// progress (distinct hashes), unlike a send where those are stripped as volatile.
recordSend(state, "message", params, { ok: true, messageId: "m_0", ts: 1000 }, 0);
recordSend(state, "message", params, { ok: true, messageId: "m_1", ts: 2000 }, 1);
const hashes = state.toolCallHistory
?.filter((call) => call.toolName === "message")
.map((call) => call.resultHash);
expect(hashes?.[0]).toBeTypeOf("string");
expect(hashes?.[0]).not.toBe(hashes?.[1]);
});
it("keeps full result hashing for administrative message mutations", () => {
const state = createState();
const params = { action: "channel-create", name: "support" };
recordSend(state, "message", params, { ok: true, messageId: "m_0", timestamp: 1000 }, 0);
recordSend(state, "message", params, { ok: true, messageId: "m_1", timestamp: 2000 }, 1);
const hashes = state.toolCallHistory
?.filter((call) => call.toolName === "message")
.map((call) => call.resultHash);
expect(hashes?.[0]).toBeTypeOf("string");
expect(hashes?.[0]).not.toBe(hashes?.[1]);
});
it("preserves stable nested route ids so distinct routes stay distinguishable", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
// messageId is volatile (stripped); a nested route id is a stable fact that must survive,
// so two sends resolving to different routes keep distinct hashes.
recordSend(
state,
"message",
params,
{ ok: true, messageId: "om_0", route: { id: "conv-A" } },
0,
);
recordSend(
state,
"message",
params,
{ ok: true, messageId: "om_1", route: { id: "conv-B" } },
1,
);
const hashes = state.toolCallHistory
?.filter((call) => call.toolName === "message")
.map((call) => call.resultHash);
expect(hashes?.[0]).not.toBe(hashes?.[1]);
});
it("counts loop vetoes until the global circuit breaker becomes reachable", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
for (let i = CRITICAL_THRESHOLD; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) {
const before = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(before).toMatchObject({
stuck: true,
level: "critical",
detector: "generic_repeat",
count: i,
});
const recorded = recordToolCallOutcome(state, {
toolName: "message",
toolParams: params,
toolCallId: `message-veto-${i}`,
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "tool-loop" },
},
config: enabledLoopDetectionConfig,
});
expect(recorded).toMatchObject({
toolCallId: `message-veto-${i}`,
outcomeKind: "tool-loop-veto",
resultHash: undefined,
});
}
const after = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(after).toMatchObject({
stuck: true,
level: "critical",
detector: "global_circuit_breaker",
count: GLOBAL_CIRCUIT_BREAKER_THRESHOLD,
});
});
it("does not count unrelated hashless calls as no-progress outcomes", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
for (let i = CRITICAL_THRESHOLD; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) {
recordToolCall(state, "message", params, `pending-${i}`, enabledLoopDetectionConfig);
}
expect(
detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig),
).toMatchObject({
stuck: true,
detector: "generic_repeat",
count: CRITICAL_THRESHOLD,
});
});
it("does not carry older loop vetoes across a later progressing outcome", () => {
const state = createState();
const params = { action: "send", target: "feishu:oc_chat", text: "ping" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(state, "message", params, sendPayload(i), i);
}
for (let i = 0; i < 5; i += 1) {
recordToolCallOutcome(state, {
toolName: "message",
toolParams: params,
toolCallId: `old-veto-${i}`,
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "tool-loop" },
},
config: enabledLoopDetectionConfig,
});
}
recordSend(state, "message", params, { ...sendPayload(25), route: { id: "new-route" } }, 25);
expect(
detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig),
).toMatchObject({
stuck: true,
level: "warning",
detector: "generic_repeat",
count: 26,
});
});
it("still escalates repeated plugin/approval vetoes to a critical loop", () => {
const state = createState();
const params = { action: "read", target: "feishu:oc_chat" };
// A non-loop veto (plugin/approval) keeps a stable result hash, so repeated identical
// denials still accumulate a no-progress streak and reach a critical block.
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordToolCall(state, "message", params, `veto-${i}`, enabledLoopDetectionConfig);
recordToolCallOutcome(state, {
toolName: "message",
toolParams: params,
toolCallId: `veto-${i}`,
result: {
content: [{ type: "text", text: "blocked" }],
details: { status: "blocked", deniedReason: "plugin-before-tool-call" },
},
config: enabledLoopDetectionConfig,
});
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("blocks plugin-shaped send results whose message object carries a bare per-send id", () => {
const state = createState();
const params = { action: "send", to: "feishu:chat-1", content: "hello" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
// The volatile id is the message object's own `id`; conversation.id is stable.
recordSend(
state,
"message",
params,
{
message: {
id: `qa_${i}`,
accountId: "default",
direction: "outbound",
conversation: { id: "loop-room", chatType: "channel" },
senderId: "openclaw",
text: "hello",
timestamp: 1_800_000_000_000 + i,
},
},
i,
);
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("critical");
}
});
it("does not escalate when a stable conversation id changes between sends", () => {
const state = createState();
const params = { action: "send", to: "feishu:chat-1", content: "hello" };
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
recordSend(
state,
"message",
params,
{
message: {
id: `qa_${i}`,
direction: "outbound",
conversation: { id: `loop-room-${i}`, chatType: "channel" },
text: "hello",
},
},
i,
);
}
const loopResult = detectToolCallLoop(state, "message", params, enabledLoopDetectionConfig);
expect(loopResult.stuck && loopResult.level).not.toBe("critical");
});
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */