fix: harden long conversation continuity
Memind CI / Test, build, and release guards (push) Successful in 1m28s
Memind CI / Test, build, and release guards (push) Successful in 1m28s
This commit is contained in:
+143
-5
@@ -1737,6 +1737,97 @@ export function createTkmindProxy({
|
||||
return Array.isArray(session?.conversation) ? session.conversation : [];
|
||||
}
|
||||
|
||||
async function compactSessionConversationForUser(
|
||||
userId,
|
||||
sessionId,
|
||||
{
|
||||
messageThreshold = 180,
|
||||
charThreshold = 120_000,
|
||||
} = {},
|
||||
) {
|
||||
if (!userId || !sessionId) {
|
||||
return { compacted: false, reason: 'missing_session', conversation: [] };
|
||||
}
|
||||
const owns = await sessionStore.validateOwnership(userId, sessionId);
|
||||
if (!owns) {
|
||||
return { compacted: false, reason: 'not_owned', conversation: [] };
|
||||
}
|
||||
const target = await resolveTarget(sessionId);
|
||||
const upstream = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
return {
|
||||
compacted: false,
|
||||
reason: 'session_unavailable',
|
||||
status: upstream.status,
|
||||
conversation: [],
|
||||
};
|
||||
}
|
||||
const session = await upstream.json().catch(() => null);
|
||||
const conversation = Array.isArray(session?.conversation) ? session.conversation : [];
|
||||
const messageCount = conversation.length;
|
||||
let charCount = 0;
|
||||
try {
|
||||
charCount = JSON.stringify(conversation).length;
|
||||
} catch {
|
||||
charCount = conversation.reduce(
|
||||
(total, message) => total + String(message?.content ?? '').length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
const normalizedMessageThreshold = Math.max(1, Number(messageThreshold) || 180);
|
||||
const normalizedCharThreshold = Math.max(1, Number(charThreshold) || 120_000);
|
||||
if (
|
||||
messageCount < normalizedMessageThreshold
|
||||
&& charCount < normalizedCharThreshold
|
||||
) {
|
||||
return {
|
||||
compacted: false,
|
||||
reason: 'below_threshold',
|
||||
conversation,
|
||||
messageCount,
|
||||
charCount,
|
||||
};
|
||||
}
|
||||
|
||||
const update = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ conversation: [] }),
|
||||
},
|
||||
);
|
||||
if (!update.ok) {
|
||||
const freshSessionRequired = update.status === 404 || update.status === 405;
|
||||
const error = new Error(
|
||||
freshSessionRequired
|
||||
? '当前 Agent 运行时不支持原地压缩会话,需要迁移到新会话'
|
||||
: `会话上下文压缩失败 (${update.status})`,
|
||||
);
|
||||
error.code = freshSessionRequired
|
||||
? 'SESSION_CONTEXT_FRESH_SESSION_REQUIRED'
|
||||
: 'SESSION_CONTEXT_COMPACTION_FAILED';
|
||||
error.retryable = false;
|
||||
error.status = update.status;
|
||||
error.conversation = conversation;
|
||||
error.messageCount = messageCount;
|
||||
error.charCount = charCount;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
compacted: true,
|
||||
conversation,
|
||||
messageCount,
|
||||
charCount,
|
||||
status: update.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function repairSessionToolHistory(sessionId) {
|
||||
if (!sessionId) return { changed: false, updated: false };
|
||||
const target = await resolveTarget(sessionId);
|
||||
@@ -1980,6 +2071,12 @@ export function createTkmindProxy({
|
||||
const finishPromise = consumeSessionEventsUntilFinish(eventsResponse.body, {
|
||||
requestId,
|
||||
timeoutMs,
|
||||
// A brand-new Goose session can replay an initial unscoped Finish before
|
||||
// the POST /reply request starts. Require activity for this request before
|
||||
// accepting an unscoped Finish so a zero-token stale frame cannot mark the
|
||||
// agent run complete.
|
||||
requireRequestActivityBeforeUnscopedFinish: true,
|
||||
emptyFinishGraceMs: 1_500,
|
||||
});
|
||||
// Prevent unhandled rejection from killing the Portal process when reply
|
||||
// setup fails before we await Finish (e.g. provider Error frames arrive later).
|
||||
@@ -2003,7 +2100,38 @@ export function createTkmindProxy({
|
||||
throw new Error(text || `发送失败 (${replyResponse.status})`);
|
||||
}
|
||||
replyResponse.body?.cancel?.().catch?.(() => {});
|
||||
const finishResult = await trackedFinish;
|
||||
let finishResult = await trackedFinish;
|
||||
if (!finishResult.ok && finishResult.error?.code === 'SESSION_EMPTY_FINISH') {
|
||||
eventsResponse.body?.cancel?.().catch?.(() => {});
|
||||
const retryEventsResponse = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/events`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
if (!retryEventsResponse.ok || !retryEventsResponse.body) {
|
||||
const text = await retryEventsResponse.text().catch(() => '');
|
||||
throw new Error(
|
||||
text || `无法重新建立 session 事件流 (${retryEventsResponse.status})`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const retriedFinish = await consumeSessionEventsUntilFinish(
|
||||
retryEventsResponse.body,
|
||||
{
|
||||
requestId,
|
||||
timeoutMs: Math.max(1, timeoutMs - 1_500),
|
||||
requireRequestActivityBeforeUnscopedFinish: true,
|
||||
},
|
||||
);
|
||||
finishResult = { ok: true, value: retriedFinish };
|
||||
} finally {
|
||||
retryEventsResponse.body?.cancel?.().catch?.(() => {});
|
||||
}
|
||||
}
|
||||
if (!finishResult.ok) throw finishResult.error;
|
||||
const finish = finishResult.value;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
|
||||
@@ -2494,6 +2622,19 @@ export function createTkmindProxy({
|
||||
|
||||
const billingTransform = createSseBillingTransform({
|
||||
onFinish: async (event) => {
|
||||
// Page delivery finalization must not depend on billing. The Agent Run
|
||||
// may already have billed this request, and a transient/idempotency
|
||||
// failure here must not leave its HTML contract stuck in `preparing`.
|
||||
if (typeof onAfterFinish === 'function') {
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
onAfterFinish(
|
||||
sessionId,
|
||||
req.currentUser.id,
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, event.token_state);
|
||||
const result = await userAuth.billSessionUsage(
|
||||
@@ -2513,10 +2654,6 @@ export function createTkmindProxy({
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fire-and-forget snapshot refresh after conversation finishes.
|
||||
if (typeof onAfterFinish === 'function') {
|
||||
void Promise.resolve().then(() => onAfterFinish(sessionId, req.currentUser.id)).catch(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2749,6 +2886,7 @@ export function createTkmindProxy({
|
||||
resolveTarget,
|
||||
startSessionForUser,
|
||||
fetchSessionConversationForUser,
|
||||
compactSessionConversationForUser,
|
||||
getRuntimeStatus,
|
||||
submitSessionReplyForUser,
|
||||
submitSessionReplyAndAwaitFinishForUser,
|
||||
|
||||
Reference in New Issue
Block a user