From 5dfaed184608e0c8a069e35f1c14fb3e222a5e55 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 01:12:32 +0100 Subject: [PATCH] refactor(agents): use loop detection switch for post-compaction guard --- CHANGELOG.md | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/tools/loop-detection.md | 3 +- .../post-compaction-loop-guard.test.ts | 4 +- .../post-compaction-loop-guard.ts | 3 +- .../run.compaction-loop-guard.test.ts | 52 ++++++++++++++++++- src/agents/pi-embedded-runner/run.ts | 1 + src/config/schema.base.generated.ts | 14 ----- src/config/schema.help.ts | 2 - src/config/schema.labels.ts | 1 - src/config/types.tools.ts | 2 - src/config/zod-schema.agent-runtime.ts | 1 - .../zod-schema.post-compaction-guard.test.ts | 4 +- 13 files changed, 60 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f871258a08..e3a2d32cdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ Docs: https://docs.openclaw.ai - Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as ``. Fixes #69793. Thanks @aspalagin. - Agents/cache: keep per-turn runtime context out of ordinary chat system prompts while still delivering hidden current-turn context, restoring prompt-cache reuse on chat continuations. Fixes #77431. Thanks @Udjin79. - Gateway/startup: include resolved thinking and fast-mode defaults in the `agent model` startup log line, defaulting unset startup thinking to `medium` without mixing in reasoning visibility. -- Agents/Tools: add post-compaction loop guard in `pi-embedded-runner` that arms after auto-compaction-retry and aborts the run with `compaction_loop_persisted` when the agent emits the same `(tool, args, result)` triple `windowSize` times (default 3) within that window. Configurable via `tools.loopDetection.postCompactionGuard.{enabled,windowSize}`. Targets the failure mode where context-overflow + compaction does not break a tool-call loop. Refs #77474; carries forward #21597. Thanks @efpiva. +- Agents/Tools: add post-compaction loop guard in `pi-embedded-runner` that arms after auto-compaction-retry and aborts the run with `compaction_loop_persisted` when the agent emits the same `(tool, args, result)` triple `windowSize` times (default 3) within that window. Disable via existing `tools.loopDetection.enabled`; tune via `tools.loopDetection.postCompactionGuard.windowSize`. Targets the failure mode where context-overflow + compaction does not break a tool-call loop. Refs #77474; carries forward #21597. Thanks @efpiva. - Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. - Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible. - Plugins/runtime-deps: include `json5` in the memory-core plugin runtime dependency set so packaged `memory_search` sandboxes can resolve generated OpenClaw runtime chunks that parse JSON5 config. Fixes #77461. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 0bba7ad65a5..0553f2a3cd0 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -a963f7242f75fbc137a7891b8056c199b42eeb8f4887aeaaa079bff6394feb89 config-baseline.json -51f525cfffa40659d6273e56705e91f3cb1255b217dadab59c32f77e0f770b37 config-baseline.core.json +21a7c6f83f21d2de3b580fbcc5e9e507bc43b97714a338627caff089cb8ff29d config-baseline.json +06f12009f24c2120fb572c808a0b7858d1c52c2ef2386e0fa6244739b2e67680 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/docs/tools/loop-detection.md b/docs/tools/loop-detection.md index c50c49c999c..4503d2c8ff7 100644 --- a/docs/tools/loop-detection.md +++ b/docs/tools/loop-detection.md @@ -96,8 +96,8 @@ This is a separate code path from the global `tools.loopDetection` detectors. It { tools: { loopDetection: { + enabled: true, // existing master switch; set false to disable loop guards postCompactionGuard: { - enabled: true, // default: true windowSize: 3, // default: 3 }, }, @@ -105,7 +105,6 @@ This is a separate code path from the global `tools.loopDetection` detectors. It } ``` -- `enabled`: master switch for the guard. - `windowSize`: number of post-compaction tool calls during which the guard stays armed _and_ the count of identical (tool, args, result) triples that triggers an abort. The guard never aborts when results are changing, only when results are byte-identical across the window. It is intentionally narrow: it fires only in the immediate aftermath of a compaction-retry. diff --git a/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts b/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts index f5e48b717c6..c11136e2868 100644 --- a/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts +++ b/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts @@ -92,8 +92,8 @@ describe("createPostCompactionLoopGuard", () => { expect(guard.snapshot().remainingAttempts).toBe(2); }); - it("respects enabled: false (always returns shouldAbort: false even when armed)", () => { - const guard = createPostCompactionLoopGuard({ enabled: false, windowSize: 3 }); + it("respects the parent loop detection disabled state", () => { + const guard = createPostCompactionLoopGuard({ windowSize: 3 }, { enabled: false }); guard.armPostCompaction(); guard.observe(callOutcome("gateway", { x: 1 }, "r1")); guard.observe(callOutcome("gateway", { x: 1 }, "r1")); diff --git a/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts b/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts index b5f7f52ae2f..b6bb8237cfa 100644 --- a/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts +++ b/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts @@ -45,9 +45,10 @@ function asPositiveInt(value: number | undefined, fallback: number): number { export function createPostCompactionLoopGuard( config?: ToolLoopPostCompactionGuardConfig, + options?: { enabled?: boolean }, ): PostCompactionLoopGuard { const state: GuardState = { - enabled: config?.enabled ?? true, + enabled: options?.enabled ?? true, windowSize: asPositiveInt(config?.windowSize, DEFAULT_WINDOW_SIZE), remainingAttempts: 0, history: [], diff --git a/src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts b/src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts index 83b9780416a..59e3e245298 100644 --- a/src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts +++ b/src/agents/pi-embedded-runner/run.compaction-loop-guard.test.ts @@ -274,7 +274,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { config: { tools: { loopDetection: { - postCompactionGuard: { enabled: true, windowSize: 2 }, + postCompactionGuard: { windowSize: 2 }, }, }, } as never, @@ -322,7 +322,7 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { config: { tools: { loopDetection: { - postCompactionGuard: { enabled: true, windowSize: 2 }, + postCompactionGuard: { windowSize: 2 }, }, }, agents: { @@ -345,6 +345,54 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => { expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); }); + it("does not arm the post-compaction guard when loop detection is disabled", async () => { + const overflowError = makeOverflowError(); + + mockedRunEmbeddedAttempt.mockImplementationOnce(async () => + makeAttemptResult({ promptError: overflowError }), + ); + mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams: unknown) => { + const onToolOutcome = (attemptParams as { onToolOutcome?: ToolOutcomeObserver }) + .onToolOutcome; + for (let i = 0; i < 3; i += 1) { + await executeWrappedToolOutcome( + "gateway", + { action: "lookup", path: "x" }, + "identical-result", + onToolOutcome, + ); + } + return makeAttemptResult({ + promptError: null, + toolMetas: [{ toolName: "gateway" }, { toolName: "gateway" }, { toolName: "gateway" }], + }); + }); + + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "Compacted session", + firstKeptEntryId: "entry-5", + tokensBefore: 150000, + }), + ); + + const result = await runEmbeddedPiAgent({ + ...baseParams, + config: { + tools: { + loopDetection: { + enabled: false, + postCompactionGuard: { windowSize: 2 }, + }, + }, + } as never, + }); + + expect(result.meta.error).toBeUndefined(); + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + }); + it("aborts post-compaction loop from the live tool path even when toolCallHistory is at its trim cap", async () => { // Long-running sessions accumulate up to historySize (default 30) records // in toolCallHistory. The live observer must still see the new outcome diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index f3e99919081..81219bd8ee2 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -797,6 +797,7 @@ export async function runEmbeddedPiAgent( }); const postCompactionGuard = createPostCompactionLoopGuard( resolvedLoopDetectionConfig?.postCompactionGuard, + { enabled: resolvedLoopDetectionConfig?.enabled !== false }, ); const observePostCompactionToolOutcome = ( observation: PostCompactionGuardObservation, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 7bffb562b85..8b1e238f020 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -8334,9 +8334,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { postCompactionGuard: { type: "object", properties: { - enabled: { - type: "boolean", - }, windowSize: { type: "integer", exclusiveMinimum: 0, @@ -18272,12 +18269,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { postCompactionGuard: { type: "object", properties: { - enabled: { - type: "boolean", - title: "Post-compaction Loop Guard", - description: - "Enable the post-compaction loop guard that aborts the run when the agent repeats the same (tool, args, result) triple windowSize times immediately after auto-compaction-retry (default: true).", - }, windowSize: { type: "integer", exclusiveMinimum: 0, @@ -25649,11 +25640,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Global no-progress breaker threshold (default: 30).", tags: ["reliability", "tools"], }, - "tools.loopDetection.postCompactionGuard.enabled": { - label: "Post-compaction Loop Guard", - help: "Enable the post-compaction loop guard that aborts the run when the agent repeats the same (tool, args, result) triple windowSize times immediately after auto-compaction-retry (default: true).", - tags: ["tools"], - }, "tools.loopDetection.postCompactionGuard.windowSize": { label: "Post-compaction Loop Guard Window Size", help: "Number of post-compaction attempts during which the guard stays armed (default: 3). Lower values are stricter; higher values give the agent more attempts before abort.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index c5d34294f8a..006051ea634 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -658,8 +658,6 @@ export const FIELD_HELP: Record = { "tools.loopDetection.detectors.knownPollNoProgress": "Enable known poll tool no-progress loop detection (default: true).", "tools.loopDetection.detectors.pingPong": "Enable ping-pong loop detection (default: true).", - "tools.loopDetection.postCompactionGuard.enabled": - "Enable the post-compaction loop guard that aborts the run when the agent repeats the same (tool, args, result) triple windowSize times immediately after auto-compaction-retry (default: true).", "tools.loopDetection.postCompactionGuard.windowSize": "Number of post-compaction attempts during which the guard stays armed (default: 3). Lower values are stricter; higher values give the agent more attempts before abort.", "tools.exec.notifyOnExit": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index f73243b4f3f..eb683423c90 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -215,7 +215,6 @@ export const FIELD_LABELS: Record = { "tools.loopDetection.unknownToolThreshold": "Unknown-tool Loop Threshold", "tools.loopDetection.criticalThreshold": "Tool-loop Critical Threshold", "tools.loopDetection.globalCircuitBreakerThreshold": "Tool-loop Global Circuit Breaker Threshold", - "tools.loopDetection.postCompactionGuard.enabled": "Post-compaction Loop Guard", "tools.loopDetection.postCompactionGuard.windowSize": "Post-compaction Loop Guard Window Size", "tools.loopDetection.detectors.genericRepeat": "Tool-loop Generic Repeat Detection", "tools.loopDetection.detectors.knownPollNoProgress": "Tool-loop Poll No-Progress Detection", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 1e1a96ccacb..bc0b1c85385 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -167,8 +167,6 @@ export type ToolLoopDetectionDetectorConfig = { }; export type ToolLoopPostCompactionGuardConfig = { - /** Enable a strict guard that aborts when the agent re-enters the same tool-call loop immediately after a successful auto-compaction-retry (default: true). */ - enabled?: boolean; /** How many attempts post-compaction the guard remains armed (default: 3). */ windowSize?: number; }; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 8bbdef76dab..16527d064d5 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -504,7 +504,6 @@ const ToolLoopDetectionDetectorSchema = z const ToolLoopPostCompactionGuardSchema = z .object({ - enabled: z.boolean().optional(), windowSize: z.number().int().positive().optional(), }) .strict() diff --git a/src/config/zod-schema.post-compaction-guard.test.ts b/src/config/zod-schema.post-compaction-guard.test.ts index 93b047287bc..9ad73816fb0 100644 --- a/src/config/zod-schema.post-compaction-guard.test.ts +++ b/src/config/zod-schema.post-compaction-guard.test.ts @@ -9,7 +9,6 @@ describe("OpenClawSchema tools.loopDetection.postCompactionGuard validation", () loopDetection: { enabled: true, postCompactionGuard: { - enabled: false, windowSize: 5, }, }, @@ -34,7 +33,6 @@ describe("OpenClawSchema tools.loopDetection.postCompactionGuard validation", () tools: { loopDetection: { postCompactionGuard: { - enabled: true, windowSize: 3, bogus: "key", }, @@ -73,7 +71,7 @@ describe("OpenClawSchema tools.loopDetection.postCompactionGuard validation", () it("validates via ToolsSchema directly", () => { const result = ToolsSchema.safeParse({ loopDetection: { - postCompactionGuard: { enabled: true, windowSize: 4 }, + postCompactionGuard: { windowSize: 4 }, }, }); expect(result.success).toBe(true);