Skip to content

fix(devin-connect): rescue thinking-only finishes with a corrective nudge — unstick agentic loops on swe-1-7 + drop poisoning empty assistant turns - #238

Closed
warelik wants to merge 2 commits into
dwgx:masterfrom
warelik:fix/devin-connect-thinking-only-rescue
Closed

fix(devin-connect): rescue thinking-only finishes with a corrective nudge — unstick agentic loops on swe-1-7 + drop poisoning empty assistant turns#238
warelik wants to merge 2 commits into
dwgx:masterfrom
warelik:fix/devin-connect-thinking-only-rescue

Conversation

@warelik

@warelik warelik commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

中文 TL;DR

swe-1-7(Kimi K2 fine-tune,目前免费档可用)在 agentic 循环里间歇性把整个 turn 花在 reasoning 通道,声明了工具意图却不发出 tool call,代理原样转发 thinking-only + end_turn:严格客户端(kimi CLI)报 APIEmptyResponseError,宽容客户端静默停滞;而重试历史里的空 assistant turn 被原样发给上游,导致重试 10/10 复现同一空回复。本 PR 按条件(非按模型名)修这一类问题:reasoning-only finish 且请求带 tools 时自动追加纠正 nudge 重试(实测 24/24 恢复 tool call)、重试历史剔除空 assistant turn、无 tools 或重试耗尽时把 reasoning 提升为可见 content 兜底。感谢 kimi CLI 的严格校验——其它客户端静默丢弃,只有它给出完整 trace,才让这个 bug 可捕获。

Summary

Closes #237. Three links of one chain — upstream spends the turn in the reasoning channel, the proxy classifies that as a valid completion, and retry history poisoned with an empty assistant turn makes every retry reproduce it. All three are fixed together; any subset has no observable effect.

The fix is keyed on the failure signature (reasoning-only finish, tools in play), not on the model name — swe-1-7 is just the first model caught red-handed; any DEVIN_CONNECT model that develops the same quirk is rescued by the same path.

