feat(rectification): stream agent execution and redesign entry routing
Replace the Direct Agentic textStream relay with a durable V9 agent runtime:
- agentic-rectification.ts: short boundary-only system prompt (no gate->scan
->score->diagnostics copy); pins skills/jyotish-birth-time-rectification;
per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12,
accept/confirm 6) with a hard ceiling and repeated-tool-call detection.
- rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/
revise-evidence, compare-candidates, read-diagnostics, offer-candidates,
accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs
only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and
accepted!=confirmed semantics; confirm requires gate + grounded consent.
- /api/rectification/agent: caseId/sessionId/requestId/action/message; exact
Case<->Session binding verified server-side; client history never overrides
the durable dossier; pending turn -> completed/failed/retryable; consumes
result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider
metadata/tool payloads/birth data/scores never forwarded); first-turn real
skill.started/skill.loaded gate with one controlled retry; billing bound to
rectification:case:{caseId}.
- New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql:
case dossier/compute, turn finalize, fingerprint-cached candidate persist,
case-scoped accept, consent-gated confirm, guarded transitions,
needs_rebaseline profile guard, run_phases receipt table, and the
rectification_runtime_version feature flag (v9 default, legacy read-only).
- Frontend: homepage/sidebar entry routing now uses the server Case open API
(openRectificationFromHomepage/openRectificationSession/startNewRectification)
with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven
by entry-summary; chat restores from persisted turns, candidate cards from
the Candidate Snapshot API, activity from real NDJSON + persisted receipts;
direct durable candidate-accept endpoint for the UI cards.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Client-side rectification entry routing contracts.
|
||||
*
|
||||
* Every disposition decision comes from the server Case open API. The browser
|
||||
* never guesses "resume vs create" from session lists or message counts.
|
||||
*/
|
||||
|
||||
export type RectificationEntrySummary = Readonly<{
|
||||
hasResumableCase: boolean;
|
||||
hasTerminalCaseWithTime: boolean;
|
||||
latestResumable: Readonly<{
|
||||
caseId: string;
|
||||
status: string;
|
||||
lastActivityAt: string;
|
||||
}> | null;
|
||||
latestTerminal: Readonly<{
|
||||
caseId: string;
|
||||
status: string;
|
||||
hasUsableTime: boolean;
|
||||
}> | null;
|
||||
}>;
|
||||
|
||||
export type RectificationCardAction = "start" | "resume" | "restart";
|
||||
|
||||
export const rectificationEntryLabels: Readonly<Record<RectificationCardAction, string>> = {
|
||||
start: "开始生时校正",
|
||||
resume: "继续上次校正",
|
||||
restart: "再次校正",
|
||||
};
|
||||
|
||||
export function resolveRectificationEntryAction(
|
||||
summary: RectificationEntrySummary,
|
||||
): RectificationCardAction {
|
||||
if (summary.hasResumableCase) return "resume";
|
||||
if (summary.hasTerminalCaseWithTime) return "restart";
|
||||
return "start";
|
||||
}
|
||||
|
||||
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
|
||||
|
||||
export type OpenRectificationCaseResponse = Readonly<{
|
||||
disposition: OpenRectificationDisposition;
|
||||
caseId: string;
|
||||
sessionId: string;
|
||||
status: string;
|
||||
shouldStartOpening: boolean;
|
||||
skillVersion: string;
|
||||
}>;
|
||||
|
||||
const RESUMABLE_STATUSES = new Set([
|
||||
"draft",
|
||||
"collecting_evidence",
|
||||
"candidate_ready",
|
||||
"candidate_accepted",
|
||||
"needs_rebaseline",
|
||||
"paused",
|
||||
]);
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
"confirmed",
|
||||
"closed",
|
||||
"abandoned",
|
||||
"superseded",
|
||||
]);
|
||||
|
||||
export function isResumableRectificationStatus(status: string): boolean {
|
||||
return RESUMABLE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function isTerminalRectificationStatus(status: string): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
|
||||
}
|
||||
const row = value as Record<string, unknown>;
|
||||
const latestResumable = row.latest_resumable && typeof row.latest_resumable === "object"
|
||||
? row.latest_resumable as Record<string, unknown>
|
||||
: null;
|
||||
const latestTerminal = row.latest_terminal && typeof row.latest_terminal === "object"
|
||||
? row.latest_terminal as Record<string, unknown>
|
||||
: null;
|
||||
return {
|
||||
hasResumableCase: row.has_resumable_case === true,
|
||||
hasTerminalCaseWithTime: row.has_terminal_case_with_time === true,
|
||||
latestResumable: latestResumable && typeof latestResumable.case_id === "string"
|
||||
? {
|
||||
caseId: latestResumable.case_id,
|
||||
status: String(latestResumable.status ?? ""),
|
||||
lastActivityAt: String(latestResumable.last_activity_at ?? ""),
|
||||
}
|
||||
: null,
|
||||
latestTerminal: latestTerminal && typeof latestTerminal.case_id === "string"
|
||||
? {
|
||||
caseId: latestTerminal.case_id,
|
||||
status: String(latestTerminal.status ?? ""),
|
||||
hasUsableTime: latestTerminal.has_usable_time === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function openResponseFromPayload(value: unknown): OpenRectificationCaseResponse | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
const disposition = row.disposition;
|
||||
const caseId = typeof row.caseId === "string" ? row.caseId : "";
|
||||
const sessionId = typeof row.sessionId === "string" ? row.sessionId : "";
|
||||
const status = typeof row.status === "string" ? row.status : "";
|
||||
if (
|
||||
(disposition !== "created" && disposition !== "resumed" && disposition !== "readonly")
|
||||
|| !caseId || !sessionId || !status
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
disposition,
|
||||
caseId,
|
||||
sessionId,
|
||||
status,
|
||||
shouldStartOpening: row.shouldStartOpening === true,
|
||||
skillVersion: typeof row.skillVersion === "string" ? row.skillVersion : "",
|
||||
};
|
||||
}
|
||||
|
||||
export type RectificationEntryOpenIntent = "homepage" | "session" | "new";
|
||||
|
||||
/** Build the server open request body; the browser never adds business state. */
|
||||
export function openRectificationRequestBody(
|
||||
intent: RectificationEntryOpenIntent,
|
||||
sessionId: string | null,
|
||||
): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string } {
|
||||
const requestId = globalThis.crypto?.randomUUID?.() ?? fallbackUuid();
|
||||
if (intent === "session" && sessionId) {
|
||||
return { intent, requestId, sessionId };
|
||||
}
|
||||
return { intent, requestId };
|
||||
}
|
||||
|
||||
function fallbackUuid(): string {
|
||||
return "00000000-0000-4000-8000-000000000000";
|
||||
}
|
||||
Reference in New Issue
Block a user