Skip to content

fix(messages): reroute leading think-tagged content to the thinking channel — break reasoning-as-text loops (opt-in, default OFF) - #243

Open
warelik wants to merge 6 commits into
dwgx:masterfrom
warelik:pr/thinktext-reroute
Open

fix(messages): reroute leading think-tagged content to the thinking channel — break reasoning-as-text loops (opt-in, default OFF)#243
warelik wants to merge 6 commits into
dwgx:masterfrom
warelik:pr/thinktext-reroute

Conversation

@warelik

@warelik warelik commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

PR: fix(messages): reroute leading think-tagged content to the thinking channel — break reasoning-as-text loops (opt-in, default OFF)

中文 TL;DR

有些 thinking 模型偶尔把推理(reasoning)写进 CONTENT 通道、并用原生 标签包起来。客户端会把
这段当成可见的 assistant 文本存进历史、下一轮原样发回——模型再次被引导输出 reasoning-as-text,
形成自我强化循环(输出被打散、原地打转)。本 PR 在 Anthropic Messages 出口加一个分类器:检测
开头的 think 标签块(前面只允许空白),把它从 content 改路由到 thinking 通道。客户端不重发
thinking,循环被切断。流式 + 非流式两条路径都覆盖。默认关闭,由
DEVIN_CONNECT_THINKTEXT_REROUTE=1 开启;关闭时行为逐字节不变。

Why this matters (кратко)

Это симптом более общей проблемы: thinking-модели (Kimi/DeepSeek/Claude/GLM) возвращают цепочку
рассуждений в reasoning_content/thinking-блоках, и официальные документации требуют передавать её
обратно as-is в multi-turn + tool-use (Anthropic: thinking-блоки нельзя менять — подпись;
DeepSeek: с tool calls reasoning_content обязан передаваться полностью; Kimi: assistant возвращается
целиком с reasoning_content). Когда модель выплёвывает рассуждение в content в виде нативных
think-тегов, клиент сохраняет это как видимый текст и пересылает обратно — модель снова уходит в
рассуждение вместо действия, петля «заявил намерение, но не сделал». Фикс ре-раутит такой ведущий
think-блок в thinking-канал, разрывая петлю. Полное обоснование деградации — в #242.

Summary

Closes the "reasoning leaks into visible content -> client resends -> self-reinforcing loop"
degradation (this repo's mission: keep thinking-models' reasoning chain fully available so
quality does not degrade). Keyed to the failure signature (leading think-tag on the content
channel), not to any model name. Detection is local classification only — no synthetic
signatures, no reliance on routers (routers that strip thinking cause their own degradation).
This is one link of the chain (classifier + reroute); it is defensive and inert on clean streams.

Problem

  • A thinking model occasionally emits its reasoning on the CONTENT channel wrapped in its
    native ... markers instead of the reasoning channel.
  • Agentic clients store that as visible assistant text and resend it in the next turn.
  • The resent reasoning-as-text re-primes the model into more reasoning-as-text — the loop
    (observed live: output shredded, model spinning; see incident notes).
  • Live probe of normal traffic confirms channels are otherwise clean (reasoning ->
    thinking_delta, answer -> text_delta); the leak appears only when the model writes the
    markers into content.

Fix

  • src/response-classifier.js (new): ThinkTextClassifier. Detects a LEADING think block
    (marker at the very start, only whitespace before it), handles markers split across stream
    deltas, buffers the span until the close marker proves it is reasoning, then reroutes it.
    Mid-answer markers (after real text was committed) are NOT rerouted — protects code
    samples/explanations. An unterminated span at flush is delivered as text (visible beats
    dropped).
  • src/handlers/messages.js stream path (AnthropicStreamTranslator): when the gate is on,
    content deltas pass through the classifier; thinking output -> emitThinkingDelta, text
    output -> emitTextDelta; flush in finish() before closeCurrentBlock().
  • src/handlers/messages.js non-stream path (openAIToAnthropic): same reroute, gated to
    the no-reasoning_content case (the classic leak is reasoning emitted AS content; when a
    real reasoning channel is present, content is left untouched to avoid a second thinking block).
  • Gate DEVIN_CONNECT_THINKTEXT_REROUTE (default 0), documented in .env.example.

