mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
e92a98c77d
Co-authored-by: Peter Steinberger <steipete@macos.shared>
1975 lines
64 KiB
TypeScript
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. */
|