Skip to content

fix(core): send thinkingBudget, not thinkingLevel, to Gemini 2.5 - #9094

Open
axiom-of-choice wants to merge 4 commits into
QwenLM:mainfrom
axiom-of-choice:fix/gemini-25-thinking-budget
Open

fix(core): send thinkingBudget, not thinkingLevel, to Gemini 2.5#9094
axiom-of-choice wants to merge 4 commits into
QwenLM:mainfrom
axiom-of-choice:fix/gemini-25-thinking-budget

Conversation

@axiom-of-choice

@axiom-of-choice axiom-of-choice commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Gemini 2.5 takes a thinking budget, not a thinking level, so the Gemini/Vertex generator now sends thinkingConfig.thinkingBudget for that family and keeps thinkingLevel for Gemini 3. When no reasoning effort is configured it sends { includeThoughts: true } and no level at all, instead of the THINKING_LEVEL_UNSPECIFIED placeholder.

Effort tiers map to budgets by inverting the budget → level thresholds the design doc already records:

tier thinkingBudget
low 2048
medium 8192
high 16384
xhigh / max 32768 (2.5 Pro), 24576 (Flash / Flash-Lite)

Say the word if you want different numbers, it is one function.

One side effect worth calling out: xhigh and max now mean something on 2.5 instead of clamping to HIGH, because budgets reach higher than the Gemini 3 ladder can express.

Why it's needed

Every request to a Gemini 2.5 model fails with 400 thinking_level is not supported by this model, which makes the whole family unusable. Two paths, same failure: with no effort set the default carried THINKING_LEVEL_UNSPECIFIED, and with /effort set it carried LOW/MEDIUM/HIGH. The API rejects the field itself rather than the value, so reasoning: false was the only escape. The placeholder also bought nothing on models that do accept the field: THINKING_LEVEL_UNSPECIFIED means "the model decides", which is exactly what omitting the field does.

This is not new design. docs/design/2026-06-30-unified-reasoning-effort-cli.md already specifies toGeminiThinking(tier, model) as thinking_level for Gemini 3 or thinkingConfig.thinkingBudget for Gemini 2.5, and its header states "Nothing is deferred" even though the 2.5 branch was never implemented. This PR implements that branch.

Reviewer Test Plan

How to verify

Unit tests: cd packages/core && npx vitest run src/core/geminiContentGenerator gives 28 passing, including one case per tier on 2.5 Pro, the Flash ceiling, an assertion that thinkingLevel is never sent to a 2.5 model, and one that an explicit request-level thinkingConfig still wins over the default (this last one protects the internal call sites that pass thinkingBudget: 0).

Full checks: npm run build and npm run typecheck are clean.

Live, which I could not run and which a reviewer with 2.5 access should confirm: select gemini-2.5-pro on Vertex or the Gemini API, send any prompt, and confirm the request completes instead of returning 400 thinking_level is not supported by this model. Then /effort high and confirm it still completes. On a Gemini 3 model, confirm thought summaries still appear, since the no-effort default changed shape there too.

Evidence (Before & After)

Not user-visible, so no screenshots. The change is at the wire level, for gemini-2.5-pro:

