Files
Jyotisha/docs/tasks/TASK-consult-followup-tool-contract-fix-20260917.md
T

19 KiB
Raw Blame History

修复单 · 第 0 步 toolChoice: required 让全部咨询整轮失败(BUG-282 复发)+ 供应商拒收被咨询流静默吞掉(2026-09-17)

0. 基线

  • 基线 commit:db5b0a21(origin/staging head)。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. 硬红线

  1. 不改 contractReady();不改扣点语义(失败轮仍不扣点)。
  2. 咨询两条路线的第 0 步 toolChoice 只能是 "auto";不得为了让工具「一定被调」再发 named / required(BUG-282)。
  3. mapChunk / consumeAttempt 对 error 块的处理不得把供应商原文、请求体、用户资料写进公开事件;公开事件只带闭合错误码,原文只进服务端日志且脱敏(对齐 BUG-282「attempt 失败日志不含正文」)。
  4. page.tsx、jyotish_api_server.py、Skill、.gitea/workflows/** 不动。
  5. 既有测试断言不得静默弱化;改 BUG-923 那两条 required 断言时写「原值 / 新值 / 原因」三栏。
  6. 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.ts consumeAttempt:遇到 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」当防复发。
  • 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. 让步顺序

  1. T2 若 state.steps 的 kind 枚举扩展会牵动 agentExecutionReceiptSchema 与客户端解析(kind: z.enum(["skill", "tool", "validation"])),就用 kind: "validation" + status: "failed",不扩枚举。
  2. T2 若 runFailedCode() 的内部码与公开码分离改动过大,最低要求是:error 块必须让 attempt 结束、不进合同 retry、agent-observability 的 errorCode 能区分 provider_error 与 runtime_contract_incomplete。
  3. 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 步骤。