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
@@ -51,6 +51,18 @@ export function rectificationPhaseLabel(
return phaseLabels[phase];
}
export function rectificationProgressLabel(
phase: NonNullable<RectificationV4ApiResponse["job"]>["phase"],
): string {
const activeStep = ["collecting_evidence", "extracting_evidence"].includes(phase)
? 0
: ["scoring_candidates", "checking_robustness"].includes(phase)
? 1
: 2;
const steps = ["整理已确认事件", "比较候选时间差异", "确定下一步验证方向"];
return [`${rectificationPhaseLabel(phase)}\n正在分析:`, ...steps.map((step, index) => `${index < activeStep || phase === "complete" ? "✓" : index === activeStep ? "●" : "○"} ${step}`)].join("\n");
}
function durationLabel(durationMs: number | null): string | null {
if (durationMs === null || durationMs < 0) return null;
return durationMs < 1_000 ? `${durationMs} 毫秒` : `${(durationMs / 1_000).toFixed(1)}`;
@@ -246,7 +258,7 @@ export function rectificationV4ChatMessages(
if (processing) {
messages.push({
role: "assistant",
text: rectificationPhaseLabel(data.job?.phase ?? caseValue.phase),
text: rectificationProgressLabel(data.job?.phase ?? caseValue.phase),
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
state: "thinking",
});
@@ -5,8 +5,8 @@ const uuid = z.string().uuid();
const hash = z.string().regex(/^[a-f0-9]{64}$/);
const nonblank = (max: number) => z.string().trim().min(1).max(max);
export const CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const;
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v2" as const;
export const CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v8" as const;
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v4" as const;
export const rectificationDiagnosticSchema = z.enum([
"leave_one_event_out",
@@ -17,6 +17,25 @@ export const rectificationDiagnosticSchema = z.enum([
]);
export type RectificationDiagnostic = z.infer<typeof rectificationDiagnosticSchema>;
export const rectificationAgentToolSchema = z.enum([
"case_read",
"candidate_scan",
"evidence_gap",
"diagnostic_read",
]);
export type RectificationAgentTool = z.infer<typeof rectificationAgentToolSchema>;
export const toolObservationSchema = z.object({
round: z.number().int().positive().max(10),
tool: rectificationAgentToolSchema,
diagnostic: rectificationDiagnosticSchema.nullable(),
outcome: z.enum(["succeeded", "failed"]),
result: z.record(z.string(), z.unknown()),
dossierRevision: z.number().int().positive().max(10),
errorCode: nonblank(120).nullable(),
}).strict();
export type ToolObservation = z.infer<typeof toolObservationSchema>;
export const targetDispositionSchema = z.enum([
"resolved",
"unknown",
@@ -72,6 +91,11 @@ const directorActionSchema = z.discriminatedUnion("type", [
question: nonblank(240),
optionalQuickReplies: z.array(z.object({ label: nonblank(40), value: nonblank(120) }).strict()).max(4),
}).strict(),
z.object({
type: z.literal("request_tool"),
tool: rectificationAgentToolSchema,
diagnostic: rectificationDiagnosticSchema.nullable(),
}).strict(),
z.object({ type: z.literal("request_diagnostic"), diagnostic: rectificationDiagnosticSchema }).strict(),
z.object({ type: z.literal("offer_candidate_range"), snapshotId: uuid }).strict(),
z.object({ type: z.literal("stop_low_confidence"), reasonCodes: z.array(nonblank(80)).min(1).max(8) }).strict(),
@@ -158,11 +182,20 @@ export const rectificationCaseDossierSchema = z.object({
gateReasons: z.array(z.string()).max(20),
currentSnapshotId: uuid.nullable(),
}).strict(),
runtime: z.object({
revision: z.number().int().nonnegative().max(10),
observations: z.array(toolObservationSchema).max(10),
hypotheses: z.array(z.object({
candidateRank: z.number().int().positive(),
supportingEventIds: z.array(uuid).max(100),
conflictingEventIds: z.array(uuid).max(100),
}).strict()).max(4),
}).strict(),
capabilities: z.object({
supportedDomains: z.array(evidenceDomainSchema),
supportedEventKinds: z.array(eventKindSchema),
maxQuestionsPerTurn: z.literal(1),
maxDiagnosticsPerRun: z.number().int().min(0).max(2),
maxToolRounds: z.literal(10),
forbiddenPublicClaims: z.array(z.string()),
}).strict(),
}).strict();
@@ -456,7 +489,7 @@ export const agentRunSchema = z.object({
deploymentMode: rectificationDeploymentModeSchema,
decision: rectificationDecisionSchema.nullable(),
validatedDecision: validatedDecisionSchema,
toolCalls: z.array(toolCallTraceSchema).max(8),
toolCalls: z.array(toolCallTraceSchema).max(10),
fallbackReason: nonblank(120).nullable(),
inputTokenCount: z.number().int().nonnegative().nullable(),
outputTokenCount: z.number().int().nonnegative().nullable(),
@@ -6,7 +6,7 @@ import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, P
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
import { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts";
import { buildCandidateContrastPacket } from "./opportunity-builder.ts";
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } from "./contracts.ts";
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationAgentTool, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace, type ToolObservation } from "./contracts.ts";
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
const domains: EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family", "other"];
@@ -28,7 +28,7 @@ function asksMultipleQuestions(value: string): boolean {
const declinedPattern = /(?:|便|||||)/u;
type Generated = Readonly<{ object: unknown; totalUsage?: { inputTokens?: number; outputTokens?: number } | Promise<{ inputTokens?: number; outputTokens?: number }> }>;
const regeneratedQuestionSchema = z.object({ question: z.string().trim().min(8).max(500) }).strict();
export type RectificationDirectorGenerator = (prompt: string, phase: "evidence" | "final" | "after_diagnostic" | "repair") => Promise<Generated>;
export type RectificationDirectorGenerator = (prompt: string, phase: "evidence" | "final" | "after_observation" | "converge" | "repair") => Promise<Generated>;
function summarizeEarlierTurns(turns: readonly RectificationV4Turn[]): string | null {
const older = turns.slice(0, -12);
@@ -57,7 +57,15 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })),
interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [...new Set(input.turns.flatMap((turn) => turn.questionDomain && declinedPattern.test(turn.answer) ? [turn.questionDomain] : []))], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], pendingEvidence: (input.pendingEvidence ?? []).filter((item) => !item.resolvedAt).map(({ rawText, reasonCode, targetEventId, createdAt }) => ({ rawText, reasonCode, targetEventId, createdAt })), askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition },
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), contrastIntelligence: buildCandidateContrastPacket({ events: input.events, snapshot: input.snapshot, diagnostics: input.diagnostics }), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 2, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
runtime: {
revision: 0,
observations: [],
hypotheses: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => {
const candidate = input.snapshot?.candidates.find((item) => item.time === cluster.representativeTime);
return { candidateRank: cluster.rank, supportingEventIds: candidate?.supportingEventIds ?? [], conflictingEventIds: candidate?.conflictingEventIds ?? [] };
}),
},
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxToolRounds: 10, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
});
}
@@ -146,7 +154,7 @@ export async function regenerateDirectorQuestion(input: Readonly<{
export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) {
const started = Date.now();
const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel();
const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Direct the interview from the complete dossier. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null;
const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Direct the interview from the server-owned dossier and tool observations. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null;
const generate = input.generatePlan ?? (async (prompt: string) => {
if (!agent) throw new Error("director_model_unavailable");
return agent.generate(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 25_000), structuredOutput: { schema: rectificationTurnPlanSchema, jsonPromptInjection: "inline" } });
@@ -154,7 +162,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
let inputTokens = 0, outputTokens = 0;
let usageObserved = false;
const toolCalls: ToolCallTrace[] = [];
const diagnosticResults: Array<{ diagnostic: RectificationDiagnostic; result: ReturnType<typeof diagnosticResult> }> = [];
let dossier = input.dossier;
const addUsage = async (generated: Generated) => {
if (!generated.totalUsage) return;
const usage = await generated.totalUsage;
@@ -162,31 +170,91 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
outputTokens += Math.max(0, Math.trunc(usage.outputTokens ?? 0));
usageObserved = true;
};
const requestedTool = (action: RectificationTurnPlan["action"]): Readonly<{ tool: RectificationAgentTool; diagnostic: RectificationDiagnostic | null }> | null => {
if (action.type === "request_tool") {
if ((action.tool === "diagnostic_read") !== Boolean(action.diagnostic)) throw new Error("director_tool_request_invalid");
return { tool: action.tool, diagnostic: action.diagnostic };
}
if (action.type === "request_diagnostic") return { tool: "diagnostic_read", diagnostic: action.diagnostic };
return null;
};
const toolKey = (request: Readonly<{ tool: RectificationAgentTool; diagnostic: RectificationDiagnostic | null }>) => `${request.tool}:${request.diagnostic ?? ""}`;
const promptDossier = () => input.phase === "evidence" ? dossier : {
runtime: { revision: dossier.runtime.revision, observations: dossier.runtime.observations },
capabilities: dossier.capabilities,
availableTools: {
readOnly: ["case_read", "candidate_scan", "evidence_gap"],
diagnostics: ["leave_one_event_out", "leave_one_domain_out", "date_sensitivity", "neighbor_stability", "candidate_split"],
},
};
const executeTool = (request: Readonly<{ tool: RectificationAgentTool; diagnostic: RectificationDiagnostic | null }>, round: number): ToolObservation => {
const result = request.tool === "case_read"
? { case: dossier.case, conversation: dossier.conversation, eventLedger: dossier.eventLedger, interviewState: dossier.interviewState }
: request.tool === "candidate_scan"
? { candidateState: dossier.candidateState, hypotheses: dossier.runtime.hypotheses }
: request.tool === "evidence_gap"
? { pendingEvidence: dossier.interviewState.pendingEvidence, contrastIntelligence: dossier.candidateState.contrastIntelligence, hypotheses: dossier.runtime.hypotheses }
: { diagnostic: request.diagnostic, result: diagnosticResult(request.diagnostic!, input.diagnostics) };
return { round, tool: request.tool, diagnostic: request.diagnostic, outcome: "succeeded", result, dossierRevision: dossier.runtime.revision + 1, errorCode: null };
};
try {
const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty because staging is complete.", latestAnswer: input.latestAnswer, dossier: input.dossier }), input.phase);
const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty. Use a read-only tool only when its observation can materially change the next action.", latestAnswer: input.latestAnswer, dossier: promptDossier() }), input.phase);
await addUsage(first);
let candidate = rectificationTurnPlanSchema.parse(first.object);
while (input.phase === "final" && candidate.action.type === "request_diagnostic" && toolCalls.length < input.dossier.capabilities.maxDiagnosticsPerRun) {
const observedTools = new Set<string>();
let convergenceReason: "tool_repeated" | "tool_round_limit" | null = null;
let request = requestedTool(candidate.action);
while (input.phase === "final" && request) {
const key = toolKey(request);
if (observedTools.has(key)) {
convergenceReason = "tool_repeated";
break;
}
if (toolCalls.length >= dossier.capabilities.maxToolRounds) {
convergenceReason = "tool_round_limit";
break;
}
const toolStarted = Date.now();
const result = diagnosticResult(candidate.action.diagnostic, input.diagnostics);
diagnosticResults.push({ diagnostic: candidate.action.diagnostic, result });
toolCalls.push({ tool: "run_rectification_diagnostics", diagnostic: candidate.action.diagnostic, outcome: "succeeded", durationMs: Date.now() - toolStarted, errorCode: null });
const second = await generate(JSON.stringify({ task: "Use the diagnostic results and return a final non-diagnostic action with no evidence proposals.", latestAnswer: input.latestAnswer, dossier: input.dossier, diagnosticResults }), "after_diagnostic");
await addUsage(second);
candidate = rectificationTurnPlanSchema.parse(second.object);
const observation = executeTool(request, toolCalls.length + 1);
observedTools.add(key);
dossier = rectificationCaseDossierSchema.parse({
...dossier,
runtime: { ...dossier.runtime, revision: observation.dossierRevision, observations: [...dossier.runtime.observations, observation] },
});
toolCalls.push({ tool: request.tool, diagnostic: request.diagnostic, outcome: observation.outcome, durationMs: Date.now() - toolStarted, errorCode: observation.errorCode });
const next = await generate(JSON.stringify({
task: "Read the updated dossier and latest observation. Request another unobserved read-only tool only if it can materially change the interview strategy; otherwise converge to one final non-tool action. evidenceProposals must stay empty.",
latestAnswer: input.latestAnswer,
dossier: promptDossier(),
latestObservation: observation,
loopState: { round: toolCalls.length, maxRounds: dossier.capabilities.maxToolRounds, observedTools: [...observedTools] },
}), "after_observation");
await addUsage(next);
candidate = rectificationTurnPlanSchema.parse(next.object);
request = requestedTool(candidate.action);
}
if (input.phase === "final" && candidate.action.type === "request_diagnostic") throw new Error("director_final_plan_not_final");
let validated = validateRectificationTurnPlan({ plan: candidate, dossier: input.dossier, latestAnswer: input.latestAnswer, phase: input.phase });
if (input.phase === "final" && requestedTool(candidate.action)) {
const converged = await generate(JSON.stringify({
task: "The tool loop has reached its convergence boundary. Return one safe final non-tool action now; do not request another tool and keep evidenceProposals empty.",
latestAnswer: input.latestAnswer,
dossier: promptDossier(),
loopState: { round: toolCalls.length, maxRounds: dossier.capabilities.maxToolRounds, observedTools: [...observedTools], convergenceReason: convergenceReason ?? "tool_round_limit" },
}), "converge");
await addUsage(converged);
candidate = rectificationTurnPlanSchema.parse(converged.object);
if (requestedTool(candidate.action)) throw new Error("director_final_plan_not_final");
}
let validated = validateRectificationTurnPlan({ plan: candidate, dossier, latestAnswer: input.latestAnswer, phase: input.phase });
if (!validated.plan) {
const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", latestAnswer: input.latestAnswer, dossier: input.dossier, rejectedPlan: candidate, validationIssues: validated.issues }), "repair");
const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", latestAnswer: input.latestAnswer, dossier: promptDossier(), rejectedPlan: candidate, validationIssues: validated.issues }), "repair");
await addUsage(repaired);
candidate = rectificationTurnPlanSchema.parse(repaired.object);
if (candidate.action.type === "request_diagnostic") throw new Error("director_repair_requested_diagnostic");
validated = validateRectificationTurnPlan({ plan: candidate, dossier: input.dossier, latestAnswer: input.latestAnswer, phase: input.phase });
if (requestedTool(candidate.action)) throw new Error("director_repair_requested_tool");
validated = validateRectificationTurnPlan({ plan: candidate, dossier, latestAnswer: input.latestAnswer, phase: input.phase });
}
if (!validated.plan) throw new Error(`director_plan_rejected:${validated.issues.join(",")}`);
return { plan: validated.plan, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
return { plan: validated.plan, dossier, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
} catch (error) {
return { plan: fallback(input.dossier, input.latestAnswer), mode: "deterministic_fallback" as const, fallbackReason: error instanceof Error ? error.message.slice(0, 120) : "director_failed", toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
return { plan: fallback(dossier, input.latestAnswer), dossier, mode: "deterministic_fallback" as const, fallbackReason: error instanceof Error ? error.message.slice(0, 120) : "director_failed", toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
}
}
@@ -383,8 +383,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
});
const plan = directed.plan;
const action = plan.action;
if (action.type === "request_diagnostic") {
throw new Error("rectification_director_diagnostic_loop_incomplete");
if (action.type === "request_diagnostic" || action.type === "request_tool") {
throw new Error("rectification_director_tool_loop_incomplete");
}
const decision = action.type === "ask_question"
? { action: "ask_question" as const, focus: action.focus, question: action.question }
@@ -400,7 +400,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
await enterPhase("rendering");
finishPhase();
for (const call of directed.toolCalls) analysisToolCalls.push({
category: "agent_diagnostic", label: call.diagnostic ? diagnosticLabels[call.diagnostic] : "只读诊断",
category: "agent_diagnostic",
label: call.diagnostic ? diagnosticLabels[call.diagnostic] : ({ case_read: "读取案件档案", candidate_scan: "读取候选扫描", evidence_gap: "检查证据缺口" } as Record<string, string>)[call.tool] ?? "只读诊断",
outcome: call.outcome, durationMs: call.durationMs,
});
const publicMessage: StoredPublicMessage = {