From cbf6c0f1fc4eb9901da8a9eb1c33a9f36b68d1d8 Mon Sep 17 00:00:00 2001
From: modusensus
Date: Sat, 26 Sep 2026 18:45:03 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat(summarize):=20=E8=92=B8=E9=A6=8F?=
=?UTF-8?q?=E6=80=9D=E8=80=83=E5=BC=BA=E5=BA=A6=E8=AE=BE=E7=BD=AE=E9=A1=B9?=
=?UTF-8?q?=20summarizeReasoningEffort=EF=BC=88#315=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增配置键 summarizeReasoningEffort(off/low/medium/high/none,默认 none
= 不发送字段,行为不变),settings 白名单注册
- 思考型模型蒸馏时推理烧光输出预算 → 总结空/截断(#9 同款失败面),
off/low 封顶推理
- 档位被拒收时去掉字段重试一次:复用 dream 的 withEffortFallback,
拒收判别式提为导出常量 EFFORT_REJECT_RE 单一来源;蒸馏流失败以
aborted 结果返回,effort 型失败折叠成 undefined 走既有甄别分支
- 面板:自动总结行下新增档位下拉(opt-in 语义对齐实体抽取),i18n
中英成对
- 回归 +5(test/summarize-reasoning-effort.test.js);api.test.js
effective 计数锁同步;双 README 徽章 1384→1389
---
README.md | 12 +-
dsh-mneme/CHANGELOG.md | 2 +
dsh-mneme/README.md | 12 +-
dsh-mneme/lib/client.js | 35 +++-
dsh-mneme/lib/config.js | 17 ++
dsh-mneme/lib/dream.js | 7 +-
dsh-mneme/lib/settings.js | 3 +
dsh-mneme/lib/summarize.js | 42 ++++-
dsh-mneme/src/config.js | 17 ++
dsh-mneme/src/dream.js | 7 +-
dsh-mneme/src/settings.js | 3 +
dsh-mneme/src/summarize.js | 42 ++++-
dsh-mneme/test/api.test.js | 6 +-
dsh-mneme/test/reasoning-effort.test.js | 6 +
.../test/summarize-reasoning-effort.test.js | 150 ++++++++++++++++++
15 files changed, 337 insertions(+), 24 deletions(-)
create mode 100644 dsh-mneme/test/summarize-reasoning-effort.test.js
diff --git a/README.md b/README.md
index c5fe4673..24e269aa 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@
-
-
+
+
@@ -183,8 +183,8 @@ dsh web
```bash
cd dsh-mneme && npm install
-npm test # 1342 个测试
-npm test # 1342 个测试
+npm test # 1389 个测试
+npm test # 1389 个测试
npm run stress # 三轴线压测
npm run sync # src → lib 同步
```
@@ -366,8 +366,8 @@ The plugin ships a zero-dependency stdio MCP server (standalone npm package **`m
```bash
cd dsh-mneme && npm install
-npm test # 1342 tests
-npm test # 1342 tests
+npm test # 1389 tests
+npm test # 1389 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 463f4870..c2d1b95a 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -79,6 +79,8 @@
## 🆕 新增
+- **蒸馏思考强度设置项(issue #315)**:蒸馏(会话总结提炼)LLM 新增 `summarizeReasoningEffort`(`off`/`low`/`medium`/`high`/`none`,默认 `none` = 不发送字段、服务商默认生效,行为与此前一致)。思考型模型蒸馏时推理会烧光输出预算、总结为空或截断(#9 同款失败面,此前仅巩固/睡眠/实体抽取三链路有档位控制),配 `off`/`low` 可封顶推理。档位被模型拒收时自动去掉字段重试一次(与巩固/睡眠同一降级策略,`withEffortFallback` 共享、拒收判别式 `EFFORT_REJECT_RE` 提为单一来源);面板「功能开关 → 自动总结」下新增档位下拉(opt-in 语义与实体抽取 `entityExtractionReasoning` 对齐,settings 白名单注册)。新增回归 5 条(`test/summarize-reasoning-effort.test.js`)。
+
- **压缩边缘双落点(issue #249 N3)**:上下文即将被宿主压缩前抢救「正在做什么」,新增
opt-in 键 `continuityRescueEnabled`(注入父开关 `autoInject` 的子项,默认关;`lightMode`
强制关)。**必须是双落点**:①落一条连续性提案到新表 `continuity_proposals`(脱离对话
diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md
index 9d871606..cb54a6aa 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -5,8 +5,8 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -582,8 +582,8 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
-test/ # 1342 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
-test/ # 1342 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 1389 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 1389 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
```
@@ -592,8 +592,8 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 1342 个测试
-npm test # 运行 1342 个测试
+npm test # 运行 1389 个测试
+npm test # 运行 1389 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js
index 3cb3858a..aaf429a0 100644
--- a/dsh-mneme/lib/client.js
+++ b/dsh-mneme/lib/client.js
@@ -397,6 +397,12 @@ window.__ModuleLoader__.load({
"memory.features.entityExtractionReasoning.low": "低",
"memory.features.entityExtractionReasoning.medium": "中",
"memory.features.entityExtractionReasoning.high": "高",
+ "memory.features.summarizeReasoningEffort": "蒸馏思考强度",
+ "memory.features.summarizeReasoningEffort.none": "跟随默认",
+ "memory.features.summarizeReasoningEffort.low": "低",
+ "memory.features.summarizeReasoningEffort.medium": "中",
+ "memory.features.summarizeReasoningEffort.high": "高",
+ "memory.features.summarizeReasoningEffort.hint": "思考型模型建议选低档,避免推理烧光输出预算导致总结失败;改后重启 DSH 生效",
"memory.features.entityExtractionModelHint": "Provider/模型留空 = 跟随主对话模型;思考强度 none = 服务商默认",
"memory.features.codingRetrospect": "编码记忆蒸馏",
"memory.features.codingRetrospect.hint": "用完整转录(含工具调用与报错)提炼踩坑、约束与被否决方案",
@@ -787,6 +793,12 @@ window.__ModuleLoader__.load({
"memory.features.entityExtractionReasoning.low": "Low",
"memory.features.entityExtractionReasoning.medium": "Medium",
"memory.features.entityExtractionReasoning.high": "High",
+ "memory.features.summarizeReasoningEffort": "Distill reasoning effort",
+ "memory.features.summarizeReasoningEffort.none": "Follow default",
+ "memory.features.summarizeReasoningEffort.low": "Low",
+ "memory.features.summarizeReasoningEffort.medium": "Medium",
+ "memory.features.summarizeReasoningEffort.high": "High",
+ "memory.features.summarizeReasoningEffort.hint": "Prefer low for thinking models so reasoning cannot drain the output budget; takes effect after a DSH restart",
"memory.features.entityExtractionModelHint": "Provider / model empty = follow the main conversation model; reasoning none = provider default",
"memory.features.codingRetrospect": "Coding retrospection",
"memory.features.codingRetrospect.hint": "Distill pitfalls, constraints and rejected solutions from full transcripts (tools and errors included)",
@@ -1949,7 +1961,8 @@ window.__ModuleLoader__.load({
const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel"];
const EMBED_PROVIDERS = ["openai", "local", "ollama"];
// 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。
- const ENTITY_REASONING = ["none", "low", "medium", "high"];
+ // #315 起蒸馏思考强度共用同一组档位(后端枚举多一个 off,一样可发)。
+ const REASONING_OPTIONS = ["none", "low", "medium", "high"];
function FeatureRow({ name, hint, on, disabled, onToggle, sub }) {
return h("div", { className: "mneme-featrow", style: sub ? { paddingLeft: 18, opacity: 0.86 } : undefined },
@@ -2230,11 +2243,28 @@ window.__ModuleLoader__.load({
disabled: busy,
onChange: (e) => put({ entityExtractionReasoning: e.target.value })
},
- ENTITY_REASONING.map((r) => h("option", { key: r, value: r }, t(`memory.features.entityExtractionReasoning.${r}`))))
+ REASONING_OPTIONS.map((r) => h("option", { key: r, value: r }, t(`memory.features.entityExtractionReasoning.${r}`))))
),
h("div", { className: "mneme-featsubhint" }, t("memory.features.entityExtractionModelHint"))
);
+ // 蒸馏思考强度(issue #315):autoSummarize 开着才展开,档位与实体抽取
+ // 同款枚举下拉、即时提交。蒸馏没有独立 provider/model 路由键(跟随会话
+ // 头或 config 文件的 summarizeProvider/summarizeModel),不上连通性测试。
+ const summarizeSub = eff.autoSummarize && h("div", { className: "mneme-featsub" },
+ h("div", { className: "mneme-featnum" },
+ h("span", { className: "mneme-featnumlabel" }, t("memory.features.summarizeReasoningEffort")),
+ h("select", {
+ className: "mneme-select",
+ value: eff.summarizeReasoningEffort || "none",
+ disabled: busy,
+ onChange: (e) => put({ summarizeReasoningEffort: e.target.value })
+ },
+ REASONING_OPTIONS.map((r) => h("option", { key: r, value: r }, t(`memory.features.summarizeReasoningEffort.${r}`))))
+ ),
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizeReasoningEffort.hint"))
+ );
+
if (error && !state) return h("section", { className: "mneme-set-card" },
h("div", { className: "mneme-set-title" }, t("memory.features.title")),
h("div", { className: "mneme-set-hint" }, error));
@@ -2252,6 +2282,7 @@ window.__ModuleLoader__.load({
FEATURE_GROUPS.map((g) => h(react.Fragment, { key: g.key },
h("div", { className: "mneme-featgroup" }, t(`memory.features.${g.key}`)),
g.items.map(flagRow),
+ g.key === "group.core" && h(react.Fragment, null, summarizeSub),
g.key === "group.enhance" && h(react.Fragment, null, embedSub, entitySub),
g.key === "group.dream" && h(react.Fragment, null, dreamSub, sleepSub)
)),
diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js
index 2f9f96f8..c62c1784 100644
--- a/dsh-mneme/lib/config.js
+++ b/dsh-mneme/lib/config.js
@@ -492,6 +492,23 @@ export const Config = z.object({
z.const("high"),
z.const("none")
]).description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,未配置 = 自动取模型支持的最低档;显式 'none' = 不发送字段、用服务商自带默认。"),
+ // Pass-through reasoning effort for the distill (summarize) LLM call
+ // (issue #315). Mirrors entityExtractionReasoning rather than dream: the
+ // default 'none' omits the field entirely (provider default), so current
+ // behavior is unchanged until the user opts in — no auto-lowest resolution
+ // here, distill failures are retried at the window level anyway. off/low/
+ // medium/high are forwarded verbatim; a provider that rejects the effort
+ // retries once without it (withEffortFallback), so opting in is safe to
+ // experiment with. The failure being addressed is the #9 shape on the
+ // distill path: a thinking model drains the output budget on reasoning and
+ // the summary comes back empty/truncated.
+ summarizeReasoningEffort: z.union([
+ z.const("off"),
+ z.const("low"),
+ z.const("medium"),
+ z.const("high"),
+ z.const("none")
+ ]).default("none").description("蒸馏(会话总结提炼)LLM 的推理档位:默认 'none' = 不发送字段、用服务商自带默认;off/low/medium/high 原样传递,被模型拒收时自动去掉字段重试一次(与巩固/睡眠同款降级,#315)。思考型模型建议 off/low,避免推理烧光输出预算。"),
// Issue #257:sleep 冲突/模式两阶段的输出预算(原硬编码 2048)。实测默认档
// 每对裁决约 90 token、24 对 2097——2048 恰好压在边界(53 次运行 48 败);
// full 档六分支实测约 290 token/对、24 对 6967,2048 必然截断。默认 8192
diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js
index f75607b8..093f0078 100644
--- a/dsh-mneme/lib/dream.js
+++ b/dsh-mneme/lib/dream.js
@@ -341,6 +341,9 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
* accepted → reasoning capped; rejected → provider default (old behavior),
* logged so the rejection is observable.
*/
+// Issue #315:effort 拒收判别式的单一来源。summarize 的流失败以 aborted 结果
+// 返回,需在折叠成 undefined 前用同一甄别(各处手写会漂移)。
+export const EFFORT_REJECT_RE = /reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i;
async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
if (!effort || effort === "none") return attempt();
try {
@@ -351,7 +354,7 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
// on the chunk's failure reason here or the retry below is dead code
// for the stream path.
const reason = String(getStreamError?.() ?? "");
- if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
+ if (EFFORT_REJECT_RE.test(reason)) {
ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
return fallback();
}
@@ -361,7 +364,7 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
const message = String(error?.message ?? error);
// matches both "reasoning effort" (natural language) and the bare
// "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
- if (!/reasoning[\s_]*effort/i.test(message)) throw error;
+ if (!EFFORT_REJECT_RE.test(message)) throw error;
ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
return fallback();
}
diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js
index 9c1423b0..a6888f19 100644
--- a/dsh-mneme/lib/settings.js
+++ b/dsh-mneme/lib/settings.js
@@ -172,6 +172,9 @@ const FEATURE_FLAG_ENUMS = {
recallFusion: ["blend", "rrf", "minmax"],
// 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
entityExtractionReasoning: ["low", "medium", "high", "none"],
+ // 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
+ // 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
+ summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
// Issue #127:落库前去重档位(off 默认,等同现状)。
summarizeDedupeMode: ["off", "title", "vector"],
// Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js
index 7935a96d..5b2110b9 100644
--- a/dsh-mneme/lib/summarize.js
+++ b/dsh-mneme/lib/summarize.js
@@ -1,5 +1,9 @@
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
import { STR, langOf } from "./lang.js";
+// Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
+// 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
+// 三条链路同一降级语义,不另造第二份实现。
+import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -584,6 +588,11 @@ export function createSummarizer(ctx, service, config, deps = {}) {
};
}
+ // Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
+ // 行为与历史版本一致);off/low/medium/high 原样传递。
+ const summarizeEffort = config.summarizeReasoningEffort;
+ const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
+
const options = {
provider: route.provider,
model: route.model,
@@ -597,15 +606,26 @@ export function createSummarizer(ctx, service, config, deps = {}) {
// 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
// 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
- const { text, assembledText, aborted } = await enqueueDistill(async () => {
+ // Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
+ // 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
+ // runNarratives 的 streamFailure 同款模式)。
+ let streamFailure = "";
+ const runDistill = (withEffort) => {
+ streamFailure = "";
+ return enqueueDistill(async () => {
const retries = config.distillRateLimitRetries ?? 3;
const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
+ // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
+ // 不带 reasoningEffort,不能复用带字段的同一 options 对象。
+ const callOptions = withEffort && summarizeEffort
+ ? { ...options, reasoningEffort: summarizeEffort }
+ : options;
for (let attempt = 0; ; attempt++) {
const assembler = new BlockAssembler();
let text = "";
let aborted = false;
try {
- for await (const chunk of ctx.llm.stream(options)) {
+ for await (const chunk of ctx.llm.stream(callOptions)) {
if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
if (chunk.type === "text-delta") {
text += chunk.text ?? chunk.delta ?? "";
@@ -627,6 +647,9 @@ export function createSummarizer(ctx, service, config, deps = {}) {
if (isRateLimited(chunk.reason ?? chunk)) {
throw Object.assign(new Error("rate limited"), { status: 429 });
}
+ // Issue #315:把失败原因原样留给外层的 effort 甄别
+ // (withEffortFallback 只认「effort 被拒收」型失败)。
+ streamFailure = describeStreamFailure(chunk.reason ?? chunk);
if (audit) {
audit.status = "error";
audit.errorMessage = `llm stream ${reasonKind}`;
@@ -666,6 +689,21 @@ export function createSummarizer(ctx, service, config, deps = {}) {
}
}
}, intervalMs);
+ };
+ // Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
+ // 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
+ // 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
+ // undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
+ // 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
+ const result = await withEffortFallback(
+ ctx,
+ summarizeEffort,
+ () => runDistill(withEffort).then((r) =>
+ (r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
+ () => runDistill(false),
+ () => streamFailure
+ );
+ const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
if (aborted) {
abortedRun = true;
return;
diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js
index 2f9f96f8..c62c1784 100644
--- a/dsh-mneme/src/config.js
+++ b/dsh-mneme/src/config.js
@@ -492,6 +492,23 @@ export const Config = z.object({
z.const("high"),
z.const("none")
]).description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,未配置 = 自动取模型支持的最低档;显式 'none' = 不发送字段、用服务商自带默认。"),
+ // Pass-through reasoning effort for the distill (summarize) LLM call
+ // (issue #315). Mirrors entityExtractionReasoning rather than dream: the
+ // default 'none' omits the field entirely (provider default), so current
+ // behavior is unchanged until the user opts in — no auto-lowest resolution
+ // here, distill failures are retried at the window level anyway. off/low/
+ // medium/high are forwarded verbatim; a provider that rejects the effort
+ // retries once without it (withEffortFallback), so opting in is safe to
+ // experiment with. The failure being addressed is the #9 shape on the
+ // distill path: a thinking model drains the output budget on reasoning and
+ // the summary comes back empty/truncated.
+ summarizeReasoningEffort: z.union([
+ z.const("off"),
+ z.const("low"),
+ z.const("medium"),
+ z.const("high"),
+ z.const("none")
+ ]).default("none").description("蒸馏(会话总结提炼)LLM 的推理档位:默认 'none' = 不发送字段、用服务商自带默认;off/low/medium/high 原样传递,被模型拒收时自动去掉字段重试一次(与巩固/睡眠同款降级,#315)。思考型模型建议 off/low,避免推理烧光输出预算。"),
// Issue #257:sleep 冲突/模式两阶段的输出预算(原硬编码 2048)。实测默认档
// 每对裁决约 90 token、24 对 2097——2048 恰好压在边界(53 次运行 48 败);
// full 档六分支实测约 290 token/对、24 对 6967,2048 必然截断。默认 8192
diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js
index f75607b8..093f0078 100644
--- a/dsh-mneme/src/dream.js
+++ b/dsh-mneme/src/dream.js
@@ -341,6 +341,9 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
* accepted → reasoning capped; rejected → provider default (old behavior),
* logged so the rejection is observable.
*/
+// Issue #315:effort 拒收判别式的单一来源。summarize 的流失败以 aborted 结果
+// 返回,需在折叠成 undefined 前用同一甄别(各处手写会漂移)。
+export const EFFORT_REJECT_RE = /reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i;
async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
if (!effort || effort === "none") return attempt();
try {
@@ -351,7 +354,7 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
// on the chunk's failure reason here or the retry below is dead code
// for the stream path.
const reason = String(getStreamError?.() ?? "");
- if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
+ if (EFFORT_REJECT_RE.test(reason)) {
ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
return fallback();
}
@@ -361,7 +364,7 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
const message = String(error?.message ?? error);
// matches both "reasoning effort" (natural language) and the bare
// "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
- if (!/reasoning[\s_]*effort/i.test(message)) throw error;
+ if (!EFFORT_REJECT_RE.test(message)) throw error;
ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
return fallback();
}
diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js
index 9c1423b0..a6888f19 100644
--- a/dsh-mneme/src/settings.js
+++ b/dsh-mneme/src/settings.js
@@ -172,6 +172,9 @@ const FEATURE_FLAG_ENUMS = {
recallFusion: ["blend", "rrf", "minmax"],
// 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
entityExtractionReasoning: ["low", "medium", "high", "none"],
+ // 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
+ // 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
+ summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
// Issue #127:落库前去重档位(off 默认,等同现状)。
summarizeDedupeMode: ["off", "title", "vector"],
// Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js
index 7935a96d..5b2110b9 100644
--- a/dsh-mneme/src/summarize.js
+++ b/dsh-mneme/src/summarize.js
@@ -1,5 +1,9 @@
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
import { STR, langOf } from "./lang.js";
+// Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
+// 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
+// 三条链路同一降级语义,不另造第二份实现。
+import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -584,6 +588,11 @@ export function createSummarizer(ctx, service, config, deps = {}) {
};
}
+ // Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
+ // 行为与历史版本一致);off/low/medium/high 原样传递。
+ const summarizeEffort = config.summarizeReasoningEffort;
+ const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
+
const options = {
provider: route.provider,
model: route.model,
@@ -597,15 +606,26 @@ export function createSummarizer(ctx, service, config, deps = {}) {
// 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
// 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
- const { text, assembledText, aborted } = await enqueueDistill(async () => {
+ // Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
+ // 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
+ // runNarratives 的 streamFailure 同款模式)。
+ let streamFailure = "";
+ const runDistill = (withEffort) => {
+ streamFailure = "";
+ return enqueueDistill(async () => {
const retries = config.distillRateLimitRetries ?? 3;
const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
+ // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
+ // 不带 reasoningEffort,不能复用带字段的同一 options 对象。
+ const callOptions = withEffort && summarizeEffort
+ ? { ...options, reasoningEffort: summarizeEffort }
+ : options;
for (let attempt = 0; ; attempt++) {
const assembler = new BlockAssembler();
let text = "";
let aborted = false;
try {
- for await (const chunk of ctx.llm.stream(options)) {
+ for await (const chunk of ctx.llm.stream(callOptions)) {
if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
if (chunk.type === "text-delta") {
text += chunk.text ?? chunk.delta ?? "";
@@ -627,6 +647,9 @@ export function createSummarizer(ctx, service, config, deps = {}) {
if (isRateLimited(chunk.reason ?? chunk)) {
throw Object.assign(new Error("rate limited"), { status: 429 });
}
+ // Issue #315:把失败原因原样留给外层的 effort 甄别
+ // (withEffortFallback 只认「effort 被拒收」型失败)。
+ streamFailure = describeStreamFailure(chunk.reason ?? chunk);
if (audit) {
audit.status = "error";
audit.errorMessage = `llm stream ${reasonKind}`;
@@ -666,6 +689,21 @@ export function createSummarizer(ctx, service, config, deps = {}) {
}
}
}, intervalMs);
+ };
+ // Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
+ // 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
+ // 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
+ // undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
+ // 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
+ const result = await withEffortFallback(
+ ctx,
+ summarizeEffort,
+ () => runDistill(withEffort).then((r) =>
+ (r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
+ () => runDistill(false),
+ () => streamFailure
+ );
+ const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
if (aborted) {
abortedRun = true;
return;
diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js
index e0e79cc5..5fc474a9 100644
--- a/dsh-mneme/test/api.test.js
+++ b/dsh-mneme/test/api.test.js
@@ -665,8 +665,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
// documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/
// pinnedInjectBudget、issue #249 N3 新增 continuityRescueEnabled,
// v0.8.5 新增 disableMemorySearch/disableMemoryArchive,
- // 本地嵌入池化新增 localEmbedPooling)
- assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1);
+ // 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort)
+ assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1);
assert.equal(data.effective.dreamSkipInvalid, true);
assert.equal(data.effective.allowCrossTypeMerge, false);
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
@@ -696,6 +696,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
assert.equal(data.effective.entityExtractionProvider, "");
assert.equal(data.effective.entityExtractionModel, "");
assert.equal(data.effective.entityExtractionReasoning, "none");
+ // issue #315:蒸馏思考强度(默认 none = 不发送字段,行为不变)
+ assert.equal(data.effective.summarizeReasoningEffort, "none");
// issue #127:summarize 节流五键(均有默认值,故计入 effective 计数)
assert.equal(data.effective.summarizeMinIntervalMinutes, 0);
assert.equal(data.effective.summarizeMaxEntriesPerRun, 0);
diff --git a/dsh-mneme/test/reasoning-effort.test.js b/dsh-mneme/test/reasoning-effort.test.js
index f21dc017..a125ab14 100644
--- a/dsh-mneme/test/reasoning-effort.test.js
+++ b/dsh-mneme/test/reasoning-effort.test.js
@@ -48,11 +48,17 @@ test("issue#135: reasoningEffort unset = undefined (auto-lowest at resolve time)
const cfg = Config({});
assert.equal(cfg.dreamReasoningEffort, undefined, "unset must be distinguishable from explicit 'none'");
assert.equal(cfg.sleepReasoningEffort, undefined);
+ // issue #315: the distill knob defaults to 'none' (entity-extraction shape,
+ // no auto-lowest resolution — distill keeps its current behavior until the
+ // user opts in).
+ assert.equal(cfg.summarizeReasoningEffort, "none", "distill effort defaults to 'none'");
+ assert.equal(Config({ summarizeReasoningEffort: "off" }).summarizeReasoningEffort, "off");
assert.equal(Config({ dreamReasoningEffort: "none" }).dreamReasoningEffort, "none", "explicit 'none' kept");
assert.equal(Config({ dreamReasoningEffort: "off" }).dreamReasoningEffort, "off", "'off' now a valid explicit choice");
assert.equal(Config({ sleepReasoningEffort: "medium" }).sleepReasoningEffort, "medium");
assert.throws(() => Config({ dreamReasoningEffort: "bogus" }), "invalid effort rejected");
assert.throws(() => Config({ sleepReasoningEffort: "ultra" }), "invalid effort rejected");
+ assert.throws(() => Config({ summarizeReasoningEffort: "max" }), "invalid distill effort rejected");
});
// ---------------------------------------------------------------- dream passthrough
diff --git a/dsh-mneme/test/summarize-reasoning-effort.test.js b/dsh-mneme/test/summarize-reasoning-effort.test.js
new file mode 100644
index 00000000..1594e064
--- /dev/null
+++ b/dsh-mneme/test/summarize-reasoning-effort.test.js
@@ -0,0 +1,150 @@
+// Regression for issue #315: the distill (summarize) LLM call gets a reasoning
+// effort knob, mirroring the entity-extraction shape (issue #109) and reusing
+// the dream/sleep fallback machinery. Pins:
+// - explicit effort is forwarded on the distill stream call;
+// - default 'none' omits the field entirely (current behavior unchanged);
+// - a stream-level effort rejection (error finish chunk, the rc.1 shape that
+// never reaches catch) is retried once WITHOUT the field;
+// - non-effort stream failures are NOT retried (no blind double calls) and
+// the aborted window stays retryable (cursor not consumed);
+// - 'off' works like any explicit effort (rejected → retried without it).
+// Mock shapes (ctx.on subscription capture, {delta} text chunks, finish
+// {reason:{kind,failure}}) follow the existing summarize.test.js conventions.
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createSummarizer } from "../src/summarize.js";
+import { createStore } from "../src/store.js";
+import { createService } from "../src/service.js";
+
+const ENTRIES_JSON = JSON.stringify([
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 }
+]);
+
+function userMessage(text, seq) {
+ return {
+ seq,
+ type: "user/message",
+ data: { source: { kind: "user" }, content: [{ type: "text", text }] }
+ };
+}
+
+function setup(configOver = {}, stream) {
+ const store = createStore(":memory:");
+ const service = createService({ store, mirror: null, config: {} });
+ const calls = [];
+ const events = [];
+ const ctx = {
+ on(name, fn) {
+ events.push({ name, fn });
+ return () => {};
+ },
+ llm: {
+ stream(options) {
+ calls.push(options);
+ return stream(options);
+ }
+ }
+ };
+ const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...configOver };
+ createSummarizer(ctx, service, config, {});
+ const handler = events.find((e) => e.name === "session/event").fn;
+ const session = {
+ id: "s315",
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
+ events: [userMessage("帮我选型", 1), { seq: 2, type: "turn/end" }]
+ };
+ return { store, calls, handler, session };
+}
+
+test("issue#315: explicit summarizeReasoningEffort is forwarded on the distill call", async () => {
+ const { store, calls, handler, session } = setup(
+ { summarizeReasoningEffort: "low" },
+ () => (async function* () {
+ yield { type: "text-delta", delta: ENTRIES_JSON };
+ yield { type: "finish", kind: "ok" };
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].reasoningEffort, "low", "the configured effort rides the distill call");
+ store.close();
+});
+
+test("issue#315: default 'none' omits the reasoningEffort field entirely", async () => {
+ const { store, calls, handler, session } = setup(
+ {},
+ () => (async function* () {
+ yield { type: "text-delta", delta: ENTRIES_JSON };
+ yield { type: "finish", kind: "ok" };
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 1);
+ assert.equal("reasoningEffort" in calls[0], false, "unset/'none' must not add the field");
+ assert.equal(store.count(), 1, "entries still stored as before");
+ store.close();
+});
+
+test("issue#315: stream-level effort rejection retries once without the field", async () => {
+ const { store, calls, handler, session } = setup(
+ { summarizeReasoningEffort: "low" },
+ (options) => (async function* () {
+ if (options.reasoningEffort) {
+ // rc.1 shape: the provider rejection arrives as a terminal error finish
+ // chunk (adapterStream never throws), so the retry must be triggered
+ // from the chunk path, not a catch block.
+ yield {
+ type: "finish",
+ reason: {
+ kind: "error",
+ failure: {
+ code: "UNSUPPORTED_REASONING_EFFORT",
+ message: 'provider "deepseek" model "deepseek-chat" does not support reasoning effort "low"'
+ }
+ }
+ };
+ return;
+ }
+ yield { type: "text-delta", delta: ENTRIES_JSON };
+ yield { type: "finish", kind: "ok" };
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 2, "exactly one no-effort retry");
+ assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
+ assert.equal(store.count(), 1, "the retried run still stores its entry");
+ store.close();
+});
+
+test("issue#315: non-effort stream failures are not retried and stay abortable", async () => {
+ const { store, calls, handler, session } = setup(
+ { summarizeReasoningEffort: "low" },
+ () => (async function* () {
+ yield { type: "finish", reason: { kind: "error", failure: { code: "PROVIDER_GONE", message: "provider deepseek is not registered" } } };
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 1, "no blind retry when the stream failure is not an effort rejection");
+ assert.equal(store.count(), 0, "failed window stores nothing");
+ store.close();
+});
+
+test("issue#315: off works like any explicit effort (rejected → retried without it)", async () => {
+ const { store, calls, handler, session } = setup(
+ { summarizeReasoningEffort: "off" },
+ (options) => (async function* () {
+ if (options.reasoningEffort) {
+ yield { type: "finish", reason: { kind: "error", failure: { code: "UNSUPPORTED_REASONING_EFFORT", message: 'reasoning effort "off" is not supported here' } } };
+ return;
+ }
+ yield { type: "text-delta", delta: ENTRIES_JSON };
+ yield { type: "finish", kind: "ok" };
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 2);
+ assert.equal(calls[0].reasoningEffort, "off");
+ assert.equal(store.count(), 1);
+ store.close();
+});
From d720e217c6f4a4c35996d92de958a2dc9a8b933e Mon Sep 17 00:00:00 2001
From: modusensus
Date: Sat, 26 Sep 2026 19:26:37 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(summarize):=20=E5=AE=A1=E8=AE=A1?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E9=9A=8F=E9=87=8D=E8=AF=95=E6=81=A2=E5=A4=8D?=
=?UTF-8?q?=20+=20AbortError=20=E4=B8=8D=E8=AF=AF=E5=88=A4=20effort=20?=
=?UTF-8?q?=E6=8B=92=E6=94=B6=20+=20=E9=9D=A2=E6=9D=BF=E8=A1=A5=E9=BD=90?=
=?UTF-8?q?=20off=20=E6=A1=A3=E4=BD=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #316 审计修复(CodeRabbit + 自审发现):
1. effort 拒收→成功重试后审计行残留第一次 attempt 的 error 状态
("摘要成功但审计报失败",污染 llm_audit_logs)——每次 attempt 重置 audit.status;
2. withEffortFallback catch 未先放行 AbortError,取消可能被误判为 effort 拒收
触发多余重试——catch 首查 AbortError 直接放行;
3. 面板下拉缺 off 档位(后端枚举含 off)——拆分 ENTITY_REASONING /
SUMMARIZE_REASONING,补 zh/en i18n off 键。
新增回归 2 条(summarize-reasoning-effort.test.js):
- effort 拒收重试成功后审计记录 success;
- abort 且 message 匹配 effort 正则时不 fallback 重试。
全量 1391 pass / 0 fail。
Co-authored-by: Anans-Ivresse
---
dsh-mneme/lib/client.js | 16 +++++---
dsh-mneme/lib/dream.js | 3 ++
dsh-mneme/lib/summarize.js | 4 ++
dsh-mneme/src/dream.js | 3 ++
dsh-mneme/src/summarize.js | 4 ++
.../test/summarize-reasoning-effort.test.js | 41 ++++++++++++++++++-
6 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js
index aaf429a0..4bcf03ff 100644
--- a/dsh-mneme/lib/client.js
+++ b/dsh-mneme/lib/client.js
@@ -398,11 +398,12 @@ window.__ModuleLoader__.load({
"memory.features.entityExtractionReasoning.medium": "中",
"memory.features.entityExtractionReasoning.high": "高",
"memory.features.summarizeReasoningEffort": "蒸馏思考强度",
+ "memory.features.summarizeReasoningEffort.off": "关闭思考",
"memory.features.summarizeReasoningEffort.none": "跟随默认",
"memory.features.summarizeReasoningEffort.low": "低",
"memory.features.summarizeReasoningEffort.medium": "中",
"memory.features.summarizeReasoningEffort.high": "高",
- "memory.features.summarizeReasoningEffort.hint": "思考型模型建议选低档,避免推理烧光输出预算导致总结失败;改后重启 DSH 生效",
+ "memory.features.summarizeReasoningEffort.hint": "思考型模型建议选低档或关闭思考,避免推理烧光输出预算导致总结失败;改后重启 DSH 生效",
"memory.features.entityExtractionModelHint": "Provider/模型留空 = 跟随主对话模型;思考强度 none = 服务商默认",
"memory.features.codingRetrospect": "编码记忆蒸馏",
"memory.features.codingRetrospect.hint": "用完整转录(含工具调用与报错)提炼踩坑、约束与被否决方案",
@@ -794,11 +795,12 @@ window.__ModuleLoader__.load({
"memory.features.entityExtractionReasoning.medium": "Medium",
"memory.features.entityExtractionReasoning.high": "High",
"memory.features.summarizeReasoningEffort": "Distill reasoning effort",
+ "memory.features.summarizeReasoningEffort.off": "No reasoning",
"memory.features.summarizeReasoningEffort.none": "Follow default",
"memory.features.summarizeReasoningEffort.low": "Low",
"memory.features.summarizeReasoningEffort.medium": "Medium",
"memory.features.summarizeReasoningEffort.high": "High",
- "memory.features.summarizeReasoningEffort.hint": "Prefer low for thinking models so reasoning cannot drain the output budget; takes effect after a DSH restart",
+ "memory.features.summarizeReasoningEffort.hint": "Prefer low or No reasoning for thinking models so reasoning cannot drain the output budget; takes effect after a DSH restart",
"memory.features.entityExtractionModelHint": "Provider / model empty = follow the main conversation model; reasoning none = provider default",
"memory.features.codingRetrospect": "Coding retrospection",
"memory.features.codingRetrospect.hint": "Distill pitfalls, constraints and rejected solutions from full transcripts (tools and errors included)",
@@ -1961,8 +1963,10 @@ window.__ModuleLoader__.load({
const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel"];
const EMBED_PROVIDERS = ["openai", "local", "ollama"];
// 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。
- // #315 起蒸馏思考强度共用同一组档位(后端枚举多一个 off,一样可发)。
- const REASONING_OPTIONS = ["none", "low", "medium", "high"];
+ const ENTITY_REASONING = ["none", "low", "medium", "high"];
+ // 蒸馏思考强度(issue #315):比 entity 多一个 off(显式关思考,思考型模型
+ // 蒸馏防推理烧预算)。后端枚举含 off,面板必须能选到,否则操作者用不上。
+ const SUMMARIZE_REASONING = ["off", "none", "low", "medium", "high"];
function FeatureRow({ name, hint, on, disabled, onToggle, sub }) {
return h("div", { className: "mneme-featrow", style: sub ? { paddingLeft: 18, opacity: 0.86 } : undefined },
@@ -2243,7 +2247,7 @@ window.__ModuleLoader__.load({
disabled: busy,
onChange: (e) => put({ entityExtractionReasoning: e.target.value })
},
- REASONING_OPTIONS.map((r) => h("option", { key: r, value: r }, t(`memory.features.entityExtractionReasoning.${r}`))))
+ ENTITY_REASONING.map((r) => h("option", { key: r, value: r }, t(`memory.features.entityExtractionReasoning.${r}`))))
),
h("div", { className: "mneme-featsubhint" }, t("memory.features.entityExtractionModelHint"))
);
@@ -2260,7 +2264,7 @@ window.__ModuleLoader__.load({
disabled: busy,
onChange: (e) => put({ summarizeReasoningEffort: e.target.value })
},
- REASONING_OPTIONS.map((r) => h("option", { key: r, value: r }, t(`memory.features.summarizeReasoningEffort.${r}`))))
+ SUMMARIZE_REASONING.map((r) => h("option", { key: r, value: r }, t(`memory.features.summarizeReasoningEffort.${r}`))))
),
h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizeReasoningEffort.hint"))
);
diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js
index 093f0078..3a34c6b5 100644
--- a/dsh-mneme/lib/dream.js
+++ b/dsh-mneme/lib/dream.js
@@ -361,6 +361,9 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
}
return result;
} catch (error) {
+ // dispose/取消中止直接放行,绝不能被误判成 effort 拒收而触发 fallback
+ //(取消后重打一次不带 effort 的调用是浪费,且可能掩盖真实的取消意图)。
+ if (error?.name === "AbortError") throw error;
const message = String(error?.message ?? error);
// matches both "reasoning effort" (natural language) and the bare
// "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js
index 5b2110b9..7ea16494 100644
--- a/dsh-mneme/lib/summarize.js
+++ b/dsh-mneme/lib/summarize.js
@@ -621,6 +621,10 @@ export function createSummarizer(ctx, service, config, deps = {}) {
? { ...options, reasoningEffort: summarizeEffort }
: options;
for (let attempt = 0; ; attempt++) {
+ // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
+ // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
+ // (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
+ if (audit) audit.status = "success";
const assembler = new BlockAssembler();
let text = "";
let aborted = false;
diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js
index 093f0078..3a34c6b5 100644
--- a/dsh-mneme/src/dream.js
+++ b/dsh-mneme/src/dream.js
@@ -361,6 +361,9 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
}
return result;
} catch (error) {
+ // dispose/取消中止直接放行,绝不能被误判成 effort 拒收而触发 fallback
+ //(取消后重打一次不带 effort 的调用是浪费,且可能掩盖真实的取消意图)。
+ if (error?.name === "AbortError") throw error;
const message = String(error?.message ?? error);
// matches both "reasoning effort" (natural language) and the bare
// "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js
index 5b2110b9..7ea16494 100644
--- a/dsh-mneme/src/summarize.js
+++ b/dsh-mneme/src/summarize.js
@@ -621,6 +621,10 @@ export function createSummarizer(ctx, service, config, deps = {}) {
? { ...options, reasoningEffort: summarizeEffort }
: options;
for (let attempt = 0; ; attempt++) {
+ // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
+ // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
+ // (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
+ if (audit) audit.status = "success";
const assembler = new BlockAssembler();
let text = "";
let aborted = false;
diff --git a/dsh-mneme/test/summarize-reasoning-effort.test.js b/dsh-mneme/test/summarize-reasoning-effort.test.js
index 1594e064..70983b8b 100644
--- a/dsh-mneme/test/summarize-reasoning-effort.test.js
+++ b/dsh-mneme/test/summarize-reasoning-effort.test.js
@@ -53,7 +53,7 @@ function setup(configOver = {}, stream) {
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
events: [userMessage("帮我选型", 1), { seq: 2, type: "turn/end" }]
};
- return { store, calls, handler, session };
+ return { store, service, calls, handler, session };
}
test("issue#315: explicit summarizeReasoningEffort is forwarded on the distill call", async () => {
@@ -148,3 +148,42 @@ test("issue#315: off works like any explicit effort (rejected → retried withou
assert.equal(store.count(), 1);
store.close();
});
+
+test("issue#315: successful effort-fallback retry records audit as success, not the first failure", async () => {
+ const { store, service, calls, handler, session } = setup(
+ { summarizeReasoningEffort: "low" },
+ (options) => (async function* () {
+ if (options.reasoningEffort) {
+ // first attempt is rejected (effort unsupported)…
+ yield { type: "finish", reason: { kind: "error", failure: { code: "UNSUPPORTED_REASONING_EFFORT", message: 'reasoning effort "low" rejected' } } };
+ return;
+ }
+ // …but the no-effort retry succeeds.
+ yield { type: "text-delta", delta: ENTRIES_JSON };
+ yield { type: "finish", kind: "ok" };
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 2, "effort rejection + one retry");
+ const rows = service.listLlmAudits();
+ const summarize = rows.find((r) => r.operation_type === "summarize_compress");
+ assert.ok(summarize, "summarize audit row present");
+ assert.equal(summarize.status, "success",
+ "the retried run must record success, not the first attempt's error status");
+ store.close();
+});
+
+test("issue#315: abort is not misread as an effort rejection even when the message matches the pattern", async () => {
+ const { store, calls, handler, session } = setup(
+ { summarizeReasoningEffort: "low" },
+ () => (async function* () {
+ // The message matches EFFORT_REJECT_RE on purpose: without the AbortError
+ // guard in withEffortFallback this would be misread as an effort rejection
+ // and trigger a wasteful no-effort retry.
+ throw Object.assign(new Error("aborted by dispose: UNSUPPORTED_REASONING_EFFORT"), { name: "AbortError" });
+ })()
+ );
+ await handler(session, { seq: 2, type: "turn/end" });
+ assert.equal(calls.length, 1, "abort must not trigger a no-effort fallback retry");
+ store.close();
+});