diff --git a/src/agents/tool-loop-detection.test.ts b/src/agents/tool-loop-detection.test.ts index 03f2385ae69..e6dc3d1018e 100644 --- a/src/agents/tool-loop-detection.test.ts +++ b/src/agents/tool-loop-detection.test.ts @@ -560,6 +560,36 @@ describe("tool-loop-detection", () => { } }); + 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"; diff --git a/src/agents/tool-loop-detection.ts b/src/agents/tool-loop-detection.ts index 81b239b27c5..f692676bc37 100644 --- a/src/agents/tool-loop-detection.ts +++ b/src/agents/tool-loop-detection.ts @@ -206,6 +206,14 @@ function stringField(value: unknown): string | null { return typeof value === "string" ? value : null; } +function nonEmptyStringField(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + function hashExecToolOutcome(details: Record, text: string): string | undefined { const status = stringField(details.status); if (!status) { @@ -224,7 +232,7 @@ function hashExecToolOutcome(details: Record, text: string): st status, exitCode: typeof details.exitCode === "number" ? details.exitCode : null, timedOut: details.timedOut === true, - output: stringField(details.aggregated) ?? text, + output: nonEmptyStringField(details.aggregated) ?? text, }); }