fix(rectification): bind skill before provider run
This commit is contained in:
@@ -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 与健康检查验收为准)
|
||||
|
||||
@@ -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"),
|
||||
}];
|
||||
|
||||
@@ -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;无法唯一指向时只做简短澄清,不得猜测。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user