diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a372071f..1b34eab4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2975,3 +2975,18 @@ - 防复发:任何异步加载后传入的初始化数据不能只依赖子组件首次 state 初始化;公开 receipt 新字段必须贯通 API projection、page mapping 与组件类型。 - 相关记录:BUG-172 - 修复版本:待提交 + +## BUG-174 | V9 当前用户事件无法绑定服务端 Turn,Agent 要求重复发送 + +- 状态:resolved(本地候选) +- 首次发现:2026-08-12 +- 最近更新:2026-08-12 +- 影响面:V9 生时校正事件证据提出与最终出生时间确认的用户原话绑定。 +- 用户现象:用户输入“2016 年 9 月上大学”后,Agent 能识别事件并调用“整理事件证据”,但声称当前轮锚点没有接通,要求用户重复发送同一句话。 +- 触发条件:Agent 在当前消息轮调用 `rectification-propose-evidence`;工具 schema 要求模型回传 `sourceTurnId`,但消息上下文和 `rectification-read-case` 安全投影都不公开当前 Turn UUID。 +- 根因:服务端 runner 已在 Agent 执行前创建并持有可信 `turnId`,工具仍错误地把该内部引用交给模型提供,形成模型无法满足的参数合同;重复发送不会修复这一合同缺口。 +- 修复:`rectification-propose-evidence` 与 `rectification-confirm-birth-time` 不再接受模型提供的 `sourceTurnId`,统一使用工具上下文中的服务端当前 `turnId`;保留数据库 Case ownership、Turn existence 与 quote/consent 原文匹配校验。 +- 验证:回归断言 propose/confirm schema 拒绝模型传入 Turn ID,并确认事件证据与最终确认 RPC 的 `p_source_turn_id` 均等于服务端当前 Turn;聚焦测试通过,staging 部署证据见本轮发布记录。 +- 防复发:当前请求已经由服务器掌握的内部 ID 不得再要求模型猜测或回传;原文真实性继续在数据库信任边界验证,不能以放宽 quote 校验规避绑定问题。 +- 相关记录:BUG-170、BUG-172、BUG-173 +- 修复版本:本次提交(staging 精确 SHA 以发布记录为准) diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 23723866..f163cc09 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -55,7 +55,7 @@ const agenticRectificationInstructions = `你是生时校正 Agent,只服务 硬性边界(必须服从): 1. 先调用 skill 工具加载 jyotish-birth-time-rectification(本 Case 固定版本),再调用 rectification-read-case 读取服务端 Case/Dossier 后才能行动。 2. 事件事实只能来自用户原话:不得虚构事件、日期、候选、分盘数据、评分或出生分钟;计算与持久化只能通过工具。 -3. 工具 input 只传最小引用(caseId、sourceTurnId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。 +3. 工具 input 只传最小引用(caseId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。 4. 日期精度如实保留:用户只说年份就按 year 处理,不得诱导编造月份。 5. 三层语义严格分开:candidate=当前候选比较结果;accepted=用户明确采用的排盘时间;confirmed=通过服务器确认门且用户明确同意。accepted 不等于 confirmed。 6. “是/对”只能确认当前 pending draft;用户更正事实用 revise(生成 revision,不覆盖历史)。 diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index f04b003c..98452905 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -1,8 +1,8 @@ /** * V9 rectification tools (Case-ref only). * - * The model may only pass minimal references: caseId, sourceTurnId, quote, - * proposedKind, evidenceId, resultId, candidateId (plus optional date fields + * The model may only pass minimal references: caseId, quote, proposedKind, + * evidenceId, resultId, candidateId (plus optional date fields * the model grounded in the user's own words). userId, birth data, candidate * ranges, event arrays, scores and permission booleans never appear in tool * inputs. Every tool authenticates through the Case row, validates ownership @@ -214,13 +214,6 @@ function assertEvidenceRef(input: { evidenceId?: unknown }): string { return input.evidenceId; } -function assertTurnRef(input: { sourceTurnId?: unknown }): string { - if (typeof input.sourceTurnId !== "string" || !uuidPattern.test(input.sourceTurnId)) { - throw new RectificationToolServiceError("invalid_source_turn_id"); - } - return input.sourceTurnId; -} - function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } { if ( typeof input.resultId !== "string" || !uuidPattern.test(input.resultId) @@ -304,7 +297,6 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { "提出一条事件证据草稿。quote 必须能规范化匹配当前用户轮次原文;日期只保留用户说过的精度(只说年份则 year);subject 只能 self/family/other;eventKind 使用受控枚举;summary 只复述用户原话,不得补充月份、原因、主动被动或人物关系。返回 evidenceId(服务器生成)。", inputSchema: z.object({ caseId: z.string().uuid(), - sourceTurnId: z.string().uuid(), quote: z.string().trim().min(2).max(400), proposedKind: z.string().trim().min(1).max(48), subject: z.enum(["self", "family", "other"]).default("self"), @@ -316,13 +308,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }).strict(), execute: async (input) => { assertCaseRef(input); - const sourceTurnId = assertTurnRef(input); if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind"); if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain"); if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision"); const inputFingerprint = canonicalToolInputFingerprint("rectification-propose-evidence", { caseId: input.caseId, - sourceTurnId, + sourceTurnId: turnId, quote: input.quote, proposedKind: input.proposedKind, }); @@ -331,7 +322,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null; const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null; const result = await proposeV9Evidence(accounting, userId, input.caseId, { - sourceTurnId, + sourceTurnId: turnId, quote: input.quote, subject: input.subject, eventKind: input.proposedKind, @@ -651,13 +642,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { caseId: z.string().uuid(), resultId: z.string().uuid(), candidateId: z.string().regex(timePattern), - sourceTurnId: z.string().uuid(), consentQuote: z.string().trim().min(2).max(400), }).strict(), execute: async (input) => { assertCaseRef(input); const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId }); - const sourceTurnId = assertTurnRef(input); const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-birth-time", { caseId: input.caseId, resultId, @@ -669,7 +658,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { resultId, time: input.candidateId, consentQuote: input.consentQuote, - sourceTurnId, + sourceTurnId: turnId, }); const projection = { saved_time: result.savedTime, diff --git a/frontend/tests/rectification-v9-evidence.test.ts b/frontend/tests/rectification-v9-evidence.test.ts index acd09a84..cc445d0f 100644 --- a/frontend/tests/rectification-v9-evidence.test.ts +++ b/frontend/tests/rectification-v9-evidence.test.ts @@ -22,7 +22,6 @@ import { receiptHandlers, } from "./rectification-v9-test-support.ts"; -const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777"; function toolContext(overrides: { accounting?: ReturnType; @@ -47,14 +46,13 @@ function toolContext(overrides: { }; } -test("propose-evidence schema rejects model-provided ids, birth data and ranges", async () => { +test("propose-evidence binds the server-owned current turn and rejects model-provided ids, birth data and ranges", async () => { const { tools } = toolContext(); const schema = (tools as Record); const propose = schema["rectification-propose-evidence"]; assert.ok(propose?.inputSchema); const valid = propose.inputSchema!.safeParse({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", subject: "self", @@ -65,9 +63,21 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges" }); assert.equal(valid.success, true); + const withTurnId = propose.inputSchema!.safeParse({ + caseId: CASE_ID, + sourceTurnId: "77777777-7777-4777-8777-777777777777", + quote: "2016年9月离开家去北京工作", + proposedKind: "career_entry", + subject: "self", + domain: "career", + datePrecision: "month", + occurredFrom: "2016-09", + summary: "2016年9月离家去北京工作", + }); + assert.equal(withTurnId.success, false); + const withModelId = propose.inputSchema!.safeParse({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", datePrecision: "month", @@ -78,7 +88,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges" const withBirthData = propose.inputSchema!.safeParse({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", datePrecision: "month", @@ -89,7 +98,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges" const withRange = propose.inputSchema!.safeParse({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", datePrecision: "month", @@ -113,7 +121,6 @@ test("quote must be grounded in the source turn's own message", async () => { execute(input: unknown): Promise; }).execute({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "这段话根本不在用户消息里", proposedKind: "career_entry", subject: "self", @@ -133,7 +140,6 @@ test("year-only evidence keeps year precision and normalizes to a year start", a execute(input: unknown): Promise; }).execute({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年离开家去北京开始工作", proposedKind: "career_entry", subject: "self", @@ -144,6 +150,7 @@ test("year-only evidence keeps year precision and normalizes to a year start", a }); const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence"); assert.ok(proposeCall); + assert.equal(proposeCall.args.p_source_turn_id, TURN_ID); assert.equal(proposeCall.args.p_date_precision, "year"); assert.equal(proposeCall.args.p_occurred_from, "2016-01-01"); // The model cannot supply an evidence id; the server generates it. @@ -227,7 +234,6 @@ test("propose is idempotent: replay returns the existing draft without a second execute(input: unknown): Promise<{ idempotent: boolean }>; }).execute({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", subject: "self", @@ -262,7 +268,6 @@ test("terminal cases reject evidence writes", async () => { execute(input: unknown): Promise; }).execute({ caseId: CASE_ID, - sourceTurnId: SOURCE_TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", subject: "self", diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index 8173124d..8d7c9e37 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -94,6 +94,43 @@ test("confirmed requires the engine gate plus explicit grounded consent", async ); }); +test("confirm-birth-time binds consent to the server-owned current turn", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + confirm_agentic_rectification_birth_time: () => ({ + success: true, + saved_time: "05:02", + status: "confirmed", + result_id: RESULT_ID, + case_status: "confirmed", + idempotent: false, + }), + }); + const tools = createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); + const confirm = tools["rectification-confirm-birth-time"] as unknown as { + inputSchema: { safeParse(value: unknown): { success: boolean } }; + execute(input: unknown): Promise; + }; + const input = { + caseId: CASE_ID, + resultId: RESULT_ID, + candidateId: "05:02", + consentQuote: "就用05:02", + }; + assert.equal(confirm.inputSchema.safeParse(input).success, true); + assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false); + + await confirm.execute(input); + const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time"); + assert.ok(call); + assert.equal(call.args.p_source_turn_id, TURN_ID); +}); + test("candidate ownership is case-scoped: the RPC always receives the case id", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case: () => ({ @@ -152,7 +189,6 @@ test("terminal cases reject evidence writes and candidate actions", async () => await assert.rejects( (tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID, - sourceTurnId: TURN_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", subject: "self", diff --git a/skills/jyotish-birth-time-rectification/SKILL.md b/skills/jyotish-birth-time-rectification/SKILL.md index d34078e4..ef8edeac 100644 --- a/skills/jyotish-birth-time-rectification/SKILL.md +++ b/skills/jyotish-birth-time-rectification/SKILL.md @@ -42,7 +42,7 @@ description: "生时校正专用 Skill(V9)。以用户原话事件 + 服务 ## 4. 可调用工具与边界 -只调用服务器提供的 `rectification-*` 工具(read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case)。工具 input 只含最小引用(caseId、evidenceId、turnId、quote、proposedKind 等),**绝不**传: +只调用服务器提供的 `rectification-*` 工具(read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case)。工具 input 只含最小引用(caseId、evidenceId、quote、proposedKind 等),**绝不**传: - userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。