🛄 feat: Restore Compaction Guidance Across Continuations (#15356)

* 🧭 feat: preserve compaction guidance across continuations

* 🔧 fix: narrow persisted compaction fields
This commit is contained in:
Danny Avila 2026-08-30 08:13:24 -04:00 • committed by GitHub
parent b97d2868d4
commit e3ccaba5af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 655 additions and 4 deletions

View file

@ -2647,7 +2647,22 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
});
it('seeds the thread parent before reconstruction and maps the decision to the SDK', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const compactionSemanticIndex = {
version: 1,
entries: [
{
type: 'activity_phase',
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed',
text: 'Verified the release state',
},
],
};
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { compactionSemanticIndex } }),
);
await post(approveBody());
await settled;
await flush();
@ -2667,6 +2682,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect.objectContaining({
resumeValue: { tc1: { type: 'approve' } },
userMCPAuthMap: { server1: { token: 't' } },
compactionSemanticIndex,
}),
);
});

View file

@ -129,6 +129,8 @@ const {
createInitializedAgentContextFingerprint,
createSkillContentDigest,
normalizeAgentEventActorDiscoveredTools,
createCompactionSemanticIndexProjection,
restoreCompactionSemanticIndex,
MAX_AGENT_CONTEXT_SKILLS,
} = require('@librechat/api');
const {
@ -287,6 +289,9 @@ class AgentClient extends BaseClient {
this.eventActorSkillPrimeResult = undefined;
this.eventActorDiscoveredToolNames = undefined;
this.eventActorSummary = undefined;
/** Advisory compaction guidance retained across graph reconstruction.
* @type {import('@librechat/agents').CompactionSemanticIndex | undefined} */
this.compactionSemanticIndex = undefined;
/** @type {AgentRun} */
this.run;
@ -1693,10 +1698,12 @@ class AgentClient extends BaseClient {
let discoveredToolNames;
let summary;
let contextMeta;
let compactionSemanticIndex;
try {
discoveredToolNames = normalizeAgentEventActorDiscoveredTools(state.discoveredToolNames);
summary = normalizeEventActorSummary(state.summary);
contextMeta = normalizeEventActorContextMeta(state.contextMeta);
compactionSemanticIndex = restoreCompactionSemanticIndex(state.compactionSemanticIndex);
} catch {
return undefined;
}
@ -1724,6 +1731,7 @@ class AgentClient extends BaseClient {
this.eventActorDiscoveredToolNames = discoveredToolNames;
this.eventActorSummary = summary;
this.contextMeta = contextMeta;
this.compactionSemanticIndex = compactionSemanticIndex;
const context = await this.getEventActorContext(storedManifest, discoveredToolNames);
const skillBodies = new Map(skillPrimeResult?.skills ?? []);
const rootAgentContext = this.eventActorAgentContextSources?.[0];
@ -1782,6 +1790,9 @@ class AgentClient extends BaseClient {
]);
const summary = getLatestEventActorSummary(this.contentParts) ?? this.eventActorSummary;
this.eventActorSummary = summary;
const compactionSemanticIndex = createCompactionSemanticIndexProjection(
this.compactionSemanticIndex,
);
return {
fingerprint: createInitializedAgentContextFingerprint({
agents: this.eventActorAgentContextSources ?? agents,
@ -1795,6 +1806,7 @@ class AgentClient extends BaseClient {
discoveredToolNames,
...(summary == null ? {} : { summary }),
...(this.contextMeta == null ? {} : { contextMeta: this.contextMeta }),
...(compactionSemanticIndex == null ? {} : { compactionSemanticIndex }),
};
}
@ -1814,6 +1826,7 @@ class AgentClient extends BaseClient {
* non-checkpointed state from durable history, never from that stale head. */
this.eventActorSummary = undefined;
this.contextMeta = undefined;
this.compactionSemanticIndex = undefined;
}
/** Always pass mapMethod; getMessagesForConversation applies it only to messages with addedConvo flag */
const orderedMessages = this.constructor.getMessagesForConversation({
@ -3442,6 +3455,9 @@ class AgentClient extends BaseClient {
...(staged.activityPhaseSnapshot == null
? {}
: { activityPhaseSnapshot: staged.activityPhaseSnapshot }),
...(staged.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: staged.compactionSemanticIndex }),
persistencePending: true,
...(eventActorSuspension == null
? {}
@ -3684,6 +3700,9 @@ class AgentClient extends BaseClient {
interruptType: interrupt.payload.type,
discoveredTools,
activityPhaseSnapshot: this.activityPhaseWiring?.snapshot?.(),
compactionSemanticIndex: createCompactionSemanticIndexProjection(
this.compactionSemanticIndex,
),
};
if (this.eventActorInvocationId != null) {
return;
@ -3907,9 +3926,14 @@ class AgentClient extends BaseClient {
);
if (this.eventActorContinuation !== 'warm') {
this.eventActorSummary = initialSummary;
this.compactionSemanticIndex = compactionSemanticIndex;
}
const continuationSummary =
this.eventActorContinuation === 'warm' ? this.eventActorSummary : initialSummary;
const continuationCompactionSemanticIndex =
this.eventActorContinuation === 'warm'
? this.compactionSemanticIndex
: compactionSemanticIndex;
if (boundaryTokenAdjustment) {
logger.debug(
`[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`,
@ -4159,7 +4183,9 @@ class AgentClient extends BaseClient {
// rebuilt turns use the summary reconstructed from durable history.
indexTokenCountMap: this.eventActorContinuation === 'warm' ? {} : indexTokenCountMap,
initialSummary: continuationSummary,
...(deriveCompactionSemanticIndex ? { compactionSemanticIndex } : {}),
...(continuationCompactionSemanticIndex == null
? {}
: { compactionSemanticIndex: continuationCompactionSemanticIndex }),
initialSessions,
calibrationRatio,
runId: this.responseMessageId,
@ -4456,6 +4482,7 @@ class AgentClient extends BaseClient {
* @param {Array} [params.seedContent] - content aggregated before the pause
* @param {Array<import('@librechat/agents').RunStep>} [params.runSteps] - run steps emitted before the pause
* @param {import('@librechat/api').ActivityPhaseSnapshot} [params.activityPhaseSnapshot]
* @param {import('@librechat/data-schemas').ICompactionSemanticIndexProjection} [params.compactionSemanticIndex]
* @param {Array} [params.storedMessages] - persisted user messages restored for the resume
* @param {AbortController} [params.abortController]
* @param {Pick<import('@langchain/langgraph').Command, 'update' | 'goto'>} [params.commandOptions]
@ -4470,6 +4497,7 @@ class AgentClient extends BaseClient {
userMCPAuthMap,
discoveredToolNames,
activityPhaseSnapshot,
compactionSemanticIndex,
}) {
/** @type {Partial<GraphRunnableConfig>} */
let config;
@ -4530,6 +4558,7 @@ class AgentClient extends BaseClient {
}
const tokenCounter = await createCachedTokenCounter(this.getEncoding());
this.compactionSemanticIndex = restoreCompactionSemanticIndex(compactionSemanticIndex);
const agents = collectReachableAgents([
this.options.agent,
...(this.agentConfigs?.size > 0 ? this.agentConfigs.values() : []),
@ -4661,6 +4690,9 @@ class AgentClient extends BaseClient {
// batches keep claiming slots and generating group headers.
activityLabel,
activityPhase,
...(this.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: this.compactionSemanticIndex }),
// Replay deferred tools discovered before the pause. With `messages: []` the
// discovery scan finds nothing, so these names restore the schemas to the
// rebuilt model binding. Undefined/empty for non-deferred turns is a no-op.

View file

@ -90,6 +90,19 @@ jest.mock('@librechat/api', () => ({
describe('AgentClient - event actor history adapter', () => {
const fingerprint = { algorithm: 'sha256', version: 1, digest: 'context' };
const compactionSemanticIndex = {
version: 1,
entries: [
{
type: 'activity_phase',
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed',
text: 'Verified the release state',
},
],
};
it('loads no durable history after compatibility selected a warm continuation', async () => {
const loadHistory = jest.spyOn(BaseClient.prototype, 'loadHistory');
@ -133,6 +146,7 @@ describe('AgentClient - event actor history adapter', () => {
discoveredToolNames: ['deferred_tool'],
summary: { text: 'Earlier compacted context.', tokenCount: 12 },
contextMeta: { calibrationRatio: 1.25, encoding: 'o200k_base' },
compactionSemanticIndex,
});
await expect(
@ -142,6 +156,7 @@ describe('AgentClient - event actor history adapter', () => {
discoveredToolNames: ['deferred_tool'],
summary: { text: 'Earlier compacted context.', tokenCount: 12 },
contextMeta: { calibrationRatio: 1.25, encoding: 'o200k_base' },
compactionSemanticIndex,
}),
).resolves.toMatchObject({
fingerprint,
@ -149,6 +164,7 @@ describe('AgentClient - event actor history adapter', () => {
discoveredToolNames: ['deferred_tool'],
summary: { text: 'Earlier compacted context.', tokenCount: 12 },
contextMeta: { calibrationRatio: 1.25, encoding: 'o200k_base' },
compactionSemanticIndex,
checkpointMessageOverlay: {
source: 'skill',
messages: [
@ -171,6 +187,7 @@ describe('AgentClient - event actor history adapter', () => {
tokenCount: 12,
});
expect(client.contextMeta).toEqual({ calibrationRatio: 1.25, encoding: 'o200k_base' });
expect(client.compactionSemanticIndex).toEqual(compactionSemanticIndex.entries);
});
it('falls back when a durable Skill resolves to a different revision', async () => {
@ -284,12 +301,14 @@ describe('AgentClient - event actor history adapter', () => {
},
];
client.contextMeta = { calibrationRatio: 1.3, encoding: 'o200k_base' };
client.compactionSemanticIndex = compactionSemanticIndex.entries;
client.getEventActorAgents = jest.fn(() => []);
client.getEventActorMemorySnapshots = jest.fn().mockResolvedValue([]);
await expect(client.getEventActorContext()).resolves.toMatchObject({
summary: { text: 'Fresh compacted context.', tokenCount: 18 },
contextMeta: { calibrationRatio: 1.3, encoding: 'o200k_base' },
compactionSemanticIndex,
});
});
@ -855,6 +874,16 @@ describe('AgentClient - interrupt discovery persistence', () => {
client.conversationId = streamId;
client.responseMessageId = 'response-discovered-pause';
client.jobCreatedAt = job.createdAt;
client.compactionSemanticIndex = [
{
type: 'activity_phase',
sourceMessageId: 'assistant-before-pause',
sourceContentIndex: 1,
revision: 1,
status: 'committed',
text: 'Prepared the change',
},
];
await client.handleRunInterrupt(
{
@ -875,6 +904,10 @@ describe('AgentClient - interrupt discovery persistence', () => {
const paused = await GenerationJobManager.getJob(streamId);
expect(paused?.status).toBe('requires_action');
expect(paused?.metadata.discoveredTools).toEqual(['save_issue_mcp_linear']);
expect(paused?.metadata.compactionSemanticIndex).toEqual({
version: 1,
entries: client.compactionSemanticIndex,
});
});
it('caps an event-bound pause at the inherited binding deadline', async () => {
@ -2014,6 +2047,16 @@ describe('AgentClient - startup telemetry', () => {
client.eventActorDiscoveredToolNames = ['deferred_tool'];
client.eventActorSummary = { text: 'summary of earlier turns', tokenCount: 40 };
client.contextMeta = { calibrationRatio: 1.25, encoding: client.getEncoding() };
client.compactionSemanticIndex = [
{
type: 'activity_phase',
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed',
text: 'Verified the release state',
},
];
client.recordCollectedUsage = jest.fn().mockResolvedValue();
await client.chatCompletion({ payload: [] });
@ -2031,10 +2074,11 @@ describe('AgentClient - startup telemetry', () => {
indexTokenCountMap: {},
initialSummary: { text: 'summary of earlier turns', tokenCount: 40 },
calibrationRatio: 1.25,
compactionSemanticIndex: client.compactionSemanticIndex,
}),
);
expect(mockCreateRun.mock.calls[0][0]).not.toHaveProperty('compactionSemanticIndex');
expect(mockFormatAgentMessages.mock.calls[0][4]).toBeUndefined();
expect(mockFormatAgentMessages).toHaveBeenCalledTimes(1);
expect(mockStripActivityLabelParts).toHaveBeenCalledTimes(1);
expect(processStream).toHaveBeenCalledWith(
{ messages: [currentEvent] },
@ -6889,12 +6933,29 @@ describe('AgentClient - resumeCompletion content protection', () => {
},
});
await AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} });
const compactionSemanticIndex = {
version: 1,
entries: [
{
type: 'activity_phase',
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed',
text: 'Verified the release state',
},
],
};
await AgentClient.prototype.resumeCompletion.call(context, {
resumeValue: {},
compactionSemanticIndex,
});
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockCreateRun.mock.calls[0][0]).toEqual(
expect.objectContaining({
modelCallbacks: [expect.objectContaining({ name: 'librechat-model-bound-content-filter' })],
compactionSemanticIndex: compactionSemanticIndex.entries,
}),
);
expect(resume).toHaveBeenCalledTimes(1);

View file

@ -1823,6 +1823,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
// graph passes `messages: []`, so without these the model would lose their schemas.
discoveredToolNames: job.metadata?.discoveredTools,
activityPhaseSnapshot: job.metadata?.activityPhaseSnapshot,
compactionSemanticIndex: job.metadata?.compactionSemanticIndex,
});
if (
!(await GenerationJobManager.beginProviderExecution(

View file

@ -0,0 +1,101 @@
import { COMPACTION_SEMANTIC_INDEX_LIMITS } from '@librechat/agents';
import {
MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES,
MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX,
MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH,
} from '@librechat/data-schemas';
import type { ICompactionSemanticIndexProjection } from '@librechat/data-schemas';
import type { CompactionSemanticIndex } from '@librechat/agents';
import {
createCompactionSemanticIndexProjection,
restoreCompactionSemanticIndex,
} from './compaction';
const index = [
{
type: 'activity_phase',
sourceMessageId: 'message-1',
sourceContentIndex: 2,
revision: 1,
status: 'committed',
text: 'Verified the release',
},
{
type: 'reasoning_label',
sourceMessageId: 'message-1',
sourceContentIndex: 3,
revision: 2,
status: 'pending',
text: 'This pending text must not persist',
reasoningStepId: 'reasoning-1',
},
] satisfies CompactionSemanticIndex;
describe('compaction semantic index continuation projection', () => {
it('keeps persistence bounds aligned with the SDK admission limits', () => {
expect(MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES).toBe(
COMPACTION_SEMANTIC_INDEX_LIMITS.maxInputEntries,
);
expect(MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH).toBe(
COMPACTION_SEMANTIC_INDEX_LIMITS.maxInputTextChars,
);
expect(MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH).toBe(
COMPACTION_SEMANTIC_INDEX_LIMITS.maxIdentityChars,
);
expect(MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX).toBe(
COMPACTION_SEMANTIC_INDEX_LIMITS.maxSourceContentIndex,
);
});
it('snapshots exact JSON-safe guidance and blanks pending text', () => {
const projection = createCompactionSemanticIndexProjection(index);
expect(projection).toEqual({
version: 1,
entries: [
index[0],
{
...index[1],
text: '',
},
],
});
expect(restoreCompactionSemanticIndex(projection)).toEqual(projection?.entries);
});
it('fails closed for malformed or oversized continuation state', () => {
const malformed = {
version: 1,
entries: [{ ...index[0], sourceContentIndex: -1 }],
} as ICompactionSemanticIndexProjection;
const oversized = {
version: 1,
entries: Array.from({ length: MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES + 1 }, () => index[0]),
} as ICompactionSemanticIndexProjection;
const corrupt = {
version: 1,
entries: [null],
} as never;
expect(restoreCompactionSemanticIndex(malformed)).toBeUndefined();
expect(restoreCompactionSemanticIndex(oversized)).toBeUndefined();
expect(restoreCompactionSemanticIndex(corrupt)).toBeUndefined();
expect(createCompactionSemanticIndexProjection(oversized.entries)).toBeUndefined();
});
it('redacts oversized text before persistence', () => {
const projection = createCompactionSemanticIndexProjection([
{
...index[0],
text: 'x'.repeat(MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH + 1),
},
]);
expect(projection?.entries[0]).toEqual({
...index[0],
text: '',
redacted: true,
});
});
});

View file

@ -0,0 +1,110 @@
import {
COMPACTION_SEMANTIC_INDEX_PROJECTION_VERSION,
MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES,
MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX,
MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH,
isCompactionSemanticIndexProjection,
} from '@librechat/data-schemas';
import type {
ICompactionSemanticIndexProjection,
TCompactionSemanticIndexEntry,
} from '@librechat/data-schemas';
import type { CompactionSemanticIndex, CompactionSemanticIndexEntry } from '@librechat/agents';
function snapshotEntry(
entry: CompactionSemanticIndexEntry,
): TCompactionSemanticIndexEntry | undefined {
const { type, sourceMessageId, sourceContentIndex, revision, status, text, redacted } = entry;
if (
typeof sourceMessageId !== 'string' ||
sourceMessageId.length === 0 ||
sourceMessageId.length > MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH ||
!Number.isSafeInteger(sourceContentIndex) ||
sourceContentIndex < 0 ||
sourceContentIndex > MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX ||
!Number.isSafeInteger(revision) ||
revision < 0 ||
(status !== 'committed' && status !== 'pending') ||
typeof text !== 'string' ||
(redacted !== undefined && typeof redacted !== 'boolean')
) {
return undefined;
}
const oversized = text.length > MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH;
const snapshotRedacted = redacted === true || oversized;
const snapshotText = status === 'pending' || snapshotRedacted ? '' : text;
const common = {
sourceMessageId,
sourceContentIndex,
revision,
status,
text: snapshotText,
...(redacted !== undefined || oversized ? { redacted: snapshotRedacted } : {}),
};
if (type === 'activity_phase') {
return { type, ...common };
}
if (type === 'reasoning_label') {
const reasoningStepId = entry.reasoningStepId;
if (
typeof reasoningStepId !== 'string' ||
reasoningStepId.length === 0 ||
reasoningStepId.length > MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH
) {
return undefined;
}
return { type, reasoningStepId, ...common };
}
const toolCallId = entry.toolCallId;
if (
typeof toolCallId !== 'string' ||
toolCallId.length === 0 ||
toolCallId.length > MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH
) {
return undefined;
}
return { type, toolCallId, ...common };
}
export function createCompactionSemanticIndexProjection(
index: CompactionSemanticIndex | undefined,
): ICompactionSemanticIndexProjection | undefined {
if (
index == null ||
!Array.isArray(index) ||
index.length === 0 ||
index.length > MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES
) {
return undefined;
}
const entries: TCompactionSemanticIndexEntry[] = [];
for (const entry of index) {
const snapshot = snapshotEntry(entry);
if (snapshot == null) {
return undefined;
}
entries.push(snapshot);
}
return {
version: COMPACTION_SEMANTIC_INDEX_PROJECTION_VERSION,
entries,
};
}
export function restoreCompactionSemanticIndex(
projection: ICompactionSemanticIndexProjection | null | undefined,
): CompactionSemanticIndex | undefined {
if (!isCompactionSemanticIndexProjection(projection)) {
return undefined;
}
const entries: CompactionSemanticIndexEntry[] = [];
for (const entry of projection.entries) {
const snapshot = snapshotEntry(entry);
if (snapshot == null) {
return undefined;
}
entries.push(Object.freeze(snapshot));
}
return Object.freeze(entries);
}

View file

@ -6,6 +6,7 @@ export * from './client';
export * from './config';
export * from './checkpointer';
export * from './compatibility';
export * from './compaction';
export * from './contact';
export * from './context';
export * from './control';

View file

@ -82,6 +82,7 @@ describe('event actor host adapter', () => {
discoveredToolNames,
summary,
contextMeta,
compactionSemanticIndex,
}) => {
if (
expectedEpoch !== epoch ||
@ -95,6 +96,8 @@ describe('event actor host adapter', () => {
JSON.stringify(state.discoveredToolNames) ||
JSON.stringify(expected.summary) !== JSON.stringify(state.summary) ||
JSON.stringify(expected.contextMeta) !== JSON.stringify(state.contextMeta) ||
JSON.stringify(expected.compactionSemanticIndex) !==
JSON.stringify(state.compactionSemanticIndex) ||
(expected.requiresColdStart === true) !== (state.requiresColdStart === true)))
) {
return { status: 'stale' as const, ...(state == null ? {} : { state }) };
@ -108,6 +111,7 @@ describe('event actor host adapter', () => {
...(discoveredToolNames == null ? {} : { discoveredToolNames }),
...(summary == null ? {} : { summary }),
...(contextMeta == null ? {} : { contextMeta }),
...(compactionSemanticIndex == null ? {} : { compactionSemanticIndex }),
...(previous == null ? {} : { previousCheckpoint: previous }),
};
return { status: 'committed' as const, state };
@ -685,6 +689,19 @@ describe('event actor host adapter', () => {
const current = createAgentContextFingerprint({ agents: [{ id: 'agent-1', version: 2 }] });
const storedSkill = { id: 'skill-1', name: 'analysis', version: 3 };
const invokedSkill = { id: 'skill-2', name: 'reporting', version: 1 };
const storedCompactionSemanticIndex = {
version: 1 as const,
entries: [
{
type: 'activity_phase' as const,
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed' as const,
text: 'Verified the release state',
},
],
};
state = {
generation: 1,
checkpoint: {
@ -697,6 +714,7 @@ describe('event actor host adapter', () => {
discoveredToolNames: ['deferred_lookup'],
summary: { text: 'Earlier compacted context.', tokenCount: 12 },
contextMeta: { calibrationRatio: 1.25, encoding: 'o200k_base' },
compactionSemanticIndex: storedCompactionSemanticIndex,
};
let continuation: 'warm' | 'cold' | undefined;
const checkpointMessageOverlay = { source: 'skill', messages: [] };
@ -714,6 +732,7 @@ describe('event actor host adapter', () => {
discoveredToolNames: observed.discoveredToolNames ?? [],
summary: observed.summary,
contextMeta: observed.contextMeta,
compactionSemanticIndex: observed.compactionSemanticIndex,
checkpointMessageOverlay,
}),
readResultContext: async () => ({
@ -722,6 +741,7 @@ describe('event actor host adapter', () => {
discoveredToolNames: ['deferred_lookup', 'deferred_write'],
summary: { text: 'Updated compacted context.', tokenCount: 15 },
contextMeta: { calibrationRatio: 1.3, encoding: 'o200k_base' },
compactionSemanticIndex: storedCompactionSemanticIndex,
}),
invoke: async (context) => {
continuation = context.continuation;
@ -737,6 +757,7 @@ describe('event actor host adapter', () => {
expect(state?.discoveredToolNames).toEqual(['deferred_lookup', 'deferred_write']);
expect(state?.summary).toEqual({ text: 'Updated compacted context.', tokenCount: 15 });
expect(state?.contextMeta).toEqual({ calibrationRatio: 1.3, encoding: 'o200k_base' });
expect(state?.compactionSemanticIndex).toEqual(storedCompactionSemanticIndex);
expect(mockedFork).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),

View file

@ -78,6 +78,7 @@ export interface AgentEventActorContext {
discoveredToolNames?: string[];
summary?: IAgentEventActorState['summary'];
contextMeta?: IAgentEventActorState['contextMeta'];
compactionSemanticIndex?: IAgentEventActorState['compactionSemanticIndex'];
checkpointMessageOverlay?: AgentEventCheckpointMessageOverlay;
}
@ -685,6 +686,9 @@ export async function executeAgentEventActor<T>(
...(observedState.contextMeta == null
? {}
: { contextMeta: observedState.contextMeta }),
...(observedState.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: observedState.compactionSemanticIndex }),
};
const committed = await deps.commitState({
user: input.user,
@ -712,6 +716,9 @@ export async function executeAgentEventActor<T>(
...(resultContext.contextMeta == null
? {}
: { contextMeta: resultContext.contextMeta }),
...(resultContext.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: resultContext.compactionSemanticIndex }),
}),
});
if (committed.status === 'stale') {
@ -1172,6 +1179,9 @@ export async function resumeAgentEventActor<T>(
...(observedState.contextMeta == null
? {}
: { contextMeta: observedState.contextMeta }),
...(observedState.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: observedState.compactionSemanticIndex }),
...(observedState.requiresColdStart === true ? { requiresColdStart: true } : {}),
};
const committed = await deps.commitState({
@ -1196,6 +1206,9 @@ export async function resumeAgentEventActor<T>(
...(resultContext.contextMeta == null
? {}
: { contextMeta: resultContext.contextMeta }),
...(resultContext.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: resultContext.compactionSemanticIndex }),
}),
});
if (committed.status === 'stale') {

View file

@ -1,4 +1,5 @@
import { logger } from '@librechat/data-schemas';
import type { ICompactionSemanticIndexProjection } from '@librechat/data-schemas';
import type { Agents } from 'librechat-data-provider';
import type {
IJobStoreV2,
@ -30,6 +31,7 @@ export interface ApprovalLifecycleCallbacks {
export interface ApprovalPauseOptions {
discoveredTools?: string[];
activityPhaseSnapshot?: ActivityPhaseSnapshot;
compactionSemanticIndex?: ICompactionSemanticIndexProjection;
/** Generation identity observed by the interrupted run. */
expectedCreatedAt?: number;
/** Hold Stop/resume until the paused assistant row is durably unfinished. */
@ -113,6 +115,7 @@ export class ApprovalLifecycle {
const expectedCreatedAt = options.expectedCreatedAt ?? job.createdAt;
const discoveredTools = options.discoveredTools;
const activityPhaseSnapshot = options.activityPhaseSnapshot;
const compactionSemanticIndex = options.compactionSemanticIndex;
/** The normal receipt TTL matches a running job, but a review pause can
* live for 24h or an explicit later expiry. The store extends every
* receipt in the SAME CAS that closes running enqueues: a separate pass
@ -144,6 +147,7 @@ export class ApprovalLifecycle {
? { discoveredTools: [...discoveredTools] }
: {}),
...(activityPhaseSnapshot != null ? { activityPhaseSnapshot } : {}),
...(compactionSemanticIndex != null ? { compactionSemanticIndex } : {}),
...(options.agentEventSuspension != null
? { agentEventSuspension: options.agentEventSuspension }
: {}),

View file

@ -2658,6 +2658,7 @@ class GenerationJobManagerClass {
// replay them into createRun (the rebuilt graph passes `messages: []`).
discoveredTools: jobData.discoveredTools,
activityPhaseSnapshot: jobData.activityPhaseSnapshot,
compactionSemanticIndex: jobData.compactionSemanticIndex,
// Surface the owning replica's seal capability so the steer route can
// honour it instead of probing its own (possibly older) SDK.
preemptCapable: jobData.preemptCapable,

View file

@ -352,6 +352,19 @@ describe('RedisJobStore', () => {
preserveForScheduleReconcile: true,
promptTokens: 0,
discoveredTools: [],
compactionSemanticIndex: {
version: 1,
entries: [
{
type: 'activity_phase',
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed',
text: 'Verified the release state',
},
],
},
preemptCapable: true,
steerQuotesExecutionId: 'exec-1',
generationProtocolVersion: 2,
@ -425,6 +438,15 @@ describe('RedisJobStore', () => {
preserveForScheduleReconcile: true,
promptTokens: 0,
discoveredTools: [],
compactionSemanticIndex: {
version: 1,
entries: [
expect.objectContaining({
type: 'activity_phase',
text: 'Verified the release state',
}),
],
},
resolvedAskUserQuestions: [
{
request: { question: 'Deploy where?' },

View file

@ -249,6 +249,33 @@ describe('ApprovalLifecycle via GenerationJobManager.approvals (in-memory)', ()
expect(paused?.metadata.activityPhaseSnapshot).toEqual(activityPhaseSnapshot);
});
test('persists compaction guidance in the same transition as the pause', async () => {
const streamId = 'stream-pause-compaction-index';
await manager.createJob(streamId, 'user-1');
const compactionSemanticIndex = {
version: 1 as const,
entries: [
{
type: 'activity_phase' as const,
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed' as const,
text: 'Verified the release state',
},
],
};
expect(
await manager.approvals.pause(streamId, buildAction(streamId), {
compactionSemanticIndex,
}),
).toBe(true);
const paused = await manager.getJob(streamId);
expect(paused?.metadata.compactionSemanticIndex).toEqual(compactionSemanticIndex);
});
test('does not write a stale pause or discoveries onto a replacement job', async () => {
const streamId = 'stream-pause-replaced';
const original = await manager.createJob(streamId, 'user-1');

View file

@ -4917,6 +4917,9 @@ export class RedisJobStore implements IJobStoreV2 {
activityPhaseSnapshot: data.activityPhaseSnapshot
? JSON.parse(data.activityPhaseSnapshot)
: undefined,
compactionSemanticIndex: data.compactionSemanticIndex
? JSON.parse(data.compactionSemanticIndex)
: undefined,
/** The owning replica's seal capability. `serializeJob` writes every
* boolean generically, but this mapper is explicit — omitting it here
* drops the flag on every read, so the steer route would compute

View file

@ -4,6 +4,7 @@ import type {
TPendingSteer,
UserSubmittedMessageFieldPath,
} from 'librechat-data-provider';
import type { ICompactionSemanticIndexProjection } from '@librechat/data-schemas';
import type { RunStep, StandardGraph } from '@librechat/agents';
import type { AgentEventDetachedTerminalEvidence } from '~/agents/triggers/types';
import type { ActivityPhaseSnapshot } from '~/agents/activityPhases/runtime';
@ -174,6 +175,8 @@ export interface SerializableJobData {
discoveredTools?: string[];
/** Bounded collector state for continuing a phase across HITL resume. */
activityPhaseSnapshot?: ActivityPhaseSnapshot;
/** Exact bounded compaction guidance captured atomically with a HITL pause. */
compactionSemanticIndex?: ICompactionSemanticIndexProjection;
/**
* Whether the replica that OWNS this generation can seal mid-stream
* (`PreemptBoundary` wiring). Recorded at createJob because the steer route
@ -452,6 +455,7 @@ export type JobMetadataPatch = Partial<
| 'promptTokens'
| 'discoveredTools'
| 'activityPhaseSnapshot'
| 'compactionSemanticIndex'
| 'preemptCapable'
| 'steerQuotesCapable'
| 'steerQuotesExecutionId'

View file

@ -104,5 +104,8 @@ export function sanitizeJobMetadata(metadata: Partial<GenerationJobMetadata>): J
if (metadata.activityPhaseSnapshot) {
patch.activityPhaseSnapshot = metadata.activityPhaseSnapshot;
}
if (metadata.compactionSemanticIndex) {
patch.compactionSemanticIndex = metadata.compactionSemanticIndex;
}
return patch;
}

View file

@ -1,4 +1,5 @@
import type { Agents, UserSubmittedMessageFieldPath } from 'librechat-data-provider';
import type { ICompactionSemanticIndexProjection } from '@librechat/data-schemas';
import type { EventEmitter } from 'events';
import type {
AgentEventDetachedTerminalEvidence,
@ -86,6 +87,8 @@ export interface GenerationJobMetadata {
discoveredTools?: string[];
/** Bounded collector state for continuing a phase across HITL resume. */
activityPhaseSnapshot?: ActivityPhaseSnapshot;
/** Exact bounded compaction guidance captured atomically with a HITL pause. */
compactionSemanticIndex?: ICompactionSemanticIndexProjection;
/** See `SerializableJobData.preemptCapable`. */
preemptCapable?: boolean;
/** See `SerializableJobData.steerQuotesCapable`. */

View file

@ -53,6 +53,14 @@ export {
MAX_AGENT_EVENT_ACTOR_SUMMARY_LENGTH,
MAX_AGENT_EVENT_ACTOR_TOOL_NAME_LENGTH,
} from './types/convo';
export {
COMPACTION_SEMANTIC_INDEX_PROJECTION_VERSION,
MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES,
MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX,
MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH,
isCompactionSemanticIndexProjection,
} from './types/compaction';
export { AGENT_TRIGGER_WORKER_CAPABILITY_DETACHED_ACTION_V1 } from './types/triggerDelivery';
export type * from './types';
export type * from './methods';

View file

@ -4678,6 +4678,19 @@ describe('Conversation Operations', () => {
const discoveredToolNames = ['deferred_lookup', 'deferred_write'];
const summary = { text: 'Earlier compacted context.', tokenCount: 12 };
const contextMeta = { calibrationRatio: 1.25, encoding: 'o200k_base' };
const compactionSemanticIndex = {
version: 1 as const,
entries: [
{
type: 'activity_phase' as const,
sourceMessageId: 'assistant-history',
sourceContentIndex: 1,
revision: 1,
status: 'committed' as const,
text: 'Verified the release state',
},
],
};
await Conversation.create({
conversationId,
user: 'actor-context-user',
@ -4723,6 +4736,7 @@ describe('Conversation Operations', () => {
discoveredToolNames,
summary,
contextMeta,
compactionSemanticIndex,
});
expect(committed).toMatchObject({
@ -4735,6 +4749,7 @@ describe('Conversation Operations', () => {
discoveredToolNames,
summary,
contextMeta,
compactionSemanticIndex,
},
});
await expect(
@ -4746,6 +4761,7 @@ describe('Conversation Operations', () => {
discoveredToolNames,
summary,
contextMeta,
compactionSemanticIndex,
},
});
@ -4800,6 +4816,21 @@ describe('Conversation Operations', () => {
contextMeta: { calibrationRatio: 9, encoding: 'o200k_base' },
}),
).rejects.toThrow('Event actor context calibration is invalid');
await expect(
methods.commitAgentEventActorState({
user: 'actor-context-user',
conversationId,
invocationId: 'invalid-compaction-index',
expectedEpoch: 0,
action: { toolName: 'submit_move' },
checkpoint: actorCheckpoint,
compactionSemanticIndex: {
version: 1,
entries: [{ ...compactionSemanticIndex.entries[0], sourceContentIndex: -1 }],
},
}),
).rejects.toThrow('Event actor compaction semantic index is invalid');
});
it('retains exact legacy settled receipts until delivery-ledger migration', async () => {

View file

@ -34,6 +34,7 @@ import {
refreshChatProjectStatsForUser,
updateChatProjectLastConversationForUser,
} from './chatProject';
import { isCompactionSemanticIndexProjection } from '~/types/compaction';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import { isValidObjectIdString } from '~/utils/objectId';
@ -314,6 +315,7 @@ export interface ConversationMethods {
discoveredToolNames?: IAgentEventActorState['discoveredToolNames'];
summary?: IAgentEventActorState['summary'];
contextMeta?: IAgentEventActorState['contextMeta'];
compactionSemanticIndex?: IAgentEventActorState['compactionSemanticIndex'];
settlementAuthority?: AgentEventActorSettlementAuthority;
}): Promise<AgentEventActorCommitResult>;
storeAgentEventActorSuspension(input: {
@ -950,6 +952,7 @@ export function createConversationMethods(
discoveredToolNames?: IAgentEventActorState['discoveredToolNames'];
summary?: IAgentEventActorState['summary'];
contextMeta?: IAgentEventActorState['contextMeta'];
compactionSemanticIndex?: IAgentEventActorState['compactionSemanticIndex'];
settlementAuthority?: AgentEventActorSettlementAuthority;
}): Promise<AgentEventActorCommitResult> {
if (input.checkpoint.threadId !== input.conversationId) {
@ -986,6 +989,12 @@ export function createConversationMethods(
) {
throw new RangeError('Event actor context calibration is invalid');
}
if (
input.compactionSemanticIndex != null &&
!isCompactionSemanticIndexProjection(input.compactionSemanticIndex)
) {
throw new RangeError('Event actor compaction semantic index is invalid');
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
/** A legacy turn against a headless or already cold-marked actor leaves
* the head fields unchanged, so the CAS must also require the invalidation
@ -1026,6 +1035,11 @@ export function createConversationMethods(
...(input.expected.contextMeta == null
? { 'agentEventActor.contextMeta': { $exists: false } }
: { 'agentEventActor.contextMeta': input.expected.contextMeta }),
...(input.expected.compactionSemanticIndex == null
? { 'agentEventActor.compactionSemanticIndex': { $exists: false } }
: {
'agentEventActor.compactionSemanticIndex': input.expected.compactionSemanticIndex,
}),
'agentEventActor.requiresColdStart':
input.expected.requiresColdStart === true ? true : { $ne: true },
}),
@ -1050,6 +1064,9 @@ export function createConversationMethods(
: { discoveredToolNames: input.discoveredToolNames }),
...(input.summary == null ? {} : { summary: input.summary }),
...(input.contextMeta == null ? {} : { contextMeta: input.contextMeta }),
...(input.compactionSemanticIndex == null
? {}
: { compactionSemanticIndex: input.compactionSemanticIndex }),
...(input.expected == null ? {} : { previousCheckpoint: input.expected.checkpoint }),
};
const previous = await Conversation.findOneAndUpdate(

View file

@ -1,4 +1,11 @@
import { Schema } from 'mongoose';
import {
MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES,
MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX,
MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH,
isCompactionSemanticIndexProjection,
} from '~/types/compaction';
import {
MAX_AGENT_EVENT_ACTOR_DISCOVERED_TOOLS,
MAX_AGENT_EVENT_ACTOR_ENCODING_LENGTH,
@ -144,6 +151,65 @@ const convoSchema: Schema<IConversation> = new Schema(
_id: false,
default: undefined,
},
compactionSemanticIndex: {
type: {
version: { type: Number, enum: [1], required: true },
entries: {
type: [
{
type: {
type: String,
enum: ['tool_intent', 'tool_outcome', 'activity_phase', 'reasoning_label'],
required: true,
},
sourceMessageId: {
type: String,
minlength: 1,
maxlength: MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
required: true,
},
sourceContentIndex: {
type: Number,
min: 0,
max: MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX,
required: true,
},
revision: { type: Number, min: 0, required: true },
status: { type: String, enum: ['committed', 'pending'], required: true },
text: {
type: String,
maxlength: MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH,
},
redacted: { type: Boolean, default: undefined },
toolCallId: {
type: String,
minlength: 1,
maxlength: MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
default: undefined,
},
reasoningStepId: {
type: String,
minlength: 1,
maxlength: MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH,
default: undefined,
},
_id: false,
},
],
validate: {
validator: (entries: unknown[]) =>
entries.length <= MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES,
message: `Compaction semantic index exceeds ${MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES} entries`,
},
},
},
_id: false,
default: undefined,
validate: {
validator: isCompactionSemanticIndexProjection,
message: 'Compaction semantic index projection is invalid',
},
},
previousCheckpoint: {
type: {
threadId: { type: String, required: true },

View file

@ -0,0 +1,102 @@
export const COMPACTION_SEMANTIC_INDEX_PROJECTION_VERSION = 1 as const;
export const MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES = 256;
export const MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH = 4_096;
export const MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH = 512;
export const MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX = 4_095;
export type TCompactionSemanticIndexStatus = 'committed' | 'pending';
interface ICompactionSemanticIndexEntryBase {
type: 'tool_intent' | 'tool_outcome' | 'activity_phase' | 'reasoning_label';
sourceMessageId: string;
sourceContentIndex: number;
revision: number;
status: TCompactionSemanticIndexStatus;
text: string;
redacted?: boolean;
}
export interface ICompactionToolSemanticIndexEntry extends ICompactionSemanticIndexEntryBase {
type: 'tool_intent' | 'tool_outcome';
toolCallId: string;
}
export interface ICompactionActivitySemanticIndexEntry extends ICompactionSemanticIndexEntryBase {
type: 'activity_phase';
}
export interface ICompactionReasoningSemanticIndexEntry extends ICompactionSemanticIndexEntryBase {
type: 'reasoning_label';
reasoningStepId: string;
}
export type TCompactionSemanticIndexEntry =
| ICompactionToolSemanticIndexEntry
| ICompactionActivitySemanticIndexEntry
| ICompactionReasoningSemanticIndexEntry;
/** Versioned, JSON-safe continuation projection of SDK compaction guidance. */
export interface ICompactionSemanticIndexProjection {
version: typeof COMPACTION_SEMANTIC_INDEX_PROJECTION_VERSION;
entries: TCompactionSemanticIndexEntry[];
}
function isBoundedIdentity(value: string | undefined): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_COMPACTION_SEMANTIC_INDEX_IDENTITY_LENGTH
);
}
function isValidCompactionSemanticIndexEntry(
entry: Partial<TCompactionSemanticIndexEntry> | null | undefined,
): entry is TCompactionSemanticIndexEntry {
if (
entry == null ||
!isBoundedIdentity(entry.sourceMessageId) ||
typeof entry.sourceContentIndex !== 'number' ||
!Number.isSafeInteger(entry.sourceContentIndex) ||
entry.sourceContentIndex < 0 ||
entry.sourceContentIndex > MAX_COMPACTION_SEMANTIC_INDEX_SOURCE_CONTENT_INDEX ||
typeof entry.revision !== 'number' ||
!Number.isSafeInteger(entry.revision) ||
entry.revision < 0 ||
(entry.status !== 'committed' && entry.status !== 'pending') ||
typeof entry.text !== 'string' ||
entry.text.length > MAX_COMPACTION_SEMANTIC_INDEX_TEXT_LENGTH ||
(entry.redacted !== undefined && typeof entry.redacted !== 'boolean') ||
((entry.status === 'pending' || entry.redacted === true) && entry.text !== '')
) {
return false;
}
if (entry.type === 'activity_phase') {
return true;
}
if (entry.type === 'reasoning_label') {
return isBoundedIdentity(entry.reasoningStepId);
}
if (entry.type === 'tool_intent' || entry.type === 'tool_outcome') {
return isBoundedIdentity(entry.toolCallId);
}
return false;
}
export function isCompactionSemanticIndexProjection(
projection: Partial<ICompactionSemanticIndexProjection> | null | undefined,
): projection is ICompactionSemanticIndexProjection {
if (
projection?.version !== COMPACTION_SEMANTIC_INDEX_PROJECTION_VERSION ||
!Array.isArray(projection.entries) ||
projection.entries.length === 0 ||
projection.entries.length > MAX_COMPACTION_SEMANTIC_INDEX_ENTRIES
) {
return false;
}
for (const entry of projection.entries) {
if (!isValidCompactionSemanticIndexEntry(entry)) {
return false;
}
}
return true;
}

View file

@ -1,5 +1,6 @@
import type { TSubagentThreadLineage } from 'librechat-data-provider';
import type { Document, Types } from 'mongoose';
import type { ICompactionSemanticIndexProjection } from './compaction';
export const MAX_AGENT_EVENT_ACTOR_SKILLS = 64;
export const MAX_AGENT_EVENT_ACTOR_DISCOVERED_TOOLS = 128;
@ -62,6 +63,8 @@ export interface IAgentEventActorState {
summary?: IAgentEventActorSummary;
/** Pruner calibration carried by ordinary turns on the parent response message. */
contextMeta?: IAgentEventActorContextMeta;
/** Bounded advisory guidance replayed without reading durable message history. */
compactionSemanticIndex?: ICompactionSemanticIndexProjection;
previousCheckpoint?: IAgentEventActorCheckpoint;
/** Forces the next qualifying event to rebuild from durable message history. */
requiresColdStart?: boolean;

View file

@ -3,6 +3,7 @@ import type { Types } from 'mongoose';
export type ObjectId = Types.ObjectId;
export * from './app';
export * from './cache';
export * from './compaction';
export * from './user';
export * from './token';
export * from './refreshTokenBridge';