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 @@ license CI node - tests - tests + tests + tests coverage Awesome

@@ -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 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1370%20passed-success)](https://github.com/slow-stack/mneme) -[![tests](https://img.shields.io/badge/tests-1370%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1389%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1389%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](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(); +});