fix(consult): drop traces, budget checkpoints, silent summary inherit
Independent Staging Quality Gate / validate (push) Canceled after 3m8s
Independent Staging Quality Gate / publish (push) Canceled after 0s

BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot.
BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000).
BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id.
This commit is contained in:
jesse-ux
2026-09-16 07:38:36 +08:00
parent e61535f464
commit 149e1ec4c3
18 changed files with 451 additions and 31 deletions
@@ -59,6 +59,7 @@ export const chatSessionCreateSchema = z.object({
messages: z.array(chatMessageSchema).max(0),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
continued_from_session_id: z.string().uuid().optional(),
...chartBindingSchema,
updated_at: z.string().datetime().optional(),
}).strict();
@@ -148,11 +149,41 @@ export type ChatSessionCreate = Readonly<{
messages: readonly [];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
continued_from_session_id?: string;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export type ChatSessionCreateInsertValues = Readonly<{
title: string;
theme: ConsultationDomain;
model_id: string;
messages: readonly unknown[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export function chatSessionCreateInsertRow(input: {
id: string;
userId: string;
values: ChatSessionCreateInsertValues;
inheritedSummary?: unknown;
updatedAt: string;
}): Record<string, unknown> {
const row: Record<string, unknown> = {
id: input.id,
user_id: input.userId,
...input.values,
updated_at: input.updatedAt,
};
if (input.inheritedSummary) row.context_summary = input.inheritedSummary;
return row;
}
export type ChatSessionWrite = Readonly<{
title: string;
theme: ConsultationDomain;
@@ -1,11 +1,14 @@
export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000;
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
// 0.4 of the history budget. At the default 128k window the budget is 40,000,
// so the checkpoint fires at 16,000 — the previous hard-coded threshold.
// Smaller windows then checkpoint before the tail can exceed the budget.
export const CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO = 0.4;
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
@@ -51,6 +54,15 @@ export function historyBudgetChars(contextWindow: number | null | undefined): nu
);
}
export function consultationHistoryCheckpointChars(contextWindow: number | null | undefined): number {
const budget = historyBudgetChars(contextWindow);
const threshold = Math.floor(budget * CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO);
if (!(threshold < budget)) {
throw new Error("consultation history checkpoint threshold must stay below the history budget");
}
return threshold;
}
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
@@ -74,6 +86,13 @@ export function omissionMarker(omittedChars: number): string {
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
}
export function droppedRoundsMarker(droppedCount: number, hasSummary: boolean): string {
if (droppedCount <= 0) return "";
return hasSummary
? `……(更早的 ${droppedCount} 轮问答已并入上面的会话摘要)`
: `……(更早的 ${droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要)`;
}
export function clipConsultationHistoryText(text: string): string {
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
@@ -165,16 +184,18 @@ export function consultationUserTurnContent(input: {
instruction: string;
extra?: string;
summaryText?: string | null;
droppedCount?: number;
question: string;
}): string {
const summary = input.summaryText?.trim() ?? "";
const dropped = droppedRoundsMarker(input.droppedCount ?? 0, Boolean(summary));
return [
input.currentTime,
input.name ? `用户称呼:${input.name}` : "",
input.instruction,
input.extra ?? "",
input.summaryText?.trim()
? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}`
: "",
summary ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${summary}` : "",
dropped,
input.question,
].filter(Boolean).join("\n");
}
+23 -5
View File
@@ -1,7 +1,7 @@
import { Agent } from "@mastra/core/agent";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
consultationHistoryCheckpointChars,
lastConsultationPair,
parseSessionContextSummary,
storedConsultationTurns,
@@ -98,7 +98,7 @@ export function sanitizeSessionContextSummary(raw: string): string | null {
export function tailCharCount(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): number {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
@@ -110,9 +110,10 @@ export function tailCharCount(
export function shouldCheckpoint(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): boolean {
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
return tailCharCount(messages, summary, options)
> consultationHistoryCheckpointChars(options.contextWindow);
}
export function messagesForSummaryInput(
@@ -215,6 +216,19 @@ export function nextSessionContextSummary(
};
}
export async function resolveInheritedContextSummary(input: {
continuedFromSessionId?: string | null;
loadOwnedSummary: (sessionId: string) => Promise<unknown>;
}): Promise<SessionContextSummaryV1 | null> {
const sourceId = input.continuedFromSessionId?.trim();
if (!sourceId) return null;
try {
return parseSessionContextSummary(await input.loadOwnedSummary(sourceId));
} catch {
return null;
}
}
export async function writeSessionContextSummary(input: {
seenUpdatedAt: string | null;
summary: SessionContextSummaryV1;
@@ -228,13 +242,17 @@ export async function checkpointSessionContextSummary(input: {
messages: unknown;
summary: unknown;
excludeRequestId?: string;
contextWindow?: number | null;
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 })) {
if (!shouldCheckpoint(input.messages, previous, {
excludeRequestId: input.excludeRequestId,
contextWindow: input.contextWindow,
})) {
return "skipped";
}
try {