Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
19 KiB
修复单 · 第 0 步 toolChoice: required 让全部咨询整轮失败(BUG-282 复发)+ 供应商拒收被咨询流静默吞掉(2026-09-17)
0. 基线
- 基线 commit:
db5b0a21(origin/staginghead)。staging 已部署dc2f2a16(/api/health的deployment.gitCommit),即 BUG-922/923 的实现;其后到 head 只有会话列表 / 钉顶等前端改动,与本单无交集。 - 分支:
codex/consult-followup-tool-contract-fix-20260917,git worktree add -b codex/consult-followup-tool-contract-fix-20260917 .worktrees/consult-followup-tool-contract-fix-20260917 origin/staging。 - 范围:只有前端
frontend/src/mastra/consultation-tools.ts、frontend/src/lib/stream-agent-response.ts、frontend/src/lib/agent-observability.ts(如需扩错误码枚举)与对应测试。不动 Python、不动迁移、不动 Skill、不动page.tsx、不动生时校正 Agent、不动contractReady()。 - BUG 段:BUG-937 起(基线
docs/BUG_HISTORY.md最大号 BUG-936(另一会话同日已占 936),开工时再核对一次)。 - 优先级:P0。按 §2 的推断,本命与申报时段两条路线的每一轮咨询在 staging 上都会失败,不只是追问轮。
1. 事故实证
产品负责人 2026-09-17 在 staging 部署 dc2f2a16 之后再次聊天,得到与 BUG-922/923 完全相同形状的失败。事件流原样如下(已去掉会话与用户标识):
| 事件 | 内容 |
|---|---|
run.started |
runtime: mastra-agentic |
skill.started / skill.completed |
jyotish-vedic-astrology |
activity |
phase: loading-method,「正在补齐方法与计算步骤」 |
run.failed |
code: runtime_contract_incomplete,「Agent 未完成必要的方法与计算步骤,本次不会扣点。」 |
回执 skill |
loaded: true, referenceReads: 0, methodologySections: 0 |
回执 steps |
只有两步:1 skill: completed、2 validation: runtime-contract-retry: completed,没有任何 tool 步骤 |
回执 stepBudget |
planned: 11, used: 2 |
回执 workflow |
route: declared-birth-window,status: blocked,missingLayers: ["birth-minute"],techniqueTruth: declared-window |
代码定位(按符号,基线 db5b0a21):
frontend/src/mastra/consultation-tools.ts的consultationNatalPrepareStep与consultationWindowPrepareStep:第 0 步返回{ activeTools: [<排盘工具>], toolChoice: "required" },第 1 步起"auto"。这是 BUG-923 在dc2f2a16加的。frontend/src/app/api/consult/route.ts:natalStreamOptions/windowStreamOptions都是{ ...streamOptions, prepareStep },而streamOptions展开了consultationGenerationSettings(selectedModel.model);该函数固定thinking: "enabled"(agentGenerationSettings(model, { thinking: "enabled" }))。本命首轮(streamWithOverflowRetry(agent, natalStreamOptions))、窗口首轮(streamWithOverflowRetry(agent, windowStreamOptions))以及两条路线的合同retry都走这套选项。node_modules/@mastra/core(lock 固定1.50.1)dist/chunk-TOBPSKTN.js的循环:每一步先调prepareStep,把返回的toolChoice交给prepareToolsAndToolChoice,字符串"required"原样变成{ type: "required" }送到供应商。类型上接受,运行时是否被供应商接受是另一回事。- 同文件循环末尾:供应商调用抛错时,Mastra 不会让
fullStream迭代抛异常,而是controller.enqueue({ type: "error", error })然后closeStream(),流正常结束、没有finish块。 frontend/src/lib/stream-agent-response.ts的mapChunk只认data-jyotish-activity/tool-call/tool-result/tool-error四种块,consumeAttempt只对step-finish/finish/reasoning-delta/text-delta做事。全文没有任何一处判断chunk.type === "error"。于是一个失败的供应商调用在咨询流里表现为:没有工具步骤、没有文本、没有 finish、没有报错,contractReady()自然为假,进runtime-contract-retry,第二轮用同一套windowStreamOptions/natalStreamOptions再失败一次,最终runtime_contract_incomplete。frontend/src/lib/rectification-agentic/v9/agent-run.ts:校正 Agent 早就处理这条路——if (chunk.type === "error" || chunk.type === "abort") streamFailed = true,并把原文含Thinking mode does not support this tool_choice的错误归类为thinking_tool_choice_unsupported(BUG-282)。咨询流从未同步这层。
2. 根因
主根因(推断,置信度高,见 §2a)——BUG-282 复发。 BUG-282(2026-08)实证:本仓使用的 thinking 模式供应商拒绝任何非 auto 的 tool_choice,返回原文 Thinking mode does not support this tool_choice、isRetryable: false;该条的防复发写明「thinking 模式不能发送 named 或 required tool_choice。需要限制第一步工具时,用 activeTools 收窄集合,把选择权留给 auto」。BUG-630 的修复也是据此只用 activeTools + "auto"。BUG-923 把两条咨询路线的第 0 步改成 "required",而这两条路线的首轮固定开 thinking,于是第 0 步的供应商调用直接被拒。因为每一轮的第 0 步都必经这里,不只是追问轮,本命与申报时段的每一次咨询在 dc2f2a16 之后都会以同一形状失败。
次根因(确定)——咨询流把 Mastra 的 error 块当空气。 供应商拒收在回执、事件流、agent-observability 日志三处都不可见:回执只剩 skill + retry 两步,modelFinishReason 为空(没有 finish 块),错误码被 contractReady() 翻译成「合同未完成」。校正 Agent 在 BUG-282 时已经把这条路做成可诊断的 thinking_tool_choice_unsupported,咨询流没有对齐。这也是为什么 BUG-922/923 的任务书 T4 要求去 VPS 拉日志取证——即使拉了,日志里也没有这个错。
排除的候选:
- 「模型带了非法参数、Mastra 在
execute前拒掉」:窗口工具run-jyotish-window-consultation的inputSchema只有question: z.string().trim().min(1).max(500),z.object默认剥离未知键,几乎不可能被拒;本命路线遇到输入拒绝会由recordUnrecordedToolFailure补一条tool: failed步骤,回执里也没有。 - 「模型没调工具」:
required下供应商要么拒收、要么必返回工具调用;二者都不会产生「零工具步骤 + 无报错」以外的形状,只有拒收能同时解释回执与事件流。
2a. 为什么写「推断」而不是「确认」
本机没有模型凭据,无法向 staging 使用的供应商发一次带 tool_choice: required + thinking 的请求;VPS 日志因为次根因也不含供应商原文。证据链是:BUG-282 的供应商原文 + dc2f2a16 引入的 required + Mastra 1.50.1 的 error 块路径 + 咨询流零处理 + 回执形状完全吻合。T2 落地之后的第一次真实运行会把供应商原文写进日志,那才是最终确认;若那时错误码不是 thinking_tool_choice_unsupported,按 §6 处理,不得硬套。
2b. 同日晚间新增反证(2026-09-17 23:12 截图)
同一部署 dc2f2a16 下,产品负责人用 gpt-5.6-luna 在本命路线发「我 15 年高考成绩如何」,事件流走到「读取分析方法 ✓ → 正在计算本命盘(第 1/2 项)」,即第 0 步的强制工具调用被这个供应商接受了。这说明:
- 「供应商拒收
required」不能解释所有模型;上一条申报时段失败若也是gpt-5.6-luna,主根因就不成立,真实错误只能靠 T2 落地后的日志看到。T2 因此升为本单第一优先,先做 T2 再做 T1。 - T1 仍然保留:模型目录里可以切换模型,BUG-282 实证过至少一个 thinking 供应商拒收;
activeTools+auto+ BUG-922 提示词在所有供应商上都成立。 - 那次截图本身是另一个问题(计算阶段两分钟无结果、无失败态),另立单,不并进本单。
3. 决策记录
- 本单撤销 BUG-923 的
toolChoice: "required",回到 BUG-282 / BUG-630 的口径:第 0 步只用activeTools收窄到排盘工具,toolChoice为"auto"。 BUG-923 记录里「若线上某模型拒收,另立单,不得静默改回 auto 而不改提示词」——本单就是那张单;提示词侧的 BUG-922(每轮必调、packet 不跨请求)保留不动,它才是 BUG-922/923 事故里模型跳过工具的真正原因。 - 责任说明:
TASK-consult-followup-tool-contract-20260917.md的 T2 是本会话(Claude)写的,写时没有检索到 BUG-282 的防复发条款,执行方按单做了。这不是执行方的错。本单在 BUG-937 记录里如实写「复发自 BUG-282」,并说明旧防复发为何没拦住:BUG-282 的约束只落在rectification-v9-agent.test.ts里锁校正 Agent 的第一步,咨询侧没有任何测试禁止required,任务书作者也没检索到。 - 不在本单里尝试「第 0 步关 thinking 再发
required」。 Mastra 的prepareStep返回值支持providerOptions覆盖,理论上可以在第 0 步关 thinking 换取确定性的工具调用;但本仓没有任何证据证明该供应商在非 thinking 模式接受required,且第 0 步是模型选领域的一步,关掉推理会改变回答质量。这条路要另立研究单、带真实供应商实测,不得在本单顺手试。 - 产品负责人 2026-09-17 授权:先止血(T1)并把错误做成可见(T2),不等 T4 取证;T4 的日志证据在部署后补。
- 不推翻任何既有红线:
contractReady()「恰好一次成功调用」不放宽;BUG-214(失败尝试后重试成功仍过门禁)语义保持;BUG-286(契约未绿即丢弃文本)语义保持;BUG-922 的提示词保持。
4. 硬红线
- 不改
contractReady();不改扣点语义(失败轮仍不扣点)。 - 咨询两条路线的第 0 步
toolChoice只能是"auto";不得为了让工具「一定被调」再发 named / required(BUG-282)。 mapChunk/consumeAttempt对error块的处理不得把供应商原文、请求体、用户资料写进公开事件;公开事件只带闭合错误码,原文只进服务端日志且脱敏(对齐 BUG-282「attempt 失败日志不含正文」)。page.tsx、jyotish_api_server.py、Skill、.gitea/workflows/**不动。- 既有测试断言不得静默弱化;改 BUG-923 那两条
required断言时写「原值 / 新值 / 原因」三栏。 - Bug 历史与进度记录不写会话 ID、用户 ID、出生资料。
5. 任务分解
T1 第 0 步撤回 required,只留 activeTools(BUG-937)
consultationNatalPrepareStep/consultationWindowPrepareStep:第 0 步改回{ activeTools: [<排盘工具>], toolChoice: "auto" };第 1 步起保持{ toolChoice: "auto" }(保留activeTools不设,即全部工具可用,与现状一致)。- 在两个函数上方加一条注释指向 BUG-282 / BUG-937:thinking 模式不得发 named / required。
- 验收:
frontend/tests/consultation-agentic-runtime.test.ts里 BUG-923 加的两条断言改为toolChoice: "auto",三栏写「原值required/ 新值auto/ 原因 BUG-282 供应商拒收,BUG-937」。- 新增一条源码契约(放
consultation-workflow-contract.test.ts):consultation-tools.ts全文doesNotMatch(/toolChoice:\s*"required"/),并doesNotMatch(/type:\s*"tool",\s*toolName/),把 BUG-282 的约束落到咨询侧。 - 既有「natal first step exposes only the chart calculation tool」、
route.ts申报时段分支使用windowStreamOptions的契约(dc2f2a16加的)原样绿——prepareStep本身保留,只是不再 required。
T2 咨询流识别 Mastra error 块并给出可诊断错误码(BUG-938)
frontend/src/lib/stream-agent-response.tsconsumeAttempt:遇到chunk.type === "error"时记录失败并立即结束本次 attempt,抛出携带闭合错误码的Error:- 错误原文含
Thinking mode does not support this tool_choice→thinking_tool_choice_unsupported; - 其它 →
provider_error。 - 原文的提取方式对齐
rectification-agentic/v9/agent-run.ts的做法(取error.message/String(error),不取cause里的请求体)。
- 错误原文含
- 在
state.steps里追加一步{ kind: "validation", name: "model-stream-error", status: "failed" },让回执能看见「模型循环本身失败了」,与「模型没调工具」区分开。 - 供应商错误不触发合同
retry。 目前contractReady()为假就调options.retry(),对拒收错误来说第二次必然同样失败,只是多等一轮。实现方式:consumeAttempt抛出的错误直接冒到start()的catch,不进if (!contractReady(options) && options.retry)分支(它在consumeAttempt之后,所以抛出即跳过,无需改门禁)。BUG-214 的「工具失败后重试成功」路径不受影响:工具失败走的是tool-error/tool-result块,不是error块。 - 公开事件
run.failed的code枚举(consultation-agent-events.ts第 116 行)不扩:这两个码对用户没有区别,公开层继续映射到calculation_failed(文案「咨询暂时无法完成,本次不会扣点」)。可诊断码只进:runFailedCode()的内部返回(新增内部码,映射到公开calculation_failed);agent-observability.ts的knownErrorCodes:加thinking_tool_choice_unsupported、provider_error,让errorCode字段原样落日志;- 一条
console.error("[consult-provider-error]", { requestId, code, messageHead }),messageHead截 200 字符且不含请求体。
- 验收:
consultation-agentic-runtime.test.ts新增:喂一个只含{ type: "error", error: new Error("Thinking mode does not support this tool_choice") }的假流,断言公开事件序列是run.started→ skill 两条 →run.failed code=calculation_failed,没有activity loading-method、没有第二次stream调用(retry传一个会assert.fail的函数),回执steps含validation model-stream-error failed,onError收到的错误message === "thinking_tool_choice_unsupported"。- 同文件再一条:
{ type: "error", error: new Error("upstream 502") }→provider_error,公开码仍calculation_failed。 agent-observability.test.ts(若无则加):toAgentObservabilityErrorCode(new Error("provider_error")) === "provider_error"。- 既有「a calculation that succeeds only after failed attempts still satisfies the contract」「text written before the contract completes is dropped」「contract retry runs when the first attempt made no tool call」类测试原样绿。
T3 记录
docs/BUG_HISTORY.md:- 新增 BUG-937(
toolChoice: required复发 BUG-282,两条咨询路线整轮失败)。状态resolved,「复发自:BUG-282」,写明旧防复发为何未拦住(约束只锁在校正 Agent 测试与记录文字里,咨询侧无测试、任务书作者未检索到)。关联 BUG-282、BUG-630、BUG-922、BUG-923。 - 新增 BUG-938(咨询流不处理 Mastra
error块,供应商拒收不可见、合同重试空转)。关联 BUG-282、BUG-214、BUG-268(同为「回执缺字段导致不可观测」)。 - BUG-923 记录追加一行「最近更新 2026-09-17:
required被 BUG-937 撤回,防复发改为activeTools+auto」,状态保持resolved但修复描述要改成实际留下的做法,不得留着「第 0 步 required」当防复发。
- 新增 BUG-937(
CHANGELOG.md一条:修复部署dc2f2a16后所有咨询整轮失败;Skill 版本不变。docs/tasks/PROGRESS-consult-followup-tool-contract-fix-20260917.md:测试数字、失败清单与基线逐条比对、gzip 前后、两条断言的三栏说明。docs/tasks/README.md:本单一行,并把TASK-consult-followup-tool-contract-20260917.md那行状态改为「已部署但被 BUG-937 撤回 required,见修复单」。
T4 部署后取证(不占 BUG 号,留给产品负责人 / 部署侧)
本单合入并部署后,产品负责人在 staging 分别发一轮本命咨询与一轮申报时段咨询。两种结果:
- 都能回答:主根因确认,本单关闭。
- 仍失败:web 容器日志会出现
[consult-provider-error]与agent-observability里的errorCode。取证命令:
# 在 VPS 上,以 deploy 用户
cd /opt/jyotisha-production
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
| grep -E 'consult-provider-error|agent-observability' | tail -20
若 errorCode 是 thinking_tool_choice_unsupported 却仍失败,说明 T1 没生效(查部署 SHA);若是 provider_error,把 messageHead 原文(脱敏)交给 Claude 另立单。
6. 让步顺序
- T2 若
state.steps的kind枚举扩展会牵动agentExecutionReceiptSchema与客户端解析(kind: z.enum(["skill", "tool", "validation"])),就用kind: "validation"+status: "failed",不扩枚举。 - T2 若
runFailedCode()的内部码与公开码分离改动过大,最低要求是:error块必须让 attempt 结束、不进合同 retry、agent-observability的errorCode能区分provider_error与runtime_contract_incomplete。 - T1、T3 不可让步。
7. 开工前置命令
git fetch origin --prune
git worktree add -b codex/consult-followup-tool-contract-fix-20260917 .worktrees/consult-followup-tool-contract-fix-20260917 origin/staging
cd .worktrees/consult-followup-tool-contract-fix-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线总数与失败清单
grep -o "^## BUG-[0-9]*" ../docs/BUG_HISTORY.md | sed 's/## BUG-//' | sort -n | tail -1 # 应为 936
grep -n 'toolChoice: "required"' src/mastra/consultation-tools.ts # 应恰好 2 处,做完为 0
grep -n '"error"' src/lib/stream-agent-response.ts # 开工应为 0 处
验收口径:tsc 0 错;lint 0 error;npm test 失败清单与基线逐条一致(无 Docker 的那组不算新增)且总数不减;next build 后 / 仍 Static;首屏 gzip ±2%(本单不动客户端 bundle,应为 0 变化)。部署后 /api/health 的 deployment.gitCommit 等于本单合入 staging 的 SHA。
8. 环境缺口
- 本机无模型凭据,无法向供应商实测
required的拒收原文;由 T4 部署后补。 - 无登录态,无法在 staging 复现;真机走查条目:本命会话问一句正经问题、再问「?」;申报时段会话同样两句;四次都应得到回答,且回执
steps含tool步骤。