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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user