diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index d399cbd670..0677f12222 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -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, }), ); }); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 32347e30ec..37bc93ea66 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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} [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} [params.commandOptions] @@ -4470,6 +4497,7 @@ class AgentClient extends BaseClient { userMCPAuthMap, discoveredToolNames, activityPhaseSnapshot, + compactionSemanticIndex, }) { /** @type {Partial} */ 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. diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 0cfad6fd02..6949449fa4 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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); diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index 00c32ff777..74c50a1b42 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -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( diff --git a/packages/api/src/agents/compaction.spec.ts b/packages/api/src/agents/compaction.spec.ts new file mode 100644 index 0000000000..dd5f32fc35 --- /dev/null +++ b/packages/api/src/agents/compaction.spec.ts @@ -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, + }); + }); +}); diff --git a/packages/api/src/agents/compaction.ts b/packages/api/src/agents/compaction.ts new file mode 100644 index 0000000000..2f1c442f50 --- /dev/null +++ b/packages/api/src/agents/compaction.ts @@ -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); +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index fc8db29b0e..49ea87f114 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/triggers/actor.spec.ts b/packages/api/src/agents/triggers/actor.spec.ts index 64cc5d6998..3abef83f71 100644 --- a/packages/api/src/agents/triggers/actor.spec.ts +++ b/packages/api/src/agents/triggers/actor.spec.ts @@ -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), diff --git a/packages/api/src/agents/triggers/actor.ts b/packages/api/src/agents/triggers/actor.ts index ec5bdb39ac..a6f769eef8 100644 --- a/packages/api/src/agents/triggers/actor.ts +++ b/packages/api/src/agents/triggers/actor.ts @@ -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( ...(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( ...(resultContext.contextMeta == null ? {} : { contextMeta: resultContext.contextMeta }), + ...(resultContext.compactionSemanticIndex == null + ? {} + : { compactionSemanticIndex: resultContext.compactionSemanticIndex }), }), }); if (committed.status === 'stale') { @@ -1172,6 +1179,9 @@ export async function resumeAgentEventActor( ...(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( ...(resultContext.contextMeta == null ? {} : { contextMeta: resultContext.contextMeta }), + ...(resultContext.compactionSemanticIndex == null + ? {} + : { compactionSemanticIndex: resultContext.compactionSemanticIndex }), }), }); if (committed.status === 'stale') { diff --git a/packages/api/src/stream/ApprovalLifecycle.ts b/packages/api/src/stream/ApprovalLifecycle.ts index 95b10b576c..c44d381aa6 100644 --- a/packages/api/src/stream/ApprovalLifecycle.ts +++ b/packages/api/src/stream/ApprovalLifecycle.ts @@ -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 } : {}), diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 08de19ed04..5eca68126f 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -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, diff --git a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts index de54ce5911..0c3f039026 100644 --- a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts +++ b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts @@ -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?' }, diff --git a/packages/api/src/stream/__tests__/pendingAction.spec.ts b/packages/api/src/stream/__tests__/pendingAction.spec.ts index 2c12148cab..ee84323db1 100644 --- a/packages/api/src/stream/__tests__/pendingAction.spec.ts +++ b/packages/api/src/stream/__tests__/pendingAction.spec.ts @@ -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'); diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index 73a01b034b..7f2ee875d5 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -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 diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index a885ae6371..b56a259d30 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -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' diff --git a/packages/api/src/stream/metadata.ts b/packages/api/src/stream/metadata.ts index ad2f87c41b..4ddc19252b 100644 --- a/packages/api/src/stream/metadata.ts +++ b/packages/api/src/stream/metadata.ts @@ -104,5 +104,8 @@ export function sanitizeJobMetadata(metadata: Partial): J if (metadata.activityPhaseSnapshot) { patch.activityPhaseSnapshot = metadata.activityPhaseSnapshot; } + if (metadata.compactionSemanticIndex) { + patch.compactionSemanticIndex = metadata.compactionSemanticIndex; + } return patch; } diff --git a/packages/api/src/types/stream.ts b/packages/api/src/types/stream.ts index 5f9707951a..952d62231d 100644 --- a/packages/api/src/types/stream.ts +++ b/packages/api/src/types/stream.ts @@ -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`. */ diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index a887614093..2083f24f09 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -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'; diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index c480f77622..5b24bbc043 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -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 () => { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 850cedd0ee..68e0375b04 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -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; storeAgentEventActorSuspension(input: { @@ -950,6 +952,7 @@ export function createConversationMethods( discoveredToolNames?: IAgentEventActorState['discoveredToolNames']; summary?: IAgentEventActorState['summary']; contextMeta?: IAgentEventActorState['contextMeta']; + compactionSemanticIndex?: IAgentEventActorState['compactionSemanticIndex']; settlementAuthority?: AgentEventActorSettlementAuthority; }): Promise { 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; /** 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( diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index 6ce9caf8d4..61f119f2f1 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -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 = 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 }, diff --git a/packages/data-schemas/src/types/compaction.ts b/packages/data-schemas/src/types/compaction.ts new file mode 100644 index 0000000000..f72424e91c --- /dev/null +++ b/packages/data-schemas/src/types/compaction.ts @@ -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 | 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 | 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; +} diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index bfe8601078..42b706690a 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -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; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 6f29fe93e6..f80edb70f6 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -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';