fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流

schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
jesse-ux
2026-09-18 13:17:35 +08:00
parent b4fcb9466f
commit e32ce6247e
22 changed files with 617 additions and 205 deletions
@@ -37,13 +37,13 @@ import {
DAILY_HEADING,
REPORT_HEADING,
} from "../src/lib/consultation-thinking-plan.ts";
import { getJyotishAgent } from "../src/mastra/index.ts";
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
collectAgentPublicEvents,
streamAgentResponse,
} from "../src/lib/stream-agent-response.ts";
import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
const serverChart = {
name: "测试",
@@ -192,27 +192,28 @@ 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.
// 原值:别名 career/finance/home 三个都执行,domains 含 migration
// 新值:别名仍规范化,执行上限 2,migration 进 omitted_domains
// 原因:BUG-945/946,墙钟只付得起 2 个领域。
const result = await tools["run-jyotish-consultation"].execute!(
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 }> };
) as { domains: string[]; omitted_domains: string[]; consultations: Array<{ domain: string }> };
assert.deepEqual(calls, [
{ theme: "career", question: "事业、财富和迁居怎么一起规划" },
{ theme: "wealth", question: "事业、财富和迁居怎么一起规划" },
{ theme: "migration", question: "事业、财富和迁居怎么一起规划" },
]);
assert.deepEqual(result.domains, ["career", "wealth", "migration"]);
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth", "migration"]);
assert.deepEqual(result.domains, ["career", "wealth"]);
assert.deepEqual(result.omitted_domains, ["migration"]);
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth"]);
assert.deepEqual(state.workflowReceipt, {
route: "multi-domain",
status: "degraded",
preciseTiming: "allowed",
missingLayers: ["D11"],
domains: ["career", "wealth", "migration"],
domains: ["career", "wealth"],
omittedDomains: ["migration"],
});
});
@@ -253,14 +254,17 @@ test("a multi-domain result exposes the same top-level answer contract as a sing
});
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, {
// 原值:career+timing+wealth 三个都执行,D11 / 限制句来自 wealth
// 新值:只执行 2 个;把限制性字段放到 timing 上,合并规则不变
// 原因:BUG-946,MAX=2 后第三域进 omitted,不能再靠它提供合并输入。
const { result, state } = await runDomainPlan(["career", "timing"], (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"] : [],
status: theme === "timing" ? "degraded" : "ready",
missingLayers: theme === "timing" ? ["D11"] : [],
hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [],
leadWithLimitations: theme === "timing",
limitation: theme === "wealth" ? "财富层证据不完整。" : undefined,
limitation: theme === "timing" ? "财富层证据不完整。" : undefined,
chart: natalChart,
}));
@@ -364,31 +368,32 @@ test("the identical natal projection is carried once instead of per domain", asy
});
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);
// 原值:MAX=3,按 21s/领域
// 新值:MAX=2,按实测 31s/领域(110s-45s 写作预留)
// 原因:BUG-944/946,工具实测约 31s/领域,21s 会把写作预算吃光。
assert.equal(MAX_CONSULTATION_DOMAINS, 2);
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.ok(MAX_CONSULTATION_DOMAINS * 31_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
assert.ok(6 * 31_000 > AGENT_TIMEOUT_MS);
assert.deepEqual(
executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]),
{ domains: ["career", "wealth", "timing"], omittedDomains: ["marriage", "health"] },
{ domains: ["career", "wealth"], omittedDomains: ["timing", "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(31_000, 1), true);
assert.equal(domainFitsRunBudget(62_000, 2), false);
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 () => {
test("a plan larger than the execution cap is truncated not refused", async () => {
// 原值:4 个领域 schema 失败、execute 0 次、无 omitted_domains
// 新值:schema 接受最多 6 个,execute ≥1 次,omitted_domains 非空
// 原因:BUG-945,描述承诺截断,zod 却整次拒绝。
let calls = 0;
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
@@ -399,18 +404,20 @@ test("a plan larger than the cap cannot be expressed and never starts a calculat
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);
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, true);
assert.equal(inputSchema.safeParse({
question: "测试",
domains: ["career", "wealth", "timing", "marriage", "health", "education", "family"],
}).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,
const result = await tool.execute!(
modelInput({ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] }),
toolContext,
) as Record<string, unknown>;
assert.equal(calls, 0);
assert.equal("domains" in refused, false);
assert.equal(state.consultationToolStarted, false);
assert.deepEqual(runSteps(state), []);
) as PlanResult;
assert.ok(calls >= 1);
assert.ok((result.omitted_domains ?? []).length > 0);
assert.doesNotMatch(JSON.stringify(result), /Tool input validation failed/);
assert.equal(state.consultationToolStarted, true);
});
test("a plan that runs long stops early and discloses the domains it dropped", async () => {
@@ -455,9 +462,9 @@ test("the advertised domain limit matches the enforced one", () => {
})["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, /up to 6/);
assert.match(description, /omitted_domains/);
assert.match(description, new RegExp(`about ${MAX_CONSULTATION_DOMAINS}`));
assert.match(description, /top-level answer contract/);
});
@@ -947,6 +954,7 @@ test("the receipt separates method the server delivered from method the model we
});
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
const { getJyotishAgent } = await import("../src/mastra/index.ts");
const state = createConsultationRuntimeState();
const agent = getJyotishAgent({
id: "personal-agent-probe", label: "Probe", description: "", creditCost: 1, isDefault: false,
@@ -1693,6 +1701,92 @@ test("composeAnswer length continue finishes the same body", async () => {
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
test("pass4 holds verified dates and records pass4-observe without rewriting", async () => {
const state = toolOnlyRunState();
async function* chunks() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日 至 2031年11月22日。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.equal(answers.length, 1);
assert.match(answers[0] ?? "", /2013年11月21日/);
assert.doesNotMatch(answers.join(""), /具体时间已省略/);
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
});
test("pass4 retries compose once on guarantee then drops leftover clauses", async () => {
const state = toolOnlyRunState();
async function* first() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
let composed = 0;
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
composeAnswer: async (_findings, retryHint) => {
composed += 1;
if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/);
async function* body() {
yield {
type: "text-delta",
payload: {
text: "方向可以推进。我保证你一定会升职。",
},
};
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
return body();
},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(composed, 2);
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.match(answer, /方向可以推进/);
assert.doesNotMatch(answer, /一定会升职/);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true);
});
test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
async function* chunks() {
yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: false,
pass4Mode: "general_no_birth_time",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.equal(answer, GENERAL_NO_BIRTH_TIME_REFUSAL);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
});
test("natal tool success stores a Chinese thinking plan", async () => {
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
const encoded = JSON.stringify(state.thinkingPlan ?? []);