fix: harden long conversation continuity
Memind CI / Test, build, and release guards (push) Successful in 1m28s

This commit is contained in:
john
2026-07-27 21:39:34 +08:00
parent 5ae166fc44
commit 48c61a3279
16 changed files with 1635 additions and 94 deletions
+150 -18
View File
@@ -154,6 +154,14 @@ function pageDataFailureCheckId(code) {
}
}
function hasPageMutationToolEvidence(toolEvidence) {
if (!toolEvidence || !Array.isArray(toolEvidence.calls)) return null;
return toolEvidence.calls.some((name) =>
/(?:^|[-_:])(edit|write|publish|workspace|page[-_]?data)(?:[-_:]|$)/i.test(
String(name ?? ''),
));
}
function buildFreshSessionContext(conversation, { maxMessages = 16, maxChars = 12_000 } = {}) {
const visible = [];
for (const message of Array.isArray(conversation) ? conversation : []) {
@@ -219,6 +227,7 @@ const FRESH_SESSION_RECOVERY_CODES = new Set([
'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED',
'SESSION_REASONING_CONTENT_POISONED',
'SESSION_VISUAL_CONTEXT_UNSUPPORTED',
'SESSION_EMPTY_FINISH',
]);
async function resolveConversationForFreshSessionRecovery({
@@ -231,14 +240,20 @@ async function resolveConversationForFreshSessionRecovery({
if (err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED') {
return Array.isArray(err?.repairedConversation) ? err.repairedConversation : [];
}
let gooseConversation = [];
if (typeof tkmindProxy?.fetchSessionConversationForUser === 'function') {
const conversation = await tkmindProxy.fetchSessionConversationForUser(
gooseConversation = await tkmindProxy.fetchSessionConversationForUser(
userId,
previousSessionId,
).catch(() => []);
if (conversation.length > 0) return conversation;
}
return loadSnapshotMessages(sessionSnapshotService, previousSessionId);
const snapshotConversation = await loadSnapshotMessages(
sessionSnapshotService,
previousSessionId,
);
return snapshotConversation.length > gooseConversation.length
? snapshotConversation
: gooseConversation;
}
function resolveRequiredImageGeneration(row, routing) {
@@ -597,6 +612,14 @@ export function createAgentRunGateway({
process.env.MEMIND_PAGE_DATA_SUGGESTION_REPAIR_ENABLED,
true,
),
sessionCompactMessageCount = positiveInteger(
process.env.MEMIND_AGENT_SESSION_COMPACT_MESSAGE_COUNT,
180,
),
sessionCompactCharCount = positiveInteger(
process.env.MEMIND_AGENT_SESSION_COMPACT_CHAR_COUNT,
120_000,
),
workerIdentity = null,
}) {
const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {});
@@ -829,7 +852,10 @@ export function createAgentRunGateway({
if (!normalizedSessionId || isDirectChatSessionId(normalizedSessionId)) {
return { triggered: false, reason: 'no_session' };
}
if (!tkmindProxy?.submitSessionReplyForUser) {
if (
!tkmindProxy?.submitSessionReplyForUser
&& !tkmindProxy?.submitSessionReplyAndAwaitFinishForUser
) {
return { triggered: false, reason: 'no_proxy' };
}
if (suggestionRepairTriggered.has(runId)) {
@@ -852,17 +878,32 @@ export function createAgentRunGateway({
},
};
try {
await tkmindProxy.submitSessionReplyForUser(
userId,
normalizedSessionId,
requestId,
userMessage,
);
await appendEvent(runId, 'page_data_suggestion_repair_triggered', {
code: String(code ?? '').slice(0, 128),
requestId,
sessionId: normalizedSessionId,
});
if (typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function') {
const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
userId,
normalizedSessionId,
requestId,
userMessage,
{ timeoutMs: Math.min(runTimeoutMs, 120_000) },
);
await appendEvent(runId, 'page_data_suggestion_repair_completed', {
requestId,
sessionId: normalizedSessionId,
toolCalls: finish?.toolEvidence?.calls ?? [],
});
} else {
await tkmindProxy.submitSessionReplyForUser(
userId,
normalizedSessionId,
requestId,
userMessage,
);
}
return { triggered: true, requestId };
} catch (err) {
await appendEvent(runId, 'page_data_suggestion_repair_failed', {
@@ -1648,6 +1689,81 @@ export function createAgentRunGateway({
saved: transcriptPersisted.saved,
});
}
if (typeof tkmindProxy.compactSessionConversationForUser === 'function') {
try {
const compaction = await tkmindProxy.compactSessionConversationForUser(
row.user_id,
sessionId,
{
messageThreshold: sessionCompactMessageCount,
charThreshold: sessionCompactCharCount,
},
);
if (compaction?.compacted) {
userMessage = appendFreshSessionContext(
userMessage,
compaction.conversation,
);
await appendEvent(runId, 'session_context_compacted', {
sessionId,
messageCount: compaction.messageCount ?? null,
charCount: compaction.charCount ?? null,
clientMessageCount: runOptions.sessionMessageCount,
retainedContextMessages: 16,
retainedContextChars: 12_000,
});
}
} catch (err) {
if (
err?.code === 'SESSION_CONTEXT_FRESH_SESSION_REQUIRED'
&& typeof tkmindProxy.startSessionForUser === 'function'
) {
const previousSessionId = sessionId;
const conversationForContext = Array.isArray(err?.conversation)
? err.conversation
: await resolveConversationForFreshSessionRecovery({
err,
previousSessionId,
userId: row.user_id,
tkmindProxy,
sessionSnapshotService,
});
const replacement = await tkmindProxy.startSessionForUser(row.user_id);
sessionId = replacement.id;
await pool.query(
`UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
[sessionId, nowMs(), runId],
);
const persisted = await persistSessionTranscriptMessages({
conversationMemoryService,
sessionId,
userId: row.user_id,
messages: conversationForContext,
});
userMessage = appendFreshSessionContext(
userMessage,
conversationForContext,
);
await appendEvent(runId, 'session_context_rotated', {
previousSessionId,
sessionId,
reason: err.code,
messageCount: err?.messageCount ?? conversationForContext.length,
charCount: err?.charCount ?? null,
persistedMessageCount: persisted.saved,
retainedContextMessages: 16,
retainedContextChars: 12_000,
});
await appendRunSnapshot(runId);
} else {
await appendEvent(runId, 'session_context_compaction_failed', {
sessionId,
code: String(err?.code ?? '').slice(0, 128),
message: String(err instanceof Error ? err.message : err).slice(0, 500),
}).catch(() => {});
}
}
}
await invalidatePortalDirectChatSnapshot(sessionId);
let toolEvidence = null;
const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true)
@@ -1833,6 +1949,18 @@ export function createAgentRunGateway({
pageGenerationIntent
&& !isGenericPageGenerationRequest(runDisplayText)
));
if (requiresPageDeliverable && hasPageMutationToolEvidence(toolEvidence) === false) {
const error = new Error(
pageDataIntent
? 'Page Data 任务未执行页面修改工具,不能用历史页面标记成功'
: '页面任务未执行写入或发布工具,不能用历史页面标记成功',
);
error.code = pageDataIntent
? 'PAGE_DATA_DELIVERABLE_MISSING'
: 'PUBLIC_PAGE_DELIVERABLE_MISSING';
error.retryable = false;
throw error;
}
let deliverables = null;
if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') {
const latest = await getRunById(runId);
@@ -2277,14 +2405,18 @@ export function createAgentRunGateway({
new Error(`agent run stale after ${ageMs}ms`),
{ code: 'AGENT_RUN_STALE_RECOVERY' },
);
const deliverableRecovered = await recoverRunFromDeliverables({
runId: row.id,
userId: row.user_id,
sessionId: row.agent_session_id ?? null,
err: staleError,
runStartedAtMs: row.started_at ?? null,
requireRecoverableError: false,
});
// A synchronized historical page can receive fresh database timestamps
// without this run having executed any model or file tool. Only recover
// a stale run as successful after its own session Finish was observed.
const deliverableRecovered = sessionFinishedAt != null
&& await recoverRunFromDeliverables({
runId: row.id,
userId: row.user_id,
sessionId: row.agent_session_id ?? null,
err: staleError,
runStartedAtMs: row.started_at ?? null,
requireRecoverableError: false,
});
if (deliverableRecovered) {
const marked = await markRun(row.id, 'succeeded', {
agent_session_id: row.agent_session_id ?? null,