fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流
schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
@@ -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 ?? []);
|
||||
|
||||
Reference in New Issue
Block a user