From b1cfba2fc285091220bc3751624bd878c7703536 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 11:06:46 +0100 Subject: [PATCH] fix(agents): return tool loop stops as blocks --- CHANGELOG.md | 1 + .../pi-tools.before-tool-call.e2e.test.ts | 46 ++++++++++++------- src/agents/pi-tools.before-tool-call.ts | 2 +- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 892203a995c..04591c6fcfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser. - Model commands: clarify direct and inline `/model` acknowledgements for non-default selections as session-scoped. Thanks @addu2612. - TUI/chat: skip full provider model normalization during context-window warmup while preserving provider-owned context metadata, avoiding cold-start stalls with large model registries. Thanks @547895019. - Memory Wiki: accept relative Markdown links that include the `.md` suffix during broken-wikilink validation, avoiding false positives for native render-mode links. Thanks @Kenneth8128. diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 267bf9fa9f3..7d95d1ba5f4 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -181,6 +181,17 @@ describe("before_tool_call loop detection behavior", () => { expect(loopEvent?.toolName).toBe(params.toolName); } + function expectToolLoopBlockedResult(result: unknown, expectedReason: string) { + expect(result).toMatchObject({ + content: [{ type: "text", text: expect.stringContaining(expectedReason) }], + details: { + status: "blocked", + deniedReason: "tool-loop", + reason: expect.stringContaining(expectedReason), + }, + }); + } + it("blocks known poll loops when no progress repeats", async () => { const { tool, params } = createNoProgressProcessFixture("sess-1"); @@ -188,9 +199,8 @@ describe("before_tool_call loop detection behavior", () => { await expect(tool.execute(`poll-${i}`, params, undefined, undefined)).resolves.toBeDefined(); } - await expect( - tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined), - ).rejects.toThrow("CRITICAL"); + const result = await tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined); + expectToolLoopBlockedResult(result, "CRITICAL"); }); it("does nothing when loopDetection.enabled is false", async () => { @@ -240,9 +250,13 @@ describe("before_tool_call loop detection behavior", () => { await expect(tool.execute(`read-${i}`, params, undefined, undefined)).resolves.toBeDefined(); } - await expect( - tool.execute(`read-${GLOBAL_CIRCUIT_BREAKER_THRESHOLD}`, params, undefined, undefined), - ).rejects.toThrow("global circuit breaker"); + const result = await tool.execute( + `read-${GLOBAL_CIRCUIT_BREAKER_THRESHOLD}`, + params, + undefined, + undefined, + ); + expectToolLoopBlockedResult(result, "global circuit breaker"); }); it("does not carry loop history across run ids", async () => { @@ -315,14 +329,13 @@ describe("before_tool_call loop detection behavior", () => { const { readTool, listTool } = createPingPongTools(); await runPingPongSequence(readTool, listTool, CRITICAL_THRESHOLD - 1); - await expect( - listTool.execute( - `list-${CRITICAL_THRESHOLD - 1}`, - { dir: "/workspace" }, - undefined, - undefined, - ), - ).rejects.toThrow("CRITICAL"); + const result = await listTool.execute( + `list-${CRITICAL_THRESHOLD - 1}`, + { dir: "/workspace" }, + undefined, + undefined, + ); + expectToolLoopBlockedResult(result, "CRITICAL"); const loopEvent = emitted.at(-1); expectCriticalLoopEvent(loopEvent, { @@ -365,9 +378,8 @@ describe("before_tool_call loop detection behavior", () => { await tool.execute(`poll-${i}`, params, undefined, undefined); } - await expect( - tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined), - ).rejects.toThrow("CRITICAL"); + const result = await tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined); + expectToolLoopBlockedResult(result, "CRITICAL"); const loopEvent = emitted.at(-1); expectCriticalLoopEvent(loopEvent, { diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 7721c89570c..709aef74716 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -437,7 +437,7 @@ export async function runBeforeToolCallHook(args: { }); return { blocked: true, - kind: "failure", + kind: "veto", deniedReason: "tool-loop", reason: loopResult.message, params,