fix(consult): stop the model spending its step budget on invalid tool params
Independent Staging Quality Gate / validate (push) Successful in 13m24s
Independent Staging Quality Gate / publish (push) Successful in 10m13s

A staging consultation calculated the chart and then returned nothing but the
ensureFinalResponseText fallback. The model had made four calls to
run-jyotish-consultation, and two of them never reached a calculation: they set
both domains and theme, which canonicalDomainPlan rejects at execution. The
schema declared those two fields as independent optionals, the description never
mentioned the constraint, and the instructions actively told the model to use
theme for a single-domain retry. Each attempt therefore bought a rule the
contract never stated, and because the throw happens before the step-recording
try/catch, it left no trace in the receipt either.

Make the constraint unrepresentable instead of enforced. The model-facing schema
keeps only question and domains, so Mastra refuses the pair before the tool body
runs; the description states the single-array contract, and the instruction that
advertised theme is gone. canonicalDomainPlan still resolves the single-value
form for callers that build a plan without that schema, and is now exported so
that path has its own tests.

maxSteps and the abort timeout bound the same run but were hard-coded apart. One
calculation takes about 20s against a 110s budget, so time is the binding
constraint and three failed calculations exhaust it whatever the step count. The
budget only has to cover the longest useful shape, so it moves to 8 beside the
timeout with that reasoning recorded, and the recorded step list is sized to
match so an exhausted run cannot truncate its own evidence.

Step exhaustion was only ever inferable by counting events, since finishReason
was recorded nowhere and progressive-disclosure reads never reach the public
stream. Capture it as a closed enum plus a step count, normalizing anything
unrecognized, and log both as controlled fields. Neither may enter the client
receipt, whose step schema is strict and would fail a successful run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 15:14:36 +08:00
parent fb9eec54cc
commit c8d9ec64c3
10 changed files with 355 additions and 25 deletions
+15
View File
@@ -3757,3 +3757,18 @@
- 待跟进:`BUG-163``状态`/`最近更新`/`根因`/`验证`/`修复版本` 各出现两次,疑为同一记录两轮更新直接拼接。该缺陷早于本轮、不属正文错位,未处理。
- 相关记录:BUG-253
- 修复版本:本地未提交候选
## BUG-255 | 模型把步数预算耗在无效工具参数上,个人咨询只返回兜底文案
- 状态:resolved(本地修复,未提交、未发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/api/consult` 个人咨询的 Agent 步数预算、`run-jyotish-consultation` 的模型可见参数契约,以及模型运行结束原因的可观测性。
- 用户现象:staging 事业类咨询运行至星盘计算成功,但模型没有产出任何回答文本,用户只看到 `ensureFinalResponseText()` 的兜底句“本次计算已完成,但暂时没有生成可展示的回答”。事件流显示 Skill 已加载、四次 `run-jyotish-consultation`(三次失败、一次在 20278ms 后成功),此后再无 `answer.delta`
- 触发条件:模型在同一次运行内多次调用排盘工具,其中至少两次同时传入 `domains` 与兼容字段 `theme`;叠加渐进式披露的 Skill 参考读取后,`maxSteps: 6` 在写回答之前耗尽。
- 根因:三层叠加。其一,`consultationToolInputSchema``domains``theme` 声明为两个彼此独立的可选字段,互斥关系只在 `canonicalDomainPlan()` 里以 `invalid_consultation_domain_plan` 运行期抛出,工具 `description` 也从未提到该约束;更直接的是 `jyotishInstructions` 明确写着“Use the legacy theme field only for a single-domain compatibility retry”,等于主动引导模型去用一个会被拒绝的组合。其二,这类无效调用发生在步骤记录 try/catch 之前,既不产生 `chart-calculation` 活动也不追加运行步骤,因此每次都白耗一个模型步骤且在回执里不留痕迹。其三,`maxSteps: 6``AbortSignal.timeout(110_000)` 约束同一次运行却分别硬编码:1 次 Skill 加载 + 4 次工具调用已占 5 步,`skill_read` / `skill_search` 这类渐进式披露工具未映射进公开事件流,第 6 步一旦被一次不可见的参考读取拿走,运行就在没有任何回答的情况下结束。`finishReason` 在整个仓库中没有任何记录点,因此“步数耗尽”只能靠事后数事件推断,无法证实。
- 修复:把互斥关系改为不可表达而非运行期拒绝——模型可见的 `inputSchema` 只保留 `question``domains``theme` 从模型契约中移除,`.strict()` 保持不变,使 `theme` 在进入工具体之前即被 Mastra 的入参校验拒绝;`description` 补齐“只用一个有序 `domains` 数组,省略即接受服务端已选领域,出生资料服务端绑定”的显式契约;`jyotishInstructions` 同步删除引导模型使用 `theme` 的那句。`canonicalDomainPlan()` 继续处理单值 `theme` 形态并保留 `invalid_consultation_domain_plan`,导出后由直接单元测试覆盖,供不经模型 schema 构造计划的调用方使用。步数预算与时钟预算改为相邻声明的 `AGENT_MAX_STEPS = 8``AGENT_TIMEOUT_MS = 110_000`,并注明二者约束同一次运行、必须一起考虑;运行步骤记录预算随之对齐,避免耗尽步数的运行同时截断自身证据。新增受控观测字段 `modelFinishReason`(封闭枚举,未知取值一律归一为 `unknown`)与 `modelStepCount`,在流中按 `step-finish` 计数、以终止 `finish` 携带的步骤列表为准,跨重试累计。未放宽单次计算边界,未放宽运行合同门禁,未把任何模型原文或 provider payload 写入日志。
- 验证:新增修复前失败的回归 6 项——模型可见 schema 必须拒绝 `theme`(单独出现与与 `domains` 同时出现)、被拒调用不得推进任何运行状态(`consultationToolStarted` / `consultationToolCallCount` / `steps` 全部不变)、完成运行必须记录 `finishReason` 与权威步数、以 `tool-calls` 结束且无回答的运行必须记下耗尽的步数、重试必须累计步数并归一化未识别的 provider 取值、步数与时钟预算必须成对声明。回退任一源改动可确认对应回归失败。另新增公开回执守卫:`agentExecutionReceiptSchema` 是 strict`modelFinishReason` / `modelStepCount` 一旦透出会让成功运行在序列化自身回答时报错,故断言按白名单构建的回执不含这两个字段、直接透出则必须抛错。`canonicalDomainPlan()` 补 8 项直接断言,锁定“单值 theme 不得覆盖路由选定领域”。BUG-205 的缓存不被污染性质改由 schema 合法但注册表非法的输入(`domains: ["career", "unknown"]`)复验,因其原始触发条件已不可达。全量非数据库套件 1586/1586 通过,`npx tsc --noEmit` 0 错误,改动文件 `npx eslint` 0 错误。
- 待跟进:Mastra 的 `createTool` 会在调用业务 `execute` 之前完成入参校验,校验失败时**返回**错误对象而不是抛出,因此模型若仍误传 `theme`,本次运行仍会消耗一个步骤,只是拿到的是明确可纠正的提示,而不再是不透明的 `invalid_consultation_domain_plan`,且不会进入计算缓存。该类校验失败同样不追加运行步骤,回执中依旧看不到;是否为“入参被 schema 拒绝”单独记一条受控失败码,留待与 `failureCode` 分类一并评估。另记 `maxSteps` 取 8 而非更大值的依据是时钟而非步数:单次排盘约 20s,路由 `maxDuration` 为 120s、Agent 超时 110s,三次失败计算即会先耗尽时钟;8 步刚好覆盖最长有用形态——Skill 加载、两次渐进式披露参考读取、一次计算加一次重试、一次写回答,继续放大只会在注定失败的运行上多花 token,不会换来更多计算机会。
- 防复发:模型可见的工具参数不得存在两个语义重叠的字段,互斥关系必须由 schema 表达而不是运行期抛出;任何在模型契约中被移除的字段,必须同时从 Agent instructions 中删除,否则提示词会继续引导模型踩坑。步数预算与时钟预算必须相邻声明并在同一处说明彼此关系,不得分散硬编码。凡以“模型没写回答”为现象的问题,必须先能读到 `finishReason` 与实际步数再下结论;新增观测字段只能是封闭枚举或计数,且必须同时验证其不会进入 strict 的对外回执。
- 相关记录:BUG-214、BUG-205、BUG-186
- 修复版本:本地未提交候选