fix(recall): reuse reroll recall from host generation type

This commit is contained in:
youzini
2026-05-31 20:22:13 +00:00
parent 0891ce3019
commit 8cfeae0461
9 changed files with 43 additions and 234 deletions

View File

@@ -156,10 +156,6 @@ import {
resolvePersistenceChatIdCore,
resolveRuntimeGraphFallbackIdentityCore,
} from "./runtime/identity-resolver.js";
import {
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
} from "./runtime/reroll-transaction-boundary.js";
import { createRecallInputState } from "./runtime/recall-input-state.js";
import { createRerollRecallInput } from "./runtime/reroll-recall-input.js";
import { createGenerationContextTracker } from "./runtime/generation-context.js";
@@ -1666,8 +1662,6 @@ const rerollRecallInput = createRerollRecallInput({
clearPendingHostGenerationInputSnapshot(...args),
clearPendingRecallSendIntent: (...args) => clearPendingRecallSendIntent(...args),
console,
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
createTrivialRecallSkipSentinel: (...args) =>
createTrivialRecallSkipSentinel(...args),
findLatestUserChatMessageWithIndex: (...args) =>
@@ -14581,22 +14575,10 @@ function getLastNonSystemChatMessage(chat) {
return null;
}
function getPendingRerollRecallReuse() {
return rerollRecallInput.getPendingRerollRecallReuse();
}
function clearPendingRerollRecallReuse(reason = "") {
return rerollRecallInput.clearPendingRerollRecallReuse(reason);
}
function prepareRerollRecallReuse({ fromFloor = null, meta = null } = {}) {
return rerollRecallInput.prepareRerollRecallReuse({ fromFloor, meta });
}
function consumePendingRerollRecallReuse(chat = getContext()?.chat) {
return rerollRecallInput.consumePendingRerollRecallReuse(chat);
}
function buildRecallRecentMessages(chat, limit, syntheticUserMessage = "") {
return buildRecallRecentMessagesController(
chat,

View File

@@ -15,7 +15,6 @@
"test:graph-snapshot-upgrade": "node tests/graph-snapshot-upgrade.mjs",
"test:snapshot-forward-compat": "node tests/snapshot-forward-compat.mjs",
"test:luker-snapshot-forward-compat": "node tests/luker-snapshot-forward-compat.mjs",
"test:reroll-transaction-boundary": "node tests/reroll-transaction-boundary.mjs",
"test:vector-gate": "node tests/vector-gate.mjs",
"test:hide-engine": "node tests/hide-engine.mjs",
"test:maintenance-journal": "node tests/maintenance-journal.mjs",

View File

@@ -118,7 +118,14 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
"planner-handoff",
]);
const isActiveInputSource = activeInputSources.has(recallSource);
if (isActiveInputSource) {
const noNewUserGenerationTypes = new Set([
"swipe",
"regenerate",
"continue",
"history",
]);
const isNoNewUserGeneration = noNewUserGenerationTypes.has(generationType);
if (isActiveInputSource && !isNoNewUserGeneration) {
return null;
}
@@ -203,7 +210,7 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
"persisted-user-floor",
]);
const canTrustUserFloorRecord = Boolean(
!isActiveInputSource &&
(!isActiveInputSource || isNoNewUserGeneration) &&
!boundUserFloorText &&
(generationType !== "normal" || userFloorSources.has(recallSource)),
);

View File

@@ -134,10 +134,15 @@ export function bumpPersistedRecallGenerationCount(chat, userMessageIndex) {
export function resolveGenerationTargetUserMessageIndex(
chat,
{ generationType = "normal" } = {},
{ generationType = "normal", generationContext = null } = {},
) {
if (!Array.isArray(chat) || chat.length === 0) return null;
return resolveGenerationParentUserFloor(chat, { type: generationType });
return resolveGenerationParentUserFloor(
chat,
generationContext && typeof generationContext === "object"
? { ...generationContext, type: generationContext.type || generationType }
: { type: generationType },
);
}
export function resolveFinalRecallInjectionSource({

View File

@@ -1,8 +1,6 @@
export function createRerollRecallInput(deps = {}) {
let pendingRerollRecallReuse = null;
const plannerRecallHandoffs = new Map();
const getContext = (...args) => deps.getContext?.(...args);
const getCurrentChatId = (...args) => deps.getCurrentChatId?.(...args);
const normalizeChatIdCandidate = (value = "") =>
deps.normalizeChatIdCandidate?.(value) ?? String(value ?? "").trim();
@@ -13,94 +11,13 @@ export function createRerollRecallInput(deps = {}) {
deps.getLastRecallSentUserMessage?.() || {};
const getPendingRecallSendIntent = () =>
deps.getPendingRecallSendIntent?.() || {};
const getGenerationRecallTransactionTtlMs = () =>
Number.isFinite(Number(deps.GENERATION_RECALL_TRANSACTION_TTL_MS))
? Number(deps.GENERATION_RECALL_TRANSACTION_TTL_MS)
: 60000;
const getPlannerRecallHandoffTtlMs = () =>
Number.isFinite(Number(deps.PLANNER_RECALL_HANDOFF_TTL_MS))
? Number(deps.PLANNER_RECALL_HANDOFF_TTL_MS)
: 60000;
function getPendingRerollRecallReuse() {
return pendingRerollRecallReuse;
}
function clearPendingRerollRecallReuse(reason = "") {
const previous = pendingRerollRecallReuse;
pendingRerollRecallReuse = null;
return previous;
}
function prepareRerollRecallReuse({ fromFloor = null, meta = null } = {}) {
const context = getContext();
const chat = context?.chat;
if (!Array.isArray(chat) || chat.length === 0) {
pendingRerollRecallReuse = null;
return null;
}
const latestUser = deps.findLatestUserChatMessageWithIndex(chat);
const targetUserMessageIndex = Number.isFinite(latestUser?.index)
? latestUser.index
: null;
if (!Number.isFinite(targetUserMessageIndex)) {
pendingRerollRecallReuse = null;
return null;
}
const userMessage = chat[targetUserMessageIndex];
const userText = normalizeRecallInputText(userMessage?.mes || "");
if (!userText) {
pendingRerollRecallReuse = null;
return null;
}
const persistedRecord = deps.readPersistedRecallFromUserMessage(
chat,
targetUserMessageIndex,
);
const chatId = normalizeChatIdCandidate(getCurrentChatId(context));
const prepared = deps.createRerollRecallReuseMarker({
chatId,
fromFloor,
targetUserMessageIndex,
userText,
persistedRecord,
hashRecallInput,
now: Date.now(),
meta,
});
if (!prepared.marker) {
pendingRerollRecallReuse = null;
return null;
}
pendingRerollRecallReuse = prepared.marker;
return pendingRerollRecallReuse;
}
function consumePendingRerollRecallReuse(chat = getContext()?.chat) {
const reuse = pendingRerollRecallReuse;
if (!reuse) return null;
const activeChatId = normalizeChatIdCandidate(getCurrentChatId());
const latestUser = deps.findLatestUserChatMessageWithIndex(chat);
const targetUserMessageIndex = Number.isFinite(latestUser?.index)
? latestUser.index
: reuse.targetUserMessageIndex;
const userText = normalizeRecallInputText(chat?.[targetUserMessageIndex]?.mes || "");
const consumed = deps.consumeRerollRecallReuseMarker({
marker: reuse,
activeChatId,
latestUserMessageIndex: targetUserMessageIndex,
currentUserText: userText,
hashRecallInput,
now: Date.now(),
ttlMs: getGenerationRecallTransactionTtlMs(),
});
pendingRerollRecallReuse = consumed.marker;
return consumed.consumed ? consumed.override : null;
return null;
}
function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) {
@@ -115,6 +32,7 @@ export function createRerollRecallInput(deps = {}) {
const targetUserMessageIndex = deps.resolveGenerationTargetUserMessageIndex(chat, {
generationType,
generationContext: params?.generationContext,
});
// 对于 history 类型continue/regenerate/swipe必须依赖 chat 中的用户消息
@@ -125,7 +43,10 @@ export function createRerollRecallInput(deps = {}) {
targetUserMessageIndex: null,
};
}
const historyInput = buildHistoryGenerationRecallInput(chat);
const historyInput = buildHistoryGenerationRecallInput(chat, {
generationType,
generationContext: params?.generationContext,
});
if (!historyInput) {
return {
generationType,
@@ -157,11 +78,6 @@ export function createRerollRecallInput(deps = {}) {
}
function buildNormalGenerationRecallInput(chat, options = {}) {
const rerollReuse = consumePendingRerollRecallReuse(chat);
if (rerollReuse) {
return rerollReuse;
}
const lastNonSystemMessage = deps.getLastNonSystemChatMessage(chat);
const tailUserText = lastNonSystemMessage?.is_user
? normalizeRecallInputText(lastNonSystemMessage?.mes || "")
@@ -294,19 +210,24 @@ export function createRerollRecallInput(deps = {}) {
};
}
function buildHistoryGenerationRecallInput(chat) {
function buildHistoryGenerationRecallInput(chat, options = {}) {
const generationType = String(options?.generationType || "history").trim() || "history";
const lastRecallSentUserMessage = getLastRecallSentUserMessage();
const targetUserMessageIndex = deps.resolveGenerationTargetUserMessageIndex(chat, {
generationType,
generationContext: options?.generationContext,
});
const targetUserText = Number.isFinite(targetUserMessageIndex)
? normalizeRecallInputText(chat?.[targetUserMessageIndex]?.mes || "")
: "";
const latestUserText = normalizeRecallInputText(
deps.getLatestUserChatMessage(chat)?.mes || lastRecallSentUserMessage.text,
targetUserText || deps.getLatestUserChatMessage(chat)?.mes || lastRecallSentUserMessage.text,
);
if (!latestUserText) return null;
const targetUserMessageIndex = deps.resolveGenerationTargetUserMessageIndex(chat, {
generationType: "history",
});
return {
overrideUserMessage: latestUserText,
generationType: "history",
generationType,
targetUserMessageIndex,
overrideSource: Number.isFinite(targetUserMessageIndex)
? "chat-last-user"
@@ -429,10 +350,7 @@ export function createRerollRecallInput(deps = {}) {
}
return {
prepareRerollRecallReuse,
getPendingRerollRecallReuse,
clearPendingRerollRecallReuse,
consumePendingRerollRecallReuse,
buildNormalGenerationRecallInput,
buildHistoryGenerationRecallInput,
buildGenerationAfterCommandsRecallInput,

View File

@@ -201,10 +201,6 @@ import {
syncGraphLoadFromLiveContextImpl,
writeAuthorityCheckpointFromCurrentGraphImpl,
} from "../sync/graph-load-persist.js";
import {
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
} from "../runtime/reroll-transaction-boundary.js";
function isAuthorityVectorConfig(config = null) {
return config?.mode === "authority" || config?.source === "authority-trivium";
@@ -892,8 +888,6 @@ async function createGraphPersistenceHarness({
shouldUseAuthorityJobsImpl,
syncGraphLoadFromLiveContextImpl,
writeAuthorityCheckpointFromCurrentGraphImpl,
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
createRecallMessageUiController() {
return {
refreshPersistedRecallMessageUi: () => ({

View File

@@ -34,10 +34,6 @@ import {
shouldRunRecallForTransaction,
} from "../../ui/ui-status.js";
import { defaultSettings, mergePersistedSettings } from "../../runtime/settings-defaults.js";
import {
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
} from "../../runtime/reroll-transaction-boundary.js";
import { createRecallInputState } from "../../runtime/recall-input-state.js";
import { createRerollRecallInput } from "../../runtime/reroll-recall-input.js";
import { createGenerationRecallTransactions } from "../../runtime/generation-recall-transactions.js";
@@ -487,8 +483,6 @@ export async function createGenerationRecallHarness(options = {}) {
clearPendingRecallSendIntent: (...args) =>
recallInputState.clearPendingRecallSendIntent(...args),
console,
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
createTrivialRecallSkipSentinel,
findLatestUserChatMessageWithIndex,
formatInjection,
@@ -840,10 +834,6 @@ export async function createGenerationRecallHarness(options = {}) {
recallInputState.getPendingHostGenerationInputSnapshot(...args),
clearPendingHostGenerationInputSnapshot: (...args) =>
recallInputState.clearPendingHostGenerationInputSnapshot(...args),
prepareRerollRecallReuse: (...args) =>
rerollRecallInput.prepareRerollRecallReuse(...args),
getPendingRerollRecallReuse: (...args) =>
rerollRecallInput.getPendingRerollRecallReuse(...args),
clearPendingRerollRecallReuse,
recordRecallSendIntent: (...args) =>
recallInputState.recordRecallSendIntent(...args),

View File

@@ -534,11 +534,6 @@ writePersistedRecallToUserMessage(
}),
);
const preparedRerollReuse = rerollInputHarness.result.prepareRerollRecallReuse({
fromFloor: 1,
});
assert.ok(preparedRerollReuse, "assistant-only reroll should prepare recall reuse marker");
rerollInputHarness.result.recordRecallSendIntent(
"错误的主动输入不应覆盖 reroll 用户楼",
"send-intent",
@@ -548,11 +543,16 @@ rerollInputHarness.result.freezeHostGenerationInputSnapshot(
"host-generation-lifecycle",
);
const rerollReplacementInput = rerollInputHarness.result.buildNormalGenerationRecallInput(
rerollInputHarness.chat,
const rerollReplacementInput = rerollInputHarness.result.buildGenerationAfterCommandsRecallInput(
"swipe",
{
frozenInputSnapshot: rerollInputHarness.result.getPendingHostGenerationInputSnapshot(),
generationContext: {
type: "swipe",
kind: "no-new-user",
swipedAssistantFloor: 1,
},
},
rerollInputHarness.chat,
);
assert.equal(
rerollReplacementInput.overrideUserMessage,
@@ -560,14 +560,9 @@ assert.equal(
"reroll replacement should ignore stale live input sources and bind to stable user floor",
);
assert.equal(rerollReplacementInput.overrideSource, "chat-last-user");
assert.equal(rerollReplacementInput.overrideReason, "reroll-user-floor-reuse");
assert.equal(
rerollInputHarness.result.getPendingRerollRecallReuse(),
null,
"reroll reuse marker should be one-shot after binding recall input",
);
assert.equal(rerollReplacementInput.generationType, "swipe");
console.log(" ✓ reroll replacement normal input is forced to stable user-floor recall source");
console.log(" ✓ reroll replacement input is forced by host type to stable user-floor recall source");
const legacyUnboundReuseChat = [
{ is_user: true, mes: "旧记录没有绑定楼层" },

View File

@@ -1,81 +0,0 @@
// ST-BME restrained rebirth — Phase 4 reroll transaction boundary tests.
import assert from "node:assert/strict";
import {
consumeRerollRecallReuseMarker,
createRerollRecallReuseMarker,
} from "../runtime/reroll-transaction-boundary.js";
const hashRecallInput = (text) => `h:${String(text || "").trim()}`;
const prepared = createRerollRecallReuseMarker({
chatId: "chat-a",
fromFloor: 4,
targetUserMessageIndex: 2,
userText: " hello\n",
persistedRecord: {
injectionText: "memory",
boundUserFloorText: "hello",
},
hashRecallInput,
now: 1000,
});
assert.equal(prepared.reason, "prepared");
assert.equal(prepared.marker.chatId, "chat-a");
assert.equal(prepared.marker.fromFloor, 4);
assert.equal(prepared.marker.targetUserMessageIndex, 2);
assert.equal(prepared.marker.userHash, "h:hello");
const consumed = consumeRerollRecallReuseMarker({
marker: prepared.marker,
activeChatId: "chat-a",
latestUserMessageIndex: 2,
currentUserText: "hello",
hashRecallInput,
now: 1500,
ttlMs: 5000,
});
assert.equal(consumed.consumed, true);
assert.equal(consumed.override.rerollRecallReuse, true);
assert.equal(consumed.override.targetUserMessageIndex, 2);
console.log(" ✓ reroll recall reuse marker is one-shot and floor-bound");
assert.equal(
createRerollRecallReuseMarker({
userText: "changed",
persistedRecord: { injectionText: "memory", boundUserFloorText: "original" },
}).reason,
"bound-user-floor-mismatch",
);
assert.equal(
createRerollRecallReuseMarker({
userText: "hello",
persistedRecord: { injectionText: "" },
}).reason,
"missing-persisted-recall",
);
for (const [caseName, options, reason] of [
["chat", { activeChatId: "other-chat" }, "chat-mismatch"],
["ttl", { now: 7001, ttlMs: 5000 }, "expired"],
["floor", { latestUserMessageIndex: 3 }, "target-user-floor-changed"],
["text", { currentUserText: "changed" }, "user-text-changed"],
]) {
const result = consumeRerollRecallReuseMarker({
marker: prepared.marker,
activeChatId: "chat-a",
latestUserMessageIndex: 2,
currentUserText: "hello",
hashRecallInput,
now: 1500,
ttlMs: 5000,
...options,
});
assert.equal(result.consumed, false, `${caseName} mismatch must reject reuse`);
assert.equal(result.reason, reason);
assert.equal(result.marker, null, `${caseName} mismatch must clear marker`);
}
console.log(" ✓ reroll marker rejects stale, cross-chat, changed-floor, changed-text reuse");
console.log("reroll-transaction-boundary tests passed");