🩹 fix: Restore the @librechat/api Build and Remove Legacy Code (#14808)

* 🧹 chore: Remove Dead Legacy Agent Controller

`_LegacyAgentController` has been unreachable since the resumable path became
the only route: it is unreferenced, unexported, and untested. It had also
drifted out of compilability against the live file — line 2009 called
`attachConversationCreatedAt(req, { userId, conversationId, isNewConvo })`
against the 3-argument signature declared at line 97, which would await
`undefined` and then throw dereferencing `resolved.createdAt`.

Keeping it was not free. It carried a third independent copy of the response
message-id wiring (`getReqData`, `onStart`, four `updateMetadata` calls), so
every change to how a generation identifies its response row had a dead third
site to keep in step, and no test to say whether it had been kept in step.

Removing the block leaves `createCloseHandler` and the `sendEvent`,
`clientRegistry`, `requestDataMap` and `handleAbortError` imports with no
remaining callers, so those go too. `AgentController` was a three-line
passthrough to `ResumableAgentController`; the real controller is now exported
directly, which also matches the `[ResumableAgentController]` prefix every log
line in the file already uses. `server/routes/agents/chat.js` binds the export
to its own local name and passes the same five arguments, so the route is
unchanged.

No behavior change: 379 lines removed, 2 added.

* 🩹 fix: Remove Duplicated Anchor Block Breaking the `@librechat/api` Build

`dev` does not build. `packages/api/src/agents/activityPhases/runtime.ts`
carries two byte-identical 98-line copies of the same block (former lines
516-613 and 614-711), so rolldown fails to parse it:

    [PARSE_ERROR] Identifier `AnchorFields` has already been declared

The duplicated block is the anchor-construction work from #14805:
`AnchorFields`, `laterDefinedIndex`, `foldedAgentIds`, `boundedAnchor` and
`mergeAnchors`. #14807 was squashed from a branch that predated #14805 and
re-included that commit, so both copies landed. Only the `type` produced an
error — the four function declarations simply redeclare.

This removes the first copy. The two blocks were verified byte-identical
before the cut, and the resulting file has no duplicate top-level
declarations, is missing nothing that #14805 introduced, and retains
everything new to #14807 (`ResolvedPosition`, `resolvePosition`).

Verified: `tsdown` builds, `tsc --noEmit` clean, `config/circular-deps.mjs`
green across all five graphs (it was reporting `✗ @librechat/api` purely
because the build it shells out to was failing), and the 68 tests in
`activityPhases/runtime.spec.ts` pass.

Carried here rather than in a separate PR because this PR's checks cannot go
green until it lands: the failed `packages/api` build cascades into e2e, MCP
list_changed, bombadil and the Docker image jobs.
This commit is contained in:
Danny Avila 2026-08-13 19:57:32 -04:00 committed by GitHub
parent 5bd745783c
commit abc669ab58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 2 additions and 475 deletions

View file

@ -8,7 +8,6 @@ const {
isEphemeralAgentId,
} = require('librechat-data-provider');
const {
sendEvent,
toPendingSteer,
getViolationInfo,
buildMessageFiles,
@ -27,12 +26,11 @@ const {
buildRecoveredSteerPayload,
deleteAgentCheckpoint,
} = require('@librechat/api');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { disposeClient } = require('~/server/cleanup');
const {
getMCPRequestContext,
cleanupMCPRequestContextForReq,
} = require('~/server/services/MCPRequestContext');
const { handleAbortError } = require('~/server/middleware');
const { logViolation } = require('~/cache');
const { saveMessage, getMessages, getConvo } = require('~/models');
const {
@ -63,24 +61,6 @@ function getResourceRecoveryFailure(error) {
};
}
function createCloseHandler(abortController) {
return function (manual) {
if (!manual) {
logger.debug('[AgentController] Request closed');
}
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AgentController] Request aborted on close');
};
}
function resolveConversationCreatedAt({ userId, conversationId, isNewConvo }) {
return resolveConversationAnchor({
isNewConversation: isNewConvo,
@ -1953,359 +1933,4 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
}
};
/**
* Agent Controller - Routes to ResumableAgentController for all requests.
* The legacy non-resumable path is kept below but no longer used by default.
*/
const AgentController = async (req, res, next, initializeClient, addTitle) => {
return ResumableAgentController(req, res, next, initializeClient, addTitle);
};
/**
* Legacy Non-resumable Agent Controller - Uses GenerationJobManager for abort handling.
* Response is streamed directly to client via res, but abort state is managed centrally.
* @deprecated Use ResumableAgentController instead
*/
const _LegacyAgentController = async (req, res, next, initializeClient, addTitle) => {
const {
text,
isRegenerate,
endpointOption,
conversationId: reqConversationId,
isContinued = false,
editedContent = null,
parentMessageId = null,
overrideParentMessageId = null,
responseMessageId: editedResponseMessageId = null,
} = req.body;
// Generate conversationId upfront if not provided - streamId === conversationId always
// Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos)
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
const streamId = conversationId;
let userMessage;
let userMessageId;
let responseMessageId;
let client = null;
let jobCreatedAt;
let cleanupHandlers = [];
// Match the same logic used for conversationId generation above
const userId = req.user.id;
if (
await isUnpersistedPreliminaryParent({
userId,
conversationId: reqConversationId,
parentMessageId,
getMessages,
})
) {
return rejectPreliminaryParentMessageId(res);
}
await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
// Create handler to avoid capturing the entire parent scope
let getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
userMessageId = data[key].messageId;
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
// Update job metadata with prompt tokens for abort handling
GenerationJobManager.updateMetadata(streamId, { promptTokens: data[key] }, jobCreatedAt);
} else if (key === 'sender') {
GenerationJobManager.updateMetadata(streamId, { sender: data[key] }, jobCreatedAt);
}
// conversationId is pre-generated, no need to update from callback
}
};
// Create a function to handle final cleanup
const performCleanup = async () => {
logger.debug('[AgentController] Performing cleanup');
if (Array.isArray(cleanupHandlers)) {
for (const handler of cleanupHandlers) {
try {
if (typeof handler === 'function') {
handler();
}
} catch (e) {
logger.error('[AgentController] Error in cleanup handler', e);
}
}
}
// Complete the job in GenerationJobManager
if (jobCreatedAt != null) {
logger.debug('[AgentController] Completing job in GenerationJobManager');
await GenerationJobManager.completeJob(streamId, undefined, jobCreatedAt);
}
// Dispose client properly
if (client) {
disposeClient(client);
}
// Clear all references
client = null;
getReqData = null;
userMessage = null;
cleanupHandlers = null;
// Clear request data map
if (requestDataMap.has(req)) {
requestDataMap.delete(req);
}
logger.debug('[AgentController] Cleanup completed');
};
try {
let prelimAbortController = new AbortController();
const prelimCloseHandler = createCloseHandler(prelimAbortController);
res.on('close', prelimCloseHandler);
const removePrelimHandler = (manual) => {
try {
prelimCloseHandler(manual);
res.removeListener('close', prelimCloseHandler);
} catch (e) {
logger.error('[AgentController] Error removing close listener', e);
}
};
cleanupHandlers.push(removePrelimHandler);
/** @type {{ client: TAgentClient; userMCPAuthMap?: Record<string, Record<string, string>> }} */
const result = await initializeClient({
req,
res,
endpointOption,
signal: prelimAbortController.signal,
});
if (prelimAbortController.signal?.aborted) {
prelimAbortController = null;
throw new Error('Request was aborted before initialization could complete');
} else {
prelimAbortController = null;
removePrelimHandler(true);
cleanupHandlers.pop();
}
client = result.client;
// Register client with finalization registry if available
if (clientRegistry) {
clientRegistry.register(client, { userId }, client);
}
// Store request data in WeakMap keyed by req object
requestDataMap.set(req, { client });
// Create job in GenerationJobManager for abort handling
// streamId === conversationId (pre-generated above)
const job = await GenerationJobManager.createJob(streamId, userId, conversationId);
jobCreatedAt = job.createdAt;
client.jobCreatedAt = jobCreatedAt;
client.checkpointNamespace = job.metadata?.checkpointNamespace ?? '';
// Store endpoint metadata for abort handling
GenerationJobManager.updateMetadata(
streamId,
{
endpoint: endpointOption.endpoint,
iconURL: getEndpointIconURL(req, endpointOption),
model: getAgentResponseModel(req, endpointOption),
sender: client?.sender,
},
jobCreatedAt,
);
// Store content parts reference for abort
if (client?.contentParts) {
GenerationJobManager.setContentParts(streamId, client.contentParts, jobCreatedAt);
}
const closeHandler = createCloseHandler(job.abortController);
res.on('close', closeHandler);
cleanupHandlers.push(() => {
try {
res.removeListener('close', closeHandler);
} catch (e) {
logger.error('[AgentController] Error removing close listener', e);
}
});
/**
* onStart callback - stores user message and response ID for abort handling
*/
const onStart = (userMsg, respMsgId, _isNewConvo) => {
sendEvent(res, { message: userMsg, created: true });
userMessage = userMsg;
userMessageId = userMsg.messageId;
responseMessageId = respMsgId;
// Store metadata for abort handling (conversationId is pre-generated)
GenerationJobManager.updateMetadata(
streamId,
{
responseMessageId: respMsgId,
userMessage: {
messageId: userMsg.messageId,
parentMessageId: userMsg.parentMessageId,
conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
},
},
jobCreatedAt,
);
};
const messageOptions = {
user: userId,
onStart,
getReqData,
isContinued,
isRegenerate,
editedContent,
conversationId,
parentMessageId,
abortController: job.abortController,
overrideParentMessageId,
isEdited: !!editedContent,
userMCPAuthMap: result.userMCPAuthMap,
responseMessageId: editedResponseMessageId,
progressOptions: {
res,
},
};
let response = await client.sendMessage(text, messageOptions);
// Extract what we need and immediately break reference
const messageId = response.messageId;
const endpoint = endpointOption.endpoint;
response.endpoint = endpoint;
// Store database promise locally
const databasePromise = response.databasePromise;
delete response.databasePromise;
// Resolve database-related data
const { conversation: convoData = {} } = await databasePromise;
const conversation = { ...convoData };
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
if (req.body.files && Array.isArray(client.options.attachments)) {
const files = buildMessageFiles(req.body.files, client.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}
// Only send if not aborted
if (!job.abortController.signal.aborted) {
// Create a new response object with minimal copies
const finalResponse = { ...response };
sendEvent(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: finalResponse,
});
res.end();
// Save the message if needed
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
await saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...finalResponse, user: userId },
{ context: 'api/server/controllers/agents/request.js - response end' },
);
}
}
// Edge case: sendMessage completed but abort happened during sendCompletion
// We need to ensure a final event is sent
else if (!res.headersSent && !res.finished) {
logger.debug(
'[AgentController] Handling edge case: `sendMessage` completed but aborted during `sendCompletion`',
);
const finalResponse = { ...response };
finalResponse.error = true;
sendEvent(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: finalResponse,
error: { message: 'Request was aborted during completion' },
});
res.end();
}
// Save user message if needed
if (!client.skipSaveUserMessage) {
await saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
userMessage,
{ context: "api/server/controllers/agents/request.js - don't skip saving user message" },
);
}
// Add title if needed - extract minimal data
if (addTitle && parentMessageId === Constants.NO_PARENT && isNewConvo) {
addTitle(req, {
text,
response: { ...response },
client,
})
.then(() => {
logger.debug('[AgentController] Title generation started');
})
.catch((err) => {
logger.error('[AgentController] Error in title generation', err);
})
.finally(() => {
logger.debug('[AgentController] Title generation completed');
performCleanup();
});
} else {
performCleanup();
}
} catch (error) {
// Handle error without capturing much scope
handleAbortError(res, req, error, {
conversationId,
sender: client?.sender,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
userMessageId,
})
.catch((err) => {
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
})
.finally(() => {
performCleanup();
});
}
};
module.exports = AgentController;
module.exports = ResumableAgentController;

View file

@ -611,104 +611,6 @@ function mergeAnchors(earlier: TrackedActivity, later: TrackedActivity): Tracked
return fields;
}
/**
* Every field of a bounded anchor, stated explicitly. Anchors are built by
* demoting an activity and by folding two together, and both used to spread
* one side and hand-pick the rest so any field nobody named was dropped
* silently, and nothing failed until a boundary happened to land badly.
* Mapping over `keyof Required<TrackedActivity>` makes every field mandatory
* at the construction site: adding one to `TrackedActivity` is a type error
* until its anchor semantics are decided here.
*/
type AnchorFields = { [K in keyof Required<TrackedActivity>]: TrackedActivity[K] };
function laterDefinedIndex(earlier?: number, later?: number): number | undefined {
if (earlier == null) {
return later;
}
return later == null ? earlier : Math.max(earlier, later);
}
function foldedAgentIds(earlier: TrackedActivity, later: TrackedActivity): string[] | undefined {
const folded = [
...new Set(
[
...(earlier.mergedAgentIds ?? []),
...(earlier.agentId != null ? [earlier.agentId] : []),
...(later.mergedAgentIds ?? []),
...(later.agentId != null ? [later.agentId] : []),
].filter((id) => id !== earlier.agentId),
),
];
return folded.length > 0 ? folded : undefined;
}
/** Strips prompt evidence while keeping everything a boundary reasons about. */
function boundedAnchor(activity: TrackedActivity): TrackedActivity {
const fields: AnchorFields = {
startIndex: activity.startIndex,
bounded: true,
status: activity.status,
partitionStartIndex: activity.partitionStartIndex,
unresolvedToolStartIndex: activity.unresolvedToolStartIndex,
toolCallIds: activity.toolCallIds?.slice(-MAX_RETAINED_TOOL_ENTRIES),
thinkingExcerpts: activity.thinkingExcerpts
?.slice(-MAX_RETAINED_TOOL_ENTRIES)
.map((text) => text.slice(-REASONING_ANCHOR_CHARS)),
agentId: activity.agentId,
mergedCount: activity.mergedCount,
mergedFailedCount: activity.mergedFailedCount,
mergedPartialCount: activity.mergedPartialCount,
mergedAgentIds: activity.mergedAgentIds,
/** An anchor is never summarized directly, so prompt evidence and the
* child-label slot are deliberately not carried. */
label: undefined,
entries: undefined,
childLabelIndex: undefined,
};
return fields;
}
/** Folds `later` into `earlier`, which keeps its position. */
function mergeAnchors(earlier: TrackedActivity, later: TrackedActivity): TrackedActivity {
const mergedFailedCount = countFailedActivities([earlier, later]);
const mergedPartialCount = countPartialActivities([earlier, later]);
const excerpts =
earlier.thinkingExcerpts != null || later.thinkingExcerpts != null
? [...(earlier.thinkingExcerpts ?? []), ...(later.thinkingExcerpts ?? [])].slice(
-MAX_RETAINED_TOOL_ENTRIES,
)
: undefined;
const fields: AnchorFields = {
/** The pair starts where its first activity did; the later side's floor
* describes a position being absorbed and must not move the survivor. */
startIndex: earlier.startIndex,
partitionStartIndex: earlier.partitionStartIndex,
bounded: true,
status: earlier.status,
/** The later side may still be waiting on a tool call. Dropping its
* fallback lets a boundary close the whole merged count on the earlier
* side; resolution clears it once every retained id materializes. */
unresolvedToolStartIndex: laterDefinedIndex(
earlier.unresolvedToolStartIndex,
later.unresolvedToolStartIndex,
),
toolCallIds: [...(earlier.toolCallIds ?? []), ...(later.toolCallIds ?? [])].slice(
-MAX_RETAINED_TOOL_ENTRIES,
),
thinkingExcerpts: excerpts,
agentId: earlier.agentId,
mergedCount: (earlier.mergedCount ?? 1) + (later.mergedCount ?? 1),
mergedFailedCount: mergedFailedCount > 0 ? mergedFailedCount : undefined,
mergedPartialCount: mergedPartialCount > 0 ? mergedPartialCount : undefined,
mergedAgentIds: foldedAgentIds(earlier, later),
label: undefined,
entries: undefined,
childLabelIndex: undefined,
};
return fields;
}
/** Every activity is represented by exactly one positioned item, so run totals
* are a sum over that list rather than a separately maintained scalar. */
function countActivities(activities: ReadonlyArray<TrackedActivity>): number {