case before after
no effort configured { includeThoughts: true, thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' } → 400 { includeThoughts: true }
/effort high { includeThoughts: true, thinkingLevel: 'HIGH' } → 400 { includeThoughts: true, thinkingBudget: 16384 }
/effort max { includeThoughts: true, thinkingLevel: 'HIGH' } → 400 { includeThoughts: true, thinkingBudget: 32768 }
Gemini 3, any effort { includeThoughts: true, thinkingLevel: 'HIGH' } unchanged
request passes its own thinkingConfig respected when no config-level reasoning is set unchanged

The 400 itself was reproduced live on Vertex; the transcript is in #9019.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests, npm run build and npm run typecheck on macOS. No live API call for the fix itself.

Risk & Scope

  • Main risk or tradeoff: the no-effort default no longer carries thinkingLevel on Gemini 3 either. The semantics are identical per the API, and includeThoughts: true is preserved, so thought summaries are unaffected. The bucket numbers are a judgement call and easy to change.
  • Not validated / out of scope: the fix is covered by unit tests, not by a live 2.5 call. Two adjacent defects raised in the issue thread are deliberately left out because neither causes the 400. First, the four internal sites that pass thinkingBudget: 0 (goalJudge.ts, goal-verifier.ts, goal-checkpoint-verifier.ts, acp-integration/generation.ts) are still overridden whenever a config-level reasoning is set; they stop returning 400, they just think when they asked not to, and fixing it means changing getParameterValue precedence, which affects every field it handles. Second, reasoning: false on 2.5 sends includeThoughts: false alone, which does not actually stop thinking on that family, and 2.5 Pro cannot disable it at all. Happy to take either as a follow-up.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #9019

中文说明

这个 PR 做了什么

Gemini 2.5 使用的是 thinking budget,而不是 thinking level,因此 Gemini/Vertex 生成器现在对该系列发送 thinkingConfig.thinkingBudget,而对 Gemini 3 保留 thinkingLevel。未配置 reasoning effort 时,发送 { includeThoughts: true },完全不带 level,不再发送 THINKING_LEVEL_UNSPECIFIED 占位值。

各档位到 budget 的映射,来自反向套用设计文档中已记录的 budget → level 阈值:

档位 thinkingBudget
low 2048
medium 8192
high 16384
xhigh / max 32768(2.5 Pro)、24576(Flash / Flash-Lite)

如果希望换成别的数值,请直接说,只需要改一个函数。

一个值得说明的副作用:xhighmax 在 2.5 上现在有了实际意义,不再被压到 HIGH,因为 budget 能表达的范围超出 Gemini 3 的档位阶梯。

为什么需要

任何发往 Gemini 2.5 模型的请求都会失败并返回 400 thinking_level is not supported by this model,导致整个系列不可用。两条路径、同一个失败:未设置 effort 时,默认值带上 THINKING_LEVEL_UNSPECIFIED;设置了 /effort 时,带上 LOW/MEDIUM/HIGH。API 拒绝的是字段本身而不是取值,所以唯一的例外是 reasoning: false。而且这个占位值对接受该字段的模型也毫无收益:THINKING_LEVEL_UNSPECIFIED 的含义是"由模型自行决定",这与省略该字段完全等价。

这并不是新的设计。docs/design/2026-06-30-unified-reasoning-effort-cli.md 已经把 toGeminiThinking(tier, model) 规定为:Gemini 3 用 thinking_level,Gemini 2.5 用 thinkingConfig.thinkingBudget;该文档头部写着 "Nothing is deferred",但 2.5 这一分支从未实现。本 PR 实现了该分支。

审阅者测试计划

如何验证

单元测试:cd packages/core && npx vitest run src/core/geminiContentGenerator,28 项通过,其中包括 2.5 Pro 上每个档位各一例、Flash 的上限、断言绝不向 2.5 模型发送 thinkingLevel,以及断言请求级显式传入的 thinkingConfig 仍然优先于默认值(最后这一条保护了那些传 thinkingBudget: 0 的内部调用点)。

完整检查:npm run buildnpm run typecheck 均通过。

线上验证,这部分我无法执行,建议有 2.5 权限的审阅者确认:在 Vertex 或 Gemini API 上选择 gemini-2.5-pro,发送任意提示,确认请求能够完成,而不是返回 400 thinking_level is not supported by this model;随后执行 /effort high 并确认仍能完成。在 Gemini 3 模型上,确认 thought summaries 仍然显示,因为无 effort 时的默认结构在那里也发生了变化。

证据(前后对比)

该改动对用户不可见,因此没有截图。变化发生在请求层面,以 gemini-2.5-pro 为例:

场景 之前 之后
未配置 effort { includeThoughts: true, thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' } → 400 { includeThoughts: true }
/effort high { includeThoughts: true, thinkingLevel: 'HIGH' } → 400 { includeThoughts: true, thinkingBudget: 16384 }
/effort max { includeThoughts: true, thinkingLevel: 'HIGH' } → 400 { includeThoughts: true, thinkingBudget: 32768 }
Gemini 3,任意 effort { includeThoughts: true, thinkingLevel: 'HIGH' } 不变
请求自带 thinkingConfig 未设置配置级 reasoning 时生效 不变

400 本身已在 Vertex 上实际复现,记录见 #9019

测试环境

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

运行环境(可选)

在 macOS 上运行单元测试、npm run buildnpm run typecheck。修复本身没有进行线上 API 调用。

风险与范围

  • 主要风险或权衡:无 effort 时的默认值在 Gemini 3 上也不再携带 thinkingLevel。按照 API 定义两者语义相同,且保留了 includeThoughts: true,因此 thought summaries 不受影响。budget 的具体数值属于判断取舍,很容易调整。
  • 未验证 / 不在范围内:本修复由单元测试覆盖,未经过 2.5 的线上调用验证。issue 讨论中提到的另外两个相邻缺陷被有意排除,因为它们都不是 400 的成因。其一,四个传入 thinkingBudget: 0 的内部调用点(goalJudge.tsgoal-verifier.tsgoal-checkpoint-verifier.tsacp-integration/generation.ts)在设置了配置级 reasoning 时仍会被覆盖;它们不再返回 400,只是在要求不思考时仍然思考,而修复它需要改动 getParameterValue 的优先级,会影响它处理的每一个字段。其二,在 2.5 上 reasoning: false 只发送 includeThoughts: false,并不能真正关闭该系列的 thinking,而 2.5 Pro 根本无法关闭。这两项我都乐意作为后续 PR 处理。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #9019

Gemini 2.5 rejects thinkingLevel outright, so every request from the
Gemini/Vertex generator 400s on that family: with no effort set the
default sent THINKING_LEVEL_UNSPECIFIED, and /effort sent LOW/MEDIUM/HIGH.
Only reasoning: false escaped.

Map the effort tier to thinkingConfig.thinkingBudget for 2.5 and keep
thinkingLevel for Gemini 3, which is the split the unified-effort design
doc already specifies. Drop THINKING_LEVEL_UNSPECIFIED from the no-effort
default: it means the model decides, which is what omitting the field
does.

Fixes QwenLM#9019
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @axiom-of-choice — the write-up itself is genuinely thorough, but the PR body doesn't follow the PR template, so the admission gate stops here, before code review.

What's missing:

  • The required headings: ## What this PR does, ## Why it's needed, ## Reviewer Test Plan (with ### How to verify, ### Evidence (Before & After), ### Tested on), ## Risk & Scope, and ## Linked Issues
  • The Tested on OS table
  • The bilingual <details> Chinese translation of the body

The good news: everything the template asks for is already in your description — the live 400 reproduction from #9019, the budget table, the verification steps, and the deliberately excluded scope. Please reorganize that same content under the template headings (one long line per paragraph, no hard wrapping), add the OS table and the Chinese <details> section, then re-request review. A maintainer can also re-trigger with @qwen-code /triage once the body is updated.

中文说明

感谢 @axiom-of-choice——PR 描述的内容本身很扎实,但没有按照 PR 模板 组织,所以准入检查在代码审查之前先停在这里。

缺少的部分:

  • 必需标题:## What this PR does## Why it's needed## Reviewer Test Plan(含 ### How to verify### Evidence (Before & After)### Tested on)、## Risk & Scope## Linked Issues
  • Tested on 操作系统表格
  • 正文的双语 <details> 中文翻译

好消息是:模板要求的内容在你的描述里都已经有了——#9019 中的 400 实际复现、budget 映射表、验证步骤、以及刻意排除的范围。请把这些内容重新组织到模板标题下(每段写成一整行,不要硬换行),补上操作系统表格和中文 <details> 部分,然后重新请求审查。正文更新后,维护者也可以用 @qwen-code /triage 重新触发。

Qwen Code · qwen3.8-max

@axiom-of-choice

Copy link
Copy Markdown
Contributor Author

Body reorganized under the template headings, with the OS table and the Chinese translation. Same content as before, nothing dropped.

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

Not explored to full depth (tool budget reached): "This PR fixes a bug where every Gemini 2.5 model request…": verifying the ceiling constants (32768 Pro / 24576 non-Pro) against authoritative Google documentation — two fetch attempts found no numeric budget tables and …; "This PR fixes a bug where every Gemini 2.5 model request…": full repo-wide test/typecheck — only packages/core was typechecked and only the touched test file was run.; "This PR fixes a bug where every Gemini 2.5 model request…": none — all checks I started completed within the tool budget.; "This PR fixes a bug where every Gemini 2.5 model request…": none — all planned checks completed within budget..

Test Plan (not a blocker): 28 passing — this review observed 19842, 1364, 19466, 1539, 494, 3485, 559 passed.

— qwen3.8-max via Qwen Code /review (v0.21.11)

Comment on lines +235 to +237
case 'xhigh':
case 'max':
// Gemini has no tier above HIGH; log the clamp once (mirroring the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The hand-rolled xhigh/max→HIGH clamp and warn-once latch duplicate the shared clampReasoningEffort helper (packages/core/src/core/reasoning-effort.ts:89) — the Anthropic generator already uses it for the identical job (anthropicContentGenerator.ts:976, warn-once latch at 977-984), and the design doc this PR cites says the central rank-based clamp "replaces the ad-hoc per-adapter clamps". reasoning-effort.test.ts:70-74 already codifies exactly this Gemini scenario (['low','medium','high'], xhigh→high, max→high).

Failure scenario: clampReasoningEffort(effort, ['low','medium','high']) produces byte-for-byte this branch's mapping (low/medium/high pass through; xhigh/max fall back to 'high'). As written, the clamp policy lives in two places: a ladder change (new tier, rank change) must be re-derived by hand in this switch while the central helper's tests cover only the one copy, so the two generators' clamp behavior can drift.

const clamped = clampReasoningEffort(reasoning.effort, ['low', 'medium', 'high']);
if (clamped !== reasoning.effort && !this.effortClampWarned) {
  debugLogger.warn(
    `reasoning.effort='${reasoning.effort}' is not supported by Gemini; clamping to 'HIGH'.`,
  );
  this.effortClampWarned = true;
}
const thinkingLevel = { low: 'LOW', medium: 'MEDIUM', high: 'HIGH' }[clamped] as ThinkingLevel;

(the fix spans the whole switch, so no one-click suggestion block)

— qwen3.8-max via Qwen Code /review (v0.21.11)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. Addressed in b516805

Comment on lines +426 to +427
it('omits both knobs when no effort is configured', async () => {
const gen = generatorFor('gemini-2.5-pro');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The newly added reasoning.effort === undefined branch (reasoning object present but no effort) has no test coverage: this test — despite its name — constructs the generator with no reasoning object at all, so it exercises the earlier !reasoninggetParameterValue default path instead.

Failure scenario: ContentGeneratorConfig.reasoning is false | { effort?: ReasoningEffort; budget_tokens?: number }, so an effort-less reasoning object ({} or { budget_tokens: 1024 }) is a representable input. Mutation probe: with the if (reasoning.effort === undefined) return { includeThoughts: true }; early return deleted, { model: 'gemini-2.5-pro', reasoning: {} } silently sends { includeThoughts: true, thinkingBudget: 16384 } (non-2.5: thinkingLevel: 'HIGH') instead of letting the model pick its own default — and the full 25-test suite still passes, so that mutant ships green.

Add a case that constructs the generator with a reasoning object lacking effort:

it('omits both knobs when reasoning is set without an effort', async () => {
  const gen = generatorFor('gemini-2.5-pro', {});

  await gen.generateContent(
    { model: 'gemini-2.5-pro', contents: [] },
    'prompt-id',
  );

  expect(sentThinkingConfig()).toEqual({ includeThoughts: true });
});

— qwen3.8-max via Qwen Code /review (v0.21.11)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. Addressed in 2d07a2b

The hand-rolled xhigh/max clamp duplicated the Anthropic generator's
use of the same helper. Switching to it keeps clamp policy in one
place so a ladder change doesn't need re-deriving per adapter.
The existing "no effort configured" test passed reasoning: undefined,
which exercises the earlier !reasoning branch, not the new
reasoning.effort === undefined branch added for a reasoning object
present without an effort (e.g. {} or { budget_tokens }).

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not explored to full depth (tool budget reached): "You are review agent reverse-audit — Reverse audit agent…": none — all checks I started were completed within budget.; "PR #9094 changes the Gemini content generator to send…": did not run npm run typecheck repo-wide (verified via test run + source reads instead).; "PR #9094 changes the Gemini content generator to send…": verifying whether the live Gemini API still rejects thinkingBudget >= maxOutputTokens (docs fetches on ai.google.dev no longer document the budget constraint; g….

Test Plan (not a blocker): 28 passing — this review observed 1364, 19469, 1539, 494, 3485, 559 passed.

中文说明

未探索到全部深度(达到工具调用预算):"You are review agent reverse-audit — Reverse audit agent…"none — all checks I started were completed within budget."PR #9094 changes the Gemini content generator to send…"did not run npm run typecheck repo-wide (verified via test run + source reads instead)."PR #9094 changes the Gemini content generator to send…"verifying whether the live Gemini API still rejects thinkingBudget >= maxOutputTokens (docs fetches on ai.google.dev no longer document the budget constraint; g…

Test Plan(非阻断):28 passing — this review observed 1364, 19469, 1539, 494, 3485, 559 passed

— qwen3.8-max via Qwen Code /review (v0.21.11)

Comment on lines +229 to +233
const clamped = clampReasoningEffort(reasoning.effort, [
'low',
'medium',
'high',
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R2-1: gemini-3-pro-preview supports only the low/high thinking levels, but this shared ladder sends MEDIUM whenever effort === 'medium' — the same failure class this PR eliminates for Gemini 2.5. Google's thinking docs list per-model supported levels (gemini-3-pro-preview: low, high; Gemini 3 Flash: minimal/low/medium/high), and the repo's own design doc records the same split (Gemini 3 Pro → LOW/HIGH). Verified by probe at the reviewed commit: effort: 'medium' + gemini-3-pro-preview emits thinkingConfig: { includeThoughts: true, thinkingLevel: 'MEDIUM' }, and the retargeted test maps reasoning effort 'medium' to MEDIUM now asserts that unsupported value against exactly this model. The mapping predates this PR, but the PR rewrites this exact function and re-asserts the mapping in the test. — Failure scenario: a user sets /effort medium (or has persisted model.reasoningEffort: medium) on gemini-3-pro-preview → every request carries thinkingLevel: 'MEDIUM' → requests are rejected and the model is unusable at medium effort, reproducing for Gemini 3 the very 400 this PR fixes for 2.5.

Suggested fix: make the ladder per-model — clamp Pro-family Gemini 3 models with ['low', 'high'] (the rank-based clamp then maps medium/xhigh/maxhigh), keep ['low', 'medium', 'high'] for Gemini 3 Flash / other level-style models, and update the retargeted medium test accordingly.

中文说明

[Critical] R2-1:gemini-3-pro-preview 只支持 low/high 两个 thinking 档位,但这个共享梯级在 effort === 'medium' 时仍会发送 MEDIUM——这与本 PR 要为 Gemini 2.5 消除的失败属于同一类。Google 的 thinking 文档按模型列出了支持的档位(gemini-3-pro-preview:low、high;Gemini 3 Flash:minimal/low/medium/high),仓库自己的设计文档也记录了同样的划分(Gemini 3 Pro → LOW/HIGH)。已在被审 commit 上用探针验证:effort: 'medium' + gemini-3-pro-preview 会发出 thinkingConfig: { includeThoughts: true, thinkingLevel: 'MEDIUM' },而被重新定向的测试 maps reasoning effort 'medium' to MEDIUM 现在恰恰针对这个模型断言了这个不被支持的值。该映射在本 PR 之前就已存在,但本 PR 重写了这个函数本身,并在测试中重新确认了这一映射。— 失败场景:用户在 gemini-3-pro-preview 上设置 /effort medium(或已持久化 model.reasoningEffort: medium)→ 每个请求都会带上 thinkingLevel: 'MEDIUM' → 请求被拒绝,该模型在 medium 档位下完全不可用,等于在 Gemini 3 上复现了本 PR 要为 2.5 修复的那个 400。

建议修复:把梯级改为按模型区分——对 Gemini 3 Pro 系列用 ['low', 'high'] 做收敛(基于 rank 的收敛会把 medium/xhigh/max 映射到 high),对 Gemini 3 Flash 及其他 level 风格模型保留 ['low', 'medium', 'high'],并相应更新被重新定向的 medium 测试。

— qwen3.8-max via Qwen Code /review (v0.21.11)

Comment on lines +42 to +43
const BUDGET_STYLE_MODEL = /gemini-2\.5/;
const BUDGET_STYLE_PRO = /gemini-2\.5-pro/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-2: The user-facing per-provider reasoning table (docs/users/configuration/model-providers.md, ~line 592) still documents thinkingLevel-only wire behaviour for all Gemini models — thinkingConfig: { includeThoughts: true, thinkingLevel }, the tier→level mapping, and others → THINKING_LEVEL_UNSPECIFIED — all three claims are now wrong for the 2.5 family, which sends thinkingBudget buckets (2048/8192/16384, xhigh/max → 32768 Pro / 24576 other), never sends thinkingLevel, and sends no knob when effort is unset. The table's sibling rows describe current wire behaviour accurately — that is the house convention for it. Also stale: the ContentGeneratorConfig.reasoning doc comment (packages/core/src/core/contentGenerator.ts, ~line 152, "Gemini caps at 'high'"), since 2.5's xhigh/max now map to budget ceilings instead of capping. Verified verbatim at the reviewed commit; this diff updates neither. — Concrete cost: a user configuring reasoning.effort for a gemini-2.5-* model reasons about what is sent from false premises (e.g. expects thinkingLevel: HIGH for /effort max when the wire actually carries thinkingBudget: 32768), producing misdiagnosis and bug reports against behaviour the docs promise but no longer exists.

Suggested fix: update the Gemini row (or split it by family): Gemini 3 → thinkingLevel with low→LOW, medium→MEDIUM, high/xhigh/max→HIGH (one-time clamp warning); Gemini 2.5 → thinkingBudget with low→2048, medium→8192, high→16384, xhigh/max→32768 (Pro) / 24576 (other 2.5); effort unset → neither knob. Add the 2.5 nuance to the reasoning field comment.

中文说明

[Suggestion] R2-2:面向用户的 per-provider reasoning 表格(docs/users/configuration/model-providers.md,约第 592 行)仍然把所有 Gemini 模型的线上行为描述为只发 thinkingLevel——thinkingConfig: { includeThoughts: true, thinkingLevel }、档位→level 映射、以及 others → THINKING_LEVEL_UNSPECIFIED——对 2.5 系列来说,这三条现在全都错了:2.5 系列发送的是 thinkingBudget 分桶(2048/8192/16384,xhigh/max → Pro 为 32768 / 其他为 24576),从不发送 thinkingLevel,未设置 effort 时两个旋钮都不发送。该表格的其他行都准确描述了当前的线上行为——这是这张表的既有约定。同样过时的还有 ContentGeneratorConfig.reasoning 的文档注释(packages/core/src/core/contentGenerator.ts,约第 152 行,"Gemini caps at 'high'"),因为 2.5 的 xhigh/max 现在映射到 budget 上限而不是被压到 high。已在被审 commit 上逐字核实;本 diff 两处都没有更新。— 具体代价:为 gemini-2.5-* 模型配置 reasoning.effort 的用户会基于错误的前提推断实际发送的内容(例如以为 /effort max 会发 thinkingLevel: HIGH,而线上实际带的是 thinkingBudget: 32768),导致误诊断,以及针对文档承诺但已不存在的行为提交 bug 报告。

建议修复:更新 Gemini 这一行(或按系列拆成两行):Gemini 3 → thinkingLevellow→LOW、medium→MEDIUM、high/xhigh/max→HIGH(一次性收敛警告);Gemini 2.5 → thinkingBudgetlow→2048、medium→8192、high→16384、xhigh/max→32768(Pro)/ 24576(其他 2.5);未设置 effort → 两个旋钮都不发。同时在 reasoning 字段的注释里补上 2.5 的差异说明。

— qwen3.8-max via Qwen Code /review (v0.21.11)

Comment on lines +240 to +242
const thinkingLevel = (
{
low: 'LOW',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: The new thinkingLevel record's low: 'LOW' entry is never exercised — no test drives a level-style (non-2.5) model with effort: 'low'; the only 'low' case in the suite is the 2.5 budget path (['low', 2048] in the it.each). Verified by mutation probe: changing low: 'LOW' to low: 'MEDIUM' leaves all 26 tests green, while a temporary probe test (effort 'low' on gemini-3-pro-preview) fails under the mutation and passes against the real code — so the entry is genuinely unexercised, and the probe distinguishes. — Failure scenario: a typo or future regression in the low entry ships with every test green — a user running /effort low against a Gemini 3 model would silently send thinkingLevel: 'MEDIUM' (more thinking tokens and cost than requested), and no test would catch it.

Suggested fix: add one level-path case — e.g. a gemini-3-pro-preview generator with reasoning: { effort: 'low' } asserting thinkingConfig: { includeThoughts: true, thinkingLevel: 'LOW' }.

中文说明

[Suggestion] R2-3:新的 thinkingLevel 映射表中 low: 'LOW' 这一项从未被执行到——没有任何测试用 level 风格(非 2.5)模型跑 effort: 'low';测试套件里唯一的 'low' 用例是 2.5 的 budget 路径(it.each 里的 ['low', 2048])。已通过变异探针核实:把 low: 'LOW' 改成 low: 'MEDIUM' 后全部 26 个测试仍然通过,而一个临时探针测试(在 gemini-3-pro-preview 上跑 effort 'low')在该变异下会失败、在真实代码下会通过——说明这一项确实没有被覆盖,且探针本身是有效的。— 失败场景:low 项里的笔误或未来的回归会在所有测试全绿的情况下溜出去——在 Gemini 3 模型上执行 /effort low 的用户会悄悄发出 thinkingLevel: 'MEDIUM'(比用户要求的消耗更多 thinking token 和成本),而没有任何测试能发现。

建议修复:补一个 level 路径的用例——例如用 gemini-3-pro-preview 构造 generator 并设置 reasoning: { effort: 'low' },断言 thinkingConfig: { includeThoughts: true, thinkingLevel: 'LOW' }

— qwen3.8-max via Qwen Code /review (v0.21.11)

Comment on lines 187 to 188
this.buildThinkingConfig(request.model),
'thinkingConfig',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-4: When a config-level reasoning effort is active, buildThinkingConfig returns a defined value, and getParameterValue's config-over-request precedence discards explicit request-level thinkingConfig opt-outs — verified by probe: config reasoning: { effort: 'high' } + request-level { thinkingBudget: 0, includeThoughts: false } on gemini-2.5-pro ships { includeThoughts: true, thinkingBudget: 16384 }. This affects the four internal sites that pass thinkingBudget: 0 (acp-integration/generation.ts, goals/goalJudge.ts, goals/goal-verifier.ts, goals/goal-checkpoint-verifier.ts): pre-PR the same input failed loudly (400 thinking_level); post-PR it succeeds with thinking enabled — a visible failure becomes a silent override on latency-sensitive paths. The Risk & Scope section deliberately defers this (and reasoning: false not stopping 2.5 thinking), but no open issue tracks either deferral — a search finds only #9019, which this PR closes. — Concrete cost: with /effort set, the ACP fast-generation path's explicit opt-out is overridden and its cheap completion runs with thinking enabled (budget up to 32768) — added latency and token cost on the path that asked for none — and after merge nothing tracks the deferred work once the PR body scrolls out of view.

Suggested fix: file a follow-up issue capturing both deferred items (the precedence clobber of the four budget-0 sites — including the 2.5 Pro 128-minimum wrinkle noted in the issue thread — and reasoning: false not stopping 2.5 thinking) and reference it in Risk & Scope; or, if config-wins is the intended policy, document it in buildThinkingConfig so the next maintainer doesn't discover the discard by debugging a slow fast path.

中文说明

[Suggestion] R2-4:当配置级 reasoning effort 生效时,buildThinkingConfig 返回一个有定义的值,而 getParameterValue 的"配置优先于请求"优先级会丢弃请求级显式传入的 thinkingConfig 退出开关——已用探针验证:配置 reasoning: { effort: 'high' } + 请求级 { thinkingBudget: 0, includeThoughts: false },在 gemini-2.5-pro 上实际发出 { includeThoughts: true, thinkingBudget: 16384 }。受影响的是四个传 thinkingBudget: 0 的内部调用点(acp-integration/generation.tsgoals/goalJudge.tsgoals/goal-verifier.tsgoals/goal-checkpoint-verifier.ts):PR 之前同样的输入会响亮地失败(400 thinking_level);PR 之后请求成功但 thinking 被开启——一个可见的失败变成了对延迟敏感路径上的静默覆盖。Risk & Scope 部分有意推迟了这一问题(以及 reasoning: false 无法停止 2.5 thinking 的问题),但目前没有任何 open issue 跟踪这两个被推迟的事项——搜索只能找到 #9019,而本 PR 会将其关闭。— 具体代价:设置了 /effort 之后,ACP 快速生成路径显式的退出开关被覆盖,本应廉价的补全会带着 thinking 运行(budget 最高可达 32768)——在一个明确要求不思考的路径上平添延迟和 token 成本;而且合并之后,一旦 PR 描述淡出视野,就没有任何东西继续跟踪这些被推迟的工作。

建议修复:开一个后续 issue 记录这两个被推迟的事项(四个 budget-0 调用点被优先级覆盖的问题——包括 issue 讨论中提到的 2.5 Pro 最小 128 的细节——以及 reasoning: false 无法停止 2.5 thinking 的问题),并在 Risk & Scope 中引用它;或者,如果"配置优先"就是预期策略,请在 buildThinkingConfig 中写明,避免下一位维护者靠排查一条变慢的快速路径才发现这个覆盖。

— qwen3.8-max via Qwen Code /review (v0.21.11)

Comment on lines +234 to +235
if (clamped !== reasoning.effort && !this.effortClampWarned) {
debugLogger.warn(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-5: The effort-clamp warning guard — including the warn-once effortClampWarned latch — has no test pinning when it must fire: the test-efficacy probe (harness validated by positive control; 3/3 hunk probes killed) forced the condition true and all 26 tests stayed green (mutant-survived). The clamping mapping is gated (its hunk was killed), but the diagnostic path is not. — Failure scenario: a future edit that breaks this condition — inverting the comparison so in-range efforts (low/medium/high) warn spuriously, or dropping the latch so every request re-logs — ships with every test green, silently corrupting the only signal users get that their configured reasoning.effort was downgraded for Gemini 3 models.

Suggested fix: add a test asserting debugLogger.warn is called once when effort is clamped (e.g. xhighHIGH), is NOT called for an in-range effort (low/medium/high), and is not called a second time for a repeat clamped request (the latch).

中文说明

[Suggestion] R2-5:effort 收敛的警告守卫——包括只警告一次的 effortClampWarned 闩锁——没有任何测试固定它何时必须触发:测试有效性探针(harness 已通过正向对照验证;3/3 的 hunk 探针被杀死)把该条件强制为真后,全部 26 个测试依旧全绿(mutant-survived)。收敛映射本身是有测试把关的(对应 hunk 被杀死),但这条诊断路径没有。— 失败场景:未来某个破坏该条件的改动——比如把比较写反,导致档位内的 effort(low/medium/high)也误发警告;或者丢掉闩锁,导致每个请求都重复打日志——会在所有测试全绿的情况下溜出去,悄悄破坏用户得知自己配置的 reasoning.effort 在 Gemini 3 模型上被降档的唯一信号。

建议修复:补一个测试,断言 effort 被收敛时(例如 xhighHIGHdebugLogger.warn 恰好被调用一次;对档位内的 effort(low/medium/high)不被调用;对重复的收敛请求不再第二次调用(闩锁生效)。

— qwen3.8-max via Qwen Code /review (v0.21.11)

undici77 pushed a commit to undici77/qwen-code-no-telemetry that referenced this pull request Aug 17, 2026
…#9175)

* fix(review): repair seven pipeline defects found by live runs

Four full reviews (PRs QwenLM#9113, QwenLM#9094, QwenLM#9109, QwenLM#9106) were run headless against
qwen3.8-max and watched step by step. Every one of them reached a verdict, and
every one of them also exposed a defect in the pipeline itself. Each fix below
carries the measurement that found it.

The incremental anchor was being withheld for a reason that says nothing about
which lines were read. A dimension nobody can run — on this repo, the
integration suite CI skips and the local budget cannot fit — capped the verdict,
the cap withheld the anchor, and the missing anchor sent the next round over the
full diff again: 119 minutes and 34M input tokens on a pull request whose code
had not changed a line. The anchor now answers to the coverage evidence alone,
recomputed from the harness's own transcripts, so a depth gap no longer costs a
range. Two of the four runs had capped and two had not, on identical mechanical
facts, which is the second reason this could not stay a judgment call.

The build-and-test dimension could not finish, and no amount of reallocation
would have made it. One shell call is capped at ten minutes by the tool itself;
this repo needs more than that (install, the scoped builds, then a core suite at
106s and a CLI suite measured at 401s). Since the ceiling is per call, the run
now continues across calls: a resumed call reuses the installed and compiled
tree, runs only the suites the previous call could not reach, and merges into
the same report. A suite killed on a deadline the budget had shortened is
recorded as provisional rather than as a timeout, so the continuation knows to
give it a full window instead of reproducing the kill. The per-command deadline
is sized to the slowest measured suite, and the whole-call budget is now derived
from the tool's ceiling rather than from the deadline.

A machine ledger posted by another account was invisible, which turned off
cross-round recovery in precisely the case it was built for: continuous
integration posts as a bot and a maintainer runs as themselves. The two halves
of that ledger are not the same claim — the findings are a work list every round
re-rules against the code, while the reviewed-at commit decides which lines the
next round skips — so the list now travels across accounts and the anchor never
does.

The attribution of test failures was reading a truncated record. The failing
file set for the pull request side was re-parsed from a bounded report, which on
a live run recovered one file out of eleven; the same set is now measured where
the raw output still exists, before the bound is applied. The loss ran in the
direction that matters: a file only the pull request side fails is exactly the
one that should have been reported.

Three smaller repairs round it out. A review launched from the bundle never
learned which build it belonged to, so every helper command it shelled out to
resolved whatever happened to be installed on the path — one live run died on
its second command. A "nothing to disclose" answer written in Chinese was
classified as a real gap and published as one, in a body whose own evidence said
the opposite. And a count field surrounded by list fields was sent a list twice
in four runs, so it now says what to send instead.

* fix(review): tell a continuation that ended before its test phase apart from one that finished

A run that never reached its test phase — a failed install, the disk-space
gate, a budget spent during the build, a deliberate build-only probe — carries
neither a test scope nor a test result, so a continuation finds nothing to do
and said the run had reached every suite in scope. That is the same shape this
branch fixes elsewhere: prose asserting the opposite of the evidence beside it.
It now says no suite ran and names the fix, and a test pins the two facts apart.

* fix(review): close the nine findings the pipeline raised on this branch

The review this branch changes was pointed at this branch, and it filed three
blockers and six suggestions. All of them hold; each fix carries the probe that
found it.

The Chinese placeholder classifier was dropping real disclosures. It claimed to
mirror the English branch's narrowing and did not: without an all-done head the
span before the completion word swallowed a gap clause, the single-character
negation lookbehind let "not yet finished" read as finished, and forty free
characters after the completion word swallowed the clause carrying the gap.
Eight real sentences were classified as no-answers, which is the direction that
certifies depth nobody reached. The branch now requires the same head the
English side has always required, extends the lookbehinds, and ends at the
completion word with only a budget adverbial after it.

A continuation was declaring victory it had not won. A retry admitted late is
killed again — the ordinary outcome when an expensive suite gets the tail of a
budget — and the merged note still said every suite in scope had run, while a
provisional result sat in the report. Retries the budget never reached were
dropped from the accounting altogether, because a retry is a command and the
not-run list holds workspaces. Both now count, and the note names what is still
provisional and why another continuation is worth it.

A continuation also dropped the framing an install failure owes the reader. The
fresh path prepends it on every return precisely because the structured field
alone was judged insufficient; the merge replaced the note wholesale, so a
report carrying a non-zero install arrived with nothing saying that failure is
infrastructure rather than something to file against the pull request. The
framing is now shared between both paths.

The rest are smaller. The budget help text described an admission rule this
branch had already replaced, contradicting the code, the tests, and the sibling
flag's own description. The continuation test could not fail: it searched the
whole prompt, so text from the first invocation block and the sibling brief
satisfied every assertion, and deleting the block it guards left it green — it
now isolates that block, and a mutation confirms it fails when the block goes.
The report guard checked one of the three arrays the merge walks, so a partial
report died on a raw type error instead of the named refusal that is the
guard's whole purpose. Two prose copies of a constant this branch consolidated
now interpolate it. And the comment at the ledger recovery site still described
the own-account-only model this branch removes, at exactly the boundary a
reader auditing the change would consult.

* fix(review): answer the second review round's blockers

Ten more blockers, from the pipeline reviewing this branch a second time. The
sharpest one is about the change this branch makes to the anchor.

Exempting an unreviewed dimension from withholding the anchor was too wide. The
field carries two different claims: a dimension nobody could RUN, which says
nothing about which lines were read, and a lens that whiffed — made some tool
calls, opened some files, returned nothing substantive twice — which is a claim
about lines that no machine detector produces. The first cut exempted both, so a
twice-whiffed security pass could advance the range past the lines it never
reviewed, and the fixture that pinned exactly that was deleted along the way.
The exemption now rests on a fact the code can check rather than on the field's
name: exactly one dimension declares that it reads no diff, and only its gaps
are depth. The deleted fixture is restored, and the other direction is pinned
beside it.

The Chinese no-answer classifier is rewritten as a closed vocabulary. Spelling
the completion clause as bounded spans that merely refuse to cross an exception
word invited exactly what two review rounds then found: negations the
single-character lookbehind could not see, inability modifiers, hedged
completions, gap clauses swallowed on either side of the completion word, and a
span that slid past a negated completion to a later affirmed one. Every one of
those drops a real disclosure, which is the direction this module's own header
calls the worse of the two. A closed vocabulary cannot be walked through,
because there is nothing to walk: a sentence carrying a gap is built from pieces
the clause does not contain, so it fails to match and is kept. All thirteen
evasions are now keep-tested against it.

A continuation could destroy the report it was asked to continue. When no
toolchain applied at the worktree root, the early returns built a fresh report
with no reference to the previous one, and the handler wrote it over the file
the run had just read — a wrong or pruned worktree path would replace an
in-flight report with an empty one, and the chain stayed dead after the path was
fixed. A continuation now refuses instead, and the refusal leaves the file
untouched.

Recovery across accounts could also run the round counter backward. Rounds are
an id space, so a recovered round that goes down re-issues ids the pull request
already carries against different findings. A bot whose own recovery failed
transiently posts a round-one marker after a round-seven one, and ordering by
timestamp alone hands the next round a two. The counter only ever advances, so
preferring the highest round cannot lose a newer work list.

The rest: the report guard now validates the scope the merge walks, not only
its arrays; the ledger's one uncapped field is capped now that the read path
takes text any account can post; the build-and-test brief names the third shape
of unfinished work, a single-package repo whose budget stopped before its only
suite; and the continuation test builds its paths the way its neighbours do
instead of spelling them for one platform.

* fix(review): answer the third round, whose blockers were all mine

Every blocker this round came from last round's fixes. That is worth saying
plainly: the repairs were made under review pressure and shipped their own
defects, and the pipeline caught each one.

Two were outright bugs in the report guard. It read a field off the parsed
value before checking that value was an object, so a file containing the
literal `null` produced the raw type error the guard exists to replace — and it
checked that the command lists were lists without checking what was in them, so
a list holding a null cleared the gate and died one layer deeper. Both refuse
now, with the recovery instruction the sibling branches already carried.

The Chinese clause had reintroduced the overlapping-quantifier shape this
module's header bans and its linearity test exists for: four optional groups
chained across whitespace matchers, in a language that does not put whitespace
between those tokens. The whitespace bought nothing but the backtracking. It is
gone, and the pathological input that walks that shape is now in the linearity
test beside the others.

The anchor decision was reading the dimension list after a pre-existing splice
had already removed every entry mentioning the review time budget — a splice
that exists so the body does not say one gap twice, and that matches on a
phrase. An entry whose free-form reason merely mentions the budget was
therefore invisible to the decision, and that entry is exactly the
line-coverage claim the decision must respect. It now reads the list as
disclosed and renders from the spliced one.

And the two copies of the continuation rule disagreed with each other inside
one prompt about the shape that cannot be continued at all. They now say the
same thing: report the dimension unfinished, and do not spend a continuation on
a report that has no scope for one to read.

* fix(review): answer the fourth round; the anchor list has three writers

The dimension list the anchor decision reads is written at three different
points — the caller's own entries, the budget-phrase splice that removes some
of them, and the deterministic gates that push their machine-owed debts in
later — and the last two rounds each fixed one end by breaking the other. The
first version read after the splice and missed the entries it removed; the
second read before the gates and missed an unlinted script or an unwalked
defect layer, either of which is a line-coverage claim, not a dimension nobody
could run. It now reads the live list at the decision point plus the entries
the splice removed, which is the only view that sees every writer.

Two more from making foreign markers authoritative. The round a marker claims
is the id space itself, so preferring the highest round hands an unbounded one
from any poster a permanent win — and past 2^53 the increment stops advancing,
so every later round re-stamps the same ids against different findings. Rounds
are now capped on read and mirrored on write, fail-quiet like every other
malformation. And a transient identity lookup no longer costs the recovery: it
used to degrade to "no ledger", which leaves this machine's side file at the
round it last wrote while other accounts post past it, so the next verdict
re-issues ids the pull request already carries. The lookup is isolated now, and
a failure recovers the work list as foreign — no anchor rides on an identity
the run could not confirm.

* fix(review): give the report a run identity; keep foreign ids out of the pipeline's namespace

The fifth round found the two holes still open in the two boundaries this
branch loosened, and both fixes are about identity rather than shape.

A continuation trusted any well-shaped report at the out path, and that path is
stable across review rounds while nothing sweeps it on an interrupted one — so
the report a crashed round leaves behind is exactly what the next round's
resume finds. Continuing it keeps the old commit's passing entries on the new
round's tree, certifying old-commit passes for the new commit, and skips the
install the fresh worktree never had. Every report now records the run it
belongs to — the tree it ran in, and the commit its plan fetched — and a
continuation refuses anything else, a report with no identity included: one
that cannot prove it belongs here reads the same as one that provably does not.

And the trust split stripped the anchor from a foreign marker but let its
finding ids through, which hands any account the pipeline's own namespace: a
marker at round N carrying ids from round N+1 pre-claims exactly the prefix the
next compose stamps, splitting one claim across two ids and renumbering every
genuinely new finding past the squatted block. A legitimate marker cannot claim
an id from a future round — a round stamps its own ids and carries older ones
forward — so the parser now drops any finding that does, read-side only,
because the writer cannot produce the violation.

* fix(review): refuse element-level corruption instead of crashing on it

Two crash shapes from the sixth round, both the same lesson one level deeper
than the round-three fix that checked the lists: the elements are the payload.
A continuation's not-run entries become shell commands, so a list holding a
null cleared an arrays-only guard and crashed in the escaper instead of
refusing with the named fix. And the failing-file set rides a report file
anything may have edited, with exactly one consumer — a set that is not a
string array reached the set arithmetic as-is, where the honest reading is the
one an absent field has always taken: this seam supplied no measurement, fall
back to re-parsing the stored output.

* fix(review): answer the seventh round — instance identity, relay independence, and the cap round-trip

Five blockers, all in this branch's own additions, three of them in the fixes
earlier rounds bought.

The run identity was strings where it needed an instance. The path and the sha
are identical after fetch-pr destroys and recreates the worktree — which it
does every round — so the identity check admitted a continuation onto a bare
tree with no installed or compiled state, whose every suite then failed with
resolution errors framed as candidate findings against the pull request. The
report now records the worktree root's inode and birth time, which a recreated
directory cannot keep, and a continuation onto a previous instance refuses
with the reason spelled out. No legitimate continuation crosses a recreation;
the valid resumes all happen inside one round.

The anchor decision was relay-dependent for a budget or round-cap stop:
identical machine state carried the anchor when the orchestrator dropped the
mandated stop entry and withheld it when the entry was relayed — and the
stderr instruction mandates the relay, so every compliant run paid the
full-diff re-review this exemption exists to end. The stop's relayed entry now
classifies as depth, and only against the marker the machine itself wrote: no
marker, no exemption, so stop-shaped prose cannot buy an anchor the state does
not support, and a lens entry that mentions the phrase in its reason withholds
as before.

The caveat gets the cure the note already had. A resume appended to it, so a
completed chain still read "still to run" over suites that had just passed,
and the dimension brief tells the agent to quote a present caveat as
possibly-incomplete scope. Superseded budget-stop and earlier resume segments
are retired, live limitations survive verbatim, and a chain that finishes with
none ends with the caveat absent — the field's own contract for full coverage.

The round cap broke its own round-trip: the stamp was uncapped while the
serializer clamped, so at exactly the cap the writer produced a marker whose
own parser dropped every finding — invisibly, with the anchor still riding.
The one writer now caps its stamp, which also makes the squat filter's
premise true again.

And the bare "check" noun in the Chinese token made the classifier drop
standalone "did not check" lines — a live gap under the brief's own rule that
the line is only written when something was cut short. The noun group keeps
the two documented placeholder nouns and nothing else.

* fix(review): answer the eighth round — exact machine text, anchored retirement, and the local round's edge

Four blockers; three sit in the seventh round's fixes and one is the
second round's finding still alive under its replacement.

The stop exemption matched a head plus a phrase, and that shape also covers a
genuine line-coverage claim whose whiffed scope IS the reverse audit — an
entry the phrase splice then removes from the rendered body, so the anchor
rode past a whiffed audit while the posted review showed only the benign
disclosure. The exemption now matches the exact entries the machinery mints,
nothing looser: marker-anchored and text-anchored, and an edited or
paraphrased relay withholds, which is the safe direction.

The caveat retirement had two holes with one shape. Its regex matched the
marker phrases anywhere in a segment, and segments interpolate file names
from the reviewed diff — so a file named after the phrase retired the live
limitation quoting it, untrusted input silently certifying scope. And the
resume clause itself emitted the segment separator inside one clause, so a
second continuation cut it in half: the head retired, the tail — "N still to
run" — survived into a report whose note says everything ran. Retirement now
anchors on the producers' own grammar at the segment start, and the clause is
one segment.

The run identity had no edge a local round crosses. Local plans carry no sha
and the project root is never recreated, so every clause compared equal
across rounds and an interrupted round's report certified pre-edit results
for the edited tree. The plan file is the one thing every round writes afresh
in both modes; its mtime now rides the identity, and a report stamped against
a previous round's plan refuses with the reason named.

The report gate and the delta seam also stop trusting vacuous content:
workspace names and commands must be non-empty — an empty workspace resolves
npm to the root suite, a different measurement wearing the requested one's
name — timed-out entries are strings, and an empty failing-file set reads as
no measurement, since the producer omits the field rather than writing one.

* fix(review): answer the ninth round — structural caveats, bounded foreign rounds, and the starved suite

Four blockers. One had stood since round one, and two survive earlier fixes in
sharper forms; the shape of the repairs is the lesson of the round.

The caveat is no longer parsed at all. Two attempts at retiring superseded
clauses by re-reading rendered prose both lost to PR-authored names — first a
filename matching the phrase, then a workspace dir embedding the segment
separator plus the clause grammar, which fabricated a boundary and retired the
live limitation's honest tail with the fake clause. The fresh run now records
the scope's own caveat in a separate field whenever it appends a machine
clause; a continuation carries that string through untouched and rebuilds the
joined prose from it plus its own current clause. Absent means nothing was
appended and the whole caveat is live. Nothing content-matches, so nothing can
be talked out of a limitation by a name in the reviewed diff.

The resume path now honors the ordering invariant the fresh path documents.
Not-run suites are stored alphabetically, and a continuation that consumed
them verbatim starved the changed workspace's suite to the budget's worst tail
on every continuation — a chain could hit its cap with the one suite the diff
changed never run, disclosed but never measured. Retries first, then the
affected suites, then the dependents.

A foreign round implausibly far past this account's own is no longer adopted.
Round-first selection had a fixed point an attacker could pin with one post: a
round at the cap outranks every real round forever, the capped stamp holds the
counter there, and every later round re-issues the same ids against different
findings. Rounds advance one per posted review, so a legitimate interleave
sits a handful ahead at most; beyond our own highest plus a headroom of
sixty-four, a foreign marker is not a newer work list — it is not a work list
at all. Inside the bound, a hostile post can only inflate the counter by a
bounded step, which costs numbers and nothing else.

And the stop-entry splice reads both languages, because the exemption already
admits the Chinese pair as a compliant relay: with an English-only phrase the
relayed Chinese entry survived into the whiffed-dimension rendering beside the
structural stop line — the same gap said twice, one copy under the wrong
cause. The relay-independence test now asserts the strongest form available:
the body is byte-identical whether the entry was relayed in English, relayed
in Chinese, or dropped.

* fix(review): answer the tenth round — the cache reads the marker, and a typecheck slip

Two blockers. One is mine in the plainest sense: a test added late in round
nine called a helper with too few arguments, and the local gate that would
have caught it — the typecheck — was the one step that round skipped. Fixed,
and the same test now also relays the Chinese round-cap pair, pinning byte
identity across all three relay states for that branch too.

The other is the first round's finding come back through the document: the
skill's cache rule was a hand-copied condition list, and it aged out of sync
with the module the moment the anchor net grew the depth-only distinction — a
whiffed-lens round had its sha withheld by the marker and cached by the
prose. The rule is now mechanical instead of descriptive: the cache advances
exactly when the composed body's marker carries a sha, because the module
already computed the net and two copies of it is how the two anchors came to
disagree about what a clean round is.

From the suggestions: resume with build-only now refuses instead of silently
ignoring the flag — the pair names no work at all; the lockfile's
merge-collateral peer-flag churn is reverted to main's copy; and three
comments now say what is true — the inode does not separate instances on a
filesystem that reuses them (the plan mtime is the cross-round floor there),
an inside-bound hostile round can still win one recovery's work list (which
is re-ruled, like every foreign work list), and a failed identity lookup
bounds recovery to the headroom rather than recovering everything.

* fix(review): answer the eleventh round — a proven identity, a validated identity, and the bullet the merge ate

Three blockers, and the middle one is a regression my own round-four fix
introduced.

The isolated identity lookup turned a rate-limit blip into proof of absence.
With the lookup's failure swallowed into a null login, the recovery walk had
no name to look for, recorded "no own review exists" about an identity it
never knew, and the deletion arm removed the side file and reset the round
counter — the id-space collision this whole recovery redesign exists to
prevent, delivered by a transient network error. The pre-isolation code got
this right by accident, because the throw reached the outer catch and took
the conservative strip path. Deletion now requires a CONFIRMED identity, and
the handler-level pin drives the real handler with a throwing lookup and
asserts nothing is removed.

The identity gate crashed on the one corrupt shape it did not name: the
report's run field was never validated, so `tree: null` slipped past a
presence-only check and died on a null dereference inside the very gate that
exists to refuse with a named fix. The gate now validates everything the
identity check walks — root, sha, plan, and the tree fingerprint's own
fields — and the crash shapes joined the refusal table.

And the merge resolution had eaten the skill's documentation for a state
field the module still consumes: the duplicate-drop account's bullet was
adjacent to a bullet this branch rewrote, and the conflict resolution kept
the rewrite and dropped the neighbour — leaving a channel the verdict
arithmetic counts with no documented way to reach it. Restored verbatim.

From the suggestions: the lockfile's merge-collateral churn is reverted
again — this time after the last npm operation rather than before the next
one — and the seven comments the sweep named now describe the shipped
mechanisms: the structural caveat carry-through that replaced both parses,
the stamp that writes this build's own entry instead of blanking the slot,
the truncation flag that fires only on the re-parse fallback and loses in
both directions, the depth exemption's full exception set, and the
continuation contract's retry-first half.

* fix(review): answer the twelfth round — the union that ends displacement, and the gate's last fields

Two blockers.

A foreign marker one round ahead — deep inside the plausibility bound — could
displace this account's entire work list, and displaced entries owed no
ruling, so one drive-by comment retired a certified Critical from the marker
chain for the rest of the PR's life; the doctored variant copies the own list
minus the entry to suppress. A foreign winner is now MERGED over the own
latest findings instead of replacing them: own entries are authoritative on
an id collision, foreign entries with new ids join after, the merged list
re-caps with an honest dropped count, and the round number still advances —
the counter is a shared id space, the work list is not a prize. The headroom
comment now says what is true because of this, rather than what was hoped.

And the continuation's shape gate now validates the last fields the merge
walks: a non-iterable affected crashed the ordering seed, a string notBuilt
crashed the refusal's join, and a bare true notBuilt — the worst shape —
skipped the unbuilt-tree refusal silently and ran suites against packages
that were never compiled, manufacturing the exact failures the refusal
exists to prevent. The caveat strings are typed too, since the brief quotes
them.

From the suggestions: the resumed note carries the caveat exactly as the
fresh path does, counts build failures into its passed/failed sentence so it
cannot contradict the recomputed ok beside it, and the stray duplicated
doc-comment opener is gone.

* fix(review): answer the thirteenth round — exec evidence, a command grammar, and an empty login

Three blockers from the automatic review, all probe-confirmed on the
merge head.

R1-11 (isUnusableScriptEntry): the gate answered 'usable' by extension
enumeration — anything outside .js/.mjs/.cjs returned usable with no
isFile/X_OK/shebang check — so the childEnv stamp could hand skill
subcommands an entry no shell can exec: a tsx dev launch stamps the 0644
index.ts, and 'node <pkg-dir>' stamps the DIRECTORY (which passes an
X_OK probe as search permission). Every '"${QWEN_CODE_CLI:-qwen}"'
then died on exit 126 where empty would have fallen back. The gate now
demands positive evidence: a regular file with the execute bit, a '#!'
header for any known script extension (the TS family included), no
shebang needed for native binaries. Both entrances are pinned at the
childEnv level and the primitive's own suite covers the three shapes.

R3-38 (--resume injection): the continuation re-executed report-stored
test[].command strings verbatim under shell: true, while the identity
check pins a report to this run's tree — not to this program's
authorship — and the gate's own corrupt-report fixtures declare the
edited-in-place report the accepted threat model. Stored commands are
now held to the emitter's grammar (npm test [--workspace="<dir>"]),
the same policy test-delta already applies before re-running report
commands; an alien command refuses the resume with a named fix, before
anything runs.

identityKnown empty-login: currentUser() answering empty-with-exit-0 (a
stubbed or proxied gh) set identityKnown=true while recoverLedger reads
'' as unknown, so the deletion arm could remove the prev-ledger side
file — resetting the round counter — over an identity that was never
proven. Empty is now unknown, matching presubmit's own '' handling.

Each fix is pinned by a test its reverted mutant fails.

* fix(review): answer the fourteenth round — an anonymous recovery cannot swap the list

R13-1: during an identity-lookup outage (currentUser throws, or answers
empty) every marker walks as FOREIGN — there is no me — so the union
that protects the certified work list never has an own side to merge
over, and the recovered winner was written to the side file WHOLESALE: a
drive-by marker posted at this account's current round (visible to any
authenticated user, inside the zero-base headroom) replaced this
machine's last known-good list on equal round + larger review id, and
the swap persisted — the marker stays on the PR, so every later outage
reopened it. The suppression class the union merge exists to kill,
reopened through the anonymous path.

persistRecoveredLedger now takes identityKnown and gives the anonymous
recovery its own outcome: with a readable existing file, a same-round
winner changes NOTHING, and a strictly-higher round advances only the
round counter (refusing that too would re-expose R4-2's id-space
collision — a lagging counter re-issues ids the PR already carries) plus
the reviewId tiebreak; the findings stay this machine's own, and
sha/commitId are dropped — an anonymous round cannot be re-vouched, and
an anchor superseded by rounds this account never certified must not
scope the next review (the healthy foreign-winner path strips it at the
recovery seam for the same reason). With no readable file there is
nothing to protect and the write stays wholesale.

The new suite pins the drive-by (file byte-identical), the advance
(counter moves, list survives, anchor and age reference gone — with
noOwnReview deliberately true so a positional swap of the two booleans
deletes the file and fails the test), and the no-file arm; the guard
mutant fails exactly the first two.
…g-budget

Resolve the geminiContentGenerator -> llm-content-generator split:

Main renamed the module and left the old path as a re-export shim while
this branch rewrote geminiContentGenerator.ts. Take main's shim and
3-way replay the branch's thinkingBudget work onto
llm-content-generator.ts (git merge-file against the merge base); the
only non-applying hunk was the observeGeminiStream -> observeLlmStream
rename, kept as main spells it.

llm-content-generator.test.ts: take this branch's side of each hunk (the
existing thinkingLevel cases move to gemini-3-pro-preview, which is the
point of the fix) with the constructor renamed to LlmContentGenerator.

tsc -p packages/core clean; 25 tests pass.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R2-2 stale per-provider reasoning table + contentGenerator.ts reasoning doc comment — still stands, already reported (comment 3779075219)
  • R2-3 thinkingLevel record's low:'LOW' entry never exercised by any test — still stands, already reported (comment 3779075223)
  • R2-4 config-level reasoning discards request-level thinkingConfig opt-outs (four budget-0 sites); deferred work tracked by no issue — still stands, already reported (comment 3779075228)
  • R2-5 effort-clamp warning guard and warn-once latch have no test pinning when the warning must fire — still stands, already reported (comment 3779075237)
中文说明

本轮确认的 4 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.3)

Comment on lines +227 to +231
const clamped = clampReasoningEffort(reasoning.effort, [
'low',
'medium',
'high',
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R2-1: [fails-closed] gemini-3-pro-preview supports only the low/high thinking levels, but this shared ladder sends MEDIUM whenever effort === 'medium' — the same failure class this PR eliminates for Gemini 2.5. Google's thinking docs list per-model supported levels (gemini-3-pro-preview: low, high), and the repo's own design doc records the same split (docs/design/2026-06-30-unified-reasoning-effort-cli.md: "Gemini 3 Pro → LOW/HIGH, Flash → MINIMAL/LOW/MEDIUM/HIGH"). A user who sets /effort medium (or has persisted model.reasoningEffort: medium) on gemini-3-pro-preview gets thinkingConfig: { includeThoughts: true, thinkingLevel: 'MEDIUM' } on every request; if the live API's per-model support matches the doc, the requests are rejected and the model is unusable at medium effort — reproducing for Gemini 3 the very 400 this PR fixes for 2.5. The mapping predates this PR, but this PR rewrites this exact function and re-asserts the mapping: the retargeted test maps reasoning effort 'medium' to MEDIUM now asserts the unsupported value against exactly this model. This is the still-standing round-2 Critical, re-verified by probe at this commit.

Witness:

probe at the reviewed commit (mocked @google/genai, recorded generateContent config):
gemini-3-pro-preview + effort:'medium' -> { includeThoughts: true, thinkingLevel: 'MEDIUM' }
control: gemini-3-pro-preview + effort:'xhigh' -> thinkingLevel: 'HIGH' (the rank clamp itself works)
control: gemini-2.5-pro + effort:'medium' -> { includeThoughts: true, thinkingBudget: 8192 }
flip (Pro ladder clamped to ['low','high']): gemini-3-pro-preview + effort:'medium' -> thinkingLevel: 'HIGH'

Suggested fix: make the ladder per-model — clamp Gemini 3 Pro-family models with ['low', 'high'] (the rank-based clamp then maps medium/xhigh/maxhigh), keep ['low', 'medium', 'high'] for Gemini 3 Flash and other level-style models, and update the retargeted medium test accordingly.

The fix must respect two facts: the design doc (docs/design/2026-06-30-unified-reasoning-effort-cli.md:108-110) records "Gemini 3 Pro → LOW/HIGH", and clampReasoningEffort (packages/core/src/core/reasoning-effort.ts) prefers the next-stronger supported tier — under ['low','high'] medium clamps UP to high, so keep the clamp rather than hand-mapping. Add a test asserting effort: 'medium' on gemini-3-pro-preview emits thinkingLevel: 'HIGH' (the retargeted test currently asserts the opposite and must flip), and prove it by removing the per-model ladder and watching that test go red.

中文说明

[Critical] R2-1:gemini-3-pro-preview 只支持 low/high 两个 thinking 档位,但这个共享梯级在 effort === 'medium' 时仍会发送 MEDIUM——这与本 PR 要为 Gemini 2.5 消除的失败属于同一类。Google 的 thinking 文档按模型列出了支持的档位(gemini-3-pro-preview:low、high),仓库自己的设计文档也记录了同样的划分(docs/design/2026-06-30-unified-reasoning-effort-cli.md:"Gemini 3 Pro → LOW/HIGH,Flash → MINIMAL/LOW/MEDIUM/HIGH")。用户在 gemini-3-pro-preview 上设置 /effort medium(或已持久化 model.reasoningEffort: medium)后,每个请求都会带上 thinkingConfig: { includeThoughts: true, thinkingLevel: 'MEDIUM' };如果线上 API 的按模型支持与该文档一致,请求会被拒绝,该模型在 medium 档位下完全不可用——等于在 Gemini 3 上复现了本 PR 要为 2.5 修复的那个 400。该映射在本 PR 之前就已存在,但本 PR 重写了这个函数本身,并在测试中重新确认了这一映射:被重新定向的测试 maps reasoning effort 'medium' to MEDIUM 现在恰恰针对这个模型断言了这个不被支持的值。这是第二轮遗留、至今仍然成立的 Critical,本轮已在被审 commit 上用探针重新验证。

证据:在被审 commit 上用探针验证(mock @google/genai,记录 generateContent 的 config):gemini-3-pro-preview + effort:'medium' 发出 { includeThoughts: true, thinkingLevel: 'MEDIUM' };对照 gemini-3-pro-preview + effort:'xhigh' 发出 thinkingLevel: 'HIGH'(基于 rank 的收敛本身正常);对照 gemini-2.5-pro + effort:'medium' 发出 { includeThoughts: true, thinkingBudget: 8192 };把 Pro 梯级收敛到 ['low','high'] 后,同一行翻转为 thinkingLevel: 'HIGH'

建议修复:把梯级改为按模型区分——对 Gemini 3 Pro 系列用 ['low', 'high'] 做收敛(基于 rank 的收敛会把 medium/xhigh/max 映射到 high),对 Gemini 3 Flash 及其他 level 风格模型保留 ['low', 'medium', 'high'],并相应更新被重新定向的 medium 测试。

修复必须尊重两个既有事实:设计文档(docs/design/2026-06-30-unified-reasoning-effort-cli.md:108-110)记录了 "Gemini 3 Pro → LOW/HIGH";且 clampReasoningEffort(packages/core/src/core/reasoning-effort.ts)优先选择下一个更强的受支持档位——在 ['low','high']medium 会向上收敛到 high,因此应保留收敛逻辑而不是手工映射。补一个测试,断言 gemini-3-pro-previeweffort: 'medium' 发出 thinkingLevel: 'HIGH'(被重新定向的测试目前断言了相反的值,必须翻转),并在移除按模型区分的梯级后确认该测试变红,以此证明测试有效。

— qwen3.8-max via Qwen Code /review (v0.22.3)

Comment on lines +60 to +64
case 'xhigh':
case 'max':
return BUDGET_STYLE_PRO.test(model)
? THINKING_BUDGET_CEILING_PRO
: THINKING_BUDGET_CEILING;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-1: this diff makes the shipped setting description "Gemini caps at high" false for the 2.5 family, in user-facing places the round-2 docs finding (R2-2) did not list: packages/cli/src/config/settingsSchema.ts:1568 (the model.reasoningEffort description, showInDialog: true, so it renders in the /settings dialog) and its mirror docs/users/configuration/settings.md:176; packages/vscode-ide-companion/schemas/settings.schema.json:690 carries the same sentence and is generated from the schema. Pre-PR the claim was accurate — every Gemini model clamped xhigh/max to thinkingLevel: 'HIGH'. Post-PR, a gemini-2.5-* model maps xhigh/max to thinkingBudget 32768 (Pro) / 24576 (other), strictly above high's 16384 bucket: a user on a 2.5 model who reads the settings dialog or the settings reference concludes /effort xhigh|max is pointless on Gemini, never selects it, and silently misses the doubled thinking budget this PR exists to deliver — or files "max does nothing on Gemini" as a bug when the wire payload in fact grows.

Witness:

probe at the reviewed commit:
gemini-2.5-pro  effort=xhigh -> {"includeThoughts":true,"thinkingBudget":32768}
gemini-2.5-pro  effort=max   -> {"includeThoughts":true,"thinkingBudget":32768}
gemini-2.5-flash effort=max  -> {"includeThoughts":true,"thinkingBudget":24576}
gemini-2.5-pro  effort=high  -> {"includeThoughts":true,"thinkingBudget":16384}
gemini-3-pro-preview effort=xhigh -> {"includeThoughts":true,"thinkingLevel":"HIGH"}
wording at packages/cli/src/config/settingsSchema.ts:1568 (showInDialog: true, rendered in /settings):
"...Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at "high"; Anthropic clamps tiers a model lacks)..."

Suggested fix: reword both copies — e.g. Each provider maps and clamps this to what the active model supports (e.g. Gemini 3 caps at "high"; Gemini 2.5 maps xhigh/max to its thinking-budget ceiling; Anthropic clamps tiers a model lacks). — edit settingsSchema.ts first, update docs/users/configuration/settings.md:176 by hand, then regenerate packages/vscode-ide-companion/schemas/settings.schema.json via npm run generate:settings-schema.

中文说明

[Suggestion] R3-1:本 diff 使得随代码发布的设置描述 "Gemini caps at high"(Gemini 最高到 high)对 2.5 系列不再成立,涉及两处第二轮文档发现(R2-2)没有列出的用户可见位置:packages/cli/src/config/settingsSchema.ts:1568model.reasoningEffort 的描述,showInDialog: true,会渲染在 /settings 对话框中)及其镜像 docs/users/configuration/settings.md:176packages/vscode-ide-companion/schemas/settings.schema.json:690 带有同一句话,由 schema 生成。PR 之前这一说法是准确的——所有 Gemini 模型都会把 xhigh/max 收敛到 thinkingLevel: 'HIGH'。PR 之后,gemini-2.5-* 模型把 xhigh/max 映射为 thinkingBudget 32768(Pro)/ 24576(其他),严格高于 high 的 16384 分桶:在 2.5 模型上读到设置对话框或设置文档的用户会以为 /effort xhigh|max 在 Gemini 上没有意义,从而永远不去选择它,悄悄错过本 PR 正要提供的翻倍 thinking 预算——或者在线上 payload 实际变大的情况下,把 "max 在 Gemini 上没用" 当成 bug 提出来。

证据:在被审 commit 上用探针验证:gemini-2.5-pro effort=xhigh 发出 {"includeThoughts":true,"thinkingBudget":32768}effort=max 同样为 32768、gemini-2.5-flash effort=max 为 24576,而 effort=high 为 16384;gemini-3 一侧 effort=xhigh 仍收敛为 thinkingLevel:"HIGH"settingsSchema.ts:1568 处的描述(showInDialog: true,渲染于 /settings)仍写着 "Gemini caps at "high""。

建议修复:改写这两处描述——例如 Each provider maps and clamps this to what the active model supports (e.g. Gemini 3 caps at "high"; Gemini 2.5 maps xhigh/max to its thinking-budget ceiling; Anthropic clamps tiers a model lacks).——先改 settingsSchema.ts,手工更新 docs/users/configuration/settings.md:176,再用 npm run generate:settings-schema 重新生成 packages/vscode-ide-companion/schemas/settings.schema.json

— qwen3.8-max via Qwen Code /review (v0.22.3)

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

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gemini 2.5 models are unusable on Vertex AI: thinkingLevel is always sent, including the UNSPECIFIED placeholder

4 participants