diff --git a/apps/memos-local-plugin/core/llm/prompts/l2-induction.ts b/apps/memos-local-plugin/core/llm/prompts/l2-induction.ts
index beed29fa5..c15bf98eb 100644
--- a/apps/memos-local-plugin/core/llm/prompts/l2-induction.ts
+++ b/apps/memos-local-plugin/core/llm/prompts/l2-induction.ts
@@ -11,15 +11,22 @@ import type { PromptDef } from "./index.js";
* Boundary contract (see `docs/GRANULARITY-AND-MEMORY-LAYERS.md` §6):
* an L2 policy is **procedural** ("how to do it") — it MUST contain an
* action template. Anything declarative ("the environment looks like X")
- * belongs to the L3 world model, not here. The system prompt explicitly
- * rejects environment-fact drift to keep the two layers semantically
- * orthogonal. Bumping the version to v2 captures that change.
+ * belongs to the L3 world model, not here.
+ *
+ * v3 (issue #2318): adds a second boundary against **conversational acts**
+ * (asking, confirming, notifying, reporting status) leaking into the
+ * `action` field. Such policies passed schema validation but crystallised
+ * into dead skills — always retrieved, never callable. The revised prompt
+ * (a) enumerates the dialogue verbs to reject and (b) tells the model to
+ * abstain when the cluster's only shared behaviour is dialogue.
+ *
+ * v2 history: added the L3 world-model drift guard.
*/
export const L2_INDUCTION_PROMPT: PromptDef = {
id: "l2.induction",
- version: 2,
+ version: 3,
description:
- "Distill an L2 policy (procedural sub-task strategy) from a cluster of similar L1 traces, with explicit boundaries against L3 world-model drift.",
+ "Distill an L2 policy (procedural sub-task strategy) from a cluster of similar L1 traces, with explicit boundaries against L3 world-model drift and against conversational-act 'actions'.",
system: `You induce reusable **procedural policies** from agent experience.
A policy is a "how-to": "when you see condition X in the agent's state,
@@ -83,6 +90,59 @@ libs by default":
"Alpine container images ship only the pure-Python tier of the
Python dependency stack."
+──────────────────── Boundaries — conversational acts are NOT policies ────────────────────
+
+A **conversational act** is an I/O behaviour aimed at the user (or
+another agent) — not a procedure applied to the environment. When a
+trace cluster's dominant shared behaviour is dialogue, do NOT wrap that
+dialogue as an ACTION. Such policies pass schema validation but produce
+"dead skills" — the crystalliser promotes them, retrieval surfaces
+them, and no agent can meaningfully invoke them because there is
+nothing to invoke.
+
+Dialogue verbs / patterns to REJECT as ACTION templates (non-exhaustive):
+ - **asking the user** to clarify, choose, or provide input
+ ("ask the user to confirm the skill name before viewing it")
+ - **requesting confirmation** before proceeding
+ ("confirm with the user before deleting the file")
+ - **notifying / informing** the user about state
+ ("notify the user that the build finished")
+ - **reporting status** back to the user
+ ("report the current pipeline status to the user")
+ - **explaining / clarifying** to the user
+ ("explain to the user why the request failed")
+
+Contrast:
+
+ Wrong (dialogue as action — dead skill):
+ trigger: "user hasn't specified which skill they want to view"
+ action: "ask the user to confirm the skill name before viewing it"
+
+ Right (dialogue folded into trigger; real procedure as action):
+ trigger: "user's view-skill request omits an unambiguous skill id
+ AND at least two skills fuzzy-match the phrase they used"
+ action: "1. look up all skills whose name/tags fuzzy-match the
+ user's phrase; 2. if exactly one hits, resolve to that
+ skill's id; 3. if multiple hit, present the top 3 with
+ ids and let the user pick by id in the next turn"
+
+The distinction: the "wrong" version's action IS the dialogue; the
+"right" version's action is a resolution procedure the agent can carry
+out, and asking the user is at most a fallback branch, not the main
+step. If your only shared behaviour across traces is "the agent talks
+to the user", you have NO policy to induce — see the abstain rule
+below.
+
+Abstain when the cluster IS dialogue-only: if every trace in the input
+reduces to the same dialogue act with no follow-up procedure the agent
+executes on the environment, **do not emit a policy**. Instead return:
+
+ { "abstain": true, "reason": "cluster's shared behaviour is a
+ conversational act, not a reusable procedure — no policy to induce" }
+
+An abstained induction is a successful outcome, not a failure — it
+prevents dead skills from entering the pool.
+
──────────────────── Output ─────────────────────
Return JSON:
diff --git a/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts b/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts
index 07e3bf60e..03686fc2b 100644
--- a/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts
+++ b/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts
@@ -189,7 +189,7 @@ function buildFullChainLlm(): LlmClient {
}),
// L2 induction — distills a policy from ≥2 similar traces.
- "l2.l2.induction.v2": (input: unknown) => {
+ "l2.l2.induction.v3": (input: unknown) => {
const text = lastUserMessage(input);
const isPython = /python|pip|\.py\b/i.test(text);
return {
diff --git a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts
index fa12b9189..b55de7395 100644
--- a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts
+++ b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts
@@ -26,7 +26,7 @@
* - `capture.alpha.reflection.score.v1`— α scoring
* - `capture.summarize` — trace-level summaries
* - `reward.reward.r_human.v3` — R_human axis scoring
- * - `l2.l2.induction.v2` — L2 policy induction
+ * - `l2.l2.induction.v3` — L2 policy induction
* - `l3.abstraction.v2` — L3 world-model abstraction
* - `skill.crystallize` — skill draft
*
@@ -234,7 +234,7 @@ function buildLlm(): LlmClient {
reason: "concrete root-cause reflection",
}),
- "l2.l2.induction.v2": (input: unknown) => {
+ "l2.l2.induction.v3": (input: unknown) => {
const evidence = (input as { evidenceTraces?: Array<{ id: string }> })
?.evidenceTraces ?? [];
return {
diff --git a/apps/memos-local-plugin/tests/unit/llm/prompts.test.ts b/apps/memos-local-plugin/tests/unit/llm/prompts.test.ts
index bee52730a..053f1d705 100644
--- a/apps/memos-local-plugin/tests/unit/llm/prompts.test.ts
+++ b/apps/memos-local-plugin/tests/unit/llm/prompts.test.ts
@@ -48,6 +48,35 @@ describe("llm/prompts", () => {
expect(detectDominantLanguage(["GRPO / TRL / reward_fn.py"])).toBe("en");
});
+ it("l2 induction prompt guards against conversational-act actions (issue #2318)", () => {
+ // Version must bump when we tighten guidance so downstream
+ // `promptId@version` records attribute the new behaviour correctly.
+ expect(L2_INDUCTION_PROMPT.version).toBe(3);
+
+ const sys = L2_INDUCTION_PROMPT.system;
+
+ // The boundary block must name conversational acts as a rejected
+ // ACTION shape (symmetric to the existing L3 world-model drift guard).
+ expect(sys).toMatch(/conversational act/i);
+
+ // Enumerate the dialogue verbs we want the model to recognise as
+ // dead-skill precursors — must appear in the prompt so the LLM has a
+ // concrete list to check itself against.
+ expect(sys).toMatch(/\bask(ing)?\b.*\bthe user\b|\bask the user\b/i);
+ expect(sys).toMatch(/\bconfirm(ation)?\b/i);
+ expect(sys).toMatch(/\bnotify(ing)?\b|\bnotification\b/i);
+ expect(sys).toMatch(/\breport(ing)? status\b|\bstatus report\b/i);
+
+ // When the trace cluster's ONLY shared behaviour is dialogue, the
+ // prompt must offer the model an "abstain" escape hatch instead of
+ // forcing it to fabricate a policy.
+ expect(sys).toMatch(/abstain|no policy|do not emit|omit the policy/i);
+
+ // The existing L3-drift guard must still be present — this change is
+ // additive, not a rewrite.
+ expect(sys).toMatch(/L3|world model|world-model/i);
+ });
+
it("retrieval filter prompt asks for ranked output without selected-field leftovers", () => {
expect(RETRIEVAL_FILTER_PROMPT.system).toContain('"ranked"');
expect(RETRIEVAL_FILTER_PROMPT.system).not.toContain('"selected"');
diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/induce.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/induce.test.ts
index 06d61459f..b6df2e7bf 100644
--- a/apps/memos-local-plugin/tests/unit/memory/l2/induce.test.ts
+++ b/apps/memos-local-plugin/tests/unit/memory/l2/induce.test.ts
@@ -44,7 +44,7 @@ describe("memory/l2/induce", () => {
it("returns {ok:true, draft} and fills support_trace_ids when the LLM omits them", async () => {
const llm = fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: "install system libs first",
trigger: "pip install fails in container with missing system library",
procedure: "1. detect missing lib 2. apk/apt-get install 3. retry pip",
@@ -75,7 +75,7 @@ describe("memory/l2/induce", () => {
it("cleans unsafe markup from LLM-derived policy fields", async () => {
const llm = fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: "
install system libs",
trigger: "pip fails [bad](javascript:alert(1))",
procedure: "Use [safe](https://example.com), ignore [bad](javascript:alert(1))",
@@ -149,7 +149,7 @@ describe("memory/l2/induce", () => {
it("reason=llm_failed when the LLM draft is malformed (missing title)", async () => {
const llm = fakeLlm({
completeJson: {
- "l2.l2.induction.v2": { trigger: "no title", procedure: "..." },
+ "l2.l2.induction.v3": { trigger: "no title", procedure: "..." },
},
});
const res = await induceDraft(
diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts
index d9d062d1f..f3bc7068c 100644
--- a/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts
+++ b/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts
@@ -112,7 +112,7 @@ describe("memory/l2/integration", () => {
const llm = fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: "install missing system libs in container",
trigger: "pip install fails in container with MODULE_NOT_FOUND due to missing system lib",
procedure: "1. detect lib 2. use distro pkg manager 3. retry pip",
@@ -367,7 +367,7 @@ describe("memory/l2/integration", () => {
repos: handle.repos,
llm: fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: " 闲聊场景下避免使用emoji ",
trigger: "用户发起非任务性闲聊,且未明确要求使用emoji",
procedure: "以简洁、自然的文字回应,但不添加任何emoji或表情符号",
@@ -457,7 +457,7 @@ describe("memory/l2/integration", () => {
repos: handle.repos,
llm: fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: "结构化呈现外部数据查询结果",
trigger: "agent通过工具调用获取到外部数据源的原始响应(搜索结果、API返回、数据库查询等),需要向用户呈现信息",
procedure: "1) 从原始数据中提取关键信息要素;2) 按逻辑分类组织信息(时间、地点、数值、状态等);3) 使用结构化格式呈现(分类标题、列表、表格等);4) 可选:基于数据添加简短的实用性总结或建议",
@@ -548,7 +548,7 @@ describe("memory/l2/integration", () => {
repos: handle.repos,
llm: fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
...shared,
boundary: "仅适用于数据库事务和写入操作,不适用于天气API或公开搜索结果",
rationale: "边界不同,不能复用天气呈现经验",
@@ -630,7 +630,7 @@ describe("memory/l2/integration", () => {
repos: handle.repos,
llm: fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: "工具全部失效时坦诚说明并回退到已有知识",
trigger: "连续尝试多个同类工具(如多个搜索引擎、多个新闻源)后全部因反爬、封禁或网络错误失效,且用户问题需要实时信息",
procedure: "1) 明确告知用户所有尝试过的工具都已失效(列出具体工具名);2) 基于训练数据中的已有知识提供最接近的答案;3) 明确标注该信息的时间戳或来源时间,并提醒用户可能存在时效性差异;4) 建议用户通过其他渠道(如直接搜索、官方网站)验证",
@@ -663,7 +663,7 @@ describe("memory/l2/integration", () => {
const llm = fakeLlm({
completeJson: {
- "l2.l2.induction.v2": {
+ "l2.l2.induction.v3": {
title: "t",
trigger: "tr",
procedure: "pr",