Skip to content
Merged
29 changes: 27 additions & 2 deletions packages/api/src/agents/__tests__/run-summarization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe('summarizationConfig field passthrough', () => {
const agents = await callAndCapture({
summarizationConfig: {
enabled: true,
trigger: { type: 'token_count', value: 8000 },
trigger: { type: 'token_ratio', value: 0.8 },
provider: 'anthropic',
model: 'claude-3-haiku',
parameters: { temperature: 0.2 },
Expand All @@ -233,7 +233,7 @@ describe('summarizationConfig field passthrough', () => {
// `enabled` is not forwarded to the agent-level config β€” it is resolved
// into the separate `summarizationEnabled` boolean on the agent input.
expect(agents[0].summarizationEnabled).toBe(true);
expect(config.trigger).toEqual({ type: 'token_count', value: 8000 });
expect(config.trigger).toEqual({ type: 'token_ratio', value: 0.8 });
expect(config.provider).toBe('anthropic');
expect(config.model).toBe('claude-3-haiku');
expect(config.parameters).toEqual({ temperature: 0.2 });
Expand All @@ -254,6 +254,31 @@ describe('summarizationConfig field passthrough', () => {
expect(config.provider).toBe('openAI');
expect(config.model).toBe('gpt-4o');
});

it('preserves `token_ratio` trigger with `value: 0` (documented, extreme-but-valid)', async () => {
const agents = await callAndCapture({
summarizationConfig: {
enabled: true,
trigger: { type: 'token_ratio', value: 0 },
},
});
const config = agents[0].summarizationConfig as Record<string, unknown>;
expect(config.trigger).toEqual({ type: 'token_ratio', value: 0 });
});

it.each([
['remaining_tokens', 500],
['messages_to_refine', 4],
] as const)('passes %s trigger through unchanged', async (type, value) => {
const agents = await callAndCapture({
summarizationConfig: {
enabled: true,
trigger: { type, value },
},
});
const config = agents[0].summarizationConfig as Record<string, unknown>;
expect(config.trigger).toEqual({ type, value });
});
});

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agents/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ function shapeSummarizationConfig(
const provider = config?.provider ?? fallbackProvider;
const model = config?.model ?? fallbackModel;
const trigger =
config?.trigger?.type && config?.trigger?.value
config?.trigger?.type && typeof config?.trigger?.value === 'number'
? { type: config.trigger.type, value: config.trigger.value }
: undefined;

Expand Down
108 changes: 108 additions & 0 deletions packages/data-provider/specs/config-schemas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
interfaceSchema,
fileStorageSchema,
fileStrategiesSchema,
summarizationTriggerSchema,
summarizationConfigSchema,
} from '../src/config';
import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas';
import { FileSources } from '../src/types/files';
Expand Down Expand Up @@ -502,3 +504,109 @@ describe('interfaceSchema', () => {
expect(result.modelSelect).toBe(false);
});
});

describe('summarizationTriggerSchema', () => {
it.each([
['token_ratio', 0.8],
['remaining_tokens', 500],
['messages_to_refine', 4],
] as const)('accepts documented trigger type "%s" with a sensible value', (type, value) => {
const result = summarizationTriggerSchema.safeParse({ type, value });
expect(result.success).toBe(true);
});

it('rejects the legacy/typoed "token_count" trigger type', () => {
const result = summarizationTriggerSchema.safeParse({
type: 'token_count',
value: 8000,
});
expect(result.success).toBe(false);
});

it('rejects unknown trigger types', () => {
const result = summarizationTriggerSchema.safeParse({
type: 'never_heard_of_it',
value: 1,
});
expect(result.success).toBe(false);
});

it('rejects negative values on any trigger type', () => {
expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: -0.5 }).success).toBe(
false,
);
expect(
summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: -1 }).success,
).toBe(false);
expect(
summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: -1 }).success,
).toBe(false);
});

it('rejects zero for count-based triggers where it has no meaningful effect', () => {
expect(
summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: 0 }).success,
).toBe(false);
expect(
summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: 0 }).success,
).toBe(false);
});

it('rejects token_ratio values > 1 to catch the "80 meant as 80%" mistake', () => {
expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 80 }).success).toBe(
false,
);
expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 1.01 }).success).toBe(
false,
);
});

it('accepts token_ratio values at the inclusive 0 and 1 bounds per docs', () => {
expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 0 }).success).toBe(
true,
);
expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 1 }).success).toBe(
true,
);
});

it('allows remaining_tokens and messages_to_refine values above 1 (token/message counts)', () => {
expect(
summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: 2000 }).success,
).toBe(true);
expect(
summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: 20 }).success,
).toBe(true);
});

it('rejects non-finite values (Infinity, NaN) for every trigger type', () => {
for (const type of ['token_ratio', 'remaining_tokens', 'messages_to_refine'] as const) {
expect(summarizationTriggerSchema.safeParse({ type, value: Infinity }).success).toBe(false);
expect(summarizationTriggerSchema.safeParse({ type, value: -Infinity }).success).toBe(false);
expect(summarizationTriggerSchema.safeParse({ type, value: NaN }).success).toBe(false);
}
});

it('requires integer values for count-based triggers', () => {
expect(
summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: 500.5 }).success,
).toBe(false);
expect(
summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: 2.5 }).success,
).toBe(false);
});

it('still allows fractional values for token_ratio', () => {
expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 0.8 }).success).toBe(
true,
);
});

it('parses inside the full summarization config', () => {
const result = summarizationConfigSchema.safeParse({
enabled: true,
trigger: { type: 'token_ratio', value: 0.8 },
});
expect(result.success).toBe(true);
});
});
18 changes: 14 additions & 4 deletions packages/data-provider/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,10 +1020,20 @@ export const memorySchema = z.object({

export type TMemoryConfig = DeepPartial<z.infer<typeof memorySchema>>;

export const summarizationTriggerSchema = z.object({
type: z.enum(['token_count']),
value: z.number().positive(),
});
export const summarizationTriggerSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('token_ratio'),
value: z.number().finite().min(0).max(1),
}),
z.object({
type: z.literal('remaining_tokens'),
value: z.number().finite().int().positive(),
}),
z.object({
type: z.literal('messages_to_refine'),
value: z.number().finite().int().positive(),
}),
]);

export const contextPruningSchema = z.object({
enabled: z.boolean().optional(),
Expand Down
80 changes: 80 additions & 0 deletions packages/data-schemas/src/app/service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { DeepPartial, TCustomConfig } from 'librechat-data-provider';
import { loadSummarizationConfig } from './service';
import logger from '~/config/winston';

jest.mock('~/config/winston', () => ({
__esModule: true,
default: {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));

describe('loadSummarizationConfig', () => {
const warnSpy = logger.warn as jest.Mock;

beforeEach(() => {
warnSpy.mockClear();
});

it('returns undefined when no summarization config is provided', () => {
expect(loadSummarizationConfig({} as DeepPartial<TCustomConfig>)).toBeUndefined();
});

it('accepts a valid token_ratio trigger', () => {
const result = loadSummarizationConfig({
summarization: {
enabled: true,
trigger: { type: 'token_ratio', value: 0.8 },
},
} as DeepPartial<TCustomConfig>);

expect(result).toBeDefined();
expect(result?.enabled).toBe(true);
expect(result?.trigger).toEqual({ type: 'token_ratio', value: 0.8 });
expect(warnSpy).not.toHaveBeenCalled();
});

it('emits a targeted migration warning when trigger.type is the legacy "token_count"', () => {
const result = loadSummarizationConfig({
summarization: {
trigger: { type: 'token_count', value: 8000 },
},
} as unknown as DeepPartial<TCustomConfig>);

expect(result).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
const message = String(warnSpy.mock.calls[0][0]);
expect(message).toContain('token_count');
expect(message).toContain('token_ratio');
expect(message).toContain('remaining_tokens');
expect(message).toContain('messages_to_refine');
expect(message).toContain('fall back');
});

it('falls back to the generic warning when trigger is a bare string (not an object)', () => {
const result = loadSummarizationConfig({
summarization: {
trigger: 'token_count',
},
} as unknown as DeepPartial<TCustomConfig>);

expect(result).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('Invalid summarization config');
});

it('falls back to the generic warning for other schema violations', () => {
const result = loadSummarizationConfig({
summarization: {
trigger: { type: 'token_ratio', value: 80 },
},
} as unknown as DeepPartial<TCustomConfig>);

expect(result).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('Invalid summarization config');
});
});
20 changes: 19 additions & 1 deletion packages/data-schemas/src/app/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,30 @@ import { loadEndpoints } from './endpoints';
import { loadOCRConfig } from './ocr';
import logger from '~/config/winston';

function loadSummarizationConfig(config: DeepPartial<TCustomConfig>): AppConfig['summarization'] {
export function loadSummarizationConfig(
config: DeepPartial<TCustomConfig>,
): AppConfig['summarization'] {
const raw = config.summarization;
if (!raw || typeof raw !== 'object') {
return undefined;
}

if (
raw.trigger &&
typeof raw.trigger === 'object' &&
(raw.trigger as { type?: unknown }).type === 'token_count'
) {
logger.warn(
"[AppService] `summarization.trigger.type: 'token_count'` is no longer supported. " +
"Use 'token_ratio' (0-1), 'remaining_tokens' (positive integer), or " +
"'messages_to_refine' (positive integer). Your `summarization` config will be " +
'ignored and summarization will fall back to self-summarize defaults (the ' +
"agent's own provider/model, fires on every pruning event) until this is " +
'corrected.',
);
return undefined;
}

const parsed = summarizationConfigSchema.safeParse(raw);
if (!parsed.success) {
logger.warn('[AppService] Invalid summarization config', parsed.error.flatten());
Expand Down
Loading