fix(rectification): bind skill before provider run
Independent Staging Quality Gate / validate (push) Successful in 15m7s
Independent Staging Quality Gate / publish (push) Successful in 9m36s

This commit is contained in:
Jesse_Chen
2026-08-16 15:29:37 +08:00
parent 4f6cf5782d
commit 9df43e8112
4 changed files with 131 additions and 46 deletions
+15
View File
@@ -3480,3 +3480,18 @@
- 防复发:新增管理业务时只能在登录、权限、Origin、原因和审计边界内扩展;不得把邮箱 OTP 放回公共业务确认弹窗或新建操作级 reauth API。账户级 MFA 与普通用户身份验证必须保持独立。
- 相关记录:BUG-155、BUG-156
- 修复版本:本地未提交候选
## BUG-208 | 生时校正 opening 首步依赖模型主动加载 Skill,失败时只返回 run.started → run.failed
- 状态:resolvedstaging 发布候选,待质量门禁与业务验收)
- 首次发现: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 与健康检查验收为准)
@@ -425,14 +425,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
return failedAttempt(attemptId, "skill_not_loaded");
}
const messages = buildAgentMessages(options, attemptNumber, dossier);
const rawSkillInstructions = (frameworkSkill as { instructions?: unknown }).instructions;
const skillInstructions = typeof rawSkillInstructions === "string"
? rawSkillInstructions.trim()
: "";
if (!skillInstructions) {
return failedAttempt(attemptId, "skill_not_loaded");
}
const messages = buildAgentMessages(options, attemptNumber, dossier, skillInstructions);
const maxSteps = resolveRectificationStepBudget(action);
const abortController = new AbortController();
const onAbort = () => 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<V9Agen
try {
await recordPhase("run.started");
await insertV9SkillRunReceipt(
accounting,
userId,
caseId,
turnId,
attemptId,
"turn",
skillPackage,
);
await recordPhase("skill.bound");
events.push({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const result = await (agent as unknown as {
stream(
messages: unknown[],
streamOptions: { maxSteps: number; abortSignal: AbortSignal; instructions?: string },
streamOptions: {
maxSteps: number;
abortSignal: AbortSignal;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: { type: "tool"; toolName: string };
} | undefined;
},
): Promise<{
fullStream: AsyncIterable<{
type: string;
@@ -472,9 +500,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
...(attemptNumber > 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<V9Agen
}
const phaseEvent = mapStreamChunkToPhase(chunk as never);
if (phaseEvent) {
if (phaseEvent.type === "skill.bound") {
if (phaseEvent.type === "skill.bound" && !skillBound) {
skillBound = true;
await insertV9SkillRunReceipt(
accounting,
@@ -662,20 +693,30 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
function buildAgentMessages(
options: V9AgentRunOptions,
_attempt: number,
attempt: number,
dossier: V9CaseDossier,
skillInstructions: string,
): unknown[] {
void _attempt;
const timeContext = options.timeContext
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
const caseContext = `【服务端 Case ID】${options.caseId}。所有 rectification 工具调用的 caseId 必须原样使用此值。`;
const bootstrap = {
role: "system",
content: [
"【服务器已绑定当前 Case 的精确 Skill】运行器已在本 attempt 内加载并核验下列指令;不要重复调用 skill。第一步必须调用 rectification-read-case。",
skillInstructions,
...(attempt > 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"),
}];
+1 -1
View File
@@ -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;无法唯一指向时只做简短澄清,不得猜测。
+63 -34
View File
@@ -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);
});