Safety

  • Default OFF; byte-identical behavior when off (classifier not constructed).
  • Only a LEADING think block is rerouted; normal text never touched (unit + integration + live).
  • Non-stream reroute does not fire when reasoning_content is present (no second thinking block).
  • Unterminated span delivered as text, never silently dropped.
  • No synthetic signatures, no router reliance, zero new dependencies.

Test plan

  • test/response-classifier.test.js — new, 10 unit tests: plain pass-through; full block
    reroute; block split across deltas; bare-marker loop artifact; no reroute when the word
    "think" is merely mentioned; no reroute when real text precedes the marker; no reroute once
    text was committed mid-answer; unterminated span flushed as text; empty/null deltas inert;
    leading-whitespace allowance.
  • test/messages.test.js — +4 integration tests (gate ON): stream reroutes a leading block;
    stream plain content unaffected; non-stream reroutes a leading block; non-stream with
    reasoning_content present leaves content untouched.
  • Full suite: 3463 pass / 0 fail.
  • Mutation spec think-text-reroute.json: 3 mutations, all CAUGHT (reroute disabled /
    unterminated span dropped / buffering removed).
  • Live (scratch gate-ON instance, swe-1-7 + glm-5.2): no false reroute on normal replies;
    eval harness OPERABILITY 100% both models; loop-rate 0 (8 turns each).

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

Note

Honest status: the leak could not be force-reproduced on demand — swe-1-7 normally routes
reasoning through the native thinking channel, and controlled probes (multiple phrasings,
both models) returned reasoning on thinking_delta with zero tags in content. The fix is
therefore defensive: it is inert on clean streams (verified: no false reroute) and breaks the
loop when a leading think-tagged span does appear on the content channel. If you want a
reproduction before merge, say so and I'll keep hunting; otherwise this stands as a safe,
tested guard. Happy to iterate on the direction.

dwgx added a commit that referenced this pull request Aug 5, 2026
四处过期,前两处是本轮 gemini 复核顺带查出来的:

1. `docs/README.md` 首行把 master 声明成一个比 v3.9.19 高一位的 tag。该 tag
   不存在,而 master 实际领先 v3.9.19 五个 commit —— 两个方向同时错,且与
   交接 §6(专门讲那几个未发版 commit)直接矛盾。
2. 台账第十一轮那张"仍未 exhaustive 扫描"表**两行过期**,而交接 §3.3 指路让人
   "取最后一个命中" —— 第十二、十三轮都没有自己的表,所以读者恰好落在这张上:
     - `#235` 第十二轮就解了(`dashboard/api.js:1782` + `index.html:7417`),
       正文写了"标注顺带解掉 #235",表没改
     - `release.yml` 那行写"要等下一次打 tag",此后 v3.9.15…v3.9.19 五个 tag
       都跑过,最近一次六个 job 全 success(含 #233 修的 x64)
   **导航规则是对的,数据过期了 —— 而规则的正确性掩盖了数据的过期。**
3. 索引对台账的描述写着 "1400+ lines / thirteen rounds",而它自己的括号里承认
   "已经过期过一次"。按 §7.5 的结构修法改成不引用数字、只指路。
4. 交接 §0 写"开着的 PR:无",而 #242/#243 在写下它之后 47 分钟就开了,#244 在
   110 分钟后。

守卫第 14 条:任何 md 文档里 `master == vX.Y.Z` 形式的声明,必须对上真实 tag
(refs/tags 与 packed-refs 都读,且在两者都读不到时显式跳过而不是报干净 ——
一个报"没问题"的坏探针是本仓库最贵的复发错误)。刻意只钉"tag 是否存在",不钉
"master 是否应该等于它",因为"未发版 commit 随下次发布搭车"是合法且有文档的状态。

守卫写完立刻抓到**第二处** —— 我人工只找到一处。而第一版台账勘误把错误字符串
照抄了进去,于是守卫又抓到本文自己,与 §7.4 同形(扫原始 prose 分不清 claim 与
对 claim 的引用)。这次没给守卫加特例,改的是写法:**记录一条已修的错误声明时,
描述它、不要重贴它。**

台账第十四轮记本轮全部结论。
@dwgx

dwgx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

评审:分类器本身写得很好,但请修一条 —— 它会造出 #238 修掉的那个形状。

我实跑过什么

head b6db693,worktree 隔离,未污染 master:

结果
npm run test:release 3463 pass / 0 fail(265 个文件) —— 与你声明的一致
七个突变 spec(含你新增的) 全部 EXIT=0,70 条全部如声明
think-text-reroute.json 三条 3/3 CAUGHT
测试反模式扫查 零源码 grep 冒充守卫、零条件断言

分类器的工艺没问题:跨 delta 的标记分割、只认前导块、未闭合当文本发、两个上限都有。10 条单元 + 4 条集成,每条都咬在行为上。


M1(请修)— 整轮都是 think 块时,输出零 text 块

非流式,你这个分支上实测:

门禁开  [{"type":"thinking","thinking":"reasoning only, no action"}]
        blocks: thinking | has text? false
门禁关  [{"type":"text","text":"<think>reasoning only, no action</think>"}]

流式同样:

blocks: ["thinking"]   text emitted: ""   stop_reason: end_turn

这正是 #238 修的失效特征 —— reasoning-only finish,严格客户端(你自己用的 kimi CLI)报 APIEmptyResponseError,宽容客户端静默丢整轮。区别是这一次由代理造出来。

#238 的救援接不住它。devin-connect-openai.js:219:

if (ev.type === 'content' || ev.type === 'reasoning') { if (ev.text) { sawContent = true; ... } }

think 标签内容就是 content 通道文本,所以 sawContent 为真、isEmptyCompletion 返回 false、救援不触发;然后 messages.js 在救援决策之后把那段搬走。门禁、你的 3463、我的复测,三个绿灯都看不见。

另外这取消了一条既有不变式:openAIToAnthropic 里那个 text 块原本是无条件 push 的(content.push({ type: 'text', text: choice?.message?.content || '' })),你改成了 if (rest || !thinkSpan)

两个修法,你选:

  1. 小的:无条件 push text 块;流式在 finish() 里,当分类器只吐过 thinking 时补一个空 text 块。
  2. 我更倾向的:把分类器移到 devin-connect-openai.js,在救援决策之前把 think 标签内容重分类成 reasoning。那样 fix(devin-connect): rescue thinking-only finishes with a corrective nudge — unstick agentic loops on swe-1-7 + drop poisoning empty assistant turns #238 的救援自然接住,两个机制是组合而不是互相绕过 —— 而按你 PR 描述的意图(切断循环),这条也更接近根因。

改完请补一条断言钉住"整轮 think 块仍然产出 text 块",否则这条会悄悄回来。你现在 10 条单元测试覆盖的都是"think 块后面还有答案"的情形。


两条不阻塞的

1. 门禁名不准。 DEVIN_CONNECT_THINKTEXT_REROUTE 里的 DEVIN_CONNECT 前缀会让运维以为它只影响 connect 后端,但代码在协议层,Cascade 部署一样触发。建议去掉前缀或改成 WINDSURFAPI_

2. 只认 <think> 一种方言。 Kimi K2 系(swe-1-7 的基座)用 ◁think▷。你写"按失效特征而非模型名做键"是对的方向,但一种标记字面量本身就是一个模型家族的键。仓库里此前零 think 标记处理,所以这是新面不是漏抄 —— 不要求这轮扩,但请在注释里写成已知限制。

顺带:只覆盖 Anthropic 一条出口这点你在描述里声明了,我不算问题。但 Cline / Cursor 走的是 OpenAI 那条,所以下一环还有活。


另外:请 rebase,master 刚动过

你的 base 落后 master 6 个 commit。当前 master(b3594f2)的门禁是 3473 pass / 0 fail(266 个文件),突变是七个 spec 74 条。你那 3463 是真的,只是它测的是旧 base。

其中和你相关的一条:master 现在有 test/gemini-tool-args-parity.test.js,它 importmessages.jsopenAIToAnthropic我已经在你这个 head 上验过了 —— 把那个文件拷到你的分支上跑,9/9 通过,所以 rebase 不会因此变红。另外 master 新增了一条守卫:突变 spec 的 anchor 必须在目标文件里恰好命中一次,现在由门禁强制而不再依赖谁记得跑循环。你 think-text-reroute.json 那三条 anchor 在 rebase 后要重新核一次(#241 就是这样静默断掉两条的)。

M1 改掉我就复测合并。#238#241 你都是一轮内改到位,这条也不难。

W ARELIK added 5 commits August 6, 2026 17:56
…ning-as-text (Thinking-core item 1)

Деградация: модель иногда эмитит reasoning в CONTENT-канале, обёрнутый в нативные
маркеры. Клиент сохраняет это видимым текстом assistant и пересылает в следующем
тёрне — он снова подталкивает модель к reasoning-as-text. Самозацикливание.

- src/response-classifier.js: ThinkTextClassifier — детектирует ВЕДУЩИЙ think-блок
  (маркер в самом начале, до него лишь whitespace), обрабатывает маркеры разрезанные
  по стрим-дельтам, ре-раутит span в thinking-канал. Маркеры в середине ответа (после
  закоммиченного текста) НЕ ре-раутятся — защита от ложных срабатываний. Незакрытый
  span на flush отдаётся как текст (видимое лучше потерянного).
- messages.js AnthropicStreamTranslator: при гейте content-дельты идут через
  классификатор; thinking-выход → emitThinkingDelta, text-выход → emitTextDelta;
  flush в finish() до closeCurrentBlock.
- Гейт DEVIN_CONNECT_THINKTEXT_REROUTE (default 0), документирован в .env.example.
- Без синтетических подписей, без опоры на роутеры — только собственная классификация.

Тесты: +10 юнит (response-classifier) + 2 интеграционных (messages, гейт вкл).
Полный сьют 3485 pass / 0 fail.
…ute 补全)

流式翻译器已覆盖流式路径;非流式 openAIToAnthropic 同样可能收到 content 开头的
think 标签泄漏,补上同样的重路由。仅在无 reasoning_content 时生效(典型泄漏是
reasoning 走 content;已有 reasoning 通道时不动 content,避免第二个 thinking 块)。
未闭合的 span 在 flush 时作为文本交付(可见优于丢弃)。

测试: +2 集成(非流式重路由;有 reasoning_content 时 content 不动)。
全套 3487 pass / 0 fail。
…, 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.
@warelik
warelik force-pushed the pr/thinktext-reroute branch from b6db693 to 3b5a9f2 Compare August 6, 2026 20:37
@warelik

warelik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

M1 исправлен твоим вариантом 2, head 3b5a9f2:

我实跑过什么

结果
npm run test:release 3623 pass / 0 fail
突变 spec 全部 EXIT=0(think-text-reroute 三条 anchor 在 rebase 后重核,恰好命中一次)
rescue 与 reroute 组合 retry-rescue-budget-split baseline 81 → 86,5 条新 пин-тестов встроены в мутационную охрану

M1 — классификатор переехал в devin-connect-openai.js, ДО rescue-решения

think-tagged 内容现在在流事件层就被重新归类进 reasoning 通道,发生在 isEmptyCompletion 之前。整轮都是 think 块时:sawContent 保持 false → rescue(#238)自然接住;两个机制是组合,不再互相绕过。openAIToAnthropic 的 text 块恢复无条件 push——「text 块永远在」这条旧不变式回来了。
Пин-тесты: целый think-тёрн(非流式 и 流式)、think+答案的常规 раскладка、rescue 触发路径 — 全部在 devin-connect-openai.test.js 里咬住。

两条 не阻塞ные

  1. 门禁名保留 DEVIN_CONNECT_THINKTEXT_REROUTE:分类器搬进 connect 层之后,前缀恰好描述作用域——名字不准的问题随代码搬家一起消失了。
  2. ◁think▷(Kimi K2 系)在代码里写成已知 лимитация:当前只认 <think> 方言,扩展留到下一步。

Rebase 到当前 master 完成,gemini-tool-args-parity 在 сьюте 内绿。

@dwgx

dwgx commented Aug 7, 2026

Copy link
Copy Markdown
Owner

评审:M1 你修了,而且修法比我建议的干净。请核两条 —— 一条是注释与实际机制不符,一条是调用方内容被重路由。

我实跑过什么

head 36d5a85,worktree 隔离:

结果
npm run test:release 3623 pass / 0 fail(275 个文件)
think-text-reroute.json 3/3 CAUGHT
全仓 spec anchor 153 条,零个不唯一
dependencies 仍为空

声明的 3463 与我量到的 3623 不是错报 —— 这个分支已 rebase 到当前 master(master ^HEAD = 0),3603 + 20 条你新增的 = 3623,闭合。我核过才写这句,免得你去查一个不存在的回归。

分类器我逐条驱动过,不是读代码看的:

整轮就是 think span      → {"text":"","think":"reasoning only, no action"}
非前导 span(不该动)     → {"text":"answer first <think>then reasoning</think>","think":""}
未闭合 span              → feed:{"text":"","thinking":""}  flush:"never closed"

未闭合那条按 text 放行,与你 spec 里那条声明一致。非前导不动也对。MAX_LEAD 上限那处是这个分类器最容易漏而你没漏的地方 —— 悬而未决的 pending 超过上限就提交为文本,否则一个永不闭合的开标记会把整轮吞掉。


M1(已修,记录一下)— 空 text 块那条

上一轮我提的是「整轮都是 think 块时输出零 text 块,正是 #238 修的失效特征」。你的修法是把 reroute 提到 stream-event 层、救援决策之前,让整轮 think 保持成 reasoning-only 流。这比我建议的路子好 —— 我当时想的是在出口补,你在源头分流,救援和 reroute 因此不互相绕过。

M2(请核)— 那条注释说的机制不成立

devin-connect-openai.js:228 的注释:

reasoning-only stream: sawText stays false, so the #238 rescue fires naturally — the reroute and the rescue combine, neither bypasses the other.

sawText 确实留 false,但 #238 的救援不看 sawText。我驱动了一个无工具、整轮 think 的请求:

upstream calls: 1        ← 救援一次都没触发
text : ""
think: "reasoning only, no action"

两条救援路径都没走:

所以真实行为是:reasoning 作为 thinking 块交付、text 为空、不重试

我判断这个结果大概是可接受的 —— reasoning 没被丢,客户端拿到 thinking 块,而不是 #238 那种整轮消失。但注释把理由写错了,而错的理由比没有注释危险:下一个人读到「救援会自然触发」,就不会再去验它。本仓库栽在这个形状上不止一次(注释把意图写对、实现少一环、而注释读起来完全正确)。

请二选一:

  1. 把注释改成实话 —— 「无工具的纯 think 轮不触发救援;reasoning 经 thinking 块交付,text 为空」,并说明为什么那是可接受的。
  2. 如果你认为 text 为空可接受,那就让 isEmptyCompletion 也看 sawText,并补一条突变钉住它。

我倾向 1。sawContent 的语义是「上游说了话没有」,reroute 之后它确实说了话 —— 改判据会牵动 #238/#241 两代救援的边界,代价大于收益。

M3(请修)— 调用方粘贴的内容会被重路由

输入:  "<think>quoted from a log</think> and here is my real answer"
输出:  {"text":" and here is my real answer","think":"quoted from a log"}

分类器只看「是否前导」,不看这段文本是谁写的。用户粘一段含前导 think 块的转录、或者问「<think> 标签是什么意思」,前导那段就被当成模型自己的 reasoning 移走了。

这和 #250 那条泄漏是反方向的同一个问题:那边是模型的 reasoning 漏进 content,这边是调用方的 content 被当成 reasoning。前者丢的是通道,后者丢的是归属

难点我承认:在 egress 这一层,模型输出和调用方输入已经分不开了 —— 这条路径上没有 provenance。所以我不建议你去猜(那会变成另一个不可判定的形状判据)。可行的两条:

  1. 只在本轮 upstream 输出上跑分类器,不在任何回放/历史路径上跑。请确认现在是否已经如此 —— 如果是,这条就只是补一句注释 + 一条测试钉住「历史里的前导 think 块不被重路由」。
  2. 如果做不到隔离,就在 .env.example 那段里写明这个限制,让开启的人知道代价。

一条与本 PR 无关的

test/mutations/retry-rescue-budget-split.json本 PR 和 #242 同时改,两个都从 81 起算:你改到 86,#242 改到 82。后合的那个从合并那刻起就是错的,而 docs-consistency-guard.test.js 只核「是不是数字」和「文件存在不存在」——它注释自己写着 states the invariant without running the suite。所以基数过期时门禁全绿,那个 spec 静默中止在第一条突变之前

三个 PR 是一批,请你定合并顺序并按最终顺序重算一次。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants