From 9df43e8112fd3706bef33f05306bd9d8c692fe0d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 16 Aug 2026 15:29:37 +0800 Subject: [PATCH] fix(rectification): bind skill before provider run --- docs/BUG_HISTORY.md | 15 +++ .../lib/rectification-agentic/v9/agent-run.ts | 63 +++++++++--- frontend/src/mastra/agentic-rectification.ts | 2 +- frontend/tests/rectification-v9-agent.test.ts | 97 ++++++++++++------- 4 files changed, 131 insertions(+), 46 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f1a6aed4..42259233 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3480,3 +3480,18 @@ - 防复发:新增管理业务时只能在登录、权限、Origin、原因和审计边界内扩展;不得把邮箱 OTP 放回公共业务确认弹窗或新建操作级 reauth API。账户级 MFA 与普通用户身份验证必须保持独立。 - 相关记录:BUG-155、BUG-156 - 修复版本:本地未提交候选 + +## BUG-208 | 生时校正 opening 首步依赖模型主动加载 Skill,失败时只返回 run.started → run.failed + +- 状态:resolved(staging 发布候选,待质量门禁与业务验收) +- 首次发现:2026-08-16 +- 最近更新:2026-08-16 +- 影响面:`POST /api/rectification/agent` 的 V9 Agentic Rectification opening/普通 turn、Skill 绑定收据、首步 Case 读取与公开 NDJSON 事件。 +- 用户现象:已通过鉴权、Case/Session 绑定和模型校验的 opening 请求,只收到 `run.started` 后紧接 `run.failed`,没有可见的 Skill、Case 或回答事件。 +- 触发条件:服务端已经通过 `agent.getSkill()` 加载并核验 Case 绑定的不可变 Skill,但首个 provider step 仍使用自动工具选择;模型直接回答,或先调用 `rectification-read-case` 而没有先主动调用框架 `skill` 工具时,运行器按 `skill_not_loaded` / `skill_not_bound` fail closed。attempt 内的活动与文本在成功前统一缓冲,因此该合同错误在公开流中折叠成只有 `run.started → run.failed`。 +- 根因:Skill 的真实性与版本已经由服务器加载和校验,但运行合同仍把“是否完成绑定”交给模型是否主动选择 `skill` 工具,形成服务器事实与模型行为之间的不一致;首步 Case 读取同样没有由服务器强制。该缺陷可确定性复现用户现象,但在缺少 staging 运行日志时不据此断言某个具体 provider 一定返回了直接文本或特定工具序列。 +- 修复:要求 `agent.getSkill()` 返回非空指令,并将其作为本 attempt 的服务器 system bootstrap 注入;在 provider 执行前持久化唯一 Skill receipt 和 `skill.bound` phase,并将 Skill 标记为已绑定。通过 Mastra `prepareStep` 把 step 0 的可用工具缩减为 `rectification-read-case` 且强制调用;重试提示一并放入 bootstrap,不再覆盖 stream instructions。模型若冗余调用 `skill` 不会重复写入收据,其他校正工具在 `case.loaded` 前仍继续 fail closed。 +- 验证:新增回归覆盖服务器 Skill 指令注入、首步强制 `rectification-read-case`、无需模型调用 `skill` 即可完成、Skill receipt 只写一次,以及 `getSkill()` 缺失时 provider stream 不得启动。Rectification Agent/stream/Skill registry 聚焦测试 53/53 通过;目标 ESLint、TypeScript `--noEmit` 与 `git diff --check` 通过。部署同构 Docker `build` target 成功,镜像内 `@mastra/core` 为 `1.50.1`。 +- 防复发:服务器已经确定的 Skill 身份、指令和首个事实读取步骤不得再依赖模型自动选工具;所有 provider 调用前必须完成可审计的 Skill 绑定,首步工具面保持最小化,并继续以最终 `run.completed`、持久化 Turn 和计费不变量作为部署后验收标准。 +- 相关记录:BUG-177、BUG-198、BUG-206 +- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 与健康检查验收为准) diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 83d55705..f4631efa 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -425,14 +425,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise abortController.abort(); signal?.addEventListener("abort", onAbort, { once: true }); const timeout = setTimeout(() => abortController.abort(), 105_000); - let skillBound = false; + let skillBound = true; let caseLoaded = false; let intentClassified = false; let streamFailed = false; @@ -457,10 +465,30 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { + activeTools: string[]; + toolChoice: { type: "tool"; toolName: string }; + } | undefined; + }, ): Promise<{ fullStream: AsyncIterable<{ type: string; @@ -472,9 +500,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise 1 ? { - instructions: "严格按运行合同执行:先加载绑定 Skill,再读取 Case;不得复用上一次 attempt 的文本或工具状态。", - } : {}), + prepareStep: ({ stepNumber }) => stepNumber === 0 + ? { + activeTools: ["rectification-read-case"], + toolChoice: { type: "tool", toolName: "rectification-read-case" }, + } + : undefined, }); for await (const chunk of result.fullStream) { @@ -503,7 +534,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise 1 + ? ["【重试约束】不得复用上一次 attempt 的文本或工具状态;从 rectification-read-case 重新读取服务器事实。"] + : []), + ].join("\n\n"), + }; if (options.action === "opening") { - return [{ + return [bootstrap, { role: "user", content: [timeContext, caseContext, openingBrief(dossier)].join("\n"), }]; } - return [{ + return [bootstrap, { role: "user", content: [timeContext, caseContext, options.message ?? ""].join("\n"), }]; diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 6596fdb6..f66f48fe 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -61,7 +61,7 @@ export function resolveRectificationStepBudget(action: RectificationAgentAction) const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑定 jyotish-birth-time-rectification Skill 的生时校正 Case。方法、OpeningPolicy、ConversationFocus、长会话摘要、批量证据和候选比较策略全部以本 Case 绑定的不可变 Skill 为准,不在系统提示中重写。 硬性运行与安全边界: -1. 每轮必须先加载 Case 绑定的精确 Skill 包,再调用 rectification-read-case;运行器会阻止在此之前执行其他校正动作。 +1. 运行器会在每个 attempt 开始前加载并核验 Case 绑定的精确 Skill 包;你不要重复调用 skill,第一步直接调用 rectification-read-case。运行器会阻止在读取 Case 前执行其他校正动作。 2. 服务器是 Case、ConversationFocus、CaseConversationSummary、Evidence、Candidate、Turn、Receipt、计费、ownership 与终态的唯一权威。只使用工具返回的当前状态,不从旧正文猜测目标或事实。 3. 事实只能来自用户原话;不得虚构或补全事件、日期、人物关系、动机、分盘、评分、候选或出生分钟。日期精度按用户真实表达保留。 4. 工具只传最小引用。承接、拒答、确认和修订必须引用服务器返回且仍 active 的 focusId/evidenceId;无法唯一指向时只做简短澄清,不得猜测。 diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 11faab2d..3a4204fd 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -218,31 +218,58 @@ test("agent receives the exact server-owned case id for tool calls", async () => assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/); }); -test("first turn with no real skill evidence retries once then fails without saving success", async () => { - const { options, emitted, billing } = runOptions({ - accounting: fakeAccounting({ - ...receiptHandlers, - get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }), - append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), - finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }), - }).client, - buildAgent: async () => fakeAgentStream([ - chunk("start"), - chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), - chunk("tool-result", { toolName: "rectification-read-case" }), - chunk("text-delta", { text: "你好," }), - chunk("finish"), - ]) as never, +test("server-loaded Skill is bound before the provider and the first model step is forced to read Case", async () => { + const skillInstructions = "immutable-skill-instructions-from-server"; + let observedMessages: unknown[] = []; + let observedStreamOptions: { + prepareStep?: (input: { stepNumber: number }) => unknown; + } = {}; + const agent = fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: "你好,我先从一件你记得比较清楚的经历开始。" }), + chunk("finish"), + ]); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), }); + const { options, emitted, billing } = runOptions({ + accounting: accounting.client, + buildAgent: async () => ({ + ...agent, + getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: skillInstructions }), + stream: async (messages: unknown[], streamOptions: typeof observedStreamOptions) => { + observedMessages = messages; + observedStreamOptions = streamOptions; + return agent.stream(); + }, + }) as never, + }); + const result = await runV9AgentTurn(options); - assert.equal(result.ok, false); - assert.equal(result.turnStatus, "retryable"); - assert.equal(result.skillLoaded, false); - assert.equal(result.errorCode, "skill_not_bound"); - assert.equal(billing.released, 1, "failed first turn must release usage"); - assert.equal(billing.completed, 0); - assert.equal(emitted.some((event) => event.type === "run.failed"), true); - assert.equal(emitted.some((event) => event.type === "run.completed"), false); + + assert.equal(result.ok, true); + assert.equal(result.skillLoaded, true); + assert.equal(result.errorCode, null); + assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); + assert.match(JSON.stringify(observedMessages), /服务器已绑定当前 Case 的精确 Skill/); + assert.match(JSON.stringify(observedMessages), /不要重复调用 skill/); + assert.match(JSON.stringify(observedMessages), new RegExp(skillInstructions)); + assert.deepEqual(await observedStreamOptions.prepareStep?.({ stepNumber: 0 }), { + activeTools: ["rectification-read-case"], + toolChoice: { type: "tool", toolName: "rectification-read-case" }, + }); + assert.equal(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }), undefined); + assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1); + assert.equal(emitted.some((event) => event.type === "run.completed"), true); + assert.equal( + accounting.calls.filter((call) => call.fn === "insert_agentic_rectification_skill_run_receipt").length, + 1, + ); }); test("first turn with a bound immutable Skill completes and persists receipts", async () => { @@ -352,9 +379,8 @@ test("a repeated identical tool call is detected and aborts the turn", async () assert.equal(billing.released, 1); }); -test("a failed opening does not let the next turn skip the real skill gate", async () => { - // The dossier has one failed turn and no completed turn: the skill gate - // must still apply, so an agent that never invokes the skill tool fails. +test("a failed opening does not let the next turn skip the server Skill load gate", async () => { + let streamCount = 0; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ @@ -373,17 +399,20 @@ test("a failed opening does not let the next turn skip the real skill gate", asy }); const { options, billing } = runOptions({ accounting: accounting.client, - buildAgent: async () => fakeAgentStream([ - chunk("start"), - chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), - chunk("tool-result", { toolName: "rectification-read-case" }), - chunk("text-delta", { text: "你好," }), - chunk("finish"), - ]) as never, + buildAgent: async () => ({ + getSkill: async () => null, + stream: async () => { + streamCount += 1; + return { fullStream: (async function* () {})() }; + }, + }) as never, }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, false); - assert.equal(result.errorCode, "skill_not_bound"); + assert.equal(result.errorCode, "skill_not_loaded"); + assert.equal(streamCount, 0); assert.equal(billing.released, 1); });