fix(rectification): stop forcing named tool_choice on thinking models
Independent Staging Quality Gate / validate (push) Successful in 10m5s
Independent Staging Quality Gate / publish (push) Has been cancelled

Homepage opening failed immediately because thinking-mode providers reject a required first-tool choice.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 20:33:08 +08:00
parent 37e62094d7
commit fa6019a303
4 changed files with 75 additions and 5 deletions
+16
View File
@@ -4199,3 +4199,19 @@
- 相关记录:BUG-266(同一 runner 上的磁盘耗尽与 6 小时网络 prune)、BUG-264(本地全量 `npm test` 并发跑 `database-*` fixture 的既有抖动)
- 复发自:BUG-266(回收只覆盖磁盘与 6 小时以上空网络,未覆盖地址池)
- 修复版本:待提交
## BUG-282 | 生时纠正 opening 强制 named tool_choicethinking 模型立刻 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
- 复发自:无
- 修复版本:待提交
@@ -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<V9Agen
outcome = await streamAttempt(attemptNumber, attemptId);
} catch (error) {
const errorCode = safeErrorCode(error);
const reason = error instanceof Error ? error.message.slice(0, 180) : "UnknownError";
console.error(
`[rectification-v10] attempt failed case=${caseId} turn=${turnId} attempt=${attemptId} code=${errorCode} reason=${reason}`,
);
outcome = {
ok: false,
status: isRetryableError(errorCode) ? "retryable" : "failed",
@@ -486,7 +493,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
abortSignal: AbortSignal;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: { type: "tool"; toolName: string };
toolChoice: "auto";
} | undefined;
},
): Promise<{
@@ -500,10 +507,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
// Thinking-mode providers reject named/required tool_choice. Restrict
// the first step to read-case and keep tool_choice auto; the runner
// still refuses any other public tool before case.loaded.
prepareStep: ({ stepNumber }) => stepNumber === 0
? {
activeTools: ["rectification-read-case"],
toolChoice: { type: "tool", toolName: "rectification-read-case" },
toolChoice: "auto",
}
: undefined,
});
@@ -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);
@@ -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");
});