fix(rectification): host-fallback empty evidence turns and choice range narration (BUG-633, BUG-634)

Evidence turns that already recorded a batch no longer fail the whole run when the model emits no text; unchanged ranges now name which clock spans lead or lag, and the timeline no longer says 收窄.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 11:57:07 +08:00
co-authored by Cursor
parent 31f2a722aa
commit 0263658477
28 changed files with 817 additions and 49 deletions
@@ -59,6 +59,12 @@ import {
type PublicStreamEvent,
} from "./stream-mapping";
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
import {
batchResultFromToolChunk,
composeHostFallbackNarration,
lastCompletedPublicTool,
publicWriteToolCompleted,
} from "./host-fallback";
import {
applyStepAnswerChunk,
createStepAnswerState,
@@ -224,8 +230,14 @@ function isRetryableError(errorCode: string): boolean {
return RETRYABLE_ERROR_CODES.has(errorCode);
}
function shouldAutoRetry(errorCode: string, signal?: AbortSignal): boolean {
return !signal?.aborted && isRetryableError(errorCode);
function shouldAutoRetry(
errorCode: string,
signal?: AbortSignal,
status?: AttemptStatus,
): boolean {
if (signal?.aborted) return false;
if (errorCode === "empty_stream") return status === "retryable";
return isRetryableError(errorCode);
}
function clockWindow(range: { start_time?: string | null; end_time?: string | null } | null | undefined): string | null {
@@ -484,7 +496,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
finalOutcome = outcome;
if (outcome.ok
|| outcome.status === "failed"
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal)
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal, outcome.status)
|| attemptNumber === MAX_ATTEMPTS) break;
await emit({ type: "attempt.reset" });
}
@@ -671,12 +683,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let intentClassified = false;
let streamFailed = false;
let finished = false;
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
let answerText = "";
const answerDeltas: string[] = [];
const phases: string[] = [];
const toolsUsed = new Set<string>();
const events: PublicStreamEvent[] = [];
const toolTerminalStatus = new Map<string, "completed" | "failed">();
let batchToolResult: unknown = null;
let hostFallbackUsed = false;
const emittedKeys = new Set<string>();
const emittedActivities = new Set<string>();
const repeatedCalls = new Map<string, number>();
@@ -771,7 +786,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
},
});
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
const stepAnswer = createStepAnswerState();
let spokenRaw = "";
@@ -819,6 +833,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
};
const applyHostFallback = async (): Promise<boolean> => {
if (answerText.trim()) return false;
if (toolTerminalStatus.get("rectification-record-evidence-batch") !== "completed") {
return false;
}
const spoken = composeHostFallbackNarration(batchToolResult ?? {});
if (!spoken) return false;
hostFallbackUsed = true;
await recordPhase("answer.host_fallback");
await publish({ type: "answer.host_fallback" });
await emitVisibleSpoken(spoken);
return true;
};
try {
for await (const chunk of result.fullStream) {
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
@@ -868,6 +896,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
const capturedBatch = batchResultFromToolChunk(chunk as never);
if (capturedBatch != null) batchToolResult = capturedBatch;
const phaseEvent = skipDuplicateToolCallReceipt
? null
: mapStreamChunkToPhase(chunk as never);
@@ -972,14 +1002,36 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
if (!finished) return failedAttempt(attemptId, mapped ?? "stream_unfinished");
if (mapped === "answer_truncated") {
if (!await applyHostFallback()) {
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
} else if (mapped === "max_steps" || mapped === "provider_error") {
if (mapped !== "max_steps" || !await applyHostFallback()) {
return failedAttempt(attemptId, mapped);
}
} else if (!answerText.trim() && !await applyHostFallback()) {
const retryable = !publicWriteToolCompleted(toolTerminalStatus);
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
status: retryable ? "retryable" : "failed",
errorCode: "empty_stream",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
answerText: "",
answerDeltas: [],
phases: [...phases],
toolsUsed: [...toolsUsed],
events,
skillBound,
@@ -987,10 +1039,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
attemptId,
};
}
if (mapped === "max_steps" || mapped === "provider_error") {
return failedAttempt(attemptId, mapped);
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
let latestDossier: V9CaseDossier;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
@@ -1038,6 +1086,21 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
console.info(JSON.stringify({
scope: "RectificationRunDiagnostic",
runId: attemptId,
modelId: options.modelName,
finishReason: finishReason ?? "unknown",
inputTokens: null,
reasoningTokens: null,
outputTokens: null,
stepCount: toolsUsed.size,
toolCallCount: toolsUsed.size,
readCasePayloadBytes: null,
elapsedMs: Date.now() - startedAt,
lastCompletedTool: lastCompletedPublicTool(toolTerminalStatus),
stateMutationCommitted: publicWriteToolCompleted(toolTerminalStatus) || hostFallbackUsed,
}));
}
}
@@ -18,6 +18,7 @@ export const MACHINE_VOICE_LEXICON = [
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
"继续收窄",
"还在收窄",
"范围还在",
"领先",
"落后",
@@ -19,6 +19,7 @@ import {
} from "./choice-card";
import {
explainRangeChange,
explainScoreMovement,
type ClusterScoreDelta,
} from "./probe-explain.ts";
@@ -118,7 +119,12 @@ export function composeChoiceNarration(input: {
}
if (input.appliedInference) {
const range = explainRangeChange(input.credibleBefore, input.credibleAfter);
return `已记录,${range || "范围没变"}。`;
if (range && range !== "范围没变") {
return `已记录,${range}。`;
}
const movement = explainScoreMovement(input.deltasByCluster ?? []);
if (movement) return `已记录,范围没变;${movement}。`;
return "已记录,范围没变。";
}
return "已记录你的选择。";
}
@@ -192,6 +192,11 @@ export function displayDateLabel(
return from || "日期不明";
}
/** Short event phrase from a ledger summary. Does not invent years. */
export function eventPhraseFromSummary(summary: string): string {
return summary.replace(/\s+/g, " ").trim().replace(/[。..!?!?]+$/u, "").slice(0, 24);
}
export function quoteIsGroundedInMessage(
userMessage: string,
quote: string,
@@ -0,0 +1,78 @@
/**
* Host-spoken fallback when the model records evidence but emits no answer.
* Recap text comes only from the batch tool return.
*/
export const HOST_FALLBACK_WRITE_TOOLS = [
"rectification-record-evidence-batch",
"rectification-set-focus",
"rectification-compare-candidates",
] as const;
export type HostFallbackRecap = Readonly<{
display_date_label?: string;
event_phrase?: string;
}>;
export function publicWriteToolCompleted(
status: ReadonlyMap<string, "completed" | "failed">,
): boolean {
return HOST_FALLBACK_WRITE_TOOLS.some((tool) => status.get(tool) === "completed");
}
export function lastCompletedPublicTool(
status: ReadonlyMap<string, "completed" | "failed">,
): string | null {
let last: string | null = null;
for (const [tool, value] of status) {
if (value === "completed") last = tool;
}
return last;
}
function recapLine(item: HostFallbackRecap): string {
const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
return [label, phrase].filter(Boolean).join(" ").trim();
}
function recapsFromBatchResult(value: unknown): HostFallbackRecap[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const row = value as {
accepted_recaps?: unknown;
items?: unknown;
};
if (Array.isArray(row.accepted_recaps)) {
return row.accepted_recaps.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
return [item as HostFallbackRecap];
});
}
if (!Array.isArray(row.items)) return [];
return row.items.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const entry = item as HostFallbackRecap & { outcome?: unknown };
if (entry.outcome && entry.outcome !== "accepted") return [];
if (!entry.display_date_label && !entry.event_phrase) return [];
return [entry];
});
}
export function batchResultFromToolChunk(chunk: {
type?: string;
payload?: { toolName?: unknown; result?: unknown; output?: unknown };
object?: unknown;
}): unknown {
if (chunk.type !== "tool-result") return null;
if (chunk.payload?.toolName !== "rectification-record-evidence-batch") return null;
return chunk.payload?.result ?? chunk.payload?.output ?? chunk.object ?? null;
}
export function composeHostFallbackNarration(batchResult: unknown): string | null {
if (batchResult == null) return null;
const lines = recapsFromBatchResult(batchResult)
.map(recapLine)
.filter(Boolean);
if (lines.length > 0) return `记下了:${lines.join("、")}。`;
return "记下了。";
}
@@ -7,6 +7,7 @@ export const RECTIFICATION_MESSAGE_ORIGINS = [
"voice_input",
"retry_replay",
"system_recovery",
"host_fallback",
] as const;
export type RectificationMessageOrigin = (typeof RECTIFICATION_MESSAGE_ORIGINS)[number];
@@ -211,15 +211,23 @@ export function clusterScoreDeltas(
return [...summed.values()].filter((item) => item.delta !== 0);
}
function clockSpanLabel(range: readonly [string, string]): string {
const start = range[0]?.slice(0, 5) ?? "";
const end = range[1]?.slice(0, 5) ?? "";
if (!CLOCK.test(start)) return "";
if (!CLOCK.test(end) || start === end) return start;
return `${start}–${end}`;
}
export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): string {
if (deltas.length === 0) return "";
const rising = [...deltas].filter((item) => item.delta > 0).sort((a, b) => b.delta - a.delta)[0] ?? null;
const falling = [...deltas].filter((item) => item.delta < 0).sort((a, b) => a.delta - b.delta)[0] ?? null;
const up = rising ? formatClusterRange(rising.range) : "";
const down = falling ? formatClusterRange(falling.range) : "";
if (up && down) return `${up}领先,${down}落后`;
if (up) return `${up}领先`;
if (down) return `${down}落后`;
const up = rising ? clockSpanLabel(rising.range) : "";
const down = falling ? clockSpanLabel(falling.range) : "";
if (up && down) return `${up} 领先,${down} 落后`;
if (up) return `${up} 领先`;
if (down) return `${down} 落后`;
return "";
}
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"candidate.accepted",
"birth_time.confirmed",
"answer.composed",
"answer.host_fallback",
"billing.settled",
"answer.delta",
"activity.changed",