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:31:00 +08:00
parent 4f6cf5782d
commit 9df43e8112
4 changed files with 131 additions and 46 deletions
@@ -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);
});