diff --git a/CHANGELOG.md b/CHANGELOG.md index da831aef..db730a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-17 — 咨询不再整轮失败:每一轮先取回本轮星盘再回答 + +部署 `dc2f2a16` 之后,本命和申报时段咨询会整轮失败,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点。」现在会重新走排盘再回答。短追问(「?」「你在说什么鬼」)仍然每一轮先取回本轮星盘。Skill 版本不变。 + ## 2026-09-17 — 回答从开头开始读:发送后问题钉在顶部,回答向下生长,长出视口时显示跳到最新 主会话和生时校正发出新一轮后,视口停在这一轮的开头(你刚问的那一行,或校正里点选项后的新助手行),回答在下面往下长,不再跟着最后一个字往下跑。超过一屏时出现「跳到最新」,按下才跟到结尾。短回答下面会留一段空白,好让问题留在顶部。换到另一段对话仍先看到最新内容。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b749fa33..1391486b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12030,12 +12030,12 @@ - 用户现象:同 BUG-922。回执只有 skill 与 runtime-contract-retry,没有任何 tool 步骤。 - 触发条件:同上。 - 根因:提示词是软约束,`toolChoice: "auto"` 不能保证第 0 步调用排盘工具。申报时段路线甚至没有 `prepareStep`。 -- 修复:第 0 步 `activeTools` 仅排盘工具且 `toolChoice: "required"`;后续步骤 `"auto"`。窗口路线对称加 `prepareStep`,首次 stream 与合同 retry 使用它。`retryForAnswer` / 续写 / 分段仍不强制。未改 `contractReady()`。 +- 修复:第 0 步 `activeTools` 仅排盘工具且 `toolChoice: "auto"`(thinking 模式不得发 named / required,见 BUG-282 / BUG-937);后续步骤仍 `"auto"`。窗口路线对称加 `prepareStep`,首次 stream 与合同 retry 使用它。`retryForAnswer` / 续写 / 分段仍不强制。未改 `contractReady()`。BUG-937 撤回了本条最初落地的 `required`。 - 验证:natal / window prepareStep 单测;`route.ts` 源码契约。既有「失败后重试成功仍过门禁」「契约未绿文本丢弃」仍绿。 -- 防复发:本命与窗口第 0 步必须 `required`。思考模式供应商历史上拒 named/required,本仓 Mastra 类型接受 `'required'`;若线上某模型拒收,另立单,不得静默改回 auto 而不改提示词。 -- 相关记录:BUG-214、BUG-286、BUG-922 +- 防复发:本命与窗口第 0 步用 `activeTools` 收窄排盘工具,`toolChoice` 只能是 `auto`。咨询侧源码契约禁止 `required` 与 named `toolChoice`。提示词侧每轮必调(BUG-922)保留。 +- 相关记录:BUG-214、BUG-282、BUG-286、BUG-922、BUG-937、BUG-938 - 复发自:无 -- 修复版本:待发布 +- 修复版本:`dc2f2a16`(required 已由 BUG-937 撤回) ## BUG-924 | 校正会话上普通输入框可用,问题发到 `/api/consult` @@ -12253,3 +12253,35 @@ - 相关记录:BUG-716(`/chart` 白屏 45 秒,另一条链路) - 复发自:无 - 修复版本:未发布 + +## BUG-937 | 咨询第 0 步 `toolChoice: required` 让全部咨询整轮失败 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`consultationNatalPrepareStep`、`consultationWindowPrepareStep`;本命与申报时段两条咨询路线的每一轮 +- 用户现象:staging 部署 `dc2f2a16` 之后,本命与申报时段会话每一轮都失败,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点。」回执只有 skill 与 runtime-contract-retry,没有任何 tool 步骤。 +- 触发条件:已部署 `dc2f2a16` 的 staging 上发一轮咨询。首轮固定开 thinking。同日晚间本命路线用 `gpt-5.6-luna` 发正经问题时,第 0 步强制工具调用被该供应商接受(事件流走到「正在计算本命盘」),所以「供应商一律拒收 required」不能解释所有模型。 +- 根因:BUG-923 把两条路线的第 0 步改成 `toolChoice: "required"`。BUG-282 实证过至少一个 thinking 供应商拒绝非 auto 的 `tool_choice`(原文 `Thinking mode does not support this tool_choice`)。模型目录可切换,这条路仍会整轮失败。供应商拒收走 Mastra `error` 块,咨询流当时看不见,被翻译成合同未完成(见 BUG-938,本单第一优先)。同日反证:`gpt-5.6-luna` 接受了 `required`,那次截图另立单,不并进本条。 +- 修复:第 0 步撤回 `required`,只留 `activeTools` 收窄到排盘工具,`toolChoice` 为 `"auto"`。BUG-922 的提示词(每轮必调、packet 不跨请求)不动。不在本单尝试第 0 步关 thinking 再发 required。`activeTools` + `auto` 在所有供应商上都成立。 +- 验证:`consultation-agentic-runtime.test.ts` 两条 prepareStep 断言;`consultation-workflow-contract.test.ts` 源码契约禁止 `required` 与 named `toolChoice`。部署后真人走查见 `docs/testing/consult-followup-tool-contract-fix-20260917.md`。 +- 防复发:咨询侧 `consultation-tools.ts` 不得再写 `toolChoice: "required"` 或 named `{ type: "tool", toolName }`;thinking 模式只能用 `activeTools` 收窄。该约束以前只锁在校正 Agent 测试与 BUG-282 文字里,咨询侧无测试、任务书作者未检索到,所以没拦住 BUG-923。 +- 相关记录:BUG-282、BUG-630、BUG-922、BUG-923、BUG-938 +- 复发自:BUG-282 +- 修复版本:`a370bd54` + +## BUG-938 | 咨询流不处理 Mastra `error` 块,供应商拒收不可见 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`stream-agent-response.ts` `consumeAttempt`、`agent-observability.ts` +- 用户现象:同 BUG-937。公开事件没有报错,回执没有 tool 步骤,`modelFinishReason` 为空,错误码被翻译成 `runtime_contract_incomplete`。合同 retry 用同一套选项再失败一次。本命路线在 `gpt-5.6-luna` 上能进计算阶段,说明失败形状不只有「拒收 required」一种,真实错误只能靠本条落地后的日志看到。 +- 触发条件:供应商调用失败(含 thinking 模式拒收非 auto 的 tool_choice,以及其它上游错误)。Mastra 1.50.1 把该失败 enqueue 成 `{ type: "error" }` 后关流,不抛给 `fullStream` 迭代。 +- 根因:`mapChunk` / `consumeAttempt` 不判断 `chunk.type === "error"`。校正 Agent 在 BUG-282 已把这条路做成 `thinking_tool_choice_unsupported`,咨询流没有对齐。本条是本单第一优先。 +- 修复:遇到 `error` 块立即结束本次 attempt,按原文分类为 `thinking_tool_choice_unsupported` 或 `provider_error`,回执追加 `validation model-stream-error failed`。这两个内部码不进公开 `run.failed` 枚举,公开层仍是 `calculation_failed`。供应商错误不触发合同 retry。服务端打 `[consult-provider-error]` 日志(`requestId`、内部码、原文头 200 字,不含请求体)。 +- 验证:`consultation-agentic-runtime.test.ts` 两条假流(thinking 拒收 / upstream 502):公开码 `calculation_failed`、无 `loading-method`、retry 不调用、`onError` 收到内部码。`agent-observability.test.ts` 把这两个码原样落日志。 +- 防复发:咨询流必须识别 Mastra `error` 块;不得把供应商原文写进公开事件。合同 retry 只用于「没调工具」,不用于供应商拒收。 +- 相关记录:BUG-214、BUG-268、BUG-282、BUG-937 +- 复发自:无 +- 修复版本:`a370bd54` diff --git a/docs/tasks/PROGRESS-consult-followup-tool-contract-fix-20260917.md b/docs/tasks/PROGRESS-consult-followup-tool-contract-fix-20260917.md new file mode 100644 index 00000000..541f9fa4 --- /dev/null +++ b/docs/tasks/PROGRESS-consult-followup-tool-contract-fix-20260917.md @@ -0,0 +1,41 @@ +# 进度 · 咨询第 0 步 required 修复单(2026-09-17) + +工作树:`.worktrees/consult-followup-tool-contract-fix-20260917` +分支:`codex/consult-followup-tool-contract-fix-20260917` +基线:`origin/staging` @ `b51e7aff`(含 §2b:`gpt-5.6-luna` 接受 required,T2 第一优先) +合入提交:`a370bd54` + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| T2 / BUG-938 识别 Mastra `error` 块 | 完成 | 先做。`consumeAttempt` 遇 `error` 立即结束 attempt,内部码 `thinking_tool_choice_unsupported` / `provider_error`,公开码 `calculation_failed`,不进合同 retry | +| T1 / BUG-937 第 0 步撤回 required | 完成 | `activeTools` + `toolChoice: "auto"`。跨供应商防护;§2b 反证该模型接受 required,不推翻 T1 | +| T3 记录 | 完成 | BUG-937/938、BUG-923 防复发改写、CHANGELOG、本文件、README、走查清单 | +| T4 部署后取证 | 环境缺口 | 无登录态 / 无模型凭据 / 无 VPS。清单在 `docs/testing/consult-followup-tool-contract-fix-20260917.md` | + +未改 `page.tsx`、Python、Skill、`contractReady()`。 + +## 断言三栏 + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `consultationNatalPrepareStep` 第 0 步 `toolChoice` | `"required"` | `"auto"` | BUG-282 至少一个 thinking 供应商拒收 required;BUG-937 | +| `consultationWindowPrepareStep` 第 0 步 `toolChoice` | `"required"` | `"auto"` | 同上 | + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(118 warning,未动) | +| `consultation-workflow-contract` | 含新契约 `prepareStep never sends required or named toolChoice` **绿** | +| `agent-observability` | `provider_error` / `thinking_tool_choice_unsupported` 原样落日志 **绿** | +| BUG-938 两条假流 | 隔离跑 **2 passed**(公开码 `calculation_failed`、无 `loading-method`、retry 不调用、`onError` 内部码)。整文件 `consultation-agentic-runtime.test.ts` 因 Windows `EPERM` skill symlink 无法加载,Linux 门禁不受影响 | +| 全量 `npm test` | 本机 Windows **2541 / pass 2374 / fail 152 / skip 15**。失败以 Docker、`G:\G:\...` 双盘符 ENOENT、skill `EPERM` symlink 为主。任务点名的契约测试不在新增红里 | +| `next build` | 本单未改客户端 bundle,gzip 预期 0。本机未重量(Windows worktree 构建常撞 skill symlink `EPERM`) | +| Python | 未改 | + +## §2b + +同日 `gpt-5.6-luna` 本命路线接受过 `required`。T2 仍是第一优先:其它失败必须能从 `[consult-provider-error]` 看见原文。T1 保留,因为模型可切换,BUG-282 已实证过拒收。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index fbcf2ed5..34f0b97f 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -130,8 +130,8 @@ | `TASK-rectification-tiebreak-card-loss-20260915.md` | `PROGRESS-rectification-tiebreak-card-loss-20260915.md` | **P0**:点卡上「再答两道参考题」交付卡消失(BUG-706);按钮亮但选项建不出变成裸题(BUG-708);旁白写「相对支持度」并与卡上入口打架(BUG-709)。卡上入口删除,出卡前收集,有活题时卡留下、采用置灰 | 待验收 | `codex/rectification-p0-20260915` | | `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` | | `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` | -| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 已部署 `dc2f2a16`,但 BUG-923 的第 0 步 `required` 让全部咨询整轮失败(BUG-282 复发),由修复单撤回 | `codex/consult-followup-tool-contract-20260917` | -| `TASK-consult-followup-tool-contract-fix-20260917.md` | `PROGRESS-consult-followup-tool-contract-fix-20260917.md` | **P0**。部署 `dc2f2a16` 后再聊天仍 `runtime_contract_incomplete`,回执只有 skill + retry。主根因(推断):BUG-923 把本命/窗口第 0 步改成 `toolChoice: required`,而首轮固定开 thinking,BUG-282 实证 thinking 模式供应商拒收非 auto 的 tool_choice → 每一轮咨询在第 0 步就被拒(BUG-937,复发自 BUG-282)。次根因(确定):咨询流不处理 Mastra `error` 块,拒收在回执/事件/日志三处都不可见,合同 retry 照跑同一参数(BUG-938)。T1 撤回 required 只留 activeTools + auto;T2 error 块 → 可诊断错误码、不进合同 retry;T4 部署后取证 | 待领取 | `codex/consult-followup-tool-contract-fix-20260917` | +| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 已部署但被 BUG-937 撤回 required,见修复单 | `codex/consult-followup-tool-contract-20260917` | +| `TASK-consult-followup-tool-contract-fix-20260917.md` | `PROGRESS-consult-followup-tool-contract-fix-20260917.md` | **P0**。部署 `dc2f2a16` 后再聊天仍 `runtime_contract_incomplete`,回执只有 skill + retry。主根因(推断):BUG-923 把本命/窗口第 0 步改成 `toolChoice: required`,而首轮固定开 thinking,BUG-282 实证 thinking 模式供应商拒收非 auto 的 tool_choice → 每一轮咨询在第 0 步就被拒(BUG-937,复发自 BUG-282)。次根因(确定):咨询流不处理 Mastra `error` 块,拒收在回执/事件/日志三处都不可见,合同 retry 照跑同一参数(BUG-938)。T1 撤回 required 只留 activeTools + auto;T2 error 块 → 可诊断错误码、不进合同 retry;T4 部署后取证 | 待验收 | `codex/consult-followup-tool-contract-fix-20260917` | | `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 验收未通过 | `e4e73f56`(实现按任务书落地:多键排序 / `(app)` 路由组常驻外壳 / provider / 空会话不入列 + draft 复用 / 标题类别在前;两条让步任务书允许。**P1:4 条针对 `page.tsx` 的源码合同没跟着外壳搬家,`npm test` 在门禁里 `set -e` 下必红,staging 至今停在 `dc2f2a16`**。另:进度记录报「tsc 0 错」实为 2 错,只跑了定向测试。修复单 `TASK-session-list-single-source-fix-20260917.md`) | | `TASK-session-list-single-source-fix-20260917.md` | `PROGRESS-session-list-single-source-fix-20260917.md` | 验收修复单:F1 四条源码合同搬到 `(app)/layout.tsx` 两端对断(BUG-933);F2 两条陈旧 Python 入口断言(BUG-934,既有欠账);F3 无活跃会话时输入框静默吞发送(BUG-935);交付前必须跑全量测试 | 待验收 | `codex/session-list-single-source-fix-20260917` | | `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — | diff --git a/docs/testing/consult-followup-tool-contract-fix-20260917.md b/docs/testing/consult-followup-tool-contract-fix-20260917.md new file mode 100644 index 00000000..de183c90 --- /dev/null +++ b/docs/testing/consult-followup-tool-contract-fix-20260917.md @@ -0,0 +1,52 @@ +# Staging 人肉复核 · 咨询不再整轮失败(2026-09-17) + +给产品负责人。不要把真实出生资料、会话 ID 或对话正文写进任何记录。 + +对应 BUG-937 / BUG-938。测之前先做第 0 条。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +账号需要已填出生日期、时间和地址,且标准咨询计费已配置。 + +## 1. 本命会话正经问题(P0) + +1. 登录后开一段本命咨询(有可用出生分钟)。 +2. 问一句正经问题,等计算走完。 + +- ✅ 预期:助手写出回答,不是红字「Agent 未完成必要的方法与计算步骤,本次不会扣点。」回执 `steps` 含 `tool` 步骤。 +- ❌ 失败:只有那句红字;或回执仍只有 skill + retry。 + +## 2. 本命会话短追问(P0) + +同一段对话再发「?」。 + +- ✅ 预期:再算出一轮并回答,不是整轮失败。 +- ❌ 失败:短追问又变成「未完成必要的方法与计算步骤」。 + +## 3. 申报时段会话正经问题(P0) + +开一段申报时段咨询,问一句正经问题。 + +- ✅ 预期:同第 1 条。 +- ❌ 失败:同第 1 条。 + +## 4. 申报时段会话短追问(P0) + +同一段对话再发「?」。 + +- ✅ 预期:同第 2 条。 +- ❌ 失败:同第 2 条。 + +## 5. 若仍失败:取日志(不要贴用户资料) + +```bash +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。 +- `errorCode` 是 `provider_error`:把脱敏后的 `messageHead` 交给 Claude 另立单。 +- 同日反证:`gpt-5.6-luna` 在本命路线接受过 `required`。若该模型仍失败,优先看本条日志,不要假定是 tool_choice 拒收。 diff --git a/frontend/src/lib/agent-observability.ts b/frontend/src/lib/agent-observability.ts index 429ce871..185458c8 100644 --- a/frontend/src/lib/agent-observability.ts +++ b/frontend/src/lib/agent-observability.ts @@ -185,6 +185,8 @@ const knownErrorCodes = new Set([ "timeout", "cancelled", "settlement_failed", + "thinking_tool_choice_unsupported", + "provider_error", ]); export function toAgentObservabilityErrorCode(error: unknown): string { diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 811c5e83..62f07f32 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -20,7 +20,7 @@ import { type PublicThinkingSection, } from "./consultation-thinking-plan.ts"; -type Chunk = { type?: string; payload?: Record; data?: unknown }; +type Chunk = { type?: string; payload?: Record; data?: unknown; error?: unknown }; type ChunkStream = AsyncIterable | ReadableStream; async function* readChunks(stream: ChunkStream): AsyncIterable { @@ -90,11 +90,33 @@ function isTimeoutOrAbort(error: unknown) { } type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed"; +type ProviderStreamErrorCode = "thinking_tool_choice_unsupported" | "provider_error"; + +function providerErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function classifyProviderStreamError(error: unknown): ProviderStreamErrorCode { + return providerErrorMessage(error).includes("Thinking mode does not support this tool_choice") + ? "thinking_tool_choice_unsupported" + : "provider_error"; +} + +function chunkProviderError(chunk: Chunk): unknown { + if (chunk.error !== undefined) return chunk.error; + return chunk.payload?.error; +} function runFailedCode(error: unknown, emitted: boolean): RunFailedCode { if (error instanceof Error && error.message === "runtime_contract_incomplete") return "runtime_contract_incomplete"; if (error instanceof Error && error.message === "empty_answer") return "empty_answer"; if (error instanceof Error && error.message === "answer_truncated") return "answer_truncated"; + if ( + error instanceof Error + && (error.message === "thinking_tool_choice_unsupported" || error.message === "provider_error") + ) { + return "calculation_failed"; + } if (emitted && isTimeoutOrAbort(error)) return "answer_truncated"; return "calculation_failed"; } @@ -383,6 +405,21 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { const stepCountBeforeAttempt = options.state.modelStepCount; try { for await (const chunk of readChunks(stream)) { + if (chunk.type === "error") { + const raw = chunkProviderError(chunk); + const code = classifyProviderStreamError(raw); + appendConsultationRuntimeStep(options.state, { + kind: "validation", + name: "model-stream-error", + status: "failed", + }); + console.error("[consult-provider-error]", { + requestId: options.requestId, + code, + messageHead: providerErrorMessage(raw).slice(0, 200), + }); + throw new Error(code); + } for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event); flushThinkingPlan(controller); if (chunk.type === "step-finish") options.state.modelStepCount += 1; diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index b689c242..1fc622fe 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -60,10 +60,11 @@ export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation"; export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation"; /** - * Step 0 must call the natal chart tool. Mastra/AI SDK types accept - * toolChoice "required" (`@mastra/core` Agent.stream options: - * `'auto' | 'none' | 'required'`). Later steps stay auto so the model can - * write after the request-scoped calculation is in hand. + * Step 0 exposes only the natal chart tool. Some thinking-mode providers + * reject named and required toolChoice (BUG-282, BUG-937); the first step + * therefore narrows with activeTools and leaves the choice as auto. Later + * steps stay auto so the model can write after the request-scoped + * calculation is in hand. * * Window and general agents must not share this hook: they do not own this tool. */ @@ -71,7 +72,7 @@ export function consultationNatalPrepareStep(input: { stepNumber: number }) { return input.stepNumber === 0 ? { activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID], - toolChoice: "required" as const, + toolChoice: "auto" as const, } : { toolChoice: "auto" as const, @@ -82,7 +83,7 @@ export function consultationWindowPrepareStep(input: { stepNumber: number }) { return input.stepNumber === 0 ? { activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID], - toolChoice: "required" as const, + toolChoice: "auto" as const, } : { toolChoice: "auto" as const, diff --git a/frontend/tests/agent-observability.test.ts b/frontend/tests/agent-observability.test.ts index 729b7caa..eed45005 100644 --- a/frontend/tests/agent-observability.test.ts +++ b/frontend/tests/agent-observability.test.ts @@ -149,6 +149,14 @@ test("error normalization never records arbitrary exception messages", () => { toAgentObservabilityErrorCode(new Error("answer_truncated")), "answer_truncated", ); + assert.equal( + toAgentObservabilityErrorCode(new Error("provider_error")), + "provider_error", + ); + assert.equal( + toAgentObservabilityErrorCode(new Error("thinking_tool_choice_unsupported")), + "thinking_tool_choice_unsupported", + ); assert.equal( toAgentObservabilityErrorCode(new Error("/opt/internal/users/alice.json")), "calculation_failed", diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index d9ef23b0..b199b142 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -656,11 +656,11 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin test("natal first step exposes only the chart calculation tool", () => { assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation"); - // 原值: toolChoice "auto" / 新值: 第 0 步 "required"、第 1 步仍 "auto" - // 原因: BUG-923 每轮必须先调排盘工具,提示词例外已删 + // 原值: 第 0 步 toolChoice "required" / 新值: "auto" + // 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回 assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), { activeTools: ["run-jyotish-consultation"], - toolChoice: "required", + toolChoice: "auto", }); assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), { toolChoice: "auto", @@ -669,9 +669,11 @@ test("natal first step exposes only the chart calculation tool", () => { test("window first step requires the window consultation tool", () => { assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation"); + // 原值: 第 0 步 toolChoice "required" / 新值: "auto" + // 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回 assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), { activeTools: ["run-jyotish-window-consultation"], - toolChoice: "required", + toolChoice: "auto", }); assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), { toolChoice: "auto", @@ -1266,6 +1268,79 @@ test("incomplete runtime contract fails without saving a successful answer", asy assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); }); +test("a thinking-mode toolChoice rejection fails without a contract retry (BUG-938)", async () => { + const state = createConsultationRuntimeState(); + let onErrorMessage = ""; + async function* chunks() { + yield { type: "error", error: new Error("Thinking mode does not support this tool_choice") }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "blocked", + receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + retry: async () => { + assert.fail("provider error must not trigger a contract retry"); + return chunks(); + }, + onError: (error) => { + onErrorMessage = error instanceof Error ? error.message : String(error); + }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + + assert.deepEqual(events.map((event) => (event as { type: string }).type), [ + "run.started", + "skill.started", + "skill.completed", + "run.failed", + ]); + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { + code: string; + receipt?: { steps: Array<{ kind: string; name: string; status: string }> }; + }; + assert.equal(failure.code, "calculation_failed"); + assert.equal(onErrorMessage, "thinking_tool_choice_unsupported"); + assert.equal( + events.some((event) => (event as { type?: string; phase?: string }).type === "activity" + && (event as { phase?: string }).phase === "loading-method"), + false, + ); + assert.ok(failure.receipt?.steps.some((step) => + step.kind === "validation" && step.name === "model-stream-error" && step.status === "failed")); + assert.doesNotMatch(JSON.stringify(events), /Thinking mode does not support this tool_choice/); +}); + +test("a generic provider stream error is calculation_failed and skips retry (BUG-938)", async () => { + const state = createConsultationRuntimeState(); + let onErrorMessage = ""; + async function* chunks() { + yield { type: "error", error: new Error("upstream 502") }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "blocked", + receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + retry: async () => { + assert.fail("provider error must not trigger a contract retry"); + return chunks(); + }, + onError: (error) => { + onErrorMessage = error instanceof Error ? error.message : String(error); + }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string }; + assert.equal(failure.code, "calculation_failed"); + assert.equal(onErrorMessage, "provider_error"); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); + assert.doesNotMatch(JSON.stringify(events), /upstream 502/); +}); + function toolOnlyRunState() { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index c6df8316..bbd236cd 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -26,22 +26,29 @@ test("natal and window instructions require a chart tool every turn (BUG-922)", }); test("natal and window first steps require the chart tool (BUG-923)", () => { + // 原值: 第 0 步 toolChoice "required" / 新值: "auto" + // 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回 assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), { activeTools: ["run-jyotish-consultation"], - toolChoice: "required", + toolChoice: "auto", }); assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), { toolChoice: "auto", }); assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), { activeTools: ["run-jyotish-window-consultation"], - toolChoice: "required", + toolChoice: "auto", }); assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), { toolChoice: "auto", }); }); +test("consultation prepareStep never sends required or named toolChoice (BUG-282 / BUG-937)", () => { + assert.doesNotMatch(tools, /toolChoice:\s*"required"/); + assert.doesNotMatch(tools, /type:\s*"tool",\s*toolName/); +}); + test("consultation plans are server-owned and bounded", () => { assert.match(plan, /consultationPlanSchema/); assert.match(plan, /requestedDomains/);