From b5bcbaed7352974efd343967a12181ff32fa09e9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 18 Aug 2026 15:24:37 +0800 Subject: [PATCH] fix(consult): record a tool call the tool never received MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit staging 手测 run 951a841e 第一次工具调用发出 tool.failed 后重试成功,但回执里 steps 只有 skill 与那次成功的 tool,stepBudget.used 为 2——失败的那次完全不存在。 客户端看见失败过一次,回执说没有,两边都查不到为什么。 工具的 inputSchema 是 strict 的,模型参数不合法时 Mastra 在调用 execute 之前就拒了, 于是工具体内一切都没跑:调用不计数、失败步不记录、连 chart-calculation 活动事件都没 发出(这也是本次定位的证据——失败那次没有任何 activity,重试那次有)。工具无法记录 一次它从未收到的调用。叠加两处:safeToolError 把非超时非取消的错误全塌成 calculation_failed,而即使失败落进工具体的 catch,consultationWorkflowFailureCode 对非 ConsultationWorkflowError 返回 undefined、append 处又写成可选省略,于是最需要 解释的那条记录恰好是唯一没有原因的记录。 改为在流层补记:流是唯一能观测到全部工具失败的位置,无论失败在 schema 这侧还是 execute 那侧,且它持有 startedAt 因而能给出时长。tool-error 分支比对「流已见的错误数」 与「state 里已有的失败 tool 步数」,只在前者更多时补一条,工具仍记录它能看见的失败, 两者不重复计。另新增 consultationToolFailureCode,令每个错误都解析出一个码。 failureCode 刻意仍不进公开回执:白名单与「the public receipt never carries the internal failure classification」是刻意约束,workflow_rate_limited 这类后端内情不该 上线到客户端。原因走可观测日志的 toolCalls[].failureCode。客户端能看到「有一步失败」, 运维能在日志里看到为什么。 另记入 BUG-268 的线上实测值:单领域 referenceReads 两次均为 2,多领域为 0——不是 从不读方法,而是最需要方法的多领域路径一份都没打开。 Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 55 ++++++++++----- frontend/src/lib/stream-agent-response.ts | 51 +++++++++++++- frontend/src/mastra/consultation-tools.ts | 21 +++++- .../consultation-agentic-runtime.test.ts | 70 +++++++++++++++++++ scripts/jyotish_api_server.py | 2 +- scripts/run_quality_gate.py | 2 +- 6 files changed, 175 insertions(+), 26 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index eb398f2f..2a42af5f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3955,7 +3955,7 @@ - 验证:`tests/test_consultation_consumer_context.py` 新增 8 条并全绿(该文件共 16 条通过)。8 条覆盖:遍历 `_ROUTE_DEFINITIONS` 断言每条路由都有自己的证据门条目(这是本该拦住本 bug 的守卫),且表里每个层名都能在真实证据包的 sections 里找到;`marriage` 缺 UL 必须报 `degraded` 且 `missingLayers == ['UL']`;`wealth` 缺 D2 同理;性别边界在不含任何关键词的措辞下仍随路由挂上,且不串入其他领域的边界;出生时间边界在「问题完全没提出生时间但精度为 1hour」时出现、在「精度 minute 且 Lagna 不敏感」时不出现;精度档位缺失按不确定处理;`minute` 档位下 Lagna 敏感仍保留边界;`marriage` 路由在 `narayana_dasha` 缺失时 `can_answer_precise_timing` 必须为 false(此时 `missingLayers` 仍为 `[]`、状态仍为 `ready`,正是旧代码放行的那个组合)。已逐条验证这 8 条在旧代码上会失败:旧表下 `marriage`/`wealth` 的门确为 `['D1','D9','dasha_boundaries']`(UL、D2 均不在内),旧正则对该措辞返回 False,旧 `timing_layers_ready` 在 `narayana_dasha` 缺失时仍为 True。未做的验证:**没有在 staging 上真实跑一次婚姻类咨询复看回执**,因此「回执与回答不再矛盾」只有单测证据,线上措辞与耗时未观测。 - 待跟进:其一,`birth_time_rectifier.get_effective_accuracy()` 会返回 `'5min'`(声明 minute + 家人清楚记得),而 `ACCURACY_MATRIX` 没有 `'5min'` 这一行,`get_enabled_vargas()` 于是落到 `unknown` 档——比声明 `15min` 更差。本轮只让 `'5min'` 在边界判定上按不确定处理(方向正确),没有补这一行矩阵,分盘可用性仍被低估。其二,`jyotish_api_server.py` 约 7293 行处(穆胡尔塔领域选择)仍有一处同形态的关键词匹配,本轮未动。其三,`projectEvidenceContract()` 的投影范围仍不含 `deterministic_claims_forbidden_for`(见 BUG-256 待跟进),本轮未改投影层。 - 防复发:查表取合同时不得用 `.get(key, 默认值)` 静默兜底——本次三处缺陷里有两处都是「取不到就用一个更宽松的值」,而更宽松的失败方向不会有任何人报错。凡是按路由/领域分派的表,必须有一条测试遍历权威路由集合断言逐条覆盖,并断言表里引用的层名在运行时真实存在;键名与权威定义分处两个文件时,这条测试是唯一能发现键名漂移的机制。已经由模型声明的语义(领域、路由)不得在服务端用文本正则重新推断一遍:重复推断不会更准,只会多出一处静默失效点。判断「某层是否就绪」必须直接读该层的状态,不得借道任何按条件裁剪过的列表——`missing_route_layers` 这类列表为空既可能是齐备也可能是没检查,两者不可区分。 -- 相关记录:BUG-259(同一函数上游的路由分歧,本条是「路由定了以后合同没跟上」)、BUG-256(同为回答契约投影/合并层的缺口,其待跟进项与本条同源)、BUG-268(本条的可观测性对照:回执里同样查不到模型有没有读方法)、BUG-269(修完本条核对同一张表时发现门仍比产品声明松三处) +- 相关记录:BUG-259(同一函数上游的路由分歧,本条是「路由定了以后合同没跟上」)、BUG-256(同为回答契约投影/合并层的缺口,其待跟进项与本条同源)、BUG-268(本条的可观测性对照:回执里同样查不到模型有没有读方法)、BUG-270(修完本条核对同一张表时发现门仍比产品声明松三处) - 复发自:无 - 修复版本:`10ae149c`(staging) @@ -3970,29 +3970,12 @@ - 根因:Mastra 的 `skill` 工具返回 SKILL.md 的全文指令加上 references/scripts/assets 的**文件名清单**,真正打开某份参考文档要另调 `skill_read`。`createConsultationRuntimeHooks` 确实在 `afterToolCall` 里数了 `skillReferenceReadCount`,但这个计数器既没进公开回执,也没进 `agentObservabilityEventSchema`,数完即丢。同时 `skill_read` 按设计不记成 runtime step(只有 `skill`/`tool`/`validation` 三类会记),所以 `steps` 与 `stepBudget.used` 天然看不见它;服务端日志里唯一能间接反映的是 `modelStepCount`。结果是:唯一能直接回答该问题的数字被算出来后丢弃,只留下一个需要推断的替代量。 - 修复:把 `referenceReads` 加进回执的 `skill` 对象,并设为**必填**而非可选——正是「可选且没人填」让这个数字消失的,必填能让将来任何一处新的回执构造点无法再省掉它。同时把 `skillReferenceReads` 加进 `consultationModelStepTelemetry()`,它已被展开进可观测日志,因此服务端日志自动与 `modelStepCount` 并列拿到该值。两个构造点(工具可选路径与强制工具路径)都改为从 `state.skillReferenceReadCount` 读取。 - 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 37 项通过,其中新增 1 项:走真实 hooks,断言加载 skill 后计数仍为 0(说明「已加载」不等于「读过方法」)、两次成功的参考读取记为 2、失败的读取不计数、参考读取不出现在 steps 里(因此 steps 无法替代该字段),最后断言回执解析后 `skill.referenceReads === 2`。既有断言同步收紧:`consultationModelStepTelemetry()` 的期望值现在包含 `skillReferenceReads`。`tsc --noEmit` 清洁。未做的验证:**没有在 staging 上取一次真实回执**,因此线上那两次运行的 `referenceReads` 究竟是 0 还是别的值仍未观测——这正是本条要让它可观测的那个数。 -- 待跟进:拿到线上真实值后再判断下一步。若确认为 0,则「web 不如本地」的主因是方法文档在步数预算内从未被打开,方向应是让方法可达(预算、指令、或把关键方法上提进 SKILL.md),而不是继续加服务端约束。 +- 待跟进:**线上真实值已取到**(2026-08-18 手测三次,`10ae149c`):两次单领域 career 均为 `2`,一次多领域 career+wealth 为 `0`。所以不是「从不读」,而是**多领域路径下模型一份方法文档都没打开**——恰是最需要方法的那条路径。单样本,尚不能断定必然。下一步应先复跑几次多领域确认是否稳定为 0;若稳定,方向是让方法在多领域计划下仍可达(该路径的工具结果体积大得多,可能挤掉了模型继续读文档的动机),而不是继续加服务端约束。 - 防复发:被数出来的诊断量必须有一个出口(回执或可观测日志),否则等于没数。当某个字段的作用正是「证明某件事发生过或没发生过」时,它在 schema 里应当必填:可选字段缺失与「值为 0」在下游无法区分,而这里 0 恰恰是最需要被看见的答案。另外,不要用 `steps` / `stepBudget.used` 推断模型的全部动作——这两者只记录被显式登记的三类步骤,模型的其余工具调用在其中不可见。 - 相关记录:BUG-267(同一批手测暴露的另一处静默缺口)、BUG-255(步数预算被浪费,当时也依赖 `modelStepCount` 这一间接量定位)、BUG-258(同为「失败/结束时回执信息不足」的形态) - 复发自:无 - 修复版本:`10ae149c`(staging) -## BUG-269 | 证据门比产品自己向用户预告的层更松:两份声明分处两端且没有任何机制保证一致 - -- 状态:resolved(本地修复,待提交与发布) -- 首次发现:2026-08-18 -- 最近更新:2026-08-18 -- 影响面:`scripts/jyotish_api_server.py` 的 `_ROUTE_REQUIRED_LAYERS` 与 `frontend/src/lib/consultation-domain-registry.ts` 的 `requiredLayers`。涉及 `marriage`、`wealth`、`general` 三条路由的证据门,其中 `general` 是最常走的兜底路由。 -- 用户现象:暂无可见现象。与 BUG-267 同一形态——门比声明松,失败方向是静默放宽,健康运行里看不出差别。 -- 触发条件:任何走 `marriage`、`wealth`、`general` 路由的咨询,且该路由声明要用的某一层实际缺失。 -- 根因:修完 BUG-267 后核对时发现,前端领域注册表早就为每个领域声明了 `requiredLayers`,键名正确(`marriage`/`wealth`),并且这份列表会驱动界面上的 `evidencePreview`——也就是产品明确告诉用户「这次会用这些证据」。但服务端的门是另写的一份,两份用不同词汇描述同一个合同,彼此没有任何链接。对照下来门少查三处:`marriage` 声明了 A7(Darapada)而门只要求 UL,`wealth` 声明了 Ashtakavarga 而门只要求 D2,`general` 声明了 D10 与 D2 而门只要求 D1/D9。三者实测在真实排盘中都是 `used`,所以补进去不会把状态推成 `degraded`。这正是 BUG-267 里键名漂移能发生两个月的同一片土壤:合同有两份,没有一份是权威。 -- 修复:`_ROUTE_REQUIRED_LAYERS` 补上 `marriage` 的 A7、`wealth` 的 ashtakavarga、`general` 的 D10 与 D2。新增一条测试解析前端注册表,断言其中每个**指向真实 section** 的条目都出现在服务端的门里。之所以只比对真实 section:注册表里同时有人看的标签(`7th house/lord`、`negative holdout gate`)和引擎压根不产出的 D11,机械全量对齐会把每条路由钉死在 `degraded`。该测试对解析失败采取 fail-closed——先断言 10 条路由全部解析到且列表非空,否则一次正则失配就会让它无声通过。另外把 `tests/test_consultation_consumer_context.py` 加进 `run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`:核对时发现该文件**不在任何 CI 档位里**,BUG-267 的 8 条与本条的 1 条此前都只在本地手动执行过,staging 门(`--profile quick`)从不运行它们。防漂移的钉子本身没人跑,等于没钉。 -- 验证:`tests/test_consultation_consumer_context.py` 17 条通过(新增 1 条)。已验证这条钉子是真守卫而非恰好通过:分别从 `marriage` 去掉 A7、`wealth` 去掉 ashtakavarga、`general` 去掉 D10,三次都报出对应路由与缺失层,恢复后通过。另用真实排盘跑了 general/marriage/wealth/career/timing/health/annual 七条路由,收紧后 `core_status` 仍为 `ready`、`missing_route_layers` 仍为 `[]`,确认没有把既有运行推成 `degraded`。未做的验证:**没有在 staging 上真实跑一次复看回执**,线上表现未观测。 -- 待跟进:D11 是另一个方向的口径不一致——前端向用户预告 `wealth` 会用 D11,而引擎从不构建这个 section,属于「承诺了不存在的证据」,与本条(门比承诺松)反向。本轮未处理。更彻底的方向是让两端读同一份声明而不是靠测试比对,但两套词汇(机器 section 名 vs 人看的标签)先得想清楚归一到哪一套。 -- 防复发:同一个合同不得有两份声明而没有一份权威。当暂时无法归一时,必须有一条测试把两份的交集钉住,并且该测试要 fail-closed——跨文件、跨语言的比对里,解析失配导致的「无声通过」比比对失败更危险。补门之前先用真实数据确认该层稳定产出:把引擎不产出的层写进必需集会把状态恒推成 `degraded`,这与放太松同样是错的,只是方向相反。写完回归测试后必须回头确认它在 CI 的哪个档位里真的会被执行:`run_quality_gate.py` 有多组 pytest 目标,staging 门只跑 `CORE_PYTEST_TARGETS`,写进 `RUNTIME_TRUTH_PYTEST_TARGETS` 或压根不进列表的文件在 staging push 上一次都不会跑。「本地全绿」不等于「门会拦住它」。 -- 相关记录:BUG-267(本条是修完它以后核对同一张表时发现的,同源同形态)、BUG-259(同为路由与合同之间的口径不一致) -- 复发自:无 -- 修复版本:本地未提交候选 - ## BUG-269 | “今日星语”永久停在“正在结合你的星盘写今天的星语”:请求被一个反向的出生时间守卫拦住,从未发出 - 状态:resolved(本地修复,待提交与发布) @@ -4010,4 +3993,38 @@ - 防复发:一个界面元素的「取数条件」必须和它的「渲染条件」写成同一个表达式,不能一边用 `personalChartAvailable` 渲染、一边用另一个语义相反的谓词决定是否请求。删除兜底文案时必须回头检查被兜底遮住的空状态路径是否本来就是坏的——BUG-265 删兜底是对的,但没有验证删掉之后真实账号能不能拿到内容,代价是上线即空转。同一个概念(这里是「登录后的问候」)不允许存在多套并行实现,否则改动必然落在没被渲染的那一套上。Agent 生成的字段如果没有渲染点,就不要生成,更不能在客户端覆盖后还继续消耗 token。 - 相关记录:BUG-265(本次修复的直接前序:Agent 化改造正确但守卫未同步,且其「待跟进」已经预告了首屏等待态问题)、BUG-201(每日星语 effect 依赖完整 Profile 对象的既有决定,本次沿用其引用保持策略,未改依赖形状)、BUG-200(首页文案第一人称与真实性边界) - 复发自:无 +- 修复版本:`a12f5797`(staging) + +## BUG-270 | 证据门比产品自己向用户预告的层更松:两份声明分处两端且没有任何机制保证一致 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`scripts/jyotish_api_server.py` 的 `_ROUTE_REQUIRED_LAYERS` 与 `frontend/src/lib/consultation-domain-registry.ts` 的 `requiredLayers`。涉及 `marriage`、`wealth`、`general` 三条路由的证据门,其中 `general` 是最常走的兜底路由。 +- 用户现象:暂无可见现象。与 BUG-267 同一形态——门比声明松,失败方向是静默放宽,健康运行里看不出差别。 +- 触发条件:任何走 `marriage`、`wealth`、`general` 路由的咨询,且该路由声明要用的某一层实际缺失。 +- 根因:修完 BUG-267 后核对时发现,前端领域注册表早就为每个领域声明了 `requiredLayers`,键名正确(`marriage`/`wealth`),并且这份列表会驱动界面上的 `evidencePreview`——也就是产品明确告诉用户「这次会用这些证据」。但服务端的门是另写的一份,两份用不同词汇描述同一个合同,彼此没有任何链接。对照下来门少查三处:`marriage` 声明了 A7(Darapada)而门只要求 UL,`wealth` 声明了 Ashtakavarga 而门只要求 D2,`general` 声明了 D10 与 D2 而门只要求 D1/D9。三者实测在真实排盘中都是 `used`,所以补进去不会把状态推成 `degraded`。这正是 BUG-267 里键名漂移能发生两个月的同一片土壤:合同有两份,没有一份是权威。 +- 修复:`_ROUTE_REQUIRED_LAYERS` 补上 `marriage` 的 A7、`wealth` 的 ashtakavarga、`general` 的 D10 与 D2。新增一条测试解析前端注册表,断言其中每个**指向真实 section** 的条目都出现在服务端的门里。之所以只比对真实 section:注册表里同时有人看的标签(`7th house/lord`、`negative holdout gate`)和引擎压根不产出的 D11,机械全量对齐会把每条路由钉死在 `degraded`。该测试对解析失败采取 fail-closed——先断言 10 条路由全部解析到且列表非空,否则一次正则失配就会让它无声通过。另外把 `tests/test_consultation_consumer_context.py` 加进 `run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`:核对时发现该文件**不在任何 CI 档位里**,BUG-267 的 8 条与本条的 1 条此前都只在本地手动执行过,staging 门(`--profile quick`)从不运行它们。防漂移的钉子本身没人跑,等于没钉。 +- 验证:`tests/test_consultation_consumer_context.py` 17 条通过(新增 1 条)。已验证这条钉子是真守卫而非恰好通过:分别从 `marriage` 去掉 A7、`wealth` 去掉 ashtakavarga、`general` 去掉 D10,三次都报出对应路由与缺失层,恢复后通过。另用真实排盘跑了 general/marriage/wealth/career/timing/health/annual 七条路由,收紧后 `core_status` 仍为 `ready`、`missing_route_layers` 仍为 `[]`,确认没有把既有运行推成 `degraded`。未做的验证:**没有在 staging 上真实跑一次复看回执**,线上表现未观测。 +- 待跟进:D11 是另一个方向的口径不一致——前端向用户预告 `wealth` 会用 D11,而引擎从不构建这个 section,属于「承诺了不存在的证据」,与本条(门比承诺松)反向。本轮未处理。更彻底的方向是让两端读同一份声明而不是靠测试比对,但两套词汇(机器 section 名 vs 人看的标签)先得想清楚归一到哪一套。 +- 防复发:同一个合同不得有两份声明而没有一份权威。当暂时无法归一时,必须有一条测试把两份的交集钉住,并且该测试要 fail-closed——跨文件、跨语言的比对里,解析失配导致的「无声通过」比比对失败更危险。补门之前先用真实数据确认该层稳定产出:把引擎不产出的层写进必需集会把状态恒推成 `degraded`,这与放太松同样是错的,只是方向相反。写完回归测试后必须回头确认它在 CI 的哪个档位里真的会被执行:`run_quality_gate.py` 有多组 pytest 目标,staging 门只跑 `CORE_PYTEST_TARGETS`,写进 `RUNTIME_TRUTH_PYTEST_TARGETS` 或压根不进列表的文件在 staging push 上一次都不会跑。「本地全绿」不等于「门会拦住它」。 +- 相关记录:BUG-267(本条是修完它以后核对同一张表时发现的,同源同形态)、BUG-259(同为路由与合同之间的口径不一致) +- 复发自:无 +- 修复版本:本地未提交候选 + +## BUG-271 | 工具调用在进入工具体之前被拒时,回执里查不到它失败过:步没记、调用没计、原因塌成兜底码 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`/api/consult` 的公开回执与服务端可观测日志。凡是模型给出的工具参数不被工具 `inputSchema` 接受的运行都受影响。 +- 用户现象:staging 手测 run `951a841e`(`10ae149c`)第一次工具调用发出 `tool.failed` / `calculation_failed`,重试成功并给出了完整回答。但 `run.completed` 的回执里 `steps` 只有 2 条(skill + 那次成功的 tool)、`stepBudget.used: 2`,**失败的那次完全不存在**;`tool.failed` 的 code 是兜底值,服务端日志同样只有兜底值。也就是说:客户端看见「失败过一次」,回执却说没有,而且两边都查不到为什么。 +- 触发条件:模型对 `run-jyotish-consultation` 传出不被 `consultationToolInputSchema`(`.strict()`,只接受 `question` 与 `domains`)接受的参数。 +- 根因:三处叠加。其一,schema 拒绝发生在 Mastra 调用 `execute` **之前**,所以工具体内的一切都没跑——`consultationToolCallCount` 不增、`appendConsultationRuntimeStep` 不执行、连 `chart-calculation` 活动事件都没发出(这也正是本次能定位的证据:失败那次调用没有任何 activity,重试那次有)。工具自己无法记录一次它从未收到的调用。其二,`safeToolError()` 把一切非 `AbortError`/`TimeoutError` 的错误塌成 `calculation_failed`,公开事件因此不携带任何可区分信息。其三,即使失败落进了工具体的 `catch`,`consultationWorkflowFailureCode()` 对非 `ConsultationWorkflowError` 返回 `undefined`,而 append 处写的是 `...(failureCode ? { failureCode } : {})`——于是「最需要解释的那条记录」恰好是唯一没有原因的记录。 +- 修复:在流层补记。流是唯一能观测到**全部**工具失败的位置(无论失败发生在 schema 这一侧还是 `execute` 那一侧),且它持有 `startedAt` 计时因而能给出时长。`tool-error` 分支现在比对「流已见的工具错误数」与「state 里已有的失败 tool 步数」,仅在前者更多时补一条 `status: "failed"`、`failureCode: "tool_call_rejected"` 的步——工具仍然记录它能看见的失败(带它独有的时长与真因),流只填空档,不重复计。另新增 `consultationToolFailureCode()`,令每个错误都解析出一个码(工作流传输故障沿用原码、领域计划被拒为 `invalid_domain_plan`、其余为 `unexpected_error`),工具体 `catch` 改用它,`failureCode` 不再可能缺失。**刻意未改**:`failureCode` 仍不进公开回执——`publicConsultationRuntimeSteps()` 的白名单与名为「the public receipt never carries the internal failure classification」的现存测试是刻意约束,`workflow_rate_limited` 这类后端内情不应上线到客户端。原因走可观测日志的 `toolCalls[].failureCode`(该通道本就是内部诊断专用,`machineCodeSchema` 允许新码)。客户端现在能看到「有一步失败了」,运维能在日志里看到为什么。 +- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 40 项通过(新增 3 项)。新增:(1) 只喂 `tool-call` + `tool-error` 两个 chunk(模拟 `execute` 从未运行),断言 state 里出现带 `tool_call_rejected` 的失败 tool 步、`stepBudget.used` 变为 2、公开回执的 steps 状态序列为 `["completed","failed"]`,且序列化后不含该内部码;(2) 工具已记录过的失败不得被流重复记一遍,且原有的 `workflow_queue_full` 不被覆盖;(3) `consultationToolFailureCode()` 对工作流错误、计划错误、普通 Error 与非 Error 值逐个断言有码。已验证第 (1) 条在修复前失败(`fail 1`),修复后 40 全通过。非数据库前端套件 1675/1675 通过,`tsc --noEmit` 与改动文件 `eslint` 清洁。未做的验证:**没有在 staging 上复现一次 schema 拒绝来复看回执**——本次修复后线上是否真的出现带失败步的回执尚未观测,且触发它需要模型再犯一次同样的参数错误,无法主动构造。 +- 待跟进:模型当时到底传了什么参数仍然不知道——正因为没被记下来。修复后若再次出现,日志会给出 `tool_call_rejected`,但具体是哪个字段不合法仍不可见;若该情况反复出现,下一步是在服务端日志里补一个「被拒字段名」的封闭枚举(只记字段名,不记值,避免把模型输出当日志内容)。另外 `consultationToolCallCount` 在这条路径上仍然不增(工具体没跑),本轮刻意未动:改它会破掉「never starts a calculation」与「invalid model input does not poison a later valid contract retry」两条现存的刻意断言,而失败已经能从 `steps` 看见,计数本身不再是唯一线索。 +- 防复发:把「某件事发生过」的记录放在能观测到它的那一层。工具无法记录一次它从未收到的调用,因此这类记录必须由流(或同等的外层观察点)兜底;判断「哪一层能看见」的可靠证据是活动事件的有无,而不是错误码。失败记录的原因字段不得可选省略:`...(code ? {code} : {})` 这种写法会让分类器覆盖不到的错误静默变成无原因记录,而那恰好是最需要原因的一类。公开与内部两个通道要分别满足:客户端需要知道「失败过」,运维需要知道「为什么」,把后者塞进前者会泄漏后端内情,把前者省掉会让回执与客户端亲眼所见互相矛盾。 +- 相关记录:BUG-268(同为「诊断量算出来就丢」,本条是「诊断量压根没被创建」)、BUG-258(同为失败时回执信息不足)、BUG-255(同为模型参数被拒白扔步数,当时的修法是把互斥字段从 schema 里删掉) +- 复发自:无 - 修复版本:本地未提交候选 diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 10a9fb30..9ac12245 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -49,6 +49,7 @@ type EventOptions = { requestId: string; toolStatus: () => Status; receipt: () => AgentExecutionReceipt; + state?: ConsultationRuntimeState; }; function activity(value: unknown): ConsultationAgentPublicEvent | null { @@ -84,11 +85,43 @@ function safeToolError(error: unknown) { return "calculation_failed" as const; } +/** + * Record a tool failure the tool itself could not record. + * + * A call rejected against the tool's input schema never enters `execute`, so + * nothing in the tool runs: the run reported a `tool.failed` event to the client + * while its receipt showed no failed step and its budget counted no call. The + * stream is the only place that observes every failure, whichever side of the + * schema it came from. + * + * The tool still records the failures it can see, with the duration and cause it + * alone knows, so this only fills the gap: it appends when the stream has seen + * more tool errors than the state has failed tool steps. The error object here + * is provider-shaped and cannot be classified further, but the call not reaching + * `execute` is itself the diagnosis. + */ +function recordUnrecordedToolFailure( + state: ConsultationRuntimeState, + toolErrorsSeen: number, + durationMs: number, +) { + const recorded = state.steps.filter((step) => step.kind === "tool" && step.status === "failed").length; + if (recorded >= toolErrorsSeen) return; + appendConsultationRuntimeStep(state, { + kind: "tool", + name: "run-jyotish-consultation", + status: "failed", + durationMs, + failureCode: "tool_call_rejected", + }); +} + function mapChunk( chunk: Chunk, options: EventOptions, startedAt: Map, jyotishSkillCallIds: Set, + toolErrors: { seen: number }, ): ConsultationAgentPublicEvent[] { const payload = chunk.payload ?? {}; if (chunk.type === "data-jyotish-activity") { @@ -123,9 +156,18 @@ function mapChunk( if (chunk.type === "tool-error") { const toolName = payload.toolName; if (toolName === "run-jyotish-consultation") { + const callId = typeof payload.toolCallId === "string" ? payload.toolCallId : "tool"; + toolErrors.seen += 1; + if (options.state) { + recordUnrecordedToolFailure( + options.state, + toolErrors.seen, + Math.max(0, Date.now() - (startedAt.get(callId) ?? Date.now())), + ); + } return [{ type: "tool.failed", - callId: typeof payload.toolCallId === "string" ? payload.toolCallId : "tool", + callId, tool: "run-jyotish-consultation", code: safeToolError(payload.error), }]; @@ -138,8 +180,9 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable(); const jyotishSkillCallIds = new Set(); + const toolErrors = { seen: 0 }; for await (const chunk of stream instanceof ReadableStream || Symbol.asyncIterator in stream ? readChunks(stream as ChunkStream) : stream) { - events.push(...mapChunk(chunk, options, startedAt, jyotishSkillCallIds)); + events.push(...mapChunk(chunk, options, startedAt, jyotishSkillCallIds, toolErrors)); if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") { events.push({ type: "answer.delta", text: chunk.payload.text }); } @@ -183,6 +226,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { let fullOutput = ""; const startedAt = new Map(); const jyotishSkillCallIds = new Set(); + // A retry reuses these counters so a failure in either attempt is recorded once. + const toolErrors = { seen: 0 }; const send = (controller: ReadableStreamDefaultController | undefined, event: ConsultationAgentPublicEvent) => { if (!firstActivity && (event.type === "skill.started" || event.type === "tool.started" || event.type === "activity")) { firstActivity = true; @@ -217,7 +262,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { // total accumulates while the finish reason describes the latest attempt. const stepCountBeforeAttempt = options.state.modelStepCount; for await (const chunk of readChunks(stream)) { - for (const event of mapChunk(chunk, options, startedAt, jyotishSkillCallIds)) send(controller, event); + for (const event of mapChunk(chunk, options, startedAt, jyotishSkillCallIds, toolErrors)) send(controller, event); if (chunk.type === "step-finish") options.state.modelStepCount += 1; if (chunk.type === "finish") { const finish = finishTelemetry(chunk); diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index a1877b7b..93ca6f41 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -143,6 +143,24 @@ export function publicConsultationRuntimeSteps(state: ConsultationRuntimeState) })); } +/** + * Why a consultation tool call failed, as a closed machine code. The workflow + * classifier only recognises its own transport faults and returns undefined for + * everything else, so the failures raised by this module — a rejected domain + * plan above all — reached the observability log with no code at all. The one + * record that exists to explain a failure must never be the one without a + * reason, so every error now resolves to a code. + */ +export function consultationToolFailureCode(error: unknown): string { + const workflowCode = consultationWorkflowFailureCode(error); + if (workflowCode) return workflowCode; + const message = error instanceof Error ? error.message : ""; + if (message === "invalid_consultation_domain_plan" || message === "unsupported_consultation_domain") { + return "invalid_domain_plan"; + } + return "unexpected_error"; +} + export function appendConsultationRuntimeStep(state: ConsultationRuntimeState, step: Omit) { if (state.steps.length >= state.stepBudget.total) { state.stepsTruncated = true; @@ -522,13 +540,12 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { return toModelDomainPlanContext(executions, omittedDomains); } catch (error) { ctx.state.consultationToolDurationMs = now() - startedAt; - const failureCode = consultationWorkflowFailureCode(error); appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: ctx.state.consultationToolDurationMs, - ...(failureCode ? { failureCode } : {}), + failureCode: consultationToolFailureCode(error), }); throw error; } diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 6a67674d..d2929c1f 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -9,6 +9,7 @@ import { canonicalDomainPlan, consultationModelStepTelemetry, consultationStepBudgetReceipt, + consultationToolFailureCode, createConsultationRuntimeHooks, createConsultationTools, createConsultationRuntimeState, @@ -611,6 +612,75 @@ test("workflow failure codes classify transport and contract faults", () => { assert.equal(consultationWorkflowFailureCode(new Error("anything else")), undefined); }); +test("every tool failure resolves to a code, not an absent field", () => { + // The workflow classifier returns undefined for anything it does not own, and + // the append site omitted the field when it was undefined, so the failures + // raised inside the tool reached the log as the one record with no reason. + assert.equal(consultationToolFailureCode(new ConsultationWorkflowError("workflow_rate_limited", "x")), "workflow_rate_limited"); + assert.equal(consultationToolFailureCode(new DOMException("stop", "AbortError")), "workflow_aborted"); + assert.equal(consultationToolFailureCode(new Error("invalid_consultation_domain_plan")), "invalid_domain_plan"); + assert.equal(consultationToolFailureCode(new Error("unsupported_consultation_domain")), "invalid_domain_plan"); + assert.equal(consultationToolFailureCode(new Error("anything else")), "unexpected_error"); + assert.equal(consultationToolFailureCode("not an error"), "unexpected_error"); +}); + +test("a call rejected before the tool body runs still appears in the receipt", async () => { + // Observed on staging: the model's arguments were refused against the tool's + // strict input schema, so `execute` never ran. The client saw tool.failed + // while the receipt showed no failed step and the budget counted no call. + const state = createConsultationRuntimeState(); + state.jyotishSkillLoaded = true; + appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed", durationMs: 370 }); + async function* chunks() { + yield { type: "tool-call", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation" } }; + yield { type: "tool-error", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation", error: new Error("bad arguments") } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "blocked", + receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + onError: () => {}, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + + const failedStep = state.steps.find((step) => step.kind === "tool" && step.status === "failed"); + assert.equal(failedStep?.name, "run-jyotish-consultation"); + assert.equal(failedStep?.failureCode, "tool_call_rejected"); + assert.equal(consultationStepBudgetReceipt(state).used, 2); + // The client learns a step failed; the classification stays server-side. + const failed = events.find((event) => (event as { type?: string }).type === "run.failed") as { + receipt?: { steps: Array<{ status: string; name: string }> }; + }; + assert.deepEqual(failed.receipt?.steps.map((step) => step.status), ["completed", "failed"]); + assert.doesNotMatch(JSON.stringify(failed), /tool_call_rejected/); +}); + +test("a failure the tool already recorded is not recorded twice", async () => { + // The tool records what it can see, with the duration and cause it alone + // knows. The stream must only fill the gap, never double-count. + const state = createConsultationRuntimeState(); + state.jyotishSkillLoaded = true; + appendConsultationRuntimeStep(state, { + kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: 20936, failureCode: "workflow_queue_full", + }); + async function* chunks() { + yield { type: "tool-call", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation" } }; + yield { type: "tool-error", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation", error: new Error("boom") } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "blocked", + receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), + onError: () => {}, + }); + await response.text(); + + assert.equal(state.steps.filter((step) => step.status === "failed").length, 1); + assert.equal(state.steps[0]?.failureCode, "workflow_queue_full"); +}); + test("the public receipt never carries the internal failure classification", () => { const state = createConsultationRuntimeState(); appendConsultationRuntimeStep(state, { diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index cc5c3cf4..bbc32958 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -699,7 +699,7 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, # # The gate must also not be weaker than what the product tells the user it will use. The frontend # domain registry declares each domain's layers for its evidence preview, and every entry there that -# names a real section is required here too; a test pins that intersection (BUG-269). The registry +# names a real section is required here too; a test pins that intersection (BUG-270). The registry # additionally names human-facing items ('7th house/lord', 'negative holdout gate') and D11, which # the engine does not build at all — those are deliberately not requirements. _ROUTE_REQUIRED_LAYERS = { diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index a98d2094..8c7eb1ac 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -53,7 +53,7 @@ CORE_PYTEST_TARGETS = [ "tests/test_external_oracle_sanity_closure.py", # The staging gate runs the quick profile, so a guard absent from this list never runs in CI. # These pin the answer-truth contract every product consultation is built on, and their failure - # mode is silent widening — nothing errors when they regress (BUG-267, BUG-269). + # mode is silent widening — nothing errors when they regress (BUG-267, BUG-270). "tests/test_consultation_consumer_context.py", ]