From fa6019a303d90e672fbc628714a7f14f81975cd0 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 18 Aug 2026 20:33:08 +0800 Subject: [PATCH] fix(rectification): stop forcing named tool_choice on thinking models Homepage opening failed immediately because thinking-mode providers reject a required first-tool choice. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++++++++ .../lib/rectification-agentic/v9/agent-run.ts | 14 ++++++- frontend/tests/rectification-v9-agent.test.ts | 11 ++++-- .../tests/rectification-v9-stream.test.ts | 39 +++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3f72e8ea..c55f9948 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4199,3 +4199,19 @@ - 相关记录:BUG-266(同一 runner 上的磁盘耗尽与 6 小时网络 prune)、BUG-264(本地全量 `npm test` 并发跑 `database-*` fixture 的既有抖动) - 复发自:BUG-266(回收只覆盖磁盘与 6 小时以上空网络,未覆盖地址池) - 修复版本:待提交 + +## BUG-282 | 生时纠正 opening 强制 named tool_choice,thinking 模型立刻 run.failed + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:staging 首页进入生时纠正、`POST /api/rectification/agent` 的 `action=opening`、V10 `runV9AgentTurn` 第一步 `prepareStep` +- 用户现象:从首页进入生时纠正后流立即结束。NDJSON 只有 `run.started` 接着 `run.failed`,没有 `skill.bound`、`case.loaded` 或任何回答增量。 +- 触发条件:登录后从首页打开生时纠正;当前会话模型处于 thinking/reasoning 模式。 +- 根因:runner 为了保证第一步读取 Case,把 `prepareStep` 设成 `toolChoice: { type: "tool", toolName: "rectification-read-case" }`。上游 thinking 模式拒绝任何非 auto 的 `tool_choice`,返回 `Thinking mode does not support this tool_choice` 且 `isRetryable: false`。该错误被压成泛化 `run_failed`,客户端因此只看到两个公开事件。鉴权、Case 绑定和 turn 创建都已成功,失败发生在第一次 provider 调用。 +- 修复:第一步仍只暴露 `rectification-read-case`,但 `toolChoice` 改为 `"auto"`。运行器继续拒绝在 `case.loaded` 之前调用其他公开工具。provider 若仍返回该原文,错误码改为可诊断的 `thinking_tool_choice_unsupported`,并写一条不含正文的 attempt 失败日志。 +- 验证:`rectification-v9-agent.test.ts` 断言第一步为 `activeTools=["rectification-read-case"]` 且 `toolChoice="auto"`;`rectification-v9-stream.test.ts` 新增 thinking-mode 拒绝用例,确认只尝试一次、公开事件仍是 `run.started`/`run.failed`、attempt 错误码为 `thinking_tool_choice_unsupported`。 +- 防复发:thinking 模式不能发送 named 或 required `tool_choice`。需要限制第一步工具时,用 `activeTools` 收窄集合,把选择权留给 auto;真实读取门禁继续由 runner 在 `case.loaded` 之前拦截其他公开工具。 +- 相关记录:BUG-261 +- 复发自:无 +- 修复版本:待提交 diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index f4631efa..86a11e0a 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -134,6 +134,9 @@ function safeErrorCode(error: unknown): string { if (message.includes("agentic_rectification_case_terminal")) return "case_terminal"; if (message.includes("agentic_rectification_case_not_found")) return "case_not_found"; if (message.includes("agentic_rectification_case_session_mismatch")) return "case_session_mismatch"; + if (message.includes("Thinking mode does not support this tool_choice")) { + return "thinking_tool_choice_unsupported"; + } return "run_failed"; } @@ -276,6 +279,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { activeTools: string[]; - toolChoice: { type: "tool"; toolName: string }; + toolChoice: "auto"; } | undefined; }, ): Promise<{ @@ -500,10 +507,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise stepNumber === 0 ? { activeTools: ["rectification-read-case"], - toolChoice: { type: "tool", toolName: "rectification-read-case" }, + toolChoice: "auto", } : undefined, }); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 3a4204fd..95788ea7 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -218,7 +218,7 @@ test("agent receives the exact server-owned case id for tool calls", async () => assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/); }); -test("server-loaded Skill is bound before the provider and the first model step is forced to read Case", async () => { +test("server-loaded Skill is bound before the provider and the first model step only exposes read-case", async () => { const skillInstructions = "immutable-skill-instructions-from-server"; let observedMessages: unknown[] = []; let observedStreamOptions: { @@ -259,10 +259,15 @@ test("server-loaded Skill is bound before the provider and the first model step assert.match(JSON.stringify(observedMessages), /服务器已绑定当前 Case 的精确 Skill/); assert.match(JSON.stringify(observedMessages), /不要重复调用 skill/); assert.match(JSON.stringify(observedMessages), new RegExp(skillInstructions)); - assert.deepEqual(await observedStreamOptions.prepareStep?.({ stepNumber: 0 }), { + const firstStep = await observedStreamOptions.prepareStep?.({ stepNumber: 0 }) as { + activeTools?: unknown; + toolChoice?: unknown; + }; + assert.deepEqual(firstStep, { activeTools: ["rectification-read-case"], - toolChoice: { type: "tool", toolName: "rectification-read-case" }, + toolChoice: "auto", }); + assert.equal(typeof firstStep?.toolChoice, "string"); assert.equal(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }), undefined); assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1); assert.equal(emitted.some((event) => event.type === "run.completed"), true); diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index 804f636e..8364ce5b 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -957,3 +957,42 @@ test("non-retryable attempt errors do not start a second attempt", async () => { assert.equal(turnFinalize?.args.p_assistant_message, null); assert.equal(turnFinalize?.args.p_successful_attempt_id, null); }); + +test("thinking-mode tool_choice rejection fails the opening turn without a second identical attempt", async () => { + let buildCount = 0; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }), + }); + const { options, emitted, billing } = runOptions({ + accounting: accounting.client, + action: "opening", + message: null, + buildAgent: async () => { + buildCount += 1; + return { + getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }), + stream: async () => { + throw new Error("Thinking mode does not support this tool_choice"); + }, + } as never; + }, + }); + + const result = await runV9AgentTurn(options); + + assert.equal(buildCount, 1); + assert.equal(result.ok, false); + assert.equal(result.turnStatus, "failed"); + assert.equal(result.errorCode, "thinking_tool_choice_unsupported"); + assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 }); + assert.deepEqual(emitted, [{ type: "run.started" }, { type: "run.failed" }]); + assert.equal( + accounting.calls.filter((call) => call.fn === "create_agentic_rectification_run_attempt").length, + 1, + ); + const attemptFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_run_attempt"); + assert.equal(attemptFinalize?.args.p_error_code, "thinking_tool_choice_unsupported"); +});