fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,7 +32,7 @@ import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "
|
||||
import { toAgentModelFinishReason } from "../../agent-observability.ts";
|
||||
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
|
||||
import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { alreadyDelivered } from "./delivery-turn-guard";
|
||||
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import {
|
||||
@@ -622,6 +622,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
console.warn(
|
||||
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
|
||||
);
|
||||
try {
|
||||
interviewIdle = await ensureNonTerminalTurnExit({ accounting, userId, caseId });
|
||||
} catch (repairError) {
|
||||
console.warn(
|
||||
`[rectification-v9] nonterminal exit after idle failure failed case=${caseId} reason=${safeErrorCode(repairError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === "evidence") {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
withNakshatraBoundaryProbe,
|
||||
} from "./inference-adapter";
|
||||
import { datedEventCount } from "./divergence-panel.ts";
|
||||
import { logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
||||
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
||||
import {
|
||||
decideAfterInferenceChange,
|
||||
decideFromDossier,
|
||||
@@ -114,6 +114,47 @@ export function isExhaustedGateState(input: {
|
||||
&& !input.accepted;
|
||||
}
|
||||
|
||||
export function looksLikeTerminalNoteNarration(text: string | null | undefined): boolean {
|
||||
const trimmed = text?.trim() ?? "";
|
||||
if (!trimmed) return false;
|
||||
return trimmed.includes(RECTIFICATION_USER_COPY.noCandidatesGate)
|
||||
|| trimmed.includes("这只是代表性候选")
|
||||
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|
||||
|| trimmed.includes("两件事的日期还没对清")
|
||||
|| trimmed.includes("这次给出的范围");
|
||||
}
|
||||
|
||||
function terminalNoteHostPresent(input: {
|
||||
caseId: string;
|
||||
dossier: {
|
||||
turns?: readonly Readonly<{ role: string; text: string | null }>[];
|
||||
latestResult?: { resultId?: string } | null;
|
||||
};
|
||||
}): boolean {
|
||||
const resultId = input.dossier.latestResult?.resultId ?? "";
|
||||
if (alreadyDelivered({
|
||||
caseId: input.caseId,
|
||||
resultId,
|
||||
hasUserMessage: false,
|
||||
})) return true;
|
||||
return (input.dossier.turns ?? []).some((turn) => (
|
||||
turn.role === "assistant" && looksLikeTerminalNoteNarration(turn.text)
|
||||
));
|
||||
}
|
||||
|
||||
function logDuplicateFocus(caseId: string, questionId: string | null | undefined): void {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_focus_duplicate",
|
||||
case_id: caseId,
|
||||
question_id: questionId ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function exhaustionCarrierNarration(range: string | null | undefined, gate: string | null | undefined): string {
|
||||
return [range, gate].filter((item): item is string => Boolean(item?.trim())).join("")
|
||||
|| RECTIFICATION_USER_COPY.noCandidatesGate;
|
||||
}
|
||||
|
||||
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
|
||||
return `${askedTurnId ?? caseId}:gate`;
|
||||
}
|
||||
@@ -132,6 +173,9 @@ export async function persistExhaustionGateTurn(input: {
|
||||
resultId: input.resultId ?? null,
|
||||
terminalNote: true,
|
||||
});
|
||||
if (input.resultId) {
|
||||
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
|
||||
}
|
||||
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
|
||||
userMessage: null,
|
||||
@@ -868,6 +912,9 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
}
|
||||
if (followup?.intent === "collect_method_evidence") {
|
||||
const spoken = spokenFollowupForUser(followup);
|
||||
if (persistedFocus.status === "duplicate_focus") {
|
||||
logDuplicateFocus(input.caseId, persistedFocus.questionId);
|
||||
}
|
||||
if (
|
||||
spoken
|
||||
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
|
||||
@@ -945,6 +992,9 @@ async function persistFocusAfterChoice(input: {
|
||||
|| persisted.status === "duplicate_focus"
|
||||
|| !input.followup
|
||||
) {
|
||||
if (persisted.status === "duplicate_focus") {
|
||||
logDuplicateFocus(input.caseId, persisted.questionId);
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) {
|
||||
@@ -1422,12 +1472,28 @@ async function persistExhaustionCollect(input: {
|
||||
const persisted = Boolean(spoken) && (
|
||||
persistedFocus.status === "created" || persistedFocus.status === "already_open"
|
||||
);
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: spoken ?? range,
|
||||
focus: persistedFocus.focus,
|
||||
};
|
||||
if (persisted) {
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: spoken ?? range,
|
||||
focus: persistedFocus.focus,
|
||||
};
|
||||
}
|
||||
// duplicate_focus: this ask is already closed; fall through to adopt/gate.
|
||||
// Other persist misses still return the spoken collect so mid-session
|
||||
// turns keep a next question instead of a terminal gate.
|
||||
if (persistedFocus.status !== "duplicate_focus") {
|
||||
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
|
||||
if (hostNarration) {
|
||||
return {
|
||||
persisted: false,
|
||||
choiceReady: false,
|
||||
hostNarration,
|
||||
focus: persistedFocus.focus,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
|
||||
const adopted = {
|
||||
@@ -1463,7 +1529,7 @@ async function persistExhaustionCollect(input: {
|
||||
openingRange: openingRangeFromDossier(dossier),
|
||||
variant: "intermediate",
|
||||
});
|
||||
const hostNarration = [range, gate].filter(Boolean).join("");
|
||||
const hostNarration = exhaustionCarrierNarration(range, gate);
|
||||
return {
|
||||
persisted: false,
|
||||
choiceReady: false,
|
||||
@@ -1713,42 +1779,13 @@ async function inspectNonTerminalTurnExit(input: {
|
||||
birthDate,
|
||||
snapshotCurrent: rescored.snapshotCurrent,
|
||||
});
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const remainingCollect = exhaustionSpokenCollectFollowup({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
answeredProbes: catalog.answeredProbes,
|
||||
eventProbes: catalog.eventProbes,
|
||||
});
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
activeFocus: null,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
...catalog,
|
||||
birthDate,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}),
|
||||
});
|
||||
const exhausted = isExhaustedGateState({
|
||||
remainingCollect,
|
||||
methods: plan.methods,
|
||||
canAdopt: decision.canAdopt,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
});
|
||||
const satisfied = Boolean(
|
||||
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|
||||
|| dossier.case.acceptedTime
|
||||
|| dossier.case.confirmedTime
|
||||
|| decision.completionStatus === "provisional_range_user_stopped"
|
||||
|| publicCanAdopt(decision)
|
||||
|| exhausted
|
||||
|| terminalNoteHostPresent({ caseId: input.caseId, dossier })
|
||||
);
|
||||
return { dossier, decision, satisfied };
|
||||
}
|
||||
@@ -1775,11 +1812,38 @@ export async function ensureNonTerminalTurnExit(input: {
|
||||
decision: before.decision,
|
||||
decisionReceipt: before.dossier.latestResult?.decisionReceipt,
|
||||
});
|
||||
if (repaired.persisted && repaired.focus) {
|
||||
const afterFocus = await inspectNonTerminalTurnExit(input);
|
||||
if (!afterFocus.satisfied && !repaired.hostNarration) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
const hostNarration = (repaired.hostNarration ?? "").trim()
|
||||
|| RECTIFICATION_USER_COPY.noCandidatesGate;
|
||||
try {
|
||||
await persistExhaustionGateTurn({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
hostNarration,
|
||||
resultId: before.dossier.latestResult?.resultId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] persist exhaustion gate during exit repair failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
}
|
||||
const after = await inspectNonTerminalTurnExit(input);
|
||||
if (!after.satisfied && !repaired.terminalNote) {
|
||||
if (!after.satisfied && !hostNarration) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
|
||||
}
|
||||
return repaired;
|
||||
return {
|
||||
...repaired,
|
||||
persisted: true,
|
||||
hostNarration,
|
||||
terminalNote: true,
|
||||
};
|
||||
}
|
||||
|
||||
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
|
||||
|
||||
@@ -1778,6 +1778,15 @@ function holdoutAskFields(
|
||||
};
|
||||
}
|
||||
|
||||
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
|
||||
const next = new Set(declined);
|
||||
if (declinedHealth(declined)) {
|
||||
next.add("health");
|
||||
next.add("health_pressure");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function holdoutFollowupFor(
|
||||
input: {
|
||||
evidence: readonly MethodFollowupEvidence[];
|
||||
@@ -1788,21 +1797,13 @@ export function holdoutFollowupFor(
|
||||
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
|
||||
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
|
||||
const occupied = holdoutOccupiedDomains(input.evidence);
|
||||
const healthDeclined = declinedHealth(declined);
|
||||
const prompt = (input.oosBlindPrompts ?? []).find((item) => {
|
||||
if (!item.domain || occupied.has(item.domain)) return false;
|
||||
if (item.domain === "health" || item.domain === "health_pressure") {
|
||||
return !healthDeclined;
|
||||
}
|
||||
return !declined.has(item.domain);
|
||||
}) ?? null;
|
||||
const reserved = (input.holdoutEvents ?? []).find((item) => {
|
||||
if (item.year == null || occupied.has(item.domain)) return false;
|
||||
if (item.domain === "health" || item.domain === "health_pressure") {
|
||||
return !healthDeclined;
|
||||
}
|
||||
return !declined.has(item.domain);
|
||||
}) ?? null;
|
||||
const blocked = declinedForHoldout(declined);
|
||||
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
|
||||
Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||||
)) ?? null;
|
||||
const reserved = (input.holdoutEvents ?? []).find((item) => (
|
||||
item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||||
)) ?? null;
|
||||
return holdoutAskFields(prompt, reserved);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
|
||||
|
||||
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
|
||||
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
|
||||
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
|
||||
@@ -291,7 +293,7 @@ export type RectificationQuestionGapInput = Readonly<{
|
||||
* The gap between a settled turn and its next question. While retries remain
|
||||
* it is `preparing` (one live row, timed refetches); once they run out, or the
|
||||
* server itself reports the question unavailable, it is `unavailable` with a
|
||||
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
|
||||
* repair button. A snapshot that never arrived (hydration timed out) is a gap
|
||||
* too: the same retries fill it. Questions themselves live inside assistant
|
||||
* messages, so a visible live question means there is no gap.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user