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
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-17 — 追问、反问、短句也会先取回本轮星盘再回答
「?」「你在说什么鬼」这类跟进不再整轮失败。每一轮都会先重新取回本轮星盘证据,再据此回答。Skill 版本不变。
## 2026-09-17 — 手机键盘收起后顶栏还在;积分块和「当前盘面」一样高
iPhone 上弹键盘再收起、或带着键盘刷新,整页会停在上移后的位置,顶栏点不到。现在键盘一收,页面回到顶,顶栏随时能点。顶栏右侧「当前盘面」和积分两枚芯片同一套尺寸,积分块不再又宽又扁。Skill 版本不变。
+32
View File
@@ -12002,3 +12002,35 @@
- 相关记录:BUG-695、BUG-920
- 复发自:无
- 修复版本:待发布
## BUG-922 | 咨询追问被提示词允许跳过排盘工具,整轮失败
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`windowJyotishInstructions``jyotishInstructions`
- 用户现象:申报时段会话连发短追问得不到回答,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点」。
- 触发条件:本命或申报时段模式下发「?」「你在说什么鬼」一类追问。
- 根因:系统指令写「简单追问可复用已有 packet / context、不调工具」;`contractReady()` 却要求每次请求恰好一次成功排盘调用。跨请求没有可复用 packet(缓存只在单次请求内)。
- 修复:两处指令改为每一轮(含短追问、澄清、抱怨)都必须先调用排盘工具,并写明计算只在本请求内有效。
- 验证:`consultation-birth-time-mode.test.ts` 契约;既有 Agent 暴露工具测试仍绿。
- 防复发:不得再写「follow-ups may use the existing」。
- 相关记录:BUG-205、BUG-214、BUG-286、BUG-923
- 复发自:无
- 修复版本:待发布
## BUG-923 | 第 0 步未强制排盘工具,提示词与运行合同对不齐
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`consultationNatalPrepareStep``consultationWindowPrepareStep``consult/route.ts` 申报时段分支
- 用户现象:同 BUG-922。回执只有 skill 与 runtime-contract-retry,没有任何 tool 步骤。
- 触发条件:同上。
- 根因:提示词是软约束,`toolChoice: "auto"` 不能保证第 0 步调用排盘工具。申报时段路线甚至没有 `prepareStep`
- 修复:第 0 步 `activeTools` 仅排盘工具且 `toolChoice: "required"`;后续步骤 `"auto"`。窗口路线对称加 `prepareStep`,首次 stream 与合同 retry 使用它。`retryForAnswer` / 续写 / 分段仍不强制。未改 `contractReady()`
- 验证:natal / window prepareStep 单测;`route.ts` 源码契约。既有「失败后重试成功仍过门禁」「契约未绿文本丢弃」仍绿。
- 防复发:本命与窗口第 0 步必须 `required`。思考模式供应商历史上拒 named/required,本仓 Mastra 类型接受 `'required'`;若线上某模型拒收,另立单,不得静默改回 auto 而不改提示词。
- 相关记录:BUG-214、BUG-286、BUG-922
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,33 @@
# 进度 · 咨询追问轮必须先调排盘工具(2026-09-17)
基线:`origin/staging` @ `6d097de9`
分支:`codex/consult-followup-tool-contract-20260917`
BUG922、923。Skill 未 bump。
## 做了什么
- **T1 / BUG-922**:删掉本命与窗口两处「简单追问可复用 existing packet/context」。`frontend/src` 已无 `follow-ups may use`
- **T2 / BUG-923**`consultationNatalPrepareStep` 第 0 步 `toolChoice: "required"`。新增 `consultationWindowPrepareStep`,申报时段首次 stream 与合同 retry 走 `windowStreamOptions``retryForAnswer` / 续写 / 分段不带强制。
- **prepareStep 类型**`@mastra/core/dist/agent/types.d.ts``toolChoice``'auto' | 'none' | 'required' | { type: 'tool'; ... }`。本仓接受 `"required"`,未升级依赖。源码注释里曾写「思考模式供应商拒 named/required」;产品拍板每轮必调工具,仍上 `required`。若某模型线上拒收,另立单。
- **T4 第一轮取证**(执行方无 VPS,留给产品/部署):
```bash
cd /opt/jyotisha-production
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
| grep agent-observability | grep '<该会话的 sessionId>' | head
```
三条 `run.failed` 若都没有 `tool` 步骤,即本单同一根因;若第一轮有 `tool``status: failed`,另立单。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| 改动文件 eslint | 0 error |
| `consultation-workflow-contract` + voice | **14 passed / 0 fail**(含 BUG-922/923 契约) |
| `consultation-agentic-runtime` / `consultation-birth-time-mode` | 本机 Windows EPERM symlinkskill runtime alias),Linux CI 不受影响。断言已写在这两个文件里。 |
| Python | 未改 |
| 客户端 bundle | 未改,gzip 应为 0 |
T4 第一轮失败原因需 VPS `agent-observability` 日志,本机做不了。
+1 -1
View File
@@ -129,7 +129,7 @@
| `TASK-rectification-tiebreak-card-loss-20260915.md` | `PROGRESS-rectification-tiebreak-card-loss-20260915.md` | **P0**:点卡上「再答两道参考题」交付卡消失(BUG-706);按钮亮但选项建不出变成裸题(BUG-708);旁白写「相对支持度」并与卡上入口打架(BUG-709)。卡上入口删除,出卡前收集,有活题时卡留下、采用置灰 | 待验收 | `codex/rectification-p0-20260915` |
| `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts``<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` |
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal``dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待领取 | |
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
### 个人报告
+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 shortthe 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 contractstatus, evidence_contract, claim_cards, rectificationeven 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,沿用服务器已选定的主题/);