fix(consult): give a multi-domain plan a top-level answer contract it can obey

A staging consultation submitted a three-domain plan, calculated all three
successfully in 62.9s, and returned nothing but the ensureFinalResponseText
fallback. The step budget was barely touched, so this is not the exhaustion
c8d9ec64 fixed. toModelDomainPlanContext returns two different shapes: a single
domain flattens the evidence packet to the top level, several domains return only
success, domains and consultations. Every hard output rule in jyotishInstructions
is written against those top-level paths — evidence_contract.answer_policy,
hard_blockers, rectification.boundary, status. None of them resolve in the
multi-domain shape, and under a policy that forbids stating anything the server
evidence does not support, silence is what the instructions ask for.

Merge the packets into one top-level contract shaped exactly like the single
domain one. Merging may only restrict: status takes the worst of ready >
degraded > blocked, hard_blockers and missing_route_layers take the union,
permission booleans need every domain to agree while limitation booleans need
only one, and a field the domains genuinely disagree on is reported as
unresolved rather than decided. available_layers is the one permission-shaped
union, because a layer really was computed for some domain and denying it would
deny real evidence. The natal projection is the same chart for every domain, so
it is hoisted to one copy when the domains agree and left per-domain when they
do not.

The domain cap was six, advertised as six, and could never be paid for. Domains
run sequentially at ~21s each against a cumulative 110s abort signal, so six is
~126s and four leaves nothing to write the answer with. Concurrency is not
available: the Python API is a single GIL-bound ThreadingHTTPServer whose async
work already sits behind a two-worker bounded queue that answers 503 when full.
Derive the cap from the clock instead of choosing it — 110s minus a 45s answer
reserve, divided by 21s, is three — and let the model-facing schema carry that
bound so an unpayable plan is unrepresentable. A caller that builds a plan
without that schema is truncated rather than refused, the loop stops early when
the measured pace says the next domain will not fit, and either way the dropped
domains are disclosed through omitted_domains and the receipt while status
degrades, so a partial answer cannot be presented as complete.

run.failed carried a code and nothing else, so the step durations, step budget
and workflow route recorded by c8d9ec64 were unavailable exactly when a run
needed explaining. Send the same allowlisted receipt run.completed sends,
built through publicConsultationRuntimeSteps so the internal failure code and
model loop diagnostics stay server-side, and never let building it replace the
failure event with a silent close. An agentic run that fails before
streamAgentResponse exists never reached the settle-and-log path either, so the
request-level catch now goes through the same entry point.

Refs BUG-256, BUG-257, BUG-258.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 17:05:56 +08:00
parent c8d9ec64c3
commit 1955ba8cef
10 changed files with 759 additions and 60 deletions
+46
View File
@@ -3772,3 +3772,49 @@
- 防复发:模型可见的工具参数不得存在两个语义重叠的字段,互斥关系必须由 schema 表达而不是运行期抛出;任何在模型契约中被移除的字段,必须同时从 Agent instructions 中删除,否则提示词会继续引导模型踩坑。步数预算与时钟预算必须相邻声明并在同一处说明彼此关系,不得分散硬编码。凡以“模型没写回答”为现象的问题,必须先能读到 `finishReason` 与实际步数再下结论;新增观测字段只能是封闭枚举或计数,且必须同时验证其不会进入 strict 的对外回执。
- 相关记录:BUG-214、BUG-205、BUG-186
- 修复版本:本地未提交候选
## BUG-256 | 多领域排盘把回答契约整块藏进 consultations,模型无据可依只能不说话
- 状态:resolved(本地修复,未提交、未发布)
- 影响面:`/api/consult` 个人咨询中模型提交两个及以上领域的全部运行;单领域运行不受影响。
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 用户现象:staging 综合类咨询(问题“未来两年哪些阶段值得把握”)工具执行成功,回执 `route: "multi-domain"``domains: ["timing","career","wealth"]``status: "ready"``missingLayers: []``durationMs: 62909`,模型却产出零回答文本,用户只看到 `ensureFinalResponseText()` 的兜底句“本次计算已完成,但暂时没有生成可展示的回答”。与 BUG-255 不同,本次步数预算 `{planned:10, used:3, remaining:7}` 远未耗尽,不是步数问题。
- 触发条件:模型在一次 `run-jyotish-consultation` 调用中提交多于一个领域,且运行成功。单领域调用(Run 1,事业类)在同一批数据上正常输出完整回答。
- 根因:`toModelDomainPlanContext()` 对单领域与多领域返回两种结构不同的形状。单领域走 `{ ...consultations[0], domains, consultations }`,证据包被摊平到顶层,`evidence_contract``claim_cards``rectification``route``status``question``packet_version` 全部可达;多领域只返回 `{ success, domains, consultations }`,顶层仅此三键。而 `jyotishInstructions` 的硬性输出契约全部以顶层路径表述——`evidence_contract.answer_policy` 作为硬约束、`hard_blockers` 非空才可声称计算失败、`rectification.boundary=not_auto_rectified` 视为终态、把回答政策当作权威依据。多领域形状下这些路径一律解析不到,叠加“服务端证据不支持的内容一律不得陈述”的总政策,模型手里没有任何授权它开口的契约,沉默是它唯一符合提示词的选择。证据并未丢失,只是嵌在 `consultations[]` 里,而 instructions 从未提到该路径。反向不对称同时存在:`success` 只在多领域形状里有,单领域形状根本没有该键,因为证据包 schema 里没有这个字段。附带一处冗余:`projectNatalFoundation()` 对每个领域产出完全相同的本命投影,三领域载荷把同一大块本命数据重复三份,零信息增量。
- 修复:为整个领域计划给出一份顶层回答契约,形状与单领域逐字一致,使 instructions 引用的每条路径在两种形状下都能解析。合并一律取最严:`status` 取 ready > degraded > blocked 中最差的一档;`hard_blockers``missing_route_layers` 取并集;`answer_policy``can_answer_*` 一类许可布尔必须每个执行领域都为 true 才为 true,`should_lead_with_limitations` 一类限制布尔任一领域为 true 即为 true,数组字段取并集,其余字段仅在所有领域取值一致时保留;出现无法合并的分歧时不选边,记入 `unresolved_policy_fields` 并强制 `should_lead_with_limitations = true``available_layers` 是唯一取并集的许可类字段——某层只要为任一领域真实算出就确实存在,否认它等于否认真实证据,真正约束回答的是缺失与阻断的并集。`rectification.boundary` 只要有一个领域报 `not_auto_rectified` 就整体沿用该边界。本命投影在各领域逐字相同时上提为顶层单份并从各领域移除,不同时保持每领域各自携带,不挑一份充当共享。单领域形状继续走摊平分支,逐字不变,另补 `success``omitted_domains` 两键消除反向不对称。每领域细节仍留在 `consultations[]`,未做删减。
- 验证:新增修复前失败的回归 4 项——多领域结果必须暴露与单领域相同的顶层契约路径(`packet_version` / `question` / `route` / `status` / `evidence_contract` / `claim_cards` / `rectification`);一个领域禁止精确时机即强制合并政策同样禁止,且 `status` 取最差、缺失层与阻断项取并集;`mergeConsultationAnswerPolicies()` 的直接单元断言锁定“合并只能收紧,不能放宽”,含冲突字段不选边;合并契约必须暴露单领域暴露的每一个政策字段,防止今后新增字段被静默丢弃。第 4 项另覆盖本命投影上提与“领域不一致时不上提”。把多领域分支回退成 `{ success, domains, consultations }` 可确认这 4 项全部失败。
- 待跟进:`projectEvidenceContract()` 只投影 `available_layers` / `missing_route_layers` / `hard_blockers` / `answer_policy` / `user_facing_limitation` 五项,Python 侧在 `answer_policy` 顶层给出的 `deterministic_claims_forbidden_for` 并不在其中,因此 instructions 里“把 `answer_policy.deterministic_claims_forbidden_for` 当作硬性禁止”这句在单领域形状下同样解析不到——这是与本条同源的对称缺口,但属于投影层而非合并层,本轮未改投影范围,仅让合并逻辑对该类禁止列表按并集处理,字段一旦被投影即自动生效。
- 防复发:同一个工具结果不得对不同输入返回结构不同的顶层形状;提示词以顶层路径表述硬性契约时,每种可能的返回形状都必须让这些路径解析得到,否则模型会以沉默满足“无证据不得陈述”。跨领域聚合只允许收紧,任何许可类字段取并集前必须能说清“它为何不是放宽”;无法合并的分歧必须显式暴露并倒向限制,不得择一。以“模型没写回答”为现象的问题,先核对提示词引用的每条路径在实际载荷中是否存在,再怀疑步数或时钟。
- 相关记录:BUG-255、BUG-214、BUG-215
- 修复版本:本地未提交候选
## BUG-257 | 领域上限允许提交注定超时的计划,六领域计划在时钟上从不可能完成
- 状态:resolved(本地修复,未提交、未发布)
- 影响面:`/api/consult` 个人咨询的领域计划上限、`run-jyotish-consultation` 的模型可见参数契约与工具描述,以及领域循环的时钟纪律。
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 用户现象:staging 综合类咨询(问题“请综合说明我当前最值得关注的主题”)在一次 `tool.started``chart-calculation` 活动之后直接 `tool.failed code=calculation_failed`,没有 `evidence-validation` 活动,说明失败发生在领域循环内部;服务端补跑的第二轮模型循环既无工具调用也无文本,最终 `run.failed code=runtime_contract_incomplete`
- 触发条件:模型提交的领域数乘以单领域实际耗时超过 Agent 级 abort 信号剩余时间。观测口径为单领域 20936ms、三领域 62909ms,约 21s/领域,证实领域循环串行且延迟随领域数线性增长。
- 根因:`MAX_CONSULTATION_DOMAINS = 6`,工具描述也照此宣称“up to six allowlisted domains”,但 `for (const domain of domains)` 逐个 await 一次 Python 调用,每次约 21s,且所有调用共用同一个 `AbortSignal.timeout(AGENT_TIMEOUT_MS)`110s)——该 deadline 对整轮运行是累计的,`runConsultationWorkflow``AbortSignal.any` 叠加的 90s 才是每次调用各自的。因此六领域约 126s 永不可能完成,四领域约 84s 也几乎不给模型留下写回答的时间。schema 允许表达一个注定失败的计划,且失败时序(循环内抛出、无 `evidence-validation`)与该推断一致。并发不是出路:Python API 是单进程 `ThreadingHTTPServer`,核心计算受 GIL 约束,`/api/consultation_workflow` 为同步处理,异步作业另有 `JYOTISH_ASYNC_JOB_WORKERS=2``JYOTISH_ASYNC_JOB_QUEUE_SIZE=8` 的有界队列,满载即以 HTTP 503 `ERR_JOB_QUEUE_FULL` 回绝(前端 `workflow_queue_full` 即由此映射)。并行只会把串行等待换成排队与 GIL 争抢,不会缩短总时长,故不并行。
- 修复:上限改为由时钟推导而非选定,与它约束的同一轮预算相邻声明:`CONSULTATION_DOMAIN_DURATION_MS = 21_000`staging 实测)、`CONSULTATION_ANSWER_RESERVE_MS = 45_000`(三领域运行在 110s 内实际留给写回答的余量口径)、`CONSULTATION_DOMAIN_WALL_CLOCK_MS = 110_000 - 45_000 = 65_000``MAX_CONSULTATION_DOMAINS = floor(65_000 / 21_000) = 3`。模型可见的 `domains` 数组上界随之收为 3,使超预算计划不可表达——Mastra 在进入工具体之前即拒绝,不会启动任何计算、不推进任何运行状态。绕过模型 schema 的内部调用方走 `executableDomainPlan()`:截断到上限、把余下领域记为 `omittedDomains`,宁降级不整体失败。循环内另加 `domainFitsRunBudget()`,按已执行领域的真实耗时外推下一个领域是否还装得进循环份额,装不下就停在此处并把剩余领域计入 `omittedDomains`;第一个领域始终执行,否则无从作答。任何截断都会把顶层 `status` 压到至少 `degraded`、强制 `should_lead_with_limitations = true`,并通过 `omitted_domains` 与回执的 `omittedDomains` 同时对模型和调用方披露,使部分回答不可能被当作完整回答呈现。工具描述与 `jyotishInstructions` 同步改写为真实上限、串行执行、单领域时钟成本与截断披露语义。未提高 `AGENT_TIMEOUT_MS`:路由 `maxDuration` 为 120110s 已贴近上限。
- 验证:新增修复前失败的回归 4 项——上限必须等于时钟能支付的领域数(同时断言 `AGENT_TIMEOUT_MS`、循环份额与 `executableDomainPlan()` / `domainFitsRunBudget()` 的边界取值,并显式记录旧上限 6 在 110s 内不可能完成);超上限计划必须不可表达且一次计算都不启动(`consultationToolStarted``steps` 均不变);每领域 40s 的慢运行必须在第一个领域后停止、披露丢弃的领域、`status` 降为 `degraded`,且被截断的结果仍须携带完整顶层契约;工具描述宣称的上限必须与执行的上限一致,且不得再出现 “up to six”。把上限回退为 6 并让预算判定恒真,可确认前三项失败;描述一致性那项针对修复前的字面描述文本失败。
- 防复发:任何领域级并行提议必须先证明后端能承接并发,判据是 Python 侧的进程模型、GIL 约束与有界队列,而非前端看起来能不能同时发请求。串行循环的规模上限必须由时钟推导并与预算常量相邻声明,不得独立选定;超出上限的计划优先“执行装得下的部分并披露丢弃项”,其次才是拒绝,且披露必须同时到达模型与回执,并强制降级状态,使部分结果无法被呈现为完整结果。宣称上限的文案与强制上限必须由同一常量插值,禁止在描述里写死数字或数词。
- 相关记录:BUG-255、BUG-256、BUG-214
- 修复版本:本地未提交候选
## BUG-258 | 运行失败时回执不随事件返回,最需要解释的运行反而只剩一个错误码
- 状态:resolved(本地修复,未提交、未发布)
- 影响面:`/api/consult` 所有以 `run.failed` 结束的运行的对外诊断信息,以及在流开始之前就失败的 agentic 运行的服务端观测日志。
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 用户现象:无终端用户可见文案变化。对调用方与排查者而言,`run.completed` 携带完整回执,`run.failed` 只有 `code` 与一句提示,于是 BUG-255 刚补齐的每步 `durationMs`、步数预算、工作流路由在运行失败时一概拿不到——恰好是最需要它们的时刻。
- 触发条件:其一,任何走到 `streamAgentResponse` catch 分支的运行;其二,agentic 运行在 `streamAgentResponse` 建立之前失败(计划装配、服务端星盘真值缺失、工具构造等),此时请求级 catch 只调用 `cancel()`,永远到不到 `onError` 里的 settle-and-log 入口。
- 根因:`run.failed` 事件 schema 从设计上就没有 `receipt` 字段,catch 分支也从未尝试构建回执;而 `agentExecutionReceiptSchema` 是 strict,内部字段不能直接透出,`agent-observability.ts` 又是刻意封闭的非 PII 契约(无自由格式 metadata、无原文、无 provider payload),所以“把内部诊断塞进对外事件”这条路本就不通,最初便被整体放弃,连白名单可透出的部分也一并放弃了。服务端侧则是入口位置问题:settle-and-log 只挂在 `streamAgentResponse``onError` 上,更早的失败没有任何路径抵达它,观测事件因此对失败最重的那类运行完全缺席。
- 修复:`run.failed` 增加可选 `receipt`,内容用既有白名单助手 `publicConsultationRuntimeSteps()` 构建,与 `run.completed` 走同一条边界,因此每步 `durationMs`、步数预算与工作流路由到达调用方,而内部 `failureCode``modelFinishReason``modelStepCount` 仍留在服务端。构建回执本身被包在 try 内:回执构建失败不得把失败事件替换成一次静默关闭,此时照旧发出不带 `receipt``run.failed`。服务端侧由 agentic 装配把 settle-and-log 入口发布为 `agenticFailure.report`,请求级 catch 优先经它上报(内部按 `toAgentObservabilityErrorCode()` 归一化错误码),仅在该入口尚未发布时退回裸 `cancel()`,从而保证每条 agentic 失败路径都留下一条封闭观测事件。未放宽任何 strict schema,未新增自由格式字段。
- 验证:新增修复前失败的回归 3 项——失败运行必须携带与成功运行同构的白名单回执(断言两步的 `durationMs``stepBudget.used`,并断言序列化结果中不出现内部分类与模型循环诊断);回执构建抛错时仍须恰好发出一次不带 `receipt``run.failed`;源级契约断言请求级 catch 必须经 `agenticFailure.report` 而非裸 `cancel()` 上报。删除 `receipt` 透出可确认第一项失败;`agenticFailure` 在修复前不存在,第三项对修复前的源文件必然失败。
- 防复发:失败路径的诊断价值必须与成功路径持平,二者共用同一个白名单构建入口;对外 schema 是 strict 不能作为放弃全部诊断的理由,只能作为“哪些字段留在服务端”的划线依据。诊断信息的构建不得成为失败事件本身的前置条件。凡新增 settle-and-log 类入口,必须确认它覆盖到最早的失败点,否则失败越早、可观测性越差。
- 相关记录:BUG-255、BUG-214、BUG-256、BUG-257
- 修复版本:本地未提交候选