fix(agents): return tool loop stops as blocks

This commit is contained in:
Peter Steinberger
2026-05-02 11:06:46 +01:00
parent 546f81de04
commit b1cfba2fc2
3 changed files with 31 additions and 18 deletions

View File

@@ -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.

View File

@@ -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, {

View File

@@ -437,7 +437,7 @@ export async function runBeforeToolCallHook(args: {
});
return {
blocked: true,
kind: "failure",
kind: "veto",
deniedReason: "tool-loop",
reason: loopResult.message,
params,