diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 01effa0049..0e09f14294 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -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> }} */ - 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; diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index 37f2708da5..c542d7c258 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -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` 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[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): number {