feat(rectification): add adaptive director tool loop

This commit is contained in:
Jesse_Chen
2026-07-31 15:09:03 +08:00
parent a7a11a1a7a
commit 453eacf3e2
11 changed files with 264 additions and 52 deletions
@@ -4,7 +4,7 @@ import test from "node:test";
import {
canRegenerateRectificationMessage,
rectificationV4ChatMessages,
rectificationPhaseLabel,
rectificationPhaseLabel, rectificationProgressLabel,
toggleRectificationFeedback,
} from "../src/components/rectification-v4-panel.tsx";
import { applyRectificationV4JobUpdate } from "../src/hooks/use-rectification-v4.ts";
@@ -259,9 +259,13 @@ test("processing follows every server job phase returned by polling", () => {
const message = rectificationV4ChatMessages(data, true).at(-1);
assert.equal(message?.role, "assistant");
assert.equal(message?.state, "thinking");
assert.equal(message?.text, label);
assert.equal(message?.text, rectificationProgressLabel(phase));
assert.match(message?.text ?? "", new RegExp(label));
}
assert.equal(rectificationPhaseLabel("checking_robustness"), "正在检查候选范围的稳定性…");
assert.equal(rectificationProgressLabel("extracting_evidence"), "正在整理你刚才提到的经历…\n正在分析:\n● 整理已确认事件\n○ 比较候选时间差异\n○ 确定下一步验证方向");
assert.equal(rectificationProgressLabel("checking_robustness"), "正在检查候选范围的稳定性…\n正在分析:\n✓ 整理已确认事件\n● 比较候选时间差异\n○ 确定下一步验证方向");
assert.equal(rectificationProgressLabel("reasoning"), "正在选择下一步动作…\n正在分析:\n✓ 整理已确认事件\n✓ 比较候选时间差异\n● 确定下一步验证方向");
});
test("polling ignores an older job response so the visible phase cannot move backward", () => {
@@ -441,6 +441,15 @@ test("V6 迁移只更新未完成 Case 版本且不写 active_birth_time", () =>
assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i);
});
test("V8 Director migration advances only unfinished Agent cases", () => {
const migration = readFileSync(new URL("../supabase/migrations/20260731020000_rectification_director_v4_runtime.sql", import.meta.url), "utf8");
assert.match(migration, /alter column skill_version set default 'birth-time-rectification-v8'/);
assert.match(migration, /alter column prompt_version set default 'rectification-director-v4'/);
assert.match(migration, /where status in \('awaiting_answer', 'processing', 'paused'\)/);
assert.match(migration, /deployment_mode in \('v5_shadow', 'v5_agent'\)/);
assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i);
});
test("新事件机会提供具体回忆线索和退出方式,不把离家上大学换词重问成迁居", () => {
const university = event({ summary: "离家去外地上大学", rawText: "2016年9月离家去外地上大学" });
const opportunities = buildQuestionOpportunities({
+61 -7
View File
@@ -38,8 +38,8 @@ const caseValue: RectificationV4Case = {
latestSnapshot: null,
orchestrationModelId: null,
narrationModelId: null,
skillVersion: "birth-time-rectification-v6",
promptVersion: "rectification-director-v1",
skillVersion: "birth-time-rectification-v8",
promptVersion: "rectification-director-v4",
algorithmVersion: "rectification-v5-matrix-scoring-1",
deploymentMode: "v5_agent",
agentMode: "agent",
@@ -319,7 +319,7 @@ test("explicit subject correction is grounded and enters pending review", () =>
assert.equal(staged.revisions[0]?.scoreability, "pending_review");
});
test("declined targets cannot be reopened and diagnostics stay in a bounded tool loop", async () => {
test("declined targets cannot be reopened and the Director adapts through server-owned observations", async () => {
const target = event({ eventId: "00000000-0000-4000-8000-000000000706" });
const targetDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [target], snapshot: null, diagnostics: null, targetDisposition: "declined", currentTargetEventId: target.eventId });
const reopened = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } });
@@ -327,6 +327,55 @@ test("declined targets cannot be reopened and diagnostics stay in a bounded tool
const reopenedWithoutId = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: null, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } });
assert.ok(validateRectificationTurnPlan({ plan: reopenedWithoutId, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened"));
const phases: string[] = [];
const prompts: string[] = [];
const requests = [
{ tool: "case_read", diagnostic: null },
{ tool: "candidate_scan", diagnostic: null },
{ tool: "evidence_gap", diagnostic: null },
{ tool: "diagnostic_read", diagnostic: "candidate_split" },
] as const;
const result = await runRectificationDirector({
caseValue,
dossier: dossier(),
latestAnswer: "",
phase: "final",
diagnostics,
generatePlan: async (prompt, phase) => {
phases.push(phase);
prompts.push(prompt);
const request = requests[phases.length - 1];
return { object: request ? plan({ action: { type: "request_tool", ...request } }) : plan() };
},
});
assert.equal(result.mode, "agent");
assert.deepEqual(phases, ["final", "after_observation", "after_observation", "after_observation", "after_observation"]);
assert.deepEqual(result.toolCalls.map(({ tool, diagnostic }) => [tool, diagnostic]), [
["case_read", null],
["candidate_scan", null],
["evidence_gap", null],
["diagnostic_read", "candidate_split"],
]);
assert.equal(dossier().capabilities.maxToolRounds, 10);
const firstPrompt = JSON.parse(prompts[0]!);
assert.equal(firstPrompt.dossier.eventLedger, undefined);
assert.equal(firstPrompt.dossier.candidateState, undefined);
assert.equal(firstPrompt.dossier.runtime.hypotheses, undefined);
assert.deepEqual(firstPrompt.dossier.availableTools.readOnly, ["case_read", "candidate_scan", "evidence_gap"]);
const caseObservationPrompt = JSON.parse(prompts[1]!);
assert.ok(Array.isArray(caseObservationPrompt.latestObservation.result.eventLedger));
assert.equal(caseObservationPrompt.dossier.runtime.observations[0].tool, "case_read");
const candidateObservationPrompt = JSON.parse(prompts[2]!);
assert.equal(candidateObservationPrompt.latestObservation.result.candidateState.hasSnapshot, false);
assert.ok(Array.isArray(candidateObservationPrompt.latestObservation.result.hypotheses));
const finalPrompt = JSON.parse(prompts[4]!);
assert.equal(finalPrompt.dossier.runtime.revision, 4);
assert.deepEqual(finalPrompt.dossier.runtime.observations.map((item: { tool: string }) => item.tool), requests.map(({ tool }) => tool));
assert.equal(result.dossier.runtime.revision, 4);
assert.equal(result.plan.action.type, "ask_question");
});
test("repeated immutable tools are not executed twice and force one convergence decision", async () => {
const phases: string[] = [];
const prompts: string[] = [];
const result = await runRectificationDirector({
@@ -338,13 +387,18 @@ test("declined targets cannot be reopened and diagnostics stay in a bounded tool
generatePlan: async (prompt, phase) => {
phases.push(phase);
prompts.push(prompt);
return { object: phases.length <= 2 ? plan({ action: { type: "request_diagnostic", diagnostic: phases.length === 1 ? "candidate_split" : "date_sensitivity" } }) : plan() };
return { object: phase === "converge" ? plan() : plan({ action: { type: "request_tool", tool: "candidate_scan", diagnostic: null } }) };
},
});
assert.equal(result.mode, "agent");
assert.deepEqual(phases, ["final", "after_diagnostic", "after_diagnostic"]);
assert.equal(result.toolCalls.length, 2);
assert.deepEqual(JSON.parse(prompts[2]!).diagnosticResults.map((item: { diagnostic: string }) => item.diagnostic), ["candidate_split", "date_sensitivity"]);
assert.deepEqual(phases, ["final", "after_observation", "converge"]);
assert.equal(result.toolCalls.length, 1);
assert.deepEqual(JSON.parse(prompts[2]!).loopState, {
round: 1,
maxRounds: 10,
observedTools: ["candidate_scan:"],
convergenceReason: "tool_repeated",
});
assert.equal(result.plan.action.type, "ask_question");
});