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

13 KiB
Raw Blame History

任务书 · 咨询追问轮不调排盘工具即整轮失败:提示词与运行合同对齐(2026-09-17)

0. 基线

  • 基线 commit:1b508d5e(origin/staging head)。
  • 分支:codex/consult-followup-tool-contract-20260917,git worktree add -b codex/consult-followup-tool-contract-20260917 .worktrees/consult-followup-tool-contract-20260917 origin/staging。
  • 范围:只有前端(frontend/src/mastra/index.ts、frontend/src/mastra/consultation-tools.ts、frontend/src/app/api/consult/route.ts 与对应测试)。不动 Python、不动迁移、不动 Skill、不动生时校正 Agent。
  • BUG 段:BUG-922 起(基线 docs/BUG_HISTORY.md 最大号 BUG-921,开工时再核对一次)。

1. 事故实证

产品负责人 2026-09-17 在 staging 一个申报出生时段(declared_birth_window)会话里连发三轮,三轮都没有得到回答。第三轮的事件流原样如下(已去掉会话与用户标识):

事件 内容
请求 consultationMode: declared_birth_window,theme: general,question: 你在说什么鬼;服务端历史只有两条用户消息「请帮我看看我家庭关系的整体模式和特点」「?」,没有任何助手消息
skill.started / skill.completed jyotish-vedic-astrology
activity phase: loading-method,「正在补齐方法与计算步骤」
run.failed code: runtime_contract_incomplete,「Agent 未完成必要的方法与计算步骤,本次不会扣点。」
回执 steps 只有两步:skill 与 validation: runtime-contract-retry,没有任何 tool 步骤
回执 workflow route: declared-birth-window,status: blocked,techniqueTruth: declared-window

代码定位(按符号):

  • 门禁:frontend/src/lib/stream-agent-response.ts 的 contractReady()。requireTool 为真时要求 consultationToolCompleted && consultationToolSuccessCount === 1。第一轮不满足则调用 options.retry() 再跑一轮,仍不满足即 throw new Error("runtime_contract_incomplete"),模型已写出的文字全部丢弃(consumeAttempt 在契约未绿时不释放文本,见 BUG-286 与测试「text written before the contract completes is dropped」)。
  • 申报时段路线:frontend/src/app/api/consult/route.ts 中 shouldRunDeclaredWindowWorkflow 分支调用 streamAgentResponse({ requireTool: true, retry, ... }),retry 的追问原文是「运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答」。
  • 窗口 Agent 系统指令:frontend/src/mastra/index.ts 的 windowJyotishInstructions,含这一句:「For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.」
  • 本命 Agent 系统指令:同文件 jyotishInstructions(第 17 行起,含 natalSpokenReportContract 之后的第 6 行)含同构的一句:「For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.」
  • 工具选择:两条路线都是 toolChoice: "auto"。本命路线 consultationNatalPrepareStep(frontend/src/mastra/consultation-tools.ts)在第 0 步只把 activeTools 收窄到排盘工具,但没有强制调用;申报时段路线用的是不带 prepareStep 的 streamOptions。
  • 历史为什么只剩用户消息:用户消息在扣点预留 RPC 里就写库(route.ts 的 p_question_message: { role: "user", text: visibleQuestion }),运行失败不补助手消息。所以前两轮「家庭关系」「?」也都失败了。

2. 根因

系统提示词与运行合同互相矛盾。 提示词明确允许模型对「简单的对话式追问」不调排盘工具、复用「已有 packet / context」;服务端合同却要求每一次请求都必须恰好一次成功的排盘工具调用,否则整轮作废。像「?」「你在说什么鬼」这类追问,模型按提示词判断为对话式追问、直接用文字回答,门禁判定合同未完成;服务端追问一句后模型仍按同一套系统指令行事,第二轮也不调工具,于是 runtime_contract_incomplete。

