fix(devin-connect): rescue thinking-only finishes with a corrective nudge — unstick agentic loops on swe-1-7 + drop poisoning empty assistant turns - #238
Conversation
…udge — unstick agentic loops on swe-1-7 + drop poisoning empty assistant turns
|
评审通过,但请补一条修复再合(见下 M1)。诊断链三环我逐条复现了,结论成立。 逐条核实环 1(上游把整个 turn 花在 reasoning 通道) — 用 mock 上游复现, 环 2(代理把 reasoning-only 当成有效完成) — 在 master 上实测:
环 3(空 assistant turn 污染重试历史) — 我另外查了三个可能的连带风险,都不成立,记在这里免得后来的人重查:
M1(major,请修)— reasoning 提升成 content 时没有移除 reasoning_content,同一段文字返回两遍非流式,PR 分支实测: 流式同样(累加 delta 后 显示 thinking 的客户端会把同一段推理渲染两遍。比空 turn 好,所以是 major 不是 blocker。 非流式这条是一行的事:提升时把 流式这条有真实的设计张力:reasoning delta 早就发出去了,收不回来。所以要么接受流式重复(那就在注释里写明是已知取舍,别让下一个人以为是 bug),要么流式改成"提升时补一句简短可见文本"而不是重发整段。你实测过严格客户端的行为,这个取舍你比我有依据 —— 我不指定做法,但请显式选一个并写进注释。 门禁
另外说一句你把修复键在失效特征(reasoning-only + 有 tools)而不是模型名上,这个选择是对的,而且比"给 swe-1-7 开个特例"耐用得多 —— 本仓库吃过好几次"按模型名硬编码"的亏。issue 里那句"只有 kimi CLI 的严格校验让这个 bug 可捕获"也值得记:宽容客户端静默丢弃 turn,等于把可观测性也一起丢了。 M1 修完我就合并进 v3.9.7。 |
…eam instead of verbatim dupe (M1)
|
M1 fixed in 793ed79 → 最新 commit. 按你说的两条路分别处理: 非流式 — promotion 改成 move: 流式 — 选了"显式取舍"但不是你给的两个选项的原样:完全去掉了整段重发。思考越深我越确信全量 promotion 本身是个反模式(见下面的调研),所以流式在 thinking-only finish 时只补一条简短可见的 handoff 文案,reasoning 留在已经发出去的 thinking block 里。严格客户端拿到合法的非空 text block,UI 无双份,历史无污染。trade-off 已写进注释。 预算互相耗用也按建议写进了注释( 关于流式为什么不重发整段 —— 我顺着 M1 往下调研了 thinking/reasoning 的跨轮处理,目前的结论,先记在这里:
所以我的判断:M1 这个修法是"现在该落地的最小正确",而thinking round-trip 的完整性(回放契约 / rescue 保留失败轮 reasoning / 入站不再静默丢弃)需要单独调研 —— upstream wire 的 ChatMessage 是否支持在历史里携带 reasoning 还没验证过。等我把这块查完,要么提一个长期的优雅修法,要么拿着数据来讨论妥协方案。不塞进这个 PR。 Full suite: |
… 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
#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 条。
#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 这一支要不要也救,需要真实上游复现后再定。
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 过。
|
这个 PR 的内容已经发布了,PR 之所以一直开着是我的操作问题 —— 我在本地用 merge commit 已发布轨迹: 随 v3.9.8 发布(tag 台账给的是 S 级,理由记在 合并后我自查出两条你引入的缺陷(reasoning 提升时未移除 另外 #236 那条 GLM 工具调用停滞和你这个是同族(模型声明工具意图却不发出调用),但你的触发条件 以 merged 的实质关闭。谢谢。 |
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` 只向前指、不链回索引。改成如实说"七份链回,那一份只向前指"
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 第三态(否则"没跑成"会被当成"没问题")。
两个截断守卫都多了一个"已经开始输出"的前置条件: 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 条)。
…, 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.
中文 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-7is 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=stopat 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.isEmptyCompletioncounts reasoning as content → thinking-only never retries.buildGetChatMessageRequestforwards 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 — onfinish=stopwith reasoning but no content/tool calls (and tools in play, wired viaemulateTools), 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.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).messageTextis now exported and reused bydevin-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=0pass-through, reasoning→content promotion non-stream, promotion streamtest/devin-connect-openai.test.js±1: legacy test pinned the old empty-content:""contract for reasoning-only turns — updated to the new promoted-content contracttest/devin-connect.test.js+1: builder drops empty assistant turns, keeps ones with tool_callsswe-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 turnsAPIEmptyResponseError; server log showsrescue retry 1/2 … 2/2firing as designednpm test→ 3124 pass / 0 failBranch is on current
master(v3.9.6), applies cleanly.