diff --git a/docs/tools/loop-detection.md b/docs/tools/loop-detection.md index 84650c78b7a4..765ead224f12 100644 --- a/docs/tools/loop-detection.md +++ b/docs/tools/loop-detection.md @@ -64,6 +64,8 @@ Per-agent override (optional, at `agents.entries.*.tools.loopDetection`): The per-agent setting overrides the global setting. +You can also enable the global rolling-history detectors in **Settings -> Labs** in the Control UI. + ### Field behavior | Field | Default | Effect | diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 184380621f9c..af2b68c23336 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2695,6 +2695,11 @@ export const en: TranslationMap = { description: "Keep a bounded tool directory visible and defer the rest behind search, so large MCP and plugin catalogs stop crowding the prompt.", }, + loopDetection: { + title: "Tool-loop detection", + description: + "Enable rolling-history guards that warn or block repeated tool calls when an agent stops making progress.", + }, localModelLean: { title: "Lean tools for local models", description: diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index 44d6d41b70f3..a4c60196f73a 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -159,8 +159,15 @@ describe("LabsPage", () => { note: "labs: update toolSearch", }, { - label: "Lean tools for local models", + label: "Tool-loop detection", index: 3, + sourceConfig: { tools: { loopDetection: { enabled: false } } }, + expectedPatch: { tools: { loopDetection: { enabled: true } } }, + note: "labs: update loopDetection", + }, + { + label: "Lean tools for local models", + index: 4, sourceConfig: {}, expectedPatch: { agents: { defaults: { experimental: { localModelLean: true } } } }, note: "labs: update localModelLean", @@ -169,7 +176,7 @@ describe("LabsPage", () => { // Not a boolean gate: the on state is the conservative `direct` mode, so // enabling here cannot start recording group or unknown conversations. label: "Message audit metadata", - index: 4, + index: 5, sourceConfig: { logging: { audit: { messages: "off" } } }, expectedPatch: { logging: { audit: { messages: "direct" } } }, note: "labs: update auditMessages", @@ -324,3 +331,44 @@ describe("LabsPage tool search enablement", () => { }); }); }); + +describe("LabsPage tool loop detection enablement", () => { + const loopDetectionIndex = LAB_FEATURES.findIndex((feature) => feature.id === "loopDetection"); + + // Mirrors resolveToolLoopDetectionConfig and the detector default: only an + // explicit true enables the rolling-history detectors. + it.each([ + { label: "unset", config: {}, expected: false }, + { + label: "explicit enabled", + config: { tools: { loopDetection: { enabled: true } } }, + expected: true, + }, + { + label: "explicit disabled", + config: { tools: { loopDetection: { enabled: false } } }, + expected: false, + }, + ])("reads $label as $expected", async ({ config, expected }) => { + const { page, provider } = await mountPage(config); + + expect(labToggle(page, loopDetectionIndex, "Tool-loop detection").checked).toBe(expected); + provider.remove(); + }); + + it("patches only enabled so sibling settings remain untouched", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { loopDetection: { enabled: false, warningThreshold: 12 } }, + }); + const toggle = labToggle(page, loopDetectionIndex, "Tool-loop detection"); + + toggle.checked = true; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { loopDetection: { enabled: true } } }, + note: "labs: update loopDetection", + }); + }); +}); diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index d202b4f855bc..cd3acccb0e30 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -111,6 +111,21 @@ export const LAB_FEATURES = [ enableAlso: { mode: "directory" }, restartHint: null, }, + { + id: "loopDetection", + title: () => t("labsPage.loopDetection.title"), + description: () => t("labsPage.loopDetection.description"), + docsUrl: "https://docs.openclaw.ai/tools/loop-detection", + configPath: ["tools", "loopDetection", "enabled"], + onValue: true, + offValue: false, + activeValues: [true], + // ToolLoopDetectionSchema accepts object form only, and + // resolveToolLoopDetectionConfig reads this enabled leaf directly. + readEnabled: null, + enableAlso: null, + restartHint: null, + }, { id: "localModelLean", title: () => t("labsPage.localModelLean.title"),