Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions apps/memos-local-plugin/core/memory/l2/induce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,17 @@ export async function induceDraft(
schemaHint: `{"title":"...","trigger":"...","procedure":"...","verification":"...","rationale":"...","caveats":["..."],"confidence":0..1,"support_trace_ids":["tr_..."]}`,
validate: (v) => {
const o = v as Record<string, unknown>;
for (const k of ["title", "trigger"]) {
if (typeof o[k] !== "string" || !(o[k] as string).trim()) {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
`l2.induction: '${k}' must be a non-empty string`,
{ got: o[k] },
);
}
// `trigger` is load-bearing — a policy without a trigger has
// no matching semantics. `title` is a display attribute; if
// empty, `normaliseDraft` synthesises a fallback from the
// pattern signature so a single flaky LLM response doesn't
// discard an otherwise-usable draft.
if (typeof o.trigger !== "string" || !(o.trigger as string).trim()) {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
`l2.induction: 'trigger' must be a non-empty string`,
{ got: o.trigger },
);
}
if (
typeof o.procedure !== "string" &&
Expand All @@ -133,7 +136,19 @@ export async function induceDraft(
},
);

const draft = normaliseDraft(rsp.value, input.evidenceTraces.map((t) => t.id));
const draft = normaliseDraft(
rsp.value,
input.evidenceTraces.map((t) => t.id),
input.signatureLabel,
);
const rawTitle = (rsp.value as { title?: unknown }).title;
if (typeof rawTitle !== "string" || rawTitle.trim().length === 0) {
log.warn("induce.title_fallback", {
signatureLabel: input.signatureLabel,
synthesisedTitle: draft.title,
rawTitleType: typeof rawTitle,
});
}
if (deps.validate) deps.validate(draft);
return { ok: true, draft };
} catch (err) {
Expand Down Expand Up @@ -230,7 +245,11 @@ function truncate(s: string, n: number): string {
return s.slice(0, n - 1) + "…";
}

function normaliseDraft(value: Record<string, unknown>, traceIds: readonly TraceId[]): InductionDraft {
function normaliseDraft(
value: Record<string, unknown>,
traceIds: readonly TraceId[],
signatureLabel: string,
): InductionDraft {
const procedure =
typeof value.procedure === "string"
? (value.procedure as string)
Expand All @@ -244,8 +263,12 @@ function normaliseDraft(value: Record<string, unknown>, traceIds: readonly Trace
const supportTraceIds = Array.isArray(value.support_trace_ids)
? (value.support_trace_ids as unknown[]).filter((x): x is string => typeof x === "string")
: [];
const cleanedTitle = sanitizeDerivedText(value.title);
const title = cleanedTitle.length > 0
? cleanedTitle
: synthesiseTitle(signatureLabel);
return {
title: sanitizeDerivedText(value.title),
title,
trigger: sanitizeDerivedMarkdown(value.trigger),
procedure: sanitizeDerivedMarkdown(procedure),
verification: typeof value.verification === "string" ? sanitizeDerivedMarkdown(value.verification) : "",
Expand All @@ -256,3 +279,14 @@ function normaliseDraft(value: Record<string, unknown>, traceIds: readonly Trace
supportTraceIds: supportTraceIds.length > 0 ? (supportTraceIds as TraceId[]) : Array.from(traceIds),
};
}

/**
* Build a display-quality title from the pattern signature when the LLM
* returns an empty `title`. Keeps the whole draft usable instead of
* throwing away the trigger/procedure that we did get.
*/
function synthesiseTitle(signatureLabel: string): string {
const clean = sanitizeDerivedText(signatureLabel);
if (clean.length > 0) return truncate(clean, 120);
return "Untitled policy";
}
56 changes: 47 additions & 9 deletions apps/memos-local-plugin/core/memory/l3/abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,11 @@ export async function abstractDraft(
schemaHint: `{"title":"...","domain_tags":["..."],"environment":[{"label":"...","description":"...","evidenceIds":["..."]}],"inference":[...],"constraints":[...],"body":"markdown","confidence":0..1,"supersedes_world_ids":[]}`,
validate: (v) => {
const o = v as Record<string, unknown>;
if (typeof o.title !== "string" || !(o.title as string).trim()) {
throw new MemosError(
ERROR_CODES.LLM_OUTPUT_MALFORMED,
"l3.abstraction: 'title' must be a non-empty string",
{ got: o.title },
);
}
// Empty `title` is tolerated — `normaliseDraft` synthesises a
// fallback from `domain_tags` / first environment label so a
// single missing display attribute doesn't abort the entire
// world-model. Missing triple, however, means the draft has
// no usable payload and must still fail.
const triple = ["environment", "inference", "constraints"];
for (const k of triple) {
if (!Array.isArray(o[k])) {
Expand All @@ -126,6 +124,14 @@ export async function abstractDraft(
);

const draft = normaliseDraft(rsp.value);
const rawTitle = (rsp.value as { title?: unknown }).title;
if (typeof rawTitle !== "string" || rawTitle.trim().length === 0) {
log.warn("abstract.title_fallback", {
clusterKey: input.cluster.key,
synthesisedTitle: draft.title,
rawTitleType: typeof rawTitle,
});
}
if (deps.validate) deps.validate(draft);
return { ok: true, draft };
} catch (err) {
Expand Down Expand Up @@ -260,9 +266,14 @@ function packPolicy(

function normaliseDraft(value: Record<string, unknown>): L3AbstractionDraft {
const triple = pickTriple(value);
const domainTags = normaliseTags(value.domain_tags);
const rawTitle = sanitizeDerivedText(value.title);
const title = rawTitle.length > 0
? rawTitle
: synthesiseTitle(domainTags, triple.environment);
return {
title: sanitizeDerivedText(value.title),
domainTags: normaliseTags(value.domain_tags),
title,
domainTags,
environment: triple.environment,
inference: triple.inference,
constraints: triple.constraints,
Expand All @@ -276,6 +287,33 @@ function normaliseDraft(value: Record<string, unknown>): L3AbstractionDraft {
};
}

/**
* Build a display-quality title from whatever structure the draft carries.
* The abstractor's `title` is a display attribute; a missing one shouldn't
* lose the whole world model.
*/
function synthesiseTitle(
domainTags: readonly string[],
environment: readonly L3AbstractionDraftEntry[],
): string {
if (domainTags.length > 0) {
const joined = domainTags.slice(0, 3).map(titleCaseTag).join(" · ");
return [...joined].slice(0, 160).join("");
}
for (const e of environment) {
if (e.label && e.label.trim().length > 0) {
return [...e.label].slice(0, 160).join("");
}
}
return "Untitled world model";
}

function titleCaseTag(tag: string): string {
if (tag.length === 0) return tag;
const chars = [...tag];
return chars[0].toUpperCase() + chars.slice(1).join("");
}

function pickTriple(value: Record<string, unknown>): {
environment: L3AbstractionDraftEntry[];
inference: L3AbstractionDraftEntry[];
Expand Down
32 changes: 30 additions & 2 deletions apps/memos-local-plugin/tests/unit/memory/l2/induce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,38 @@ describe("memory/l2/induce", () => {
expect(res.reason).toBe("llm_failed");
});

it("reason=llm_failed when the LLM draft is malformed (missing title)", async () => {
it("synthesises a fallback title from the pattern signature when the LLM omits it", async () => {
// Regression for #2335 (parity with l3.abstract): the LLM
// occasionally returns an empty `title`. That is a display attribute,
// not a load-bearing field — the whole draft shouldn't be discarded.
const llm = fakeLlm({
completeJson: {
"l2.l2.induction.v2": { trigger: "no title", procedure: "..." },
"l2.l2.induction.v2": {
title: " ",
trigger: "pip install fails in alpine",
procedure: "apk add + retry",
confidence: 0.6,
},
},
});
const res = await induceDraft(
{
evidenceTraces: [mkTrace("tr_a", "ep_1", vec([1, 0]))],
episodeIds: ["ep_1"] as EpisodeId[],
signatureLabel: "docker|pip|MODULE_NOT_FOUND",
charCap: 1000,
},
{ llm, log },
);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.draft.title).toBe("docker|pip|MODULE_NOT_FOUND");
});

it("reason=llm_failed when the LLM draft is malformed (missing trigger)", async () => {
const llm = fakeLlm({
completeJson: {
"l2.l2.induction.v2": { title: "have title", procedure: "..." },
},
});
const res = await induceDraft(
Expand Down
73 changes: 73 additions & 0 deletions apps/memos-local-plugin/tests/unit/memory/l3/abstract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,79 @@ describe("memory/l3/abstract", () => {
expect(res.detail).toContain("boom");
});

it("synthesises a fallback title from domain_tags when the LLM returns empty title", async () => {
const llm = fakeLlm({
completeJson: {
[OP]: {
title: " ",
domain_tags: ["Alpine", "python", "pip"],
environment: [{ label: "Alpine", description: "musl libc" }],
inference: [{ label: "wheels fail", description: "musl" }],
constraints: [{ label: "avoid wheels", description: "no binary" }],
body: "body",
confidence: 0.6,
supersedes_world_ids: [],
},
},
});

const res = await abstractDraft(
{ cluster: mkCluster(), evidenceByPolicy: new Map() },
{ llm, log, config: cfg() },
);

expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.draft.title.trim().length).toBeGreaterThan(0);
expect(res.draft.title.toLowerCase()).toContain("alpine");
});

it("falls back to environment label when title and domain_tags are empty", async () => {
const llm = fakeLlm({
completeJson: {
[OP]: {
title: "",
domain_tags: [],
environment: [{ label: "Runtime", description: "runtime" }],
inference: [{ label: "x", description: "y" }],
constraints: [{ label: "z", description: "w" }],
body: "",
confidence: 0.5,
},
},
});
const res = await abstractDraft(
{ cluster: mkCluster(), evidenceByPolicy: new Map() },
{ llm, log, config: cfg() },
);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.draft.title).toBe("Runtime");
});

it("falls back to a static title when title / tags / environment are all empty", async () => {
const llm = fakeLlm({
completeJson: {
[OP]: {
title: "",
domain_tags: [],
environment: [{ label: "", description: "d" }],
inference: [{ label: "x", description: "y" }],
constraints: [{ label: "z", description: "w" }],
body: "",
confidence: 0.5,
},
},
});
const res = await abstractDraft(
{ cluster: mkCluster(), evidenceByPolicy: new Map() },
{ llm, log, config: cfg() },
);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.draft.title).toBe("Untitled world model");
});

it("returns llm_failed when the LLM returns missing triple", async () => {
const llm = fakeLlm({
completeJson: {
Expand Down
Loading