Root cause

  • swe-1-7 (Kimi K2 fine-tune) intermittently emits the whole turn as reasoning (proto cursor 模型命名不一样好像用不了 #9), zero content (Firebase 登入失敗: 信箱或密碼錯誤 #3), finish=stop at 21–174 completion tokens — not budget exhaustion. Measured 25% in agentic context, 100% on plain prompts; 60% of those reasonings declare tool intent in natural language.
  • isEmptyCompletion counts reasoning as content → thinking-only never retries.
  • buildGetChatMessageRequest forwards empty assistant turns verbatim → upstream repeats empty completions (client-side 10/10 identical failures).

Fix

  • src/devin-connect-openai.js:
    • streamChatWithEmptyRetry: new thinking-only rescue — on finish=stop with reasoning but no content/tool calls (and tools in play, wired via emulateTools), retry upstream ≤ DEVIN_CONNECT_RESCUE_MAX (default 2, 0 disables) with empty assistant turns dropped and a corrective user nudge appended ("Stop reasoning. Emit the tool call markup now."). Live probe: 24/24 recovery vs 20% for a blind retry. Already-streamed reasoning stays a thinking block; thinking + tool_use is a valid Anthropic turn.
    • Fallback promotion (stream + non-stream): when no content and no tool calls survive, the accumulated reasoning is promoted into visible content, so a plain-prompt answer is never an invalid empty turn.
  • src/devin-connect.js:
    • buildGetChatMessageRequest: skip empty assistant turns without tool_calls instead of forwarding them verbatim (poisoning source).
    • messageText is now exported and reused by devin-connect-openai.js (single canonical content-flattener).

Test plan

  • test/devin-connect-openai.test.js +4: rescue triggers with nudge + history cleanup (stream), DEVIN_CONNECT_RESCUE_MAX=0 pass-through, reasoning→content promotion non-stream, promotion stream
  • test/devin-connect-openai.test.js ±1: legacy test pinned the old empty-content:"" contract for reasoning-only turns — updated to the new promoted-content contract
  • test/devin-connect.test.js +1: builder drops empty assistant turns, keeps ones with tool_calls
  • Live probes vs swe-1-7 (free tier): 12/12 agentic turns delivered tool_calls (1 rescue triggered & healed transparently), 3/3 plain prompts returned visible text (previously 5/5 empty), 0 client-visible thinking-only turns
  • E2E kimi CLI (Anthropic path, through router): 3/3 multi-step agentic tasks completed, no APIEmptyResponseError; server log shows rescue retry 1/2 … 2/2 firing as designed
  • Full suite: npm test3124 pass / 0 fail

Branch is on current master (v3.9.6), applies cleanly.

…udge — unstick agentic loops on swe-1-7 + drop poisoning empty assistant turns
@dwgx

dwgx commented Aug 3, 2026

Copy link
Copy Markdown
Owner

评审通过,但请补一条修复再合(见下 M1)。诊断链三环我逐条复现了,结论成立。

逐条核实

环 1(上游把整个 turn 花在 reasoning 通道) — 用 mock 上游复现,reasoning 有内容、content 为空、finish=stop

环 2(代理把 reasoning-only 当成有效完成) — 在 master 上实测:

master:  {"content":"","reasoning_content":"I should compute 15*17 = 255."}

content:"" 正是 issue 报的那个空回复。isEmptyCompletion 把 reasoning 计入 sawContent,所以空完成重试不触发 —— 与你的描述一致。

环 3(空 assistant turn 污染重试历史)buildGetChatMessageRequest 确实原样转发。你的修法保留了带 tool_calls 的空 assistant,这点是对的:那种 turn 在 nativeToolCall 下会编码成 STRUCTURED,丢掉会破坏工具链。

我另外查了三个可能的连带风险,都不成立,记在这里免得后来的人重查:

  • 丢弃空 assistant 是否孤立 tool result — 不会。connect 的 wire 格式里 role:'tool' 同样编码成 SOURCE.USER,丢掉空 assistant 只是让两个 user-source 轮次相邻;不像 Anthropic 要求严格交替。
  • rescue 是否会被代理自己合成的终止帧触发 — 不会。合成帧在 chat.js(上层)产生,rescue 在 devin-connect-openai.js(下层),合成帧不流经它;而真实流中断会抛异常,finishEv 保持 null,rescue 条件短路。
  • 重试预算是否可能失控 — 有界。rescue 走 continue 会让 attempt 自增,所以实测总上游调用 = 3(1 原始 + 2 rescue),之后空重试预算也已耗尽。但两个预算互相耗用:DEVIN_CONNECT_RETRY_ON_EMPTY_MAX=0 仍允许 2 次 rescue,而调高 RESCUE_MAX 会吃掉空重试额度。不是缺陷,但值得在文档里写清,或者让两个计数各自独立。

M1(major,请修)— reasoning 提升成 content 时没有移除 reasoning_content,同一段文字返回两遍

非流式,PR 分支实测:

{"content":"I should compute 15*17 = 255.",
 "reasoning_content":"I should compute 15*17 = 255."}   ← 逐字相同

流式同样(累加 delta 后 content === reasoning_content)。而且它会穿到 Anthropic 路由 —— 也就是你自己用的 kimi CLI 那条路:

[{"type":"thinking","text":"I will compute 15*17 = 255."},
 {"type":"text",    "text":"I will compute 15*17 = 255."}]

显示 thinking 的客户端会把同一段推理渲染两遍。比空 turn 好,所以是 major 不是 blocker。

非流式这条是一行的事:提升时把 reasoning_content 去掉 —— 它是被搬走了而不是复制。

流式这条有真实的设计张力:reasoning delta 早就发出去了,收不回来。所以要么接受流式重复(那就在注释里写明是已知取舍,别让下一个人以为是 bug),要么流式改成"提升时补一句简短可见文本"而不是重发整段。你实测过严格客户端的行为,这个取舍你比我有依据 —— 我不指定做法,但请显式选一个并写进注释。

门禁

  • 本地合并到我这轮的 6 个修复之上,全量 npm run test:release:3157 pass / 0 fail
  • 我这轮新增的守卫(断连冷却 13 条 / 目录退化 13 条 / 三条改写的源码守卫)在合并树上全绿,无交互破坏
  • devin-connect-openai 44 条、devin-connect 166 条全绿

另外说一句

你把修复键在失效特征(reasoning-only + 有 tools)而不是模型名上,这个选择是对的,而且比"给 swe-1-7 开个特例"耐用得多 —— 本仓库吃过好几次"按模型名硬编码"的亏。issue 里那句"只有 kimi CLI 的严格校验让这个 bug 可捕获"也值得记:宽容客户端静默丢弃 turn,等于把可观测性也一起丢了。

M1 修完我就合并进 v3.9.7。

@warelik

warelik commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

M1 fixed in 793ed79 → 最新 commit. 按你说的两条路分别处理:

非流式 — promotion 改成 move: content = reasoning; reasoning = '',不再返回两遍(加了一条断言 reasoning_content === undefined)。

流式 — 选了"显式取舍"但不是你给的两个选项的原样:完全去掉了整段重发。思考越深我越确信全量 promotion 本身是个反模式(见下面的调研),所以流式在 thinking-only finish 时只补一条简短可见的 handoff 文案,reasoning 留在已经发出去的 thinking block 里。严格客户端拿到合法的非空 text block,UI 无双份,历史无污染。trade-off 已写进注释。

预算互相耗用也按建议写进了注释(attempt 共享计数,两个 budget 的互相关系 + 上限 ≤ 1 + rescueMax),计数没拆 —— 同意"不是缺陷",不值得为此加机制。


关于流式为什么不重发整段 —— 我顺着 M1 往下调研了 thinking/reasoning 的跨轮处理,目前的结论,先记在这里:

  • 把 reasoning 整段提升进 content 是行业公认的反模式:reasoning 作为 assistant 回答进入下一轮历史,会把 kimi/DeepSeek 系模型赶进自反循环(把旧思考误解为对用户的回答)。OpenRouter / LiteLLM 也都不做 reasoning→content 折叠(OpenRouter reasoning tokens 指南,LiteLLM #20246)。
  • Moonshot 官方契约:带 tool_calls 的 assistant turn 必须在历史里回放 reasoning_content,缺失直接 400;空串同样被拒,需要 " " 兜底(Kimi K2 Thinking 指南,LiteLLM #21672)。DeepSeek 契约相同(thinking mode 文档)。Anthropic interleaved thinking 则要求 thinking block 带签名原样回放,改动即 400。
  • 而本仓库目前是单向的:入站历史里的 thinking blocks / reasoning_content 被整体丢弃(messages.js 的 anthropicToOpenAI、tool-emulation 的历史序列化),rescue 重试也会丢掉失败那轮的 reasoning。对 kimi 系模型这是潜在的退化源,而且这个 bug 家族(thinking-only、空轮、自反循环)大概率同根。

所以我的判断:M1 这个修法是"现在该落地的最小正确",而thinking round-trip 的完整性(回放契约 / rescue 保留失败轮 reasoning / 入站不再静默丢弃)需要单独调研 —— upstream wire 的 ChatMessage 是否支持在历史里携带 reasoning 还没验证过。等我把这块查完,要么提一个长期的优雅修法,要么拿着数据来讨论妥协方案。不塞进这个 PR。


Full suite: npm test3124 pass / 0 fail

dwgx added a commit that referenced this pull request Aug 3, 2026
… a corrective nudge

swe-1-7(Kimi K2 fine-tune,当前免费档)在 agentic 循环里间歇性把整个 turn 花在
reasoning 通道,声明了工具意图却不发出 tool call,代理原样转发 thinking-only +
end_turn:严格客户端(kimi CLI)报 APIEmptyResponseError,宽容客户端静默停滞。
实测 agentic 25%、纯文本提问 100%。

三环根因我逐条复现:上游把 turn 花在 reasoning 通道、isEmptyCompletion 把
reasoning 计入 sawContent 所以空完成重试不触发、重试历史里的空 assistant turn
被原样转发上游导致重试 10/10 复现同一空回复。

修复键在**失效特征**(reasoning-only finish + 有 tools)而不是模型名上,这个
选择比给 swe-1-7 开特例耐用得多 —— 本仓库吃过几次按模型名硬编码的亏。

评审另查了三个可能的连带风险,均不成立(记此免得后来人重查):
- 丢弃空 assistant 不会孤立 tool result:connect wire 格式里 role:'tool' 同样
  编码成 SOURCE.USER,不像 Anthropic 要求严格交替;带 tool_calls 的空 assistant
  被正确保留
- rescue 不会被代理自己合成的终止帧触发:合成帧在 chat.js(上层)产生,rescue
  在 devin-connect-openai.js(下层);真实流中断会抛异常,finishEv 保持 null
- 重试预算有界:rescue 走 continue 会让 attempt 自增,实测总上游调用 = 3
  (1 原始 + 2 rescue)。但两个预算互相耗用,已在评审里点出

合并前验证:本地合并到本轮四个生产修复之上,全量 test:release 3157 pass / 0 fail;
devin-connect-openai 44 条、devin-connect 166 条全绿;本轮新增守卫无交互破坏。

评审查出的 M1(reasoning 提升成 content 时未移除 reasoning_content,同一段文字
返回两遍,三条路径均复现)由维护方在下一个 commit 修 —— 比它修复的缺陷轻一个
数量级,且流式那半需要维护方拍板取舍,不该让贡献者再跑一轮往返。

Closes #237
dwgx added a commit that referenced this pull request Aug 3, 2026
#238 评审查出的 M1。它比自己修复的缺陷轻一个数量级(空回复 → 有回答),所以
PR 已合;这条由维护方修,不让贡献者为一行改动再跑一轮往返。

提升是**搬走**而不是复制。原实现设置 content 之后仍然保留
reasoning_content,于是同一段文字出现两次,三条路径都复现:

  非流式:    content === reasoning_content              (逐字相同)
  流式:      累加 delta 后同样相同
  Anthropic: [{type:thinking, text:X}, {type:text, text:X}]

第三条尤其要紧 —— 那正是报告者自己用的 kimi CLI 那条路:显示 thinking 的
客户端会把答案渲染两遍。修后 Anthropic 侧只剩一个 text 块。

流式那半**修不了,记为已知取舍**(注释写在调用点):reasoning delta 早已发出,
流不能撤回已发的东西。三个替代方案都更差:缓冲 reasoning 到流末再决定会为了
少数 reasoning-only 轮次牺牲掉所有轮次的实时可见性;只发一句简短占位文本能
满足严格客户端却让用户丢掉真正的答案(纯文本提问时 reasoning **就是**答案,
而那正是这条分支服务的场景);流式干脆不提升等于把原缺陷留给最常见的部署形态。
所以重复是"中途救回一个严格客户端"的代价 —— 显式记下来,而不是留成看起来像
疏漏的样子;将来若要去重,messages.js 已经知道 text 块是否等于 thinking 块,
那是合适的位置。

改了两条既有断言:
- PR 自带的 "promotes reasoning to content" 要求**两个字段都**带同一段文字 ——
  那是把重复固化成契约。改名为 MOVES 并断言 reasoning_content 被丢弃。
- 既有的 "does NOT retry when reasoning-only content arrived" 真实主题是重试
  决策(calls === 1),reasoning_content 那句是附带的。改为断言文本没丢。

新增两条守卫:提升后不得留副本;以及模型**真的**答了的时候 reasoning 与
content 是不同文本,两者都该保留(确认提升不会误伤正常轮次)。突变验证:
恢复 PR 原样的重复行为 → 抓 2 条。
dwgx added a commit that referenced this pull request Aug 3, 2026
#238 合并后自查发现的第二条,比 M1 更明显:三次 thinking-only 尝试实测产出
`"PASS1. PASS2. PASS3. "` —— 用户看到三次尝试粘在一起当成答案。

根因:rescue 循环在 `streamChatWithEmptyRetry`(生成器)**内部**,而累加器在它的
两个消费者里,消费者只在自己那层重试的顶部重置,所以从来看不到 rescue 的尝试
边界。三次尝试的 delta 全都流进同一个 `reasoning +=`。

修法是让生成器发一个 `attempt_reset` 哨兵:一次 rescue **替换**上一次尝试,
不是续接它。两个消费者收到即清空(非流式清 content/reasoning/nativeToolCalls;
流式另清 collectedToolCalls)。已发出的 delta 收不回(那条限制记在提升点的
注释里),但累加器不能留着被放弃的尝试 —— 否则提升会把那一堆重新当答案发出去。

实测:修后只剩最后一次尝试(`"PASS3. "`);rescue 成功的场景照旧愈合
(第 2 次尝试交付 tool call,finish=tool_calls),且被放弃那次的 reasoning
不再跟着漏出去。

守卫 3 条(非流式 + 流式各一条参数化,加一条"愈合后不带陈旧 reasoning"),
突变验证:去掉哨兵 → 抓 2 条。

顺带记一条 #236 的诊断(与本条同族但 #238 覆盖不到):模型用**纯文本**声明
工具意图却不发 markup 时,代理返回 finish=stop + tool_calls=0,客户端读成
"模型选择用文字回答",agent 循环同样停滞。而 rescue 的条件是
`sawReasoning && !sawText`,所以这一支不触发。实测三种形态:
  A 文本声明意图、无 markup  -> finish=stop      tool_calls=0   (停滞)
  B 文本带 markup            -> finish=tool_calls tool_calls=1   (正常)
  C reasoning-only           -> rescue 触发并愈合                (#238 覆盖)
另外核实 #236 里我原本怀疑的"selector 别名缺失"**不成立**:glm-5-2-none /
glm-5.2-none / -max / -1m / -none-1m 全部 mapped=true(经 selectorExists 归一化
路径),不需要补别名。A 这一支要不要也救,需要真实上游复现后再定。
dwgx added a commit that referenced this pull request Aug 3, 2026
release notes 的 bump commit 早于 #238 的 merge,所以那份 notes 里没有它 ——
补上一节(三环根因、按失效特征而非模型名做键、以及维护方合并后自查修掉的两条),
并加致谢一节。

台账补 warelik #238,评 S:三环根因逐条实测、修复键在失效特征而不是模型名上
(本仓库吃过几次按模型名硬编码的亏)、实测数据扎实(nudge 24/24 vs 盲重试 20%)。
未到 MR/LR 是因为评审查出的两条自引入缺陷(reasoning 提升未移除 reasoning_content、
每次 rescue 输出被拼进同一答案)本可在提交前自查到。

致谢里同时记 andya1lan(#234 已验证成立 + #235 的动态促销诊断直接指向 credit
硬编码这个更深的问题)与 kuaile1993(#235/#236),两者都归 v3.9.8。

已 npm run sync:contributors 同步 docs 侧。

门禁:3179 pass / 0 fail(237 文件,逐文件进程隔离)、secret-scan 过、
git diff --check 过。
@dwgx

dwgx commented Aug 3, 2026

Copy link
Copy Markdown
Owner

这个 PR 的内容已经发布了,PR 之所以一直开着是我的操作问题 —— 我在本地用 merge commit
合的(14745a8),没走 GitHub 的合并按钮,所以这边一直显示 OPEN。抱歉让它挂了这么久。

已发布轨迹:

793ed79  你的 commit
14745a8  Merge PR #238
3d46f59  fix: reasoning 提升成 content 时同一段文字返回两遍     ← 合并后自查
9ef10b4  fix: 每次 rescue 尝试的输出被拼进同一个答案            ← 合并后自查
438d1d6  台账记 warelik #238 (S)

v3.9.8 发布(tag v3.9.8,门禁 3246 pass / 0 fail)。

台账给的是 S 级,理由记在 docs/dashboard/data/contributors.json 里,摘要:三环一链且
只修一环无可观测效果(上游把 turn 花在 reasoning 通道、isEmptyCompletion 把 reasoning 计入
sawContent 所以空完成重试从不触发、重试历史里的零长度 assistant turn 被原样转发反过来诱导
模型继续产空轮),而且修复键落在失效特征而不是模型名上 —— 这个仓库吃过几次按模型名硬编码
的亏,这个选择明显更耐用。实测数据也扎实:纠正 nudge 24/24 恢复 vs 盲重试 20%。

合并后我自查出两条你引入的缺陷(reasoning 提升时未移除 reasoning_content 导致同一段文字返回
两遍;每次 rescue 尝试的输出被拼进同一个答案),都由我这边直接修掉了 —— 都比它们修复的缺陷
轻一个数量级,不值得让你再跑一轮往返。这也写在台账里了。

另外 #236 那条 GLM 工具调用停滞和你这个是同族(模型声明工具意图却不发出调用),但你的触发条件
是 reasoning-only,那条是模型在文本通道里说的,rescue 的 sawReasoning && !sawText 覆盖
不到。那条还在等真实复现,判据比你这条模糊得多。

以 merged 的实质关闭。谢谢。

@dwgx dwgx closed this Aug 3, 2026
dwgx added a commit that referenced this pull request Aug 5, 2026
15 个 lens + 逐条独立验证(35 CONFIRMED / 5 REFUTED)。密度最高的三个文件正是本轮改得最多的:
交接 E、`devin-connect-openai.js`、台账。文档侧修掉这些:

## 已发布的那处最要紧

`RELEASE_NOTES_3.9.18.md` 把裸提示归到 "v3.9.16 起",**实际是 v3.9.8**(`793ed79`,PR #238)
—— 差八个版本,而 v3.9.16 的 notes 里一个 rescue/nudge 字都没有。**就地改正并加勘误**,不只
加勘误:版本号直接影响"我该从哪一版升上来"。GitHub Release body 同步。

同一文件另两处:
- "#238 的 9 组 wire 验证确认接受但不消费" —— 9 是那张 causality matrix 的**变体数**,而这条
  结论的样本是 **0/3**(`src/handlers/messages.js:587`)。把矩阵规模挂到单条结论上会高估证据
- "本文件里的另外三个 RETRY_* 旋钮" —— 本文件里没有那三个,改成点名 + 说明它们在哪

## 交接 E

- **§3 "接手第一步"仍写 v3.9.17 / 3433 / 263**。而 `docs/README.md` 让新人**第一件事**就跑
  §3 —— 五条命令里两条会假报警。§0 与权威表我发版时改了,漏了这一节
- `b709f85` **不可达**(那是 `--amend` 前的对象,任何分支都到不了),真实 hash 是 `47a1d12`
- "三处重新 spread(3295/**3437**/3454)" —— 实际**四处**(漏了 3229),而 3437 这行不存在
  (是 3435)。结论不受影响(3229 同样保留 `.tools`),但枚举错了。同句也在 merge message
  `1e2aaea` 里,那条是历史不改

## 台账

- 导航里"2026-08-05 实测:第 1150 行"**在写下的瞬间就是错的**:我量的是编辑前的文件,而把它
  推到 1154 的正是同一个 commit 的那处编辑。已删掉行号 —— 会被下次追加推走的行号不该进导航
- 第十一轮那张缺口表仍写 "PR #241 已审完,三条回给贡献者",**而导航指定这张表为"当前"**。
  append-only 保护的是历史结论,不保护一张被指定为当前状态的表。已划掉并注明已合并发布
- 第十三轮结尾写 "#241 未发版" —— 写下时对,发版在同会话稍后。**同会话内会过期的句子,发版后
  要回来改**,而我当时只改了交接 §6
- 补第十三轮的"发版后复核"小节,记三条判据

## docs/README.md

- "twelve rounds" → thirteen,并改成"以那一节自己写的轮次为准"(这一行已经过期过一次)
- "Every one carries a banner pointing here" 对八份里的一份不成立:`HANDOFF-2026-08-05.md`
  只向前指、不链回索引。改成如实说"七份链回,那一份只向前指"
dwgx added a commit that referenced this pull request Aug 5, 2026
v3.9.18 给 digest 上限加的钳只堵了一半,这一版堵另一半。只影响把
`DEVIN_CONNECT_RESCUE_REASONING_MAX_CHARS` 设成 0–1 之间小数的部署;没设或设整数的行为字节级
不变 —— 但设了小数的拿到的是**完全相反**的效果(全量送出)。

`Math.min(0.5, 32000)` 正确地得到 0.5,而消费者 `slice(-n)` 对 0<n<1 朝零截断:
`slice(-0.5)` === `slice(0)` === 整个字符串。实测 MAX_CHARS=0.5 送出全部 50000 字符。
加 `Math.floor` 后 0.5 floor 成 0,走的是和显式 0 相同的分支(裸提示)—— 运维想要的方向。

## 为什么要单独发一版而不是攒着

v3.9.18 的 notes 在讨论这条修复(它是发版后复核查出的),而修复落在 tag 之后 ——
**notes 描述了一个不在该版本里的修复**,读 v3.9.18 notes 的人会合理地以为里面写的都在里面。
改措辞会把用户留在有洞的版本上,发一版让 notes 变成真的更对。

v3.9.18 的 notes 已加警示框指向本版,并把"上限值会被向下取整"改成"本版不对上限值取整"。

## 同时更正 v3.9.18 notes 里一处已发布的版本归属错误

裸救援提示被归到 "v3.9.16 起",实际是 **v3.9.8**(`793ed79`,PR #238)—— 差八个版本,而
v3.9.16 的 notes 里一个 rescue/nudge 字都没有。就地改正 + 留勘误,因为版本号直接影响
"我该从哪一版升上来"。

## 交接 §7

记这轮 fan-out 复核(15 lens / 35 CONFIRMED / 5 REFUTED)的四条:钳住取值范围≠钳住语义、
anchor 要锚表达式而不是整行(它一天被打断三次,第三次是我自己的 Math.floor)、扇出太宽和跑太久
一样会丢结果、以及验证器必须有 UNVERIFIED 第三态(否则"没跑成"会被当成"没问题")。
dwgx added a commit that referenced this pull request Aug 6, 2026
两个截断守卫都多了一个"已经开始输出"的前置条件:

  gemini.js   finish():  this.started       && !this.sawTerminalSignal
  messages.js finish():  this.messageStarted && !this.sawTerminalSignal

于是上游"一个 delta 都没给就断了"(无 [DONE]、无 finish_reason、无 error
帧)这一种事件,直接跳过守卫落到正常终止分支。在 master c3ac2b2 上实测:

  Gemini      1 帧,candidates[0].finishReason === 'STOP',parts [{text:''}]
              客户端读到的是"答案完整,且为空"
  Anthropic   message_start → ping → message_delta(stop_reason:'end_turn')
              → message_stop,error 事件 0 个
  Responses   已经是对的:response.incomplete / 'upstream_incomplete'

同一个上游事件,三个出口协议给出三种结论,而错的那两个恰好都在告诉客户端
一切正常。改法是照 responses.js 的语义对齐(它的 aborted = !sawTerminalChunk
本来就没有 started 前置),两处守卫都只看终止信号在不在。修复后实测:

  Gemini      error 帧,status UNAVAILABLE(503,可重试),无 STOP 候选帧
  Anthropic   单个 error 事件,type overloaded_error(529 级,可重试),
              无 message_delta / message_stop

必须保住的区分,也是这次的负控制:零内容 + 有终止信号 = 合法的空完成,
线上真实发生(DEVIN_CONNECT retry-on-empty 与 reasoning-only rescue 都是
为它存在,#238 讲的就是严格客户端对空回答报错),它仍然走干净终止 —
Anthropic 侧 message_start/message_delta(end_turn)/message_stop 齐全,
Gemini 侧 STOP + parts [{text:''}]。把这一半做错就是把正常请求变成报错。

两个前端原本坏在不同输入上,顺带记下:messages.js 的 startMessage() 对任何
可解析 chunk 都会触发,所以"空字符串 delta 后断流"在它那里已经报错;
gemini.js 的 started 只认非空文本与工具调用,同一输入在它那里报 STOP。

gemini.js 的 this.started 改完已无读者,一并删掉(死字段留着只会引诱后人
重新拿它当门)。messages.js 里那句 lazy message_start 仍然需要 —— 它现在
覆盖的是合法空完成,注释按此重写,原来写的截断场景已经走不到那里。

新增 test/zero-content-stream-death.test.js:23 assertions,覆盖两条路径 ×
两个方向 × Gemini 两种线格式,含三协议一致性断言。在 master 源码上 8 条
失败。门禁 267 文件 3502 pass / 0 fail,secret-scan 退出 0。
突变 spec 见 test/mutations/zero-content-stream-death.json(6 条)。
warelik pushed a commit to warelik/WindsurfAPI that referenced this pull request Aug 6, 2026
…, before the dwgx#238 rescue

- streamChatWithEmptyRetry now reclassifies leading think-tagged content
  deltas into the reasoning channel at the stream-event level (gated by
  DEVIN_CONNECT_THINKTEXT_REROUTE), so a whole-think turn keeps sawText
  false and the dwgx#238 empty-completion rescue fires naturally — the reroute
  and the rescue combine instead of bypassing each other (dwgx M1 on dwgx#243).
- messages.js egress translators are passive again: openAIToAnthropic
  pushes the text block unconditionally (invariant: text block always
  present), the stream translator no longer holds a classifier.
- known limitation: only the ` thinking`…` dialect is recognized; Kimi K2
  uses `◁think▷` — extension left for a future PR.
- pin tests: whole-think turn -> rescue + answer; think+answer split
  (toChatCompletion and stream frames); gate off stays passthrough;
  egress text-block invariant. mutation baseline 89 -> 90.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants