feat(ui): enable tool-loop detection from Labs (#115983)

* feat(ui): add tool loop detection lab

* docs(tools): mention loop detection lab
This commit is contained in:
Peter Steinberger
2026-07-29 13:21:52 -04:00
committed by GitHub
parent e20a0039b3
commit d7d3fc5dad
4 changed files with 72 additions and 2 deletions
+2
View File
@@ -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 |
+5
View File
@@ -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:
+50 -2
View File
@@ -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",
});
});
});
+15
View File
@@ -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"),