LibreChat/api/server/services/Endpoints/agents/title.js
Danny Avila 67b7b441b2
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
2026-08-21 22:43:32 -04:00

178 lines
6.2 KiB
JavaScript

const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { saveConvo } = require('~/models');
const { resolveConversationTitle } = require('../titlePolicy');
/**
* Add title to conversation in a way that avoids memory retention.
*
* @param {ServerRequest} req
* @param {Object} params
* @param {string} params.text - The user's first message.
* @param {TMessage} [params.response] - The assistant response (legacy/`final` timing only).
* @param {AgentClient} params.client
* @param {string} [params.conversationId] - Required for `immediate` timing, where
* `response` is not yet available; falls back to `response.conversationId`.
* @param {boolean} [params.immediate] - When true, the title is generated in parallel
* with the response (from the user's first message) and persisted to the conversation
* only after `convoReady` resolves (the conversation row must exist for `noUpsert`).
* @param {Promise<void>} [params.convoReady] - Resolves once the conversation has been
* persisted; awaited before saving the title in `immediate` mode.
* @param {AbortSignal} [params.signal] - When aborted (e.g. the user stops an
* immediate-mode generation), cancels the in-flight title model call so a
* turn stopped before the title finished does not consume the title model. A
* title that already finished generating is still persisted and surfaced.
* @param {AbortSignal} [params.discardSignal] - When aborted, discards an
* already-generated title instead of persisting it. Used only when this stream
* is superseded by a newer run (or the turn failed), so a stale title does not
* clobber the conversation now owned by the newer run. A plain user Stop does
* NOT abort this — its generated title is kept.
* @param {(params: { conversationId: string, title: string }) => Promise<void>|void} [params.onTitleGenerated]
* Called after the title is cached and before persistence waits for the
* conversation row. Used by live streams to push the title immediately.
*/
const addTitle = async (
req,
{
text,
response,
client,
conversationId,
immediate = false,
convoReady,
signal,
discardSignal,
onTitleGenerated,
},
) => {
const { TITLE_CONVO = true } = process.env ?? {};
if (!isEnabled(TITLE_CONVO)) {
return;
}
if (client.options.titleConvo === false) {
return;
}
// Skip title generation for temporary conversations
if (req?.body?.isTemporary) {
return;
}
const convoId = conversationId ?? response?.conversationId;
if (!convoId) {
logger.warn('[addTitle] Missing conversationId; skipping title generation');
return;
}
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${convoId}`;
/** @type {NodeJS.Timeout} */
let timeoutId;
try {
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Title generation timeout')), 45000);
}).catch((error) => {
logger.error('Title error:', error);
});
let titlePromise;
let abortController = new AbortController();
/** Propagate a request abort (Stop) to the title generation so a cancelled
* turn does not consume the title model or surface a title. */
if (signal) {
if (signal.aborted) {
abortController.abort();
} else {
signal.addEventListener('abort', () => abortController.abort(), { once: true });
}
}
if (client && typeof client.titleConvo === 'function') {
titlePromise = Promise.race([
client
.titleConvo({
text,
abortController,
immediate,
})
.catch((error) => {
logger.error('Client title error:', error);
}),
timeoutPromise,
]);
} else {
return;
}
const generatedTitle = await titlePromise;
if (!abortController.signal.aborted) {
abortController.abort();
}
if (timeoutId) {
clearTimeout(timeoutId);
}
if (!generatedTitle) {
logger.debug(`[${key}] No title generated`);
return;
}
const title = resolveConversationTitle(req, generatedTitle);
if (title == null) {
return;
}
await titleCache.set(key, title, 120000);
if (!signal?.aborted && typeof onTitleGenerated === 'function') {
try {
await onTitleGenerated({ conversationId: convoId, title });
} catch (error) {
logger.error('Error emitting generated title:', error);
}
}
/** In immediate mode the title is generated in parallel with the response,
* so the conversation row may not exist yet. `saveConvo` with `noUpsert`
* is a silent no-op when the row is missing, which would drop the title
* from the database (the cache above still serves the live UI). Wait for
* the controller to signal the conversation has been persisted. */
if (convoReady) {
await convoReady;
}
if (discardSignal?.aborted) {
// This stream was superseded by a newer run (or the turn failed) after the
// title had already been generated — discard it so a stale title does not
// clobber the conversation now owned by the newer run. A plain user Stop is
// not a discard: its generated title falls through and is persisted below.
// Only clear the cache if it still holds THIS task's title: a replacement
// stream shares the `userId-conversationId` key and may have already cached
// its own (valid) title that we must not remove.
const cached = await titleCache.get(key);
if (cached === title) {
await titleCache.delete(key);
}
return;
}
await saveConvo(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{
conversationId: convoId,
title,
},
{ context: 'api/server/services/Endpoints/agents/title.js', noUpsert: true },
);
} catch (error) {
logger.error('Error generating title:', error);
}
};
module.exports = addTitle;