fix(consult): 每一轮回答前都必须调用排盘工具
Independent Staging Quality Gate / validate (push) Successful in 9m22s
Independent Staging Quality Gate / publish (push) Successful in 13m39s

This commit is contained in:
jesse-ux
2026-09-17 14:21:33 +08:00
parent 6d097de95b
commit dc2f2a16bb
10 changed files with 155 additions and 12 deletions
+9 -4
View File
@@ -48,6 +48,7 @@ import {
consultationContinueGenerationSettings,
consultationGenerationSettings,
consultationNatalPrepareStep,
consultationWindowPrepareStep,
consultationSliceGenerationSettings,
createConsultationAgentContext,
createWindowConsultationAgentContext,
@@ -896,14 +897,18 @@ export async function POST(request: Request) {
...streamOptions,
prepareStep: consultationNatalPrepareStep,
};
const windowStreamOptions = {
...streamOptions,
prepareStep: consultationWindowPrepareStep,
};
async function streamWithOverflowRetry(
agent: {
stream: (
messages: typeof baseMessages,
options: typeof streamOptions | typeof natalStreamOptions,
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions,
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
},
options: typeof streamOptions | typeof natalStreamOptions = streamOptions,
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions = streamOptions,
) {
try {
const result = await agent.stream(baseMessages, options);
@@ -1029,7 +1034,7 @@ export async function POST(request: Request) {
state,
});
const agent = getWindowJyotishAgent(selectedModel, agentContext);
const result = await streamWithOverflowRetry(agent);
const result = await streamWithOverflowRetry(agent, windowStreamOptions);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
@@ -1037,7 +1042,7 @@ export async function POST(request: Request) {
role: "user" as const,
content: "运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答;不要在工具参数中添加出生分钟。",
},
], streamOptions);
], windowStreamOptions);
usages.push(retried.totalUsage);
return retried.fullStream;
};
+16 -4
View File
@@ -57,12 +57,13 @@ export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
export const AGENT_SLICE_MAX_STEPS = 1;
export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation";
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
/**
* Thinking-mode providers reject named/required tool_choice. Restrict the first
* natal model step to the chart tool and keep tool_choice auto so the run cannot
* spend that step on skill_read or a spoken Level 2 draft before any evidence
* exists. Later steps leave the rest of the bound tools available.
* Step 0 must call the natal chart tool. Mastra/AI SDK types accept
* toolChoice "required" (`@mastra/core` Agent.stream options:
* `'auto' | 'none' | 'required'`). Later steps stay auto so the model can
* write after the request-scoped calculation is in hand.
*
* Window and general agents must not share this hook: they do not own this tool.
*/
@@ -70,7 +71,18 @@ export function consultationNatalPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
toolChoice: "required" as const,
}
: {
toolChoice: "auto" as const,
};
}
export function consultationWindowPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
toolChoice: "required" as const,
}
: {
toolChoice: "auto" as const,
+2 -2
View File
@@ -20,7 +20,7 @@ ${natalSpokenReportContract}
Write in Simplified Chinese: a heading-free spoken opener first (one conclusion, 23 short point-sentences of at most 30 characters each, one next-step; ≤ 400 characters), then the skill Level 2 report skeleton for natal domain questions. Markdown tables are required for raw structure, Yogas, and the Technique Audit Table.
${jyotishSkillMethodBlock}
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.
Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.
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 every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from 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. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. 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's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
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.
@@ -176,7 +176,7 @@ export function getBirthTimeGuideAgent(model: ResolvedLanguageModel) {
const windowJyotishInstructions = `You are the guide for a conversational Vedic astrology product.
${productConversationVoice}
This request has a declared birth window, not a single birth minute. Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time. Probe clocks in the tool result are comparison samples only.
For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.
Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.
Treat the tool result's answer_policy as a hard output contract:
- can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome.
- Answer only from stable_layers as personal structure that holds across the declared window.
@@ -5,6 +5,7 @@ import {
AGENT_TIMEOUT_MS,
CONSULTATION_MAX_OUTPUT_TOKENS,
CONSULTATION_NATAL_CALC_TOOL_ID,
CONSULTATION_WINDOW_CALC_TOOL_ID,
mergeConsultationAnswerPolicies,
CONSULTATION_DOMAIN_WALL_CLOCK_MS,
MAX_CONSULTATION_DOMAINS,
@@ -12,6 +13,7 @@ import {
canonicalDomainPlan,
consultationGenerationSettings,
consultationNatalPrepareStep,
consultationWindowPrepareStep,
consultationSliceGenerationSettings,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
@@ -654,15 +656,28 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
test("natal first step exposes only the chart calculation tool", () => {
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
// 原值: toolChoice "auto" / 新值: 第 0 步 "required"、第 1 步仍 "auto"
// 原因: BUG-923 每轮必须先调排盘工具,提示词例外已删
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
toolChoice: "required",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("window first step requires the window consultation tool", () => {
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "required",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("matching the pinned theme does not record a plan override", async () => {
const state = createConsultationRuntimeState();
await createConsultationTools({
@@ -171,3 +171,10 @@ test("window agent instructions forbid probe clocks as a birth minute", () => {
assert.match(windowFactory, /Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time/);
assert.doesNotMatch(windowFactory, /id: `jyotish-guide-/);
});
test("natal and window agents must call the chart tool every turn (BUG-922)", () => {
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
});
@@ -1,6 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
consultationNatalPrepareStep,
consultationWindowPrepareStep,
} from "../src/mastra/consultation-tools.ts";
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
@@ -15,6 +19,29 @@ const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging
test("natal and window instructions require a chart tool every turn (BUG-922)", () => {
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
});
test("natal and window first steps require the chart tool (BUG-923)", () => {
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "required",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "required",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("consultation plans are server-owned and bounded", () => {
assert.match(plan, /consultationPlanSchema/);
assert.match(plan, /requestedDomains/);
@@ -93,14 +120,22 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
assert.match(route, /entrypoint: consultEntrypoint/);
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
assert.match(tools, /export function consultationNatalPrepareStep/);
assert.match(tools, /export function consultationWindowPrepareStep/);
assert.match(tools, /activeTools: \[CONSULTATION_NATAL_CALC_TOOL_ID\]/);
assert.match(tools, /activeTools: \[CONSULTATION_WINDOW_CALC_TOOL_ID\]/);
const sharedStreamOptions = route.slice(
route.indexOf("const streamOptions = {"),
route.indexOf("const natalStreamOptions = {"),
);
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
assert.match(
route,
/请调用 run-jyotish-window-consultation 完成计算[\s\S]*?windowStreamOptions\)/,
);
assert.doesNotMatch(route, /先加载 Jyotish Skill/);
assert.match(route, /pinsConsultationDomains\(consultEntrypoint\)/);
assert.match(route, /调用时不要填写 domains,沿用服务器已选定的主题/);