提示词里的「existing packet / existing context」还是事实错误:排盘结果只在单次请求内缓存(createWindowConsultationTools / createConsultationTools 的闭包变量 calculation),跨请求根本没有可复用的 packet。模型信了这句话就会跳过工具。

这两句是 2026-08-21 提交 9958e00a 写进去的,门禁是 2026-08-17 提交 7886629b 定的,两者从未对齐。Bug 历史里 runtime_contract_incomplete 的既有记录(BUG-205 缓存拒绝、BUG-214 失败次数计入门禁、BUG-286 契约未绿文本丢弃)都不是这个原因,防复发措施均仍在,本单是新问题,不是复发。

第一轮「请帮我看看我家庭关系的整体模式和特点」是正经解盘问题,它为什么失败本单不下结论,见 T4。

3. 决策记录

  • 产品负责人 2026-09-17 拍板 方案 1:删掉「简单追问可以不调工具」的例外,每一轮回答前都必须调用排盘工具。理由:工具在单次请求内有缓存、代价小;每轮都有当轮证据,符合「解盘必须有原始数据」红线。
  • 否决方案 2(保留提示词、让没调工具的轮次走不扣点的纯对话回复):追问轮会没有星盘证据,与 AGENTS.md Part B 的证据要求冲突。
  • 不推翻任何既有红线:contractReady() 的「恰好一次成功调用」口径不放宽;BUG-214 允许失败尝试后重试成功的语义保持;BUG-286 契约未绿即丢弃文本的语义保持。
  • 生时校正 Agent(agentic-rectification.ts)不在本单范围,它有自己的合同。

4. 硬红线

  1. 不改 contractReady();不改 retry / retryForAnswer 的触发条件;不给失败轮扣点。
  2. page.tsx 与 jyotish_api_server.py 不动(AGENTS.md §6)。
  3. 既有测试断言不得静默弱化;改任何断言写「原值 / 新值 / 原因」三栏。
  4. 不顺手改 Skill 文件、不 bump Skill 版本(本单只改 Agent 系统指令与 SDK 调用参数)。
  5. Bug 历史与进度记录不得写入会话 ID、用户 ID、出生资料。

5. 任务分解

T1 删掉两处「简单追问可跳过工具」例外(BUG-922)

  • windowJyotishInstructions:把「For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.」改为「Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.」
  • jyotishInstructions:同样改「For questions that require a new chart claim… Simple conversational follow-ups may use the existing context.」为「Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.」
  • 全文再 grep 一次 follow-ups may use,确认 frontend/src 无残留。
  • 验收:
    • 在 frontend/tests/consultation-birth-time-mode.test.ts(它已经读窗口 Agent)或 consultation-agentic-runtime.test.ts 加契约:两段指令 doesNotMatch(/follow-ups may use the existing/) 且 match(/before answering every turn/)。
    • 既有「personal Agent exposes the Jyotish Skill and named server tool」等测试原样通过。

T2 第 0 步强制调用排盘工具(BUG-923)

提示词只是软约束,门禁是硬约束,中间应该有一层确定性的 SDK 参数把二者钉在一起。

  • consultationNatalPrepareStep:第 0 步在 activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID] 之外把 toolChoice 改为 "required";后续步骤保持 "auto"。
  • 申报时段路线:新增 consultationWindowPrepareStep(同文件、同形状:第 0 步 activeTools: ["run-jyotish-window-consultation"] + toolChoice: "required"),并在 route.ts 的申报时段分支把 streamWithOverflowRetry(agent) 与 retry 改为使用带该 prepareStep 的选项对象(照本命分支 natalStreamOptions 的写法建 windowStreamOptions)。
  • retryForAnswer / continueAfterLength / composeSection 不带强制(它们是在合同已绿之后跑的,本命分支的 composeSection 明确 toolChoice: "none",不动)。
  • 开工先确认本仓 Mastra / AI SDK 版本的 prepareStep 接受 toolChoice: "required"(查 node_modules/@mastra/core 与 ai 的类型定义,写进进度记录)。
  • 验收:
    • consultation-agentic-runtime.test.ts 既有「natal first step exposes only the chart calculation tool」扩成断言第 0 步 toolChoice === "required"、第 1 步 "auto";为窗口 prepareStep 加对称的一条。
    • 加一条 route.ts 源码契约:申报时段分支的 agent.stream( 调用与 retry 使用带 prepareStep 的选项(对齐 consultation-stream-recovery.test.ts 那类结构断言的写法)。
    • 已有的「a calculation that succeeds only after failed attempts still satisfies the contract」「text written before the contract completes is dropped」原样绿。

T3 记录

  • docs/BUG_HISTORY.md 新增 BUG-922、BUG-923:现象、触发条件(申报时段 / 本命模式下发短追问)、根因(提示词例外 vs 合同)、修复、验证、防复发(契约测试 + 第 0 步 required)、关联 BUG-205 / 214 / 286。
  • CHANGELOG.md 一条:追问、反问、短句也会先重新取回本轮星盘证据再回答,不再整轮失败。
  • docs/tasks/PROGRESS-consult-followup-tool-contract-20260917.md:测试数字、失败清单与基线逐条比对、gzip 前后、prepareStep 类型核对结果。

T4 第一轮失败原因取证(不占 BUG 号,写进进度记录)

第一轮是正经解盘问题,不能用 T1 的根因解释。回执只写到 web 容器控制台(frontend/src/lib/agent-observability.ts 的 console.info("[agent-observability]", …)),不进数据库。执行方做不了(无 VPS 登录态),把下面的取证方法原样写进进度记录,留给产品负责人或部署侧:

# 在 VPS 上,以 deploy 用户
cd /opt/jyotisha-production
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
  | grep agent-observability | grep '<该会话的 sessionId>' | head

三条 run.failed 各自的 steps 若都没有 tool 步骤,即同一根因、本单覆盖;若第一轮有 tool 步骤且 status: failed,另立单,不得并进本单。

6. 让步顺序

  1. T2 若本仓 SDK 版本的 prepareStep 不接受 toolChoice: "required"(类型或运行时报错),退而只做 T1,并在进度记录与 BLOCKED.md 写明版本与报错原文;不得顺手升级依赖。
  2. T2 若 required 让本命第 0 步在 generalDailyContext(首页今日)路径误触发(那条路径 requireTool: false,用的是 getGeneralJyotishAgent,理论上不经过 natalStreamOptions),先用测试证明,再决定是否只对申报时段路线加 required。
  3. T1、T3 不可让步。

7. 开工前置命令

git fetch origin --prune
git worktree add -b codex/consult-followup-tool-contract-20260917 .worktrees/consult-followup-tool-contract-20260917 origin/staging
cd .worktrees/consult-followup-tool-contract-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 | sort -t- -k2 -n | tail -1      # 应为 BUG-921
grep -rn "follow-ups may use" src                                            # 应恰好 2 处

验收口径:tsc 0 错;lint 0 error;npm test 失败清单与基线逐条一致(无 Docker 的那组不算新增)且总数不减;next build 后 / 仍 Static;首屏 gzip ±2%(本单不动客户端 bundle,应为 0 变化)。

8. 环境缺口(留给产品负责人真机)

部署后(先核对 /api/health 的 deployment.gitCommit 等于本单合入 staging 的 SHA)在申报时段模式的会话里:

  1. 先问一个正经问题(例如「看看我事业上的整体特点」),应正常出回答。
  2. 紧接着发「?」,再发「你在说什么鬼」。两轮都应先出现「正在比较声明出生窗口内的稳定层」再出回答,不得出现「Agent 未完成必要的方法与计算步骤」。
  3. 换到本命模式(有出生分钟)的会话重复第 2 步,同样不得整轮失败。
  4. 同一会话刷新页面后,历史里应能看到助手回复,不再是只有用户消息。