diff --git a/apps/memos-local-plugin/core/memory/l3/l3.ts b/apps/memos-local-plugin/core/memory/l3/l3.ts index 3a786b08d..dfeb6eb1a 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,25 @@ 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", { + clusterKey: cluster.key, + 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();