Files
Jyotisha/frontend/src/lib/session-context-summary.ts
T
Jesse_ChenandCursor bf8ad0d1ff fix(web): keep consultation conclusions across turns and surface cache hits (BUG-555, BUG-556)
Session history was silently clipped to the first 4000 characters of the last 12 messages, so follow-ups could not see timing or audit tables. Keep an append-only tail plus a checkpoint summary, retry overflow in the same request, and expose cache hit rate in admin usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 15:16:18 +08:00

266 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Agent } from "@mastra/core/agent";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
lastConsultationPair,
parseSessionContextSummary,
storedConsultationTurns,
type SessionContextSummaryV1,
} from "@/lib/consultation-session-history";
import type { ResolvedLanguageModel } from "@/mastra/model";
export const SESSION_CONTEXT_SUMMARY_TIMEOUT_MS = 15_000;
export const SESSION_CONTEXT_SUMMARY_MAX_HAN = 800;
export const SESSION_CONTEXT_SUMMARY_MAX_TOKENS = 600;
export const SESSION_CONTEXT_SUMMARY_INSTRUCTIONS = `你在维护咨询会话的滚动摘要,只供下一轮模型使用。
只根据给定的问答文本写作,不要发明没出现过的事实。
输出不超过 800 个汉字,必须使用下面四个标题,每个标题下用短句:
已问过的问题
已给出的结论(含应期、置信度、blocked 项)
用户补充的事实
未决与待追问
不要写出生日期、出生时间、出生地、姓名、邮箱。`;
type DisposableAbort = Readonly<{
signal: AbortSignal;
dispose: () => void;
}>;
function composedAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): DisposableAbort {
const controller = new AbortController();
// Must stay ref'd. The platform timeout helper uses an unref timer (BUG-523).
const timeoutId = globalThis.setTimeout(() => {
if (!controller.signal.aborted) {
controller.abort(new DOMException("session context summary timed out", "TimeoutError"));
}
}, timeoutMs);
const onExternalAbort = () => {
if (!controller.signal.aborted) {
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
}
};
if (signal) {
if (signal.aborted) onExternalAbort();
else signal.addEventListener("abort", onExternalAbort);
}
return {
signal: controller.signal,
dispose: () => {
globalThis.clearTimeout(timeoutId);
signal?.removeEventListener("abort", onExternalAbort);
},
};
}
function whenAborted(signal: AbortSignal): { promise: Promise<never>; dispose: () => void } {
let onAbort: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
const fail = () => {
reject(signal.reason ?? new Error("aborted"));
};
if (signal.aborted) {
fail();
return;
}
onAbort = fail;
signal.addEventListener("abort", fail, { once: true });
});
return {
promise,
dispose: () => {
if (onAbort) signal.removeEventListener("abort", onAbort);
},
};
}
function clipHan(value: string, maxChars: number): string {
const characters = Array.from(value);
return characters.length > maxChars ? characters.slice(0, maxChars).join("") : value;
}
const ISO_DATE = /\d{4}-\d{2}-\d{2}/g;
const CLOCK_TIME = /\b\d{1,2}:\d{2}(?::\d{2})?\b/g;
const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
export function sanitizeSessionContextSummary(raw: string): string | null {
const stripped = raw
.replace(EMAIL, "")
.replace(ISO_DATE, "")
.replace(CLOCK_TIME, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!stripped) return null;
return clipHan(stripped, SESSION_CONTEXT_SUMMARY_MAX_HAN);
}
export function tailCharCount(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): number {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
? turns.filter((turn) => turn.index > summary.throughMessageIndex)
: turns;
return tail.reduce((sum, turn) => sum + turn.text.length, 0);
}
export function shouldCheckpoint(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): boolean {
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
}
export function messagesForSummaryInput(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
): Array<{ role: "user" | "assistant"; text: string; index: number; requestId: string | null }> {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
? turns.filter((turn) => turn.index > summary.throughMessageIndex)
: turns;
const lastPair = lastConsultationPair(tail);
const pairStart = lastPair[0];
if (!pairStart) return [];
return tail.filter((turn) => turn.index < pairStart.index);
}
export function buildSummaryPrompt(
previous: SessionContextSummaryV1 | null,
messages: unknown,
options: { excludeRequestId?: string } = {},
): string {
const input = messagesForSummaryInput(messages, previous, options);
const lines = input.map((turn) => `${turn.role === "user" ? "用户" : "助手"}${turn.text}`);
return [
previous?.text.trim() ? `上一份摘要:\n${previous.text.trim()}` : "上一份摘要:无",
"需要并入摘要的问答(不含最后一对):",
lines.join("\n") || "(无)",
].join("\n\n");
}
export async function generateSessionContextSummaryText(
model: ResolvedLanguageModel,
prompt: string,
signal?: AbortSignal,
): Promise<string> {
const agent = new Agent({
id: `session-context-summary-${model.id}`,
name: "Session Context Summary",
model: model.model,
instructions: SESSION_CONTEXT_SUMMARY_INSTRUCTIONS,
});
const result = await agent.generate([{ role: "user", content: prompt }], {
abortSignal: signal,
modelSettings: { maxOutputTokens: SESSION_CONTEXT_SUMMARY_MAX_TOKENS },
});
return typeof result.text === "string" ? result.text : "";
}
export async function generateSessionContextSummary(input: {
model?: ResolvedLanguageModel | null;
previous: SessionContextSummaryV1 | null;
messages: unknown;
excludeRequestId?: string;
signal?: AbortSignal;
timeoutMs?: number;
generateText?: (prompt: string, signal?: AbortSignal) => Promise<string>;
}): Promise<string | null> {
const prompt = buildSummaryPrompt(input.previous, input.messages, {
excludeRequestId: input.excludeRequestId,
});
const generate = input.generateText ?? (input.model
? (nextPrompt: string, signal?: AbortSignal) => generateSessionContextSummaryText(
input.model as ResolvedLanguageModel,
nextPrompt,
signal,
)
: null);
if (!generate) return null;
const composed = composedAbortSignal(input.signal, input.timeoutMs ?? SESSION_CONTEXT_SUMMARY_TIMEOUT_MS);
const aborted = whenAborted(composed.signal);
try {
const raw = await Promise.race([generate(prompt, composed.signal), aborted.promise]);
return sanitizeSessionContextSummary(raw);
} catch {
return null;
} finally {
aborted.dispose();
composed.dispose();
}
}
export function nextSessionContextSummary(
messages: unknown,
previous: SessionContextSummaryV1 | null,
text: string,
updatedAt: string,
options: { excludeRequestId?: string } = {},
): SessionContextSummaryV1 | null {
const covered = messagesForSummaryInput(messages, previous, options);
const last = covered.at(-1);
if (!last) return null;
return {
version: 1,
text,
throughRequestId: last.requestId ?? previous?.throughRequestId ?? "",
throughMessageIndex: last.index,
messageCount: last.index + 1,
updatedAt,
};
}
export async function writeSessionContextSummary(input: {
seenUpdatedAt: string | null;
summary: SessionContextSummaryV1;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
}): Promise<"written" | "abandoned"> {
const written = await input.update(input.summary, input.seenUpdatedAt);
return written ? "written" : "abandoned";
}
export async function checkpointSessionContextSummary(input: {
messages: unknown;
summary: unknown;
excludeRequestId?: string;
now?: () => Date;
generateText: (prompt: string, signal?: AbortSignal) => Promise<string>;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
timeoutMs?: number;
}): Promise<"written" | "skipped" | "abandoned" | "failed"> {
const previous = parseSessionContextSummary(input.summary);
if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) {
return "skipped";
}
try {
const text = await generateSessionContextSummary({
previous,
messages: input.messages,
excludeRequestId: input.excludeRequestId,
generateText: input.generateText,
timeoutMs: input.timeoutMs,
});
if (!text) return "failed";
const next = nextSessionContextSummary(
input.messages,
previous,
text,
(input.now ?? (() => new Date()))().toISOString(),
{ excludeRequestId: input.excludeRequestId },
);
if (!next) return "skipped";
return writeSessionContextSummary({
seenUpdatedAt: previous?.updatedAt ?? null,
summary: next,
update: input.update,
});
} catch {
return "failed";
}
}