mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-29 10:12:39 +00:00
🛄 feat: Restore Compaction Guidance Across Continuations (#15356)
* 🧭 feat: preserve compaction guidance across continuations * 🔧 fix: narrow persisted compaction fields
This commit is contained in:
parent
b97d2868d4
commit
e3ccaba5af
24 changed files with 655 additions and 4 deletions
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
101
packages/api/src/agents/compaction.spec.ts
Normal file
101
packages/api/src/agents/compaction.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
110
packages/api/src/agents/compaction.ts
Normal file
110
packages/api/src/agents/compaction.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?' },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
102
packages/data-schemas/src/types/compaction.ts
Normal file
102
packages/data-schemas/src/types/compaction.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue