Skip to content

Commit 076df94

Browse files
committed
feat: add configurable tool loop detection
1 parent dacffd7 commit 076df94

14 files changed

Lines changed: 557 additions & 30 deletions

docs/gateway/configuration-reference.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,6 +1417,39 @@ Controls elevated (host) exec access:
14171417
}
14181418
```
14191419

1420+
### `tools.loopDetection`
1421+
1422+
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection.
1423+
Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
1424+
1425+
```json5
1426+
{
1427+
tools: {
1428+
loopDetection: {
1429+
enabled: true,
1430+
historySize: 30,
1431+
warningThreshold: 10,
1432+
criticalThreshold: 20,
1433+
globalCircuitBreakerThreshold: 30,
1434+
detectors: {
1435+
genericRepeat: true,
1436+
knownPollNoProgress: true,
1437+
pingPong: true,
1438+
},
1439+
},
1440+
},
1441+
}
1442+
```
1443+
1444+
- `historySize`: max tool-call history retained for loop analysis.
1445+
- `warningThreshold`: repeating no-progress pattern threshold for warnings.
1446+
- `criticalThreshold`: higher repeating threshold for blocking critical loops.
1447+
- `globalCircuitBreakerThreshold`: hard stop threshold for any no-progress run.
1448+
- `detectors.genericRepeat`: warn on repeated same-tool/same-args calls.
1449+
- `detectors.knownPollNoProgress`: warn/block on known poll tools (`process.poll`, `command_status`, etc.).
1450+
- `detectors.pingPong`: warn/block on alternating no-progress pair patterns.
1451+
- If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
1452+
14201453
### `tools.web`
14211454

14221455
```json5

docs/tools/index.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,35 @@ Notes:
224224
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
225225
- `process` is scoped per agent; sessions from other agents are not visible.
226226

227+
### `loop-detection` (tool-call loop guardrails)
228+
229+
OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops.
230+
Enable with `tools.loopDetection.enabled: true` (default is `false`).
231+
232+
```json5
233+
{
234+
tools: {
235+
loopDetection: {
236+
enabled: true,
237+
warningThreshold: 10,
238+
criticalThreshold: 20,
239+
globalCircuitBreakerThreshold: 30,
240+
historySize: 30,
241+
detectors: {
242+
genericRepeat: true,
243+
knownPollNoProgress: true,
244+
pingPong: true,
245+
},
246+
},
247+
},
248+
}
249+
```
250+
251+
- `genericRepeat`: repeated same tool + same params call pattern.
252+
- `knownPollNoProgress`: repeating poll-like tools with identical outputs.
253+
- `pingPong`: alternating `A/B/A/B` no-progress patterns.
254+
- Per-agent override: `agents.list[].tools.loopDetection`.
255+
227256
### `web_search`
228257

229258
Search the web using Brave Search API.

docs/tools/loop-detection.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
title: "Tool-loop detection"
3+
description: "Configure optional guardrails for preventing repetitive or stalled tool-call loops"
4+
read_when:
5+
- A user reports agents getting stuck repeating tool calls
6+
- You need to tune repetitive-call protection
7+
- You are editing agent tool/runtime policies
8+
---
9+
10+
# Tool-loop detection
11+
12+
OpenClaw can keep agents from getting stuck in repeated tool-call patterns.
13+
The guard is **disabled by default**.
14+
15+
Enable it only where needed, because it can block legitimate repeated calls with strict settings.
16+
17+
## Why this exists
18+
19+
- Detect repetitive sequences that do not make progress.
20+
- Detect high-frequency no-result loops (same tool, same inputs, repeated errors).
21+
- Detect specific repeated-call patterns for known polling tools.
22+
23+
## Configuration block
24+
25+
Global defaults:
26+
27+
```json5
28+
{
29+
tools: {
30+
loopDetection: {
31+
enabled: false,
32+
historySize: 20,
33+
detectorCooldownMs: 12000,
34+
repeatThreshold: 3,
35+
criticalThreshold: 6,
36+
detectors: {
37+
repeatedFailure: true,
38+
knownPollLoop: true,
39+
repeatingNoProgress: true,
40+
},
41+
},
42+
},
43+
}
44+
```
45+
46+
Per-agent override (optional):
47+
48+
```json5
49+
{
50+
agents: {
51+
list: [
52+
{
53+
id: "safe-runner",
54+
tools: {
55+
loopDetection: {
56+
enabled: true,
57+
repeatThreshold: 2,
58+
criticalThreshold: 5,
59+
},
60+
},
61+
},
62+
],
63+
},
64+
}
65+
```
66+
67+
### Field behavior
68+
69+
- `enabled`: Master switch. `false` means no loop detection is performed.
70+
- `historySize`: number of recent tool calls kept for analysis.
71+
- `detectorCooldownMs`: time window used by the no-progress detector.
72+
- `repeatThreshold`: minimum repeats before warning/blocking starts.
73+
- `criticalThreshold`: stronger threshold that can trigger stricter handling.
74+
- `detectors.repeatedFailure`: detects repeated failed attempts on the same call path.
75+
- `detectors.knownPollLoop`: detects known polling-like loops.
76+
- `detectors.repeatingNoProgress`: detects high-frequency repeated calls without state change.
77+
78+
## Recommended setup
79+
80+
- Start with `enabled: true`, defaults unchanged.
81+
- If false positives occur:
82+
- raise `repeatThreshold` and/or `criticalThreshold`
83+
- disable only the detector causing issues
84+
- reduce `historySize` for less strict historical context
85+
86+
## Logs and expected behavior
87+
88+
When a loop is detected, OpenClaw reports a loop event and blocks or dampens the next tool-cycle depending on severity.
89+
This protects users from runaway token spend and lockups while preserving normal tool access.
90+
91+
- Prefer warning and temporary suppression first.
92+
- Escalate only when repeated evidence accumulates.
93+
94+
## Notes
95+
96+
- `tools.loopDetection` is merged with agent-level overrides.
97+
- Per-agent config fully overrides or extends global values.
98+
- If no config exists, guardrails stay off.

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
resolveCompactionReserveTokensFloor,
5050
} from "../../pi-settings.js";
5151
import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
52-
import { createOpenClawCodingTools } from "../../pi-tools.js";
52+
import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
5353
import { resolveSandboxContext } from "../../sandbox.js";
5454
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
5555
import { repairSessionFileIfNeeded } from "../../session-file-repair.js";
@@ -544,6 +544,10 @@ export async function runEmbeddedAttempt(
544544

545545
// Add client tools (OpenResponses hosted tools) to customTools
546546
let clientToolCallDetected: { name: string; params: Record<string, unknown> } | null = null;
547+
const clientToolLoopDetection = resolveToolLoopDetectionConfig({
548+
cfg: params.config,
549+
agentId: sessionAgentId,
550+
});
547551
const clientToolDefs = params.clientTools
548552
? toClientToolDefinitions(
549553
params.clientTools,
@@ -553,6 +557,7 @@ export async function runEmbeddedAttempt(
553557
{
554558
agentId: sessionAgentId,
555559
sessionKey: params.sessionKey,
560+
loopDetection: clientToolLoopDetection,
556561
},
557562
)
558563
: [];

src/agents/pi-tool-definition-adapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
} from "@mariozechner/pi-agent-core";
66
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
77
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
8+
import type { HookContext } from "./pi-tools.before-tool-call.js";
89
import { logDebug, logError } from "../logger.js";
910
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
1011
import { isPlainObject } from "../utils.js";
@@ -190,7 +191,7 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
190191
export function toClientToolDefinitions(
191192
tools: ClientToolDefinition[],
192193
onClientToolCall?: (toolName: string, params: Record<string, unknown>) => void,
193-
hookContext?: { agentId?: string; sessionKey?: string },
194+
hookContext?: HookContext,
194195
): ToolDefinition[] {
195196
return tools.map((tool) => {
196197
const func = tool.function;

src/agents/pi-tools.before-tool-call.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,17 @@ describe("before_tool_call loop detection behavior", () => {
1919
hasHooks: ReturnType<typeof vi.fn>;
2020
runBeforeToolCall: ReturnType<typeof vi.fn>;
2121
};
22-
const defaultToolContext = { agentId: "main", sessionKey: "main" };
22+
const enabledLoopDetectionContext = {
23+
agentId: "main",
24+
sessionKey: "main",
25+
loopDetection: { enabled: true },
26+
};
27+
28+
const disabledLoopDetectionContext = {
29+
agentId: "main",
30+
sessionKey: "main",
31+
loopDetection: { enabled: false },
32+
};
2333

2434
beforeEach(() => {
2535
resetDiagnosticSessionStateForTest();
@@ -33,10 +43,14 @@ describe("before_tool_call loop detection behavior", () => {
3343
hookRunner.hasHooks.mockReturnValue(false);
3444
});
3545

36-
function createWrappedTool(name: string, execute: ReturnType<typeof vi.fn>) {
46+
function createWrappedTool(
47+
name: string,
48+
execute: ReturnType<typeof vi.fn>,
49+
loopDetectionContext = enabledLoopDetectionContext,
50+
) {
3751
return wrapToolWithBeforeToolCallHook(
3852
{ name, execute } as unknown as AnyAgentTool,
39-
defaultToolContext,
53+
loopDetectionContext,
4054
);
4155
}
4256

@@ -95,7 +109,6 @@ describe("before_tool_call loop detection behavior", () => {
95109
}
96110
}
97111
}
98-
99112
it("blocks known poll loops when no progress repeats", async () => {
100113
const execute = vi.fn().mockResolvedValue({
101114
content: [{ type: "text", text: "(no new output)\n\nProcess still running." }],
@@ -113,6 +126,22 @@ describe("before_tool_call loop detection behavior", () => {
113126
).rejects.toThrow("CRITICAL");
114127
});
115128

129+
it("does nothing when loopDetection.enabled is false", async () => {
130+
const execute = vi.fn().mockResolvedValue({
131+
content: [{ type: "text", text: "(no new output)\n\nProcess still running." }],
132+
details: { status: "running", aggregated: "steady" },
133+
});
134+
// oxlint-disable-next-line typescript/no-explicit-any
135+
const tool = wrapToolWithBeforeToolCallHook({ name: "process", execute } as any, {
136+
...disabledLoopDetectionContext,
137+
});
138+
const params = { action: "poll", sessionId: "sess-off" };
139+
140+
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
141+
await expect(tool.execute(`poll-${i}`, params, undefined, undefined)).resolves.toBeDefined();
142+
}
143+
});
144+
116145
it("does not block known poll loops when output progresses", async () => {
117146
const execute = vi.fn().mockImplementation(async (toolCallId: string) => {
118147
return {

src/agents/pi-tools.before-tool-call.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
12
import type { SessionState } from "../logging/diagnostic-session-state.js";
23
import type { AnyAgentTool } from "./tools/common.js";
34
import { createSubsystemLogger } from "../logging/subsystem.js";
45
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
56
import { isPlainObject } from "../utils.js";
67
import { normalizeToolName } from "./tool-policy.js";
78

8-
type HookContext = {
9+
export type HookContext = {
910
agentId?: string;
1011
sessionKey?: string;
12+
loopDetection?: ToolLoopDetectionConfig;
1113
};
1214

1315
type HookOutcome = { blocked: true; reason: string } | { blocked: false; params: unknown };
@@ -62,6 +64,7 @@ async function recordLoopOutcome(args: {
6264
toolCallId: args.toolCallId,
6365
result: args.result,
6466
error: args.error,
67+
config: args.ctx.loopDetection,
6568
});
6669
} catch (err) {
6770
log.warn(`tool loop outcome tracking failed: tool=${args.toolName} error=${String(err)}`);
@@ -87,7 +90,7 @@ export async function runBeforeToolCallHook(args: {
8790
sessionId: args.ctx?.agentId,
8891
});
8992

90-
const loopResult = detectToolCallLoop(sessionState, toolName, params);
93+
const loopResult = detectToolCallLoop(sessionState, toolName, params, args.ctx.loopDetection);
9194

9295
if (loopResult.stuck) {
9396
if (loopResult.level === "critical") {
@@ -126,7 +129,7 @@ export async function runBeforeToolCallHook(args: {
126129
}
127130
}
128131

129-
recordToolCall(sessionState, toolName, params, args.toolCallId);
132+
recordToolCall(sessionState, toolName, params, args.toolCallId, args.ctx.loopDetection);
130133
}
131134

132135
const hookRunner = getGlobalHookRunner();

src/agents/pi-tools.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
readTool,
77
} from "@mariozechner/pi-coding-agent";
88
import type { OpenClawConfig } from "../config/config.js";
9+
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
910
import type { ModelAuthMode } from "./model-auth.js";
1011
import type { AnyAgentTool } from "./pi-tools.types.js";
1112
import type { SandboxContext } from "./sandbox.js";
@@ -124,6 +125,33 @@ function resolveFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
124125
};
125126
}
126127

128+
export function resolveToolLoopDetectionConfig(params: {
129+
cfg?: OpenClawConfig;
130+
agentId?: string;
131+
}): ToolLoopDetectionConfig | undefined {
132+
const global = params.cfg?.tools?.loopDetection;
133+
const agent =
134+
params.agentId && params.cfg
135+
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.loopDetection
136+
: undefined;
137+
138+
if (!agent) {
139+
return global;
140+
}
141+
if (!global) {
142+
return agent;
143+
}
144+
145+
return {
146+
...global,
147+
...agent,
148+
detectors: {
149+
...global.detectors,
150+
...agent.detectors,
151+
},
152+
};
153+
}
154+
127155
export const __testing = {
128156
cleanToolSchemaForGemini,
129157
normalizeToolParams,
@@ -451,6 +479,7 @@ export function createOpenClawCodingTools(options?: {
451479
wrapToolWithBeforeToolCallHook(tool, {
452480
agentId,
453481
sessionKey: options?.sessionKey,
482+
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
454483
}),
455484
);
456485
const withAbort = options?.abortSignal

0 commit comments

Comments
 (0)