From 612964e0637aba4409d8dcdfa5b81200117990d8 Mon Sep 17 00:00:00 2001 From: MemOS AutoDev Date: Wed, 16 Sep 2026 11:24:04 +0800 Subject: [PATCH 1/2] fix(l3): pre-filter '_|_' untagged clusters from world-model abstraction Untagged clusters (cluster.key === "_|_") share only "no matching domain regex" and have no organising principle. abstractDraft consistently emits empty titles for them, tripping the schema validator and producing 100% llm_failed. Field data (issue #2374) shows 785/785 _|_-cluster abstractions failing, contributing 47% of 1686 total L3 failures over 26.2h with no cooldown. Add a per-cluster guard in runL3 that skips the "_|_" bucket before consulting cooldown or calling the LLM, records an AbstractionResult with the new "untagged_cluster" skip reason, and emits an info-level "untagged.skipped" log. domainKeyOf / clusterPolicies / abstractDraft are unchanged so bucket-count metrics and tagged-cluster behaviour stay intact. Refs: #2374 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/memos-local-plugin/core/memory/l3/l3.ts | 27 +++ .../core/memory/l3/types.ts | 1 + .../unit/memory/l3/l3.integration.test.ts | 175 ++++++++++++++++++ 3 files changed, 203 insertions(+) diff --git a/apps/memos-local-plugin/core/memory/l3/l3.ts b/apps/memos-local-plugin/core/memory/l3/l3.ts index 3a786b08d..1178fad3e 100644 --- a/apps/memos-local-plugin/core/memory/l3/l3.ts +++ b/apps/memos-local-plugin/core/memory/l3/l3.ts @@ -63,6 +63,15 @@ export interface RunL3Deps { const KV_COOLDOWN_PREFIX = "l3.lastRun."; +/** + * Catch-all cluster key emitted by `cluster.ts::domainKeyOf` when a + * policy matches none of the TAG / TOOL regexes. Clusters with this key + * have no shared organising principle; L3 pre-filters them before + * calling the abstractor to avoid the reliably-empty-title failure mode + * documented in issue #2374. + */ +const UNTAGGED_CLUSTER_KEY = "_|_"; + // ─── Public entry ────────────────────────────────────────────────────────── export async function runL3( @@ -142,6 +151,24 @@ export async function runL3( continue; } + // Untagged clusters — no TAG_REGEX or TOOL_REGEX matched any member, + // so `domainKeyOf` returned the catch-all `"_|_"` bucket. These have + // no shared organising principle; the abstractor's `DOMAIN_TAGS: -` + // prompt reliably yields empty titles and 100% `llm_failed` in the + // field (issue #2374). Pre-filter before we consult cooldown or spend + // an LLM round-trip. + if (cluster.key === UNTAGGED_CLUSTER_KEY) { + abstractLog.info("untagged.skipped", { + clusterPolicyCount: cluster.policies.length, + }); + abstractions.push( + skipped(cluster, "untagged_cluster", { + policyIds: cluster.policies.map((p) => p.id), + }), + ); + continue; + } + if (isInCooldown(cluster, repos.kv, config.cooldownDays, now)) { abstractLog.info("cooldown.skipped", { clusterKey: cluster.key, diff --git a/apps/memos-local-plugin/core/memory/l3/types.ts b/apps/memos-local-plugin/core/memory/l3/types.ts index 69d0751a8..485d89299 100644 --- a/apps/memos-local-plugin/core/memory/l3/types.ts +++ b/apps/memos-local-plugin/core/memory/l3/types.ts @@ -153,6 +153,7 @@ export interface AbstractionResult { | "draft_invalid" | "cooldown" | "no_centroid" + | "untagged_cluster" | "duplicate_of"; /** When `skippedReason === "duplicate_of"`, the existing WM id. */ duplicateOfWorldId?: WorldModelId | null; diff --git a/apps/memos-local-plugin/tests/unit/memory/l3/l3.integration.test.ts b/apps/memos-local-plugin/tests/unit/memory/l3/l3.integration.test.ts index 51ba5d4b8..09432c7a0 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l3/l3.integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l3/l3.integration.test.ts @@ -239,6 +239,181 @@ describe("memory/l3/integration", () => { expect(handle.repos.worldModel.list().length).toBe(0); }); + it("skips the '_|_' untagged cluster before calling the LLM (issue #2374)", async () => { + // Seed three policies whose text matches none of the domain regexes, + // so they all land in the "_|_" bucket. Historically this cluster + // was passed to `abstractDraft` and the LLM's empty `title` tripped + // the validator, producing 100% `llm_failed`. The fix pre-filters + // `_|_` clusters in `runL3` so no LLM call happens. + seedPolicy(handle, { + id: "po_u1" as PolicyId, + title: "abstract planning heuristic", + trigger: "when tasks become complex", + procedure: "decompose into subgoals then evaluate", + verification: "outcome satisfies goal", + boundary: "general reasoning tasks", + sourceEpisodeIds: ["ep_u1" as EpisodeId], + vec: vec([1, 0, 0]), + }); + seedPolicy(handle, { + id: "po_u2" as PolicyId, + title: "reflect on past decisions", + trigger: "at episode boundary", + procedure: "summarise and store lessons", + verification: "reflection recorded", + boundary: "reflection stage", + sourceEpisodeIds: ["ep_u2" as EpisodeId], + vec: vec([0.95, 0.05, 0]), + }); + seedPolicy(handle, { + id: "po_u3" as PolicyId, + title: "prioritise information intake", + trigger: "before starting a session", + procedure: "review recent context and open questions", + verification: "context reviewed", + boundary: "session prelude", + sourceEpisodeIds: ["ep_u3" as EpisodeId], + vec: vec([0.9, 0.1, 0]), + }); + + const bus = createL3EventBus(); + const events: L3Event[] = []; + bus.onAny((e) => events.push(e)); + + let llmCalls = 0; + const llm = fakeLlm({ + completeJson: { + [OP]: () => { + llmCalls += 1; + // If this ever fires, `runL3` failed to pre-filter the bucket. + return { + title: "should not be called", + domain_tags: [], + environment: [], + inference: [], + constraints: [], + body: "", + confidence: 0.5, + supersedes_world_ids: [], + }; + }, + }, + }); + + const result = await runL3( + { trigger: "l2.policy.induced" }, + { + repos: { + policies: handle.repos.policies, + traces: handle.repos.traces, + worldModel: handle.repos.worldModel, + kv: handle.repos.kv, + }, + llm, + log, + bus, + config: cfg(), + }, + ); + + expect(result.abstractions.length).toBe(1); + expect(result.abstractions[0]!.clusterKey).toBe("_|_"); + expect(result.abstractions[0]!.skippedReason).toBe("untagged_cluster"); + expect(result.abstractions[0]!.worldModelId).toBeNull(); + expect(result.abstractions[0]!.policyCount).toBe(3); + + // No LLM call — the pre-filter runs before `abstractDraft`. + expect(llmCalls).toBe(0); + + // No WM row was created. + expect(handle.repos.worldModel.list().length).toBe(0); + + // No `l3.failed` event — the skip is not a failure. + expect(events.some((e) => e.kind === "l3.failed")).toBe(false); + }); + + it("still processes tagged clusters when mixed with an untagged bucket (issue #2374)", async () => { + // Tagged docker+alpine+pip triplet — should still produce a WM. + seedTriplet(); + + // Additional three untagged policies that would otherwise form a + // second (broken) cluster. Only the tagged one should survive. + seedPolicy(handle, { + id: "po_u1" as PolicyId, + title: "abstract planning heuristic", + trigger: "when tasks become complex", + procedure: "decompose into subgoals then evaluate", + verification: "outcome satisfies goal", + boundary: "general reasoning tasks", + sourceEpisodeIds: ["ep_u1" as EpisodeId], + vec: vec([1, 0, 0]), + }); + seedPolicy(handle, { + id: "po_u2" as PolicyId, + title: "reflect on past decisions", + trigger: "at episode boundary", + procedure: "summarise and store lessons", + verification: "reflection recorded", + boundary: "reflection stage", + sourceEpisodeIds: ["ep_u2" as EpisodeId], + vec: vec([0.95, 0.05, 0]), + }); + seedPolicy(handle, { + id: "po_u3" as PolicyId, + title: "prioritise information intake", + trigger: "before starting a session", + procedure: "review recent context and open questions", + verification: "context reviewed", + boundary: "session prelude", + sourceEpisodeIds: ["ep_u3" as EpisodeId], + vec: vec([0.9, 0.1, 0]), + }); + + const llm = fakeLlm({ + completeJson: { + [OP]: { + title: "Alpine python dependency model", + domain_tags: ["docker", "alpine", "pip"], + environment: [{ label: "musl", description: "no glibc" }], + inference: [{ label: "wheels fail", description: "must build from source" }], + constraints: [{ label: "no prebuilt", description: "avoid binary" }], + body: "# summary", + confidence: 0.75, + supersedes_world_ids: [], + }, + }, + }); + + const result = await runL3( + { trigger: "l2.policy.induced" }, + { + repos: { + policies: handle.repos.policies, + traces: handle.repos.traces, + worldModel: handle.repos.worldModel, + kv: handle.repos.kv, + }, + llm, + log, + config: cfg(), + }, + ); + + // Two clusters seen: one untagged (skipped), one tagged (created). + const byKey = new Map(result.abstractions.map((a) => [a.clusterKey, a])); + expect(byKey.get("_|_")?.skippedReason).toBe("untagged_cluster"); + const taggedEntry = Array.from(byKey.values()).find( + (a) => a.clusterKey !== "_|_", + ); + expect(taggedEntry?.skippedReason).toBeNull(); + expect(taggedEntry?.createdNew).toBe(true); + + // Only the tagged WM was inserted. + const rows = handle.repos.worldModel.list(); + expect(rows.length).toBe(1); + expect(rows[0]!.title).toBe("Alpine python dependency model"); + }); + it("adjustConfidence clamps in [0,1] and emits an event", async () => { const wm = seedWorldModel(handle, { id: "wm_adj" as WorldModelId, confidence: 0.9 }); const bus = createL3EventBus(); From 2a6b22e2c1b9cd7669e91da0d205fe9d6f2d4102 Mon Sep 17 00:00:00 2001 From: autodev Date: Wed, 16 Sep 2026 11:33:52 +0800 Subject: [PATCH 2/2] fix(l3): add clusterKey to untagged.skipped log for consistency The 'untagged.skipped' log entry was missing clusterKey, unlike the sibling 'cooldown.skipped' log which includes it. Add clusterKey so skip logs share a consistent shape, making cluster-level correlation during debugging/metrics uniform across skip reasons. Addresses code review feedback on #2375. --- apps/memos-local-plugin/core/memory/l3/l3.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/memos-local-plugin/core/memory/l3/l3.ts b/apps/memos-local-plugin/core/memory/l3/l3.ts index 1178fad3e..dfeb6eb1a 100644 --- a/apps/memos-local-plugin/core/memory/l3/l3.ts +++ b/apps/memos-local-plugin/core/memory/l3/l3.ts @@ -159,6 +159,7 @@ export async function runL3( // an LLM round-trip. if (cluster.key === UNTAGGED_CLUSTER_KEY) { abstractLog.info("untagged.skipped", { + clusterKey: cluster.key, clusterPolicyCount: cluster.policies.length, }); abstractions.push(