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
- 修复版本:本地未提交候选
+24 -10
View File
@@ -35,6 +35,8 @@ import { streamTextResponse } from "@/lib/stream-text-response";
import { streamAgentResponse } from "@/lib/stream-agent-response";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
import {
AGENT_MAX_STEPS,
AGENT_TIMEOUT_MS,
createConsultationAgentContext,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
@@ -63,15 +65,10 @@ import { z } from "zod";
export const runtime = "nodejs";
export const maxDuration = 120;
// These two limits bound the same Agent run and must be changed together. One
// chart calculation takes about 20s and maxDuration caps the request near 120s,
// so wall-clock time, not steps, is the binding constraint: three failed
// calculations exhaust the timeout no matter how many steps remain. The step
// budget therefore only has to cover the longest useful shape—skill load, a
// couple of progressive-disclosure reference reads, one calculation plus one
// retry, and the answer turn—since a larger budget cannot buy more time.
const AGENT_MAX_STEPS = 8;
const AGENT_TIMEOUT_MS = 110_000;
// The step budget, the wall-clock budget and the domain cap all bound this same
// run, so they are declared as one group in @/mastra/consultation-tools with the
// reasoning that ties them together. maxDuration above is the ceiling they must
// stay under; raising it here without raising that is meaningless.
const chatRequestMetadataSchema = z.object({
requestId: z.string().uuid(),
@@ -518,6 +515,14 @@ export async function POST(request: Request) {
await settleResult(action);
}
// A run that fails before streamAgentResponse exists never reaches its error
// path, so the closed observability event—the only place the tool failure
// code, per-step durations and the model finish reason are recorded—would be
// lost for exactly the runs that failed hardest. The agentic setup publishes
// its settle-and-log entry point here so the request-level catch below can
// still emit it.
const agenticFailure: { report?: (error: unknown) => Promise<void> } = {};
async function runAgenticConsultation(
consultationMode: ConsultationBirthTimeMode,
history: Array<{ role: "user" | "assistant"; text: string }>,
@@ -617,6 +622,14 @@ export async function POST(request: Request) {
throw error;
}
};
agenticFailure.report = async (error) => {
try {
await settleRun(cancel, toAgentObservabilityErrorCode(error));
} catch {
// Settlement already reported itself through logRun; the request-level
// failure response must not depend on it succeeding.
}
};
const baseMessages = [
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
@@ -917,7 +930,8 @@ export async function POST(request: Request) {
onCancel: () => settle(cancel),
});
} catch (error) {
await cancel();
if (agenticFailure.report) await agenticFailure.report(error);
else await cancel();
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
@@ -15,6 +15,9 @@ export type WorkflowReceipt = Readonly<{
preciseTiming: string;
missingLayers: readonly string[];
domains?: readonly ConsultationDomain[];
// Requested but not calculated, because the run's wall clock could not pay
// for them. Present so a partial plan cannot be read as a complete one.
omittedDomains?: readonly ConsultationDomain[];
}>;
export const workflowReceiptSchema: z.ZodType<WorkflowReceipt> = z.object({
@@ -23,6 +26,7 @@ export const workflowReceiptSchema: z.ZodType<WorkflowReceipt> = z.object({
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
domains: z.array(consultationDomainSchema).min(1).max(6).optional(),
omittedDomains: z.array(consultationDomainSchema).min(1).max(6).optional(),
}).strict();
const executionStepSchema = z.object({
@@ -70,10 +74,15 @@ const toolFailedSchema = z.object({
}).strict();
const answerDeltaSchema = z.object({ type: z.literal("answer.delta"), text: z.string() }).strict();
const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema }).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
// receipt is built from live state that a hard failure may leave unparseable,
// and losing the whole failure event would be worse than losing its receipt.
const runFailedSchema = z.object({
type: z.literal("run.failed"),
code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "cancelled"]),
message: z.string().max(200),
receipt: agentExecutionReceiptSchema.optional(),
}).strict();
export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [
+13 -1
View File
@@ -278,7 +278,19 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
: error instanceof Error && error.message === "empty_answer"
? "empty_answer" as const
: "calculation_failed" as const;
send(controller, { type: "run.failed", code, message: code === "runtime_contract_incomplete" ? "Agent 未完成必要的方法与计算步骤,本次不会扣点。" : "咨询暂时无法完成,本次不会扣点。" });
// Step durations, the step budget and the workflow route are the only
// evidence the caller has for why a run failed. Building the receipt
// must not be able to replace the failure event with a silent close.
let failureReceipt: AgentExecutionReceipt | undefined;
try {
failureReceipt = agentExecutionReceiptSchema.parse(options.receipt());
} catch {}
send(controller, {
type: "run.failed",
code,
message: code === "runtime_contract_incomplete" ? "Agent 未完成必要的方法与计算步骤,本次不会扣点。" : "咨询暂时无法完成,本次不会扣点。",
...(failureReceipt ? { receipt: failureReceipt } : {}),
});
if (!disconnected) controller.close();
}
})();
+260 -32
View File
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from "node:util";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import {
@@ -10,15 +11,46 @@ import { createConsultationPlan, type ConsultationPlan } from "../lib/consultati
import type { WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import type { AgentModelFinishReason } from "../lib/agent-observability.ts";
import {
consultationEvidencePacketSchema,
consultationInputSchema,
consultationWorkflowFailureCode,
consultationWorkflowReceipt,
runConsultationWorkflow,
toAgentConsultationContext,
toModelOutput,
type ConsultationEvidencePacket,
} from "./consultation-workflow.ts";
const MAX_CONSULTATION_DOMAINS = 6;
// The budgets that bound one consultation run. They all constrain the same
// wall clock, so they are declared together and must be changed together.
//
// One chart calculation takes about 20s and the route's maxDuration caps the
// request near 120s, so time, not steps, is the binding constraint: three
// failed calculations exhaust the timeout no matter how many steps remain. The
// step budget therefore only has to cover the longest useful shape—skill load,
// a couple of progressive-disclosure reference reads, one calculation plus one
// retry, and the answer turn—since a larger budget cannot buy more time.
//
// The domain loop below is sequential and the Python API is a single GIL-bound
// process, so latency scales linearly with domain count and cannot be traded
// for concurrency. The domain cap is therefore derived from the clock instead
// of chosen: how many domains fit once the answer reserve is set aside. The
// reserve is what a staging three-domain plan actually left over—62.9s of
// calculation inside the 110s budget—so it is measured, not guessed.
export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
const CONSULTATION_DOMAIN_DURATION_MS = 21_000;
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
export const MAX_CONSULTATION_DOMAINS = Math.max(
1,
Math.floor(CONSULTATION_DOMAIN_WALL_CLOCK_MS / CONSULTATION_DOMAIN_DURATION_MS),
);
// The raw plan bound stays at the registry default so a duplicate-heavy list
// canonicalizes instead of failing outright. The executable cap is enforced
// after canonicalization, where it can degrade and disclose rather than throw.
const MAX_CONSULTATION_DOMAIN_PLAN_VALUES = 6;
const domainPlanValueSchema = z.string().trim().min(1).max(64);
// The model may only express a domain plan one way. A second, mutually
@@ -142,6 +174,9 @@ export type ConsultationAgentContext = Readonly<{
abortSignal?: AbortSignal;
state: ConsultationRuntimeState;
runWorkflow?: typeof runConsultationWorkflow;
// The domain loop budgets itself against this clock, so tests can drive the
// deadline without waiting for it.
now?: () => number;
}>;
export function createConsultationAgentContext(context: ConsultationAgentContext) {
@@ -168,7 +203,30 @@ export function canonicalDomainPlan(
return [context.theme];
}
const values = input.domains ?? (input.theme === undefined ? [] : [input.theme]);
return validateConsultationDomainPlan(values, MAX_CONSULTATION_DOMAINS);
return validateConsultationDomainPlan(values, MAX_CONSULTATION_DOMAIN_PLAN_VALUES);
}
/**
* The domains a plan may actually execute, and the ones the clock cannot pay
* for. Truncating and disclosing beats rejecting the call: a plan larger than
* the cap still answers the domains it covered, and the caller can see which
* ones it did not.
*/
export function executableDomainPlan(requested: readonly ConsultationDomain[]) {
return {
domains: requested.slice(0, MAX_CONSULTATION_DOMAINS),
omittedDomains: requested.slice(MAX_CONSULTATION_DOMAINS),
};
}
/**
* Whether the next domain is projected to finish inside the loop's share of the
* run budget, judged by how long the domains already executed actually took.
* The first domain always runs; without it there is nothing to answer from.
*/
export function domainFitsRunBudget(elapsedMs: number, executedCount: number) {
if (executedCount < 1) return true;
return elapsedMs + elapsedMs / executedCount <= CONSULTATION_DOMAIN_WALL_CLOCK_MS;
}
type DomainExecution = Readonly<{
@@ -178,24 +236,50 @@ type DomainExecution = Readonly<{
receipt: ReturnType<typeof consultationWorkflowReceipt>;
}>;
function aggregateWorkflowReceipt(executions: readonly DomainExecution[]): WorkflowReceipt {
const domains = executions.map((execution) => execution.domain);
const missingLayers: string[] = [];
for (const execution of executions) {
const values = execution.receipt.missingLayers === "none"
? []
: execution.receipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean);
for (const value of values) {
if (!missingLayers.includes(value)) missingLayers.push(value);
type ConsultationStatus = "ready" | "degraded" | "blocked";
const consultationStatusRank: Readonly<Record<ConsultationStatus, number>> = { ready: 0, degraded: 1, blocked: 2 };
/**
* Merging domain policies may only ever restrict. Statuses take the worst,
* never the best, so one blocked domain blocks the merged answer.
*/
function worstConsultationStatus(values: readonly ConsultationStatus[]): ConsultationStatus {
return values.reduce<ConsultationStatus>(
(worst, value) => (consultationStatusRank[value] > consultationStatusRank[worst] ? value : worst),
"ready",
);
}
function unionStringList(lists: readonly unknown[]) {
const merged: string[] = [];
for (const list of lists) {
for (const item of Array.isArray(list) ? list : []) {
if (typeof item === "string" && item && !merged.includes(item)) merged.push(item);
}
}
return merged.slice(0, 24);
}
function aggregateWorkflowReceipt(
executions: readonly DomainExecution[],
omittedDomains: readonly ConsultationDomain[],
): WorkflowReceipt {
const domains = executions.map((execution) => execution.domain);
const missingLayers = unionStringList(executions.map((execution) => (
execution.receipt.missingLayers === "none"
? []
: execution.receipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean)
)));
const statuses = executions.map((execution) => execution.receipt.status);
return {
route: executions.length === 1 ? executions[0].receipt.route : "multi-domain",
status: statuses.includes("blocked") ? "blocked" : statuses.includes("degraded") ? "degraded" : "ready",
route: executions.length === 1 && omittedDomains.length === 0 ? executions[0].receipt.route : "multi-domain",
// A plan the clock could not finish is by definition not the full answer,
// so truncation degrades the run even when every executed domain was ready.
status: worstConsultationStatus([...statuses, ...(omittedDomains.length > 0 ? ["degraded" as const] : [])]),
preciseTiming: executions.every((execution) => execution.receipt.preciseTiming === "allowed") ? "allowed" : "blocked",
missingLayers,
domains,
...(omittedDomains.length > 0 ? { omittedDomains: [...omittedDomains] } : {}),
};
}
@@ -204,38 +288,175 @@ function aggregateTechniqueTruth(executions: readonly DomainExecution[]) {
return values.length === 1 ? values[0] : "mixed";
}
function toModelDomainPlanContext(executions: readonly DomainExecution[]) {
type DomainConsultation = ConsultationEvidencePacket & { domain: ConsultationDomain };
type ClaimCard = ConsultationEvidencePacket["claim_cards"][number];
type EvidenceRecord = Record<string, unknown>;
function evidenceRecord(value: unknown): EvidenceRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value as EvidenceRecord : {};
}
/**
* Merges the domain answer policies into one the model may obey directly.
*
* Every rule here is chosen so the merge cannot authorize a claim that any
* single domain forbade: a permission needs unanimous consent, a limitation or
* prohibition needs only one domain to raise it. Fields beyond the three the
* projection emits today are merged by the same rule rather than dropped, so a
* prohibition list added later unions instead of silently widening the contract.
*/
export function mergeConsultationAnswerPolicies(policies: readonly EvidenceRecord[]) {
const merged: EvidenceRecord = {
can_answer_direction: policies.every((policy) => policy.can_answer_direction === true),
can_answer_precise_timing: policies.every((policy) => policy.can_answer_precise_timing === true),
};
if (policies.some((policy) => typeof policy.should_lead_with_limitations === "boolean")) {
merged.should_lead_with_limitations = policies.some((policy) => policy.should_lead_with_limitations === true);
}
const conflicts: string[] = [];
for (const key of [...new Set(policies.flatMap((policy) => Object.keys(policy)))]) {
if (key in merged) continue;
const values = policies.map((policy) => policy[key]);
if (values.every((value) => typeof value === "boolean" || value === undefined)) {
// `can_*` names a permission and needs every domain; anything else names a
// caution and is raised by one.
merged[key] = key.startsWith("can_")
? values.every((value) => value === true)
: values.some((value) => value === true);
} else if (values.some((value) => Array.isArray(value))) {
merged[key] = unionStringList(values);
} else if (values.every((value) => isDeepStrictEqual(value, values[0]))) {
merged[key] = values[0];
} else {
conflicts.push(key);
}
}
if (conflicts.length > 0) {
// An unmergeable policy field is a disagreement, not permission. Say so and
// make the answer lead with its limits rather than pick a side.
merged.should_lead_with_limitations = true;
merged.unresolved_policy_fields = conflicts.slice(0, 24);
}
return merged;
}
/**
* One top-level answer contract for a whole domain plan, in exactly the shape a
* single-domain result has. The instructions state their output rules against
* these paths, so a shape that omitted them left the model with no contract
* authorizing it to speak at all.
*/
function mergeConsultationEvidencePackets(
packets: readonly ConsultationEvidencePacket[],
options: Readonly<{ route: string; claimCards: readonly ClaimCard[]; truncated: boolean }>,
): ConsultationEvidencePacket {
const contracts = packets.map((packet) => packet.evidence_contract);
const limitations = [...new Set(contracts
.map((contract) => contract.user_facing_limitation)
.filter((value): value is string => typeof value === "string" && value.trim().length > 0))];
const boundaries = [...new Set(packets.map((packet) => packet.rectification.boundary))];
const policy = mergeConsultationAnswerPolicies(contracts.map((contract) => evidenceRecord(contract.answer_policy)));
return consultationEvidencePacketSchema.parse({
packet_version: "consultation-evidence-packet-v2",
question: packets.find((packet) => typeof packet.question === "string")?.question,
route: options.route,
// A plan the clock could not finish is not a complete answer, whatever the
// executed domains reported on their own.
status: worstConsultationStatus([
...packets.map((packet) => packet.status),
...(options.truncated ? ["degraded" as const] : []),
]),
evidence_contract: {
// An available layer stays available: it was genuinely computed for at
// least one domain, and claiming otherwise would deny real evidence. What
// restricts the answer is the union of what is missing or blocked.
available_layers: unionStringList(contracts.map((contract) => contract.available_layers)),
missing_route_layers: unionStringList(contracts.map((contract) => contract.missing_route_layers)),
hard_blockers: unionStringList(contracts.map((contract) => contract.hard_blockers)),
answer_policy: options.truncated
? { ...policy, should_lead_with_limitations: true }
: policy,
...(limitations.length > 0
? { user_facing_limitation: limitations.join(" ").slice(0, 800) }
: {}),
},
claim_cards: options.claimCards,
// `not_auto_rectified` is the restrictive boundary, so one domain reporting
// it keeps the merged plan inside it.
rectification: {
boundary: boundaries.includes("not_auto_rectified")
? "not_auto_rectified"
: boundaries.length === 1 ? boundaries[0] : boundaries.join(","),
},
});
}
/**
* The natal projection is the same chart for every domain, so a three-domain
* payload repeated an identical, large block three times. Lift it to a single
* copy when the domains truly agree, and leave it per-domain when they do not
* rather than pick one and call it shared.
*/
function hoistSharedNatalFoundation(consultations: readonly DomainConsultation[]) {
const natalCards = consultations.map((consultation) => (
consultation.claim_cards.find((card) => card.category === "natal_foundation")
));
const shared = natalCards[0];
if (!shared || natalCards.some((card) => !isDeepStrictEqual(card, shared))) {
return { sharedClaimCards: [] as ClaimCard[], consultations };
}
return {
sharedClaimCards: [shared],
consultations: consultations.map((consultation) => ({
...consultation,
claim_cards: consultation.claim_cards.filter((card) => card.category !== "natal_foundation"),
})),
};
}
function toModelDomainPlanContext(
executions: readonly DomainExecution[],
omittedDomains: readonly ConsultationDomain[],
) {
const domains = executions.map((execution) => execution.domain);
const consultations = executions.map((execution) => ({
const consultations: DomainConsultation[] = executions.map((execution) => ({
domain: execution.domain,
...execution.modelOutput,
}));
if (consultations.length === 1) {
return {
...consultations[0],
domains,
consultations,
};
}
return {
success: executions.every((execution) => execution.context.success),
const success = executions.every((execution) => execution.context.success);
const plan = {
success,
domains,
consultations,
omitted_domains: [...omittedDomains],
};
if (consultations.length === 1 && omittedDomains.length === 0) {
return { ...consultations[0], ...plan, consultations };
}
const hoisted = consultations.length === 1
? { sharedClaimCards: consultations[0].claim_cards, consultations }
: hoistSharedNatalFoundation(consultations);
const merged = mergeConsultationEvidencePackets(executions.map((execution) => execution.modelOutput), {
route: consultations.length === 1 ? consultations[0].route : "multi-domain",
claimCards: hoisted.sharedClaimCards,
truncated: omittedDomains.length > 0,
});
return { ...merged, ...plan, consultations: hoisted.consultations };
}
export function createConsultationTools(ctx: ConsultationAgentContext) {
let calculation: Promise<ReturnType<typeof toModelDomainPlanContext>> | null = null;
const consultationTool = createTool({
id: "run-jyotish-consultation",
description: "Run one server-validated plan of up to six allowlisted personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list them in priority order, or omit it entirely to accept the domain the server already selected for this consultation. Birth data is server-bound and must never be supplied. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.",
description: `Run one server-validated plan of at most ${MAX_CONSULTATION_DOMAINS} allowlisted personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list them in priority order, or omit it entirely to accept the domain the server already selected for this consultation. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock, so a shorter plan leaves more time to write the answer; if the clock runs short the server executes the domains that fit and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
inputSchema: consultationToolInputSchema,
execute: async (input, context) => {
const domains = canonicalDomainPlan(input, ctx);
const requestedDomains = canonicalDomainPlan(input, ctx);
const { domains } = executableDomainPlan(requestedDomains);
if (calculation) return calculation;
ctx.state.consultationToolStarted = true;
ctx.state.consultationToolCallCount += 1;
const startedAt = Date.now();
const now = ctx.now ?? Date.now;
const startedAt = now();
const currentCalculation = (async () => {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
@@ -245,6 +466,10 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
});
const executions: DomainExecution[] = [];
for (const domain of domains) {
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
@@ -280,9 +505,12 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
receipt: consultationWorkflowReceipt(guarded),
});
}
ctx.state.workflowReceipt = aggregateWorkflowReceipt(executions);
// Domains the cap refused and domains the clock ran out on are the
// same disclosure to the caller: requested but not calculated.
const omittedDomains = requestedDomains.slice(executions.length);
ctx.state.workflowReceipt = aggregateWorkflowReceipt(executions, omittedDomains);
ctx.state.techniqueTruth = aggregateTechniqueTruth(executions);
ctx.state.consultationToolDurationMs = Date.now() - startedAt;
ctx.state.consultationToolDurationMs = now() - startedAt;
await context.writer?.custom({
type: "data-jyotish-activity",
data: { phase: "evidence-validation", label: "正在核对可用证据" },
@@ -290,9 +518,9 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
ctx.state.consultationToolCompleted = true;
ctx.state.consultationToolSuccessCount += 1;
appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs });
return toModelDomainPlanContext(executions);
return toModelDomainPlanContext(executions, omittedDomains);
} catch (error) {
ctx.state.consultationToolDurationMs = Date.now() - startedAt;
ctx.state.consultationToolDurationMs = now() - startedAt;
const failureCode = consultationWorkflowFailureCode(error);
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
+8 -6
View File
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { createConsultationTools, type ConsultationAgentContext } from "./consultation-tools";
import { createConsultationTools, MAX_CONSULTATION_DOMAINS, type ConsultationAgentContext } from "./consultation-tools";
import { toAgentConsultationContext } from "./consultation-workflow.ts";
import { evidenceDraftModelOutputSchema } from "../lib/birth-time-guide-agent.ts";
import type { ResolvedLanguageModel } from "./model";
@@ -35,11 +35,13 @@ const jyotishInstructions = `You are the guide for a conversational Vedic astrol
Write in concise Simplified Chinese as a natural conversation, not a report or fixed template. Use Markdown only when it improves scanning; tables are allowed only for genuinely comparative information.
For Vedic astrology questions, load the jyotish-vedic-astrology skill before deciding which calculation tool or workflow to use. Follow the skill's method and truth boundaries, but use run-jyotish-consultation for actual chart calculations instead of inventing results.
For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list them in priority order and prefer the smallest plan that answers the question, since every extra domain takes time away from writing the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
When omitted_domains is non-empty, the server did not calculate those domains in this run. Name the domains you did cover, say plainly that the remaining ones were not calculated, and never present the answer as covering the whole plan.
Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text.
Treat the server-provided current time as authoritative for words such as today, now, this year, and the next few months. Never infer the current date from model knowledge or the birth date.
Treat consumer_context as the authoritative answer policy:
- When core_status is ready and can_answer_direction is true, answer the user's actual question directly. Do not begin with infrastructure or confidence disclaimers.
Treat the tool result's top-level status and evidence_contract as the authoritative answer policy:
- When status is ready and evidence_contract.answer_policy.can_answer_direction is true, answer the user's actual question directly. Do not begin with infrastructure or confidence disclaimers.
- An unavailable optional provider or external cross-check is not a calculation failure. Never call it an internal error.
- Do not mention VedAstro, snapshot, fallback, gateway, archive, provider, MEVG, or calibration unless the user explicitly asks about methodology, or the missing layer materially blocks the exact claim they requested.
@@ -54,8 +56,8 @@ When reference_transparency is present:
- If gender or sex is present in future profile context, use it only for relationship/spouse interpretation language and weighting: gender-specific spouse significators are supplements, not chart-calculation switches. For relationship questions, keep the core stack gender-neutral (7th house, 7th lord, D9, UL, Darakaraka); male charts may supplement Venus, female charts may supplement Jupiter/Mars, and unknown/nonbinary/prefer-not-to-say uses the gender-neutral stack.
- When consulting references/oracle/effective_skill_capability_view_2026_07_19.json or any derived skill map, use effective_status, not registry_status. Do not promote reference_only or blocked techniques into mastered/covered claims.
- If should_lead_with_limitations is false, do not lead with limitations. If a limitation is relevant, put it in one short sentence at the end.
- Only say the chart calculation failed when hard_blockers is non-empty.
- Never claim D2, D11, D9, D10, A10, UL, or Narayana Dasha is missing when it appears in available_layers, chart, or local_layers.
- Only say the chart calculation failed when evidence_contract.hard_blockers is non-empty.
- Never claim D2, D11, D9, D10, A10, UL, or Narayana Dasha is missing when it appears in evidence_contract.available_layers, chart, or local_layers.
- Treat evidence_contract.answer_policy as a hard output contract. When can_answer_precise_timing is false, provide only direction or structure and do not state a month, date, or guaranteed timing outcome.
- Treat answer_policy.deterministic_claims_forbidden_for as a hard prohibition. Do not use a restricted technique to make a deterministic conclusion. reference_only, partial, blocked, research_only_blocked, and partial_registry_only are commercial claim boundaries, not validated capabilities.
- Treat rectification.boundary=not_auto_rectified as final: a candidate time or score is not a verified birth time and must not be presented as one.
@@ -229,3 +229,16 @@ test("ordinary consultation logRun uses the strict logger and aggregated usage",
assert.match(route, /\.\.\.consultationModelStepTelemetry\(state\),/);
assert.doesNotMatch(route, /\[consult-agentic\]/);
});
test("an agentic run that fails before streaming still emits the observability event", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
// streamAgentResponse's onError is the only settle-and-log path for a run
// that got far enough to stream. Everything earlier—plan assembly, chart
// truth, tool construction—throws into the request-level catch, which must
// reach the same entry point instead of a bare cancel().
assert.match(route, /agenticFailure\.report = async \(error\) => \{/);
assert.match(route, /settleRun\(cancel, toAgentObservabilityErrorCode\(error\)\)/);
const requestCatch = route.slice(route.lastIndexOf("} catch (error) {"));
assert.match(requestCatch, /if \(agenticFailure\.report\) await agenticFailure\.report\(error\);\s*\n\s*else await cancel\(\);/);
});
@@ -1,12 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AGENT_TIMEOUT_MS,
mergeConsultationAnswerPolicies,
CONSULTATION_DOMAIN_WALL_CLOCK_MS,
MAX_CONSULTATION_DOMAINS,
appendConsultationRuntimeStep,
canonicalDomainPlan,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
createConsultationTools,
createConsultationRuntimeState,
domainFitsRunBudget,
executableDomainPlan,
publicConsultationRuntimeSteps,
} from "../src/mastra/consultation-tools.ts";
import {
@@ -38,22 +44,81 @@ type ModelConsultationToolInput = { question: string; domains?: string[] };
const modelInput = (input: ModelConsultationToolInput) => input as never;
const rejectedByInputSchema = (input: { question: string; theme?: string; domains?: string[] }) => input as never;
function workflow(
theme = "career",
options: { status?: "ready" | "degraded" | "blocked"; missingLayers?: string[]; preciseTiming?: boolean } = {},
) {
type WorkflowOptions = {
status?: "ready" | "degraded" | "blocked";
missingLayers?: string[];
preciseTiming?: boolean;
availableLayers?: string[];
hardBlockers?: string[];
leadWithLimitations?: boolean;
limitation?: string;
chart?: Record<string, unknown>;
};
function workflow(theme = "career", options: WorkflowOptions = {}) {
return {
success: true,
chart: {},
question: "综合看看",
chart: options.chart ?? {},
routing: { primary_theme: theme },
consumer_context: {
route: theme, core_status: options.status ?? "ready", available_layers: [], missing_route_layers: options.missingLayers ?? [], hard_blockers: [],
route: theme,
core_status: options.status ?? "ready",
available_layers: options.availableLayers ?? [],
missing_route_layers: options.missingLayers ?? [],
hard_blockers: options.hardBlockers ?? [],
technique_truth: { status: "verified" },
answer_policy: { can_answer_direction: true, can_answer_precise_timing: options.preciseTiming ?? true },
answer_policy: {
can_answer_direction: true,
can_answer_precise_timing: options.preciseTiming ?? true,
...(options.leadWithLimitations === undefined ? {} : { should_lead_with_limitations: options.leadWithLimitations }),
},
...(options.limitation === undefined ? {} : { user_facing_limitation: options.limitation }),
},
};
}
// The natal projection only survives the evidence allowlist when the chart
// actually carries allowlisted placements, and the hoisting test needs it to.
const natalChart = {
ascendant: { sign: "Leo", degree: 12.5 },
planets: [{ name: "Sun", sign: "Leo", degree: 1.25 }, { name: "Moon", sign: "Pisces", degree: 20.5 }],
houses: [{ number: 1, sign: "Leo" }],
};
const toolContext = { observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never;
type PlanResult = Record<string, unknown> & {
domains: string[];
omitted_domains: string[];
consultations: Array<Record<string, unknown> & { domain: string; claim_cards: Array<{ category: string }> }>;
evidence_contract: {
available_layers: string[];
missing_route_layers: string[];
hard_blockers: string[];
answer_policy: Record<string, unknown>;
user_facing_limitation?: string;
};
rectification: { boundary: string };
claim_cards: Array<{ category: string }>;
};
async function runDomainPlan(
domains: string[],
runWorkflow: (theme: string) => ReturnType<typeof workflow>,
options: { now?: () => number; requestId?: string } = {},
) {
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
userId: "u", sessionId: "s", requestId: options.requestId ?? `r-${domains.join("-")}`,
consultationMode: "verified_chart", serverChart, state,
...(options.now ? { now: options.now } : {}),
runWorkflow: async (input) => runWorkflow(input.theme),
})["run-jyotish-consultation"];
const result = await tool.execute!(modelInput({ question: "综合看看", domains }), toolContext) as PlanResult;
return { result, state };
}
test("the server-selected domain stays authoritative when the model omits domains", async () => {
let calls = 0;
let captured: unknown;
@@ -107,8 +172,11 @@ test("multi-domain plan canonicalizes aliases, de-duplicates, preserves order, a
return workflow(input.theme);
},
});
// Aliases, not repetitions: the array bound is now the executable domain cap,
// so a duplicate spends one of the slots the clock can actually pay for.
// canonicalDomainPlan keeps the de-duplication coverage for longer raw lists.
const result = await tools["run-jyotish-consultation"].execute!(
modelInput({ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "career", "home"] }),
modelInput({ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "home"] }),
{ observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never,
) as { domains: string[]; consultations: Array<{ domain: string }> };
@@ -128,6 +196,251 @@ test("multi-domain plan canonicalizes aliases, de-duplicates, preserves order, a
});
});
test("a multi-domain result exposes the same top-level answer contract as a single domain", async () => {
const single = await runDomainPlan(["career"], (theme) => workflow(theme, {
availableLayers: ["D10"], chart: natalChart,
}));
const multi = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, {
availableLayers: theme === "career" ? ["D10"] : ["D11"], chart: natalChart,
}));
// Every path jyotishInstructions states an output rule against has to resolve
// in both shapes. With none of them present the model has no contract that
// authorizes it to speak, which is how a successful calculation produced no
// answer at all.
for (const key of ["packet_version", "question", "route", "status", "evidence_contract", "claim_cards", "rectification"]) {
assert.equal(key in single.result, true, `single-domain result is missing ${key}`);
assert.equal(key in multi.result, true, `multi-domain result is missing ${key}`);
}
for (const contract of [single.result.evidence_contract, multi.result.evidence_contract]) {
assert.equal(Array.isArray(contract.available_layers), true);
assert.equal(Array.isArray(contract.missing_route_layers), true);
assert.equal(Array.isArray(contract.hard_blockers), true);
assert.equal(typeof contract.answer_policy.can_answer_direction, "boolean");
assert.equal(typeof contract.answer_policy.can_answer_precise_timing, "boolean");
}
assert.equal(single.result.rectification.boundary, "not_auto_rectified");
assert.equal(multi.result.rectification.boundary, "not_auto_rectified");
assert.equal(multi.result.packet_version, single.result.packet_version);
assert.equal(multi.result.question, single.result.question);
assert.equal(multi.result.route, "multi-domain");
assert.equal(multi.result.status, "ready");
assert.equal(multi.result.success, true);
assert.equal(single.result.success, true);
// An available layer stays available: it really was computed for one domain.
assert.deepEqual(multi.result.evidence_contract.available_layers, ["D10", "D11"]);
assert.deepEqual(multi.result.consultations.map((item) => item.domain), ["career", "wealth"]);
});
test("the merged answer policy is the most restrictive of the executed domains", async () => {
const { result, state } = await runDomainPlan(["career", "timing", "wealth"], (theme) => workflow(theme, {
// One domain forbidding precise timing must forbid it for the whole answer.
preciseTiming: theme !== "timing",
status: theme === "wealth" ? "degraded" : "ready",
missingLayers: theme === "wealth" ? ["D11"] : [],
hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [],
leadWithLimitations: theme === "timing",
limitation: theme === "wealth" ? "财富层证据不完整。" : undefined,
chart: natalChart,
}));
const policy = result.evidence_contract.answer_policy;
assert.equal(policy.can_answer_precise_timing, false);
assert.equal(policy.can_answer_direction, true);
assert.equal(policy.should_lead_with_limitations, true);
assert.deepEqual(result.evidence_contract.hard_blockers, ["negative_holdout_gate"]);
assert.deepEqual(result.evidence_contract.missing_route_layers, ["D11"]);
assert.equal(result.status, "degraded");
assert.equal(result.evidence_contract.user_facing_limitation, "财富层证据不完整。");
assert.equal(state.workflowReceipt?.preciseTiming, "blocked");
assert.equal(state.workflowReceipt?.status, "degraded");
const blocked = await runDomainPlan(["career", "health"], (theme) => workflow(theme, {
status: theme === "health" ? "blocked" : "ready", chart: natalChart,
}));
assert.equal(blocked.result.status, "blocked");
});
test("merging answer policies can only ever restrict", () => {
// Merged directly, because the projection currently emits only three policy
// fields and the rules have to hold for any field it may emit later.
assert.deepEqual(
mergeConsultationAnswerPolicies([
{ can_answer_direction: true, can_answer_precise_timing: true },
{ can_answer_direction: true, can_answer_precise_timing: false },
]),
{ can_answer_direction: true, can_answer_precise_timing: false },
);
assert.deepEqual(
mergeConsultationAnswerPolicies([
{ can_answer_direction: true, can_answer_precise_timing: true, should_lead_with_limitations: false },
{ can_answer_direction: false, can_answer_precise_timing: true, should_lead_with_limitations: true },
]),
{ can_answer_direction: false, can_answer_precise_timing: true, should_lead_with_limitations: true },
);
// A prohibition list unions: a technique one domain forbids stays forbidden.
const prohibitions = mergeConsultationAnswerPolicies([
{ can_answer_direction: true, can_answer_precise_timing: true, deterministic_claims_forbidden_for: ["narayana"] },
{ can_answer_direction: true, can_answer_precise_timing: true, deterministic_claims_forbidden_for: ["transit", "narayana"] },
]);
assert.deepEqual(prohibitions.deterministic_claims_forbidden_for, ["narayana", "transit"]);
// A boolean that is absent for one domain is not consent from that domain.
assert.equal(
mergeConsultationAnswerPolicies([{ can_answer_chart_interpretation: true }, {}]).can_answer_chart_interpretation,
false,
);
// A field the domains disagree on in a way that cannot be merged is reported
// as unresolved and forces the answer to lead with its limits, rather than
// being dropped, which would remove whatever it was restricting.
const conflicted = mergeConsultationAnswerPolicies([
{ can_answer_direction: true, can_answer_precise_timing: true, claim_ceiling: "direction_only" },
{ can_answer_direction: true, can_answer_precise_timing: true, claim_ceiling: "structure_only" },
]);
assert.deepEqual(conflicted.unresolved_policy_fields, ["claim_ceiling"]);
assert.equal(conflicted.should_lead_with_limitations, true);
assert.equal("claim_ceiling" in conflicted, false);
});
test("the merged contract exposes every policy field a single domain exposes", async () => {
const options: WorkflowOptions = {
leadWithLimitations: false, limitation: "边界说明。", chart: natalChart,
};
const single = await runDomainPlan(["career"], (theme) => workflow(theme, options));
const multi = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, options));
// Guards drift: a field added to the per-domain projection without being
// merged would silently vanish from the multi-domain contract.
for (const key of Object.keys(single.result.evidence_contract.answer_policy)) {
assert.equal(key in multi.result.evidence_contract.answer_policy, true, `merged policy is missing ${key}`);
}
for (const key of Object.keys(single.result.evidence_contract)) {
assert.equal(key in multi.result.evidence_contract, true, `merged contract is missing ${key}`);
}
assert.equal(multi.result.evidence_contract.answer_policy.should_lead_with_limitations, false);
});
test("the identical natal projection is carried once instead of per domain", async () => {
const { result } = await runDomainPlan(["career", "wealth", "timing"], (theme) => workflow(theme, { chart: natalChart }));
assert.deepEqual(result.claim_cards.map((card) => card.category), ["natal_foundation"]);
assert.equal(
result.consultations.every((item) => item.claim_cards.every((card) => card.category !== "natal_foundation")),
true,
);
assert.equal(result.consultations.some((item) => item.claim_cards.length > 0), true);
// When the domains genuinely disagree, nothing is presented as shared.
const differing = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, {
chart: theme === "career" ? natalChart : { ...natalChart, ascendant: { sign: "Virgo", degree: 1 } },
}));
assert.deepEqual(differing.result.claim_cards, []);
assert.equal(
differing.result.consultations.every((item) => item.claim_cards.some((card) => card.category === "natal_foundation")),
true,
);
});
test("the domain cap is what the run budget can actually pay for", () => {
// 21s per sequential domain against the 110s run budget, minus the reserve a
// three-domain staging run actually left for composing the answer.
assert.equal(MAX_CONSULTATION_DOMAINS, 3);
assert.equal(AGENT_TIMEOUT_MS, 110_000);
assert.equal(CONSULTATION_DOMAIN_WALL_CLOCK_MS, 65_000);
assert.ok(MAX_CONSULTATION_DOMAINS * 21_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
// Six domains, the previous cap, could never finish inside the deadline.
assert.ok(6 * 21_000 > AGENT_TIMEOUT_MS);
assert.deepEqual(
executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]),
{ domains: ["career", "wealth", "timing"], omittedDomains: ["marriage", "health"] },
);
assert.deepEqual(executableDomainPlan(["career"]), { domains: ["career"], omittedDomains: [] });
// The first domain always runs; after that the next one has to be projected
// to finish, judged by how long the executed ones really took.
assert.equal(domainFitsRunBudget(0, 0), true);
assert.equal(domainFitsRunBudget(21_000, 1), true);
assert.equal(domainFitsRunBudget(42_000, 2), true);
assert.equal(domainFitsRunBudget(60_000, 2), false);
assert.equal(domainFitsRunBudget(40_000, 1), false);
});
test("a plan larger than the cap cannot be expressed and never starts a calculation", async () => {
let calls = 0;
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
userId: "u", sessionId: "s", requestId: "r-cap", consultationMode: "verified_chart",
serverChart, state,
runWorkflow: async (input) => { calls += 1; return workflow(input.theme); },
})["run-jyotish-consultation"];
const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean } };
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing"] }).success, true);
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, false);
// Mastra rejects the over-budget plan before the tool body runs, so it costs
// one correctable step and nothing about the run advances.
const refused = await tool.execute!(
{ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] } as never,
toolContext,
) as Record<string, unknown>;
assert.equal(calls, 0);
assert.equal("domains" in refused, false);
assert.equal(state.consultationToolStarted, false);
assert.deepEqual(state.steps, []);
});
test("a plan that runs long stops early and discloses the domains it dropped", async () => {
let clock = 0;
const executed: string[] = [];
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
userId: "u", sessionId: "s", requestId: "r-slow", consultationMode: "verified_chart",
serverChart, state,
now: () => clock,
runWorkflow: async (input) => {
executed.push(input.theme);
clock += 40_000;
return workflow(input.theme, { chart: natalChart });
},
})["run-jyotish-consultation"];
const result = await tool.execute!(
modelInput({ question: "三个领域", domains: ["career", "wealth", "timing"] }),
toolContext,
) as PlanResult;
// 40s each cannot fit a second domain inside the loop's share of the budget,
// so the run answers what it has instead of aborting mid-loop and losing it.
assert.deepEqual(executed, ["career"]);
assert.deepEqual(result.domains, ["career"]);
assert.deepEqual(result.omitted_domains, ["wealth", "timing"]);
assert.equal(result.status, "degraded");
assert.equal(state.consultationToolCompleted, true);
assert.equal(state.consultationToolSuccessCount, 1);
assert.equal(state.consultationToolDurationMs, 40_000);
// The single executed domain still has to carry the full top-level contract.
for (const key of ["packet_version", "route", "status", "evidence_contract", "claim_cards", "rectification"]) {
assert.equal(key in result, true, `truncated result is missing ${key}`);
}
});
test("the advertised domain limit matches the enforced one", () => {
const tool = createConsultationTools({
userId: "u", sessionId: "s", requestId: "r-description", consultationMode: "verified_chart",
serverChart, state: createConsultationRuntimeState(),
runWorkflow: async (input) => workflow(input.theme),
})["run-jyotish-consultation"];
const description = tool.description ?? "";
assert.match(description, new RegExp(`at most ${MAX_CONSULTATION_DOMAINS} allowlisted`));
assert.doesNotMatch(description, /up to six|six allowlisted/);
assert.match(description, /omitted_domains/);
assert.match(description, /top-level answer contract/);
});
test("domain plan rejects unknown and product domains before any workflow runs", async () => {
for (const domain of ["unknown", "prashna", "muhurta", "rectification", "compatibility"]) {
let calls = 0;
@@ -203,6 +516,7 @@ test("the single-value domain form stays available to callers without the model
assert.deepEqual(canonicalDomainPlan({}, { plan, theme: "career" }), ["career"]);
assert.deepEqual(canonicalDomainPlan({ theme: "marriage" }, {}), ["marriage"]);
assert.deepEqual(canonicalDomainPlan({ domains: ["career", "finance"] }, {}), ["career", "wealth"]);
assert.deepEqual(canonicalDomainPlan({ domains: ["career", "finance", "career", "home"] }, {}), ["career", "wealth", "migration"]);
assert.deepEqual(canonicalDomainPlan({ domains: ["timing"] }, { plan, theme: "career" }), ["timing"]);
assert.throws(
() => canonicalDomainPlan({ domains: ["career"], theme: "career" }, {}),
@@ -662,6 +976,61 @@ test("a retry accumulates model steps and reports the latest finish reason", asy
assert.equal(state.modelFinishReason, "unknown");
});
test("a failed run reports the same allowlisted receipt a completed run does", async () => {
const state = createConsultationRuntimeState({ plannedSteps: 8 });
state.jyotishSkillLoaded = true;
state.modelFinishReason = "tool-calls";
state.modelStepCount = 8;
appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed", durationMs: 900 });
appendConsultationRuntimeStep(state, {
kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: 20936, failureCode: "workflow_queue_full",
});
async function* chunks() {
yield { type: "tool-error", payload: { toolCallId: "tool-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: () => {},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const failed = events.find((event) => (event as { type?: string }).type === "run.failed") as {
code: string;
receipt?: { steps: Array<{ durationMs?: number; name: string }>; stepBudget?: { used: number } };
};
assert.equal(failed.code, "runtime_contract_incomplete");
// Without this the caller learned only the code: no step durations, no budget,
// exactly when the run needed explaining most.
assert.deepEqual(failed.receipt?.steps.map((step) => step.durationMs), [900, 20936]);
assert.equal(failed.receipt?.stepBudget?.used, 2);
// The internal classification and the model loop diagnostics stay server-side.
assert.doesNotMatch(JSON.stringify(failed), /workflow_queue_full|modelFinishReason|modelStepCount|tool-calls/);
});
test("a receipt that cannot be built still leaves a failure event", async () => {
const state = createConsultationRuntimeState();
async function* chunks() {
yield { type: "text-delta", payload: { text: "不能保存" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "blocked",
receipt: () => { throw new Error("receipt_unavailable"); },
onError: () => {},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const failed = events.filter((event) => (event as { type?: string }).type === "run.failed");
assert.equal(failed.length, 1);
assert.equal("receipt" in (failed[0] as object), false);
});
test("persistence failure emits run.failed instead of run.completed", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillLoaded = true;
@@ -53,8 +53,10 @@ test("Agentic failures always refund and detached execution uses a server-owned
consultRoute.indexOf("async function runAgenticConsultation("),
consultRoute.indexOf(" try {\n const { history } = parsed.data;"),
);
// The value lives beside the model step budget, which bounds the same run.
assert.match(consultRoute, /const AGENT_TIMEOUT_MS = 110_000;/);
// The value lives beside the model step budget and the domain cap it funds,
// so the route imports it rather than restating it.
assert.match(consultRoute, /import \{\n AGENT_MAX_STEPS,\n AGENT_TIMEOUT_MS,[\s\S]*?\} from "@\/mastra\/consultation-tools";/);
assert.doesNotMatch(consultRoute, /const AGENT_TIMEOUT_MS =/);
assert.match(agentic, /const agentAbortSignal = AbortSignal\.timeout\(AGENT_TIMEOUT_MS\)/);
assert.equal(agentic.match(/abortSignal: agentAbortSignal/g)?.length, 2);
assert.doesNotMatch(agentic, /abortSignal: request\.signal/);
@@ -53,7 +53,11 @@ test("consultation plans are server-owned and bounded", () => {
});
test("the model step budget and the wall-clock budget are declared as one pair", () => {
assert.match(route, /const AGENT_MAX_STEPS = 8;\nconst AGENT_TIMEOUT_MS = 110_000;/);
// The pair now lives beside the domain cap it funds: the cap is derived from
// the wall clock, so a change to one that forgets the other is impossible.
assert.match(tools, /export const AGENT_MAX_STEPS = 8;\nexport const AGENT_TIMEOUT_MS = 110_000;/);
assert.match(tools, /MAX_CONSULTATION_DOMAINS = Math\.max\(\s*1,\s*Math\.floor\(CONSULTATION_DOMAIN_WALL_CLOCK_MS \/ CONSULTATION_DOMAIN_DURATION_MS\),\s*\)/);
assert.doesNotMatch(route, /const AGENT_(MAX_STEPS|TIMEOUT_MS) =/);
assert.match(route, /maxSteps: AGENT_MAX_STEPS,/);
assert.match(route, /AbortSignal\.timeout\(AGENT_TIMEOUT_MS\)/);
assert.match(route, /createConsultationRuntimeState\(\{ plannedSteps: AGENT_MAX_STEPS \}\)/);