refactor(agents): use loop detection switch for post-compaction guard

This commit is contained in:
Peter Steinberger
2026-05-05 01:12:32 +01:00
parent dbb2299e38
commit 5dfaed1846
13 changed files with 60 additions and 33 deletions

View File

@@ -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 `<media:image>`. 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.

View File

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

View File

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

View File

@@ -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"));

View File

@@ -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: [],

View File

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

View File

@@ -797,6 +797,7 @@ export async function runEmbeddedPiAgent(
});
const postCompactionGuard = createPostCompactionLoopGuard(
resolvedLoopDetectionConfig?.postCompactionGuard,
{ enabled: resolvedLoopDetectionConfig?.enabled !== false },
);
const observePostCompactionToolOutcome = (
observation: PostCompactionGuardObservation,

View File

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

View File

@@ -658,8 +658,6 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -215,7 +215,6 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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;
};

View File

@@ -504,7 +504,6 @@ const ToolLoopDetectionDetectorSchema = z
const ToolLoopPostCompactionGuardSchema = z
.object({
enabled: z.boolean().optional(),
windowSize: z.number().int().positive().optional(),
})
.strict()

View File

@@ -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);