mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-01 19:46:50 +00:00
* 🛑 fix: Forward Run Abort Signal to Foreground Tool Calls Stopping a generation left in-flight tool calls running on the far side. For MCP over streamable-http the external server kept burning CPU until the tool finished or timed out, with no `notifications/cancelled` ever sent. The MCP layer was never the problem: `MCPManager.callTool` already spreads `options.signal` into `client.request`, and the SDK's `Protocol.request` sends `notifications/cancelled` the moment that signal aborts. The signal simply never arrived. Agent tools do not execute inside LangGraph's ToolNode — `eventDrivenMode` dispatches `on_tool_execute` and `createToolExecuteHandler` invokes them. The agents SDK puts the run's abort signal on the batch request (`ToolExecuteBatchRequest.signal`, documented as one handlers should forward), but the handler destructured every field except `signal` and built its invoke config without it. `config.signal` was therefore `undefined` in every foreground tool, so `createMCPTool` derived no signal and the SDK registered no abort listener. Forward it. This restores cancellation for all signal-aware foreground tools, not just MCP. The detached background invoke keeps its own controller, since that work deliberately outlives the turn. An aborted call now rejects promptly and resolves an error result rather than hanging, so the abort is logged at debug instead of as a tool error — a user pressing Stop should not spray the error log. * 🔇 fix: Keep Cancelled Tool Calls on the Filtered Path and Out of Error Logs Addresses both findings from the Codex review of48fded564a. P1 — the cancellation branch returned before `filteredToolOutputResult`, so with tool-output filtering configured an aborted call's error text reached the turn uninspected. Worse, `runSignal.aborted` says the run is over, not that this rejection was the cancellation: an unrelated failure racing the Stop took the same unfiltered exit. Cancellation now only selects the log level; filtering and the result shape are unchanged. P2 — `createMCPTool` logs its own error before wrapping and rethrowing, so every Stop during an MCP call still emitted an error-level MCP entry and could pollute operational alerts. That catch now recognizes the abort and logs at debug; the wrapped message still reaches the turn. Both are covered by tests that fail against the previous commit. * 🎯 fix: Require an Abort-Shaped Error and Spare Shared OAuth Flows Addresses both findings from the Codex review ofb3c67082cd. An aborted run signal proves the turn is over, not that the rejection in hand was the cancellation — a permission, OAuth, or upstream failure can reject in the same tick a user presses Stop. Both quiet-log branches now require the error to look like an abort as well, so a real failure racing the Stop stays at error level and visible to operational alerts. `isAbortError` moves to `@librechat/api` and the copy in `abortMiddleware` is retired, keeping one implementation. Its spec pulls the real function through its partial package mock rather than asserting a stub. Forwarding the run signal also reached ActionService, where two of the three OAuth flows are keyed `userId:action_id` and so outlive the run that opened them: a second run for the same action joins the very same record, and the browser's OAuth callback reads its metadata to exchange the code. `monitorFlow` deletes the key when a waiter's signal aborts, so one Stop could strand a concurrent run and discard an authorization the user had already granted. Those two flows no longer take the signal; the run-scoped login flow, keyed by thread and run, still does. Every guard is covered by a test that fails when the guard is removed. * 🧷 fix: Detach the Stopped OAuth Waiter Instead of Dropping Its Signal Addresses the Codex review ofd53ff170ab. Withholding the run signal from the shared Action OAuth flows protected concurrent runs and the browser callback, but left this invocation attached to the flow after the Stop. A callback landing late then resumed `_call` straight into `preparedExecutor.execute` — a consequential API request running after the user had already stopped the turn. Handing the signal back is not the answer either, since `monitorFlow` deletes the shared `userId:action_id` key on abort. `detachOnAbort` separates the two: the caller stops waiting the moment the signal aborts, while the shared flow runs on for whoever else needs it — another run, or the callback exchanging its code. Late settlement of the detached work is swallowed rather than surfacing as an unhandled rejection. Both shared flows now use it. The run-scoped login flow keeps the signal directly, as nothing outside its run observes it. * 🪪 fix: Keep an Action OAuth Abort an Abort Addresses two of the three findings from the Codex review of70fc0782b1. Detaching the stopped waiter left the rejection to be misread on the way out. The refresh catch treated it as a failed refresh and called `requestLogin()`, emitting an OAuth prompt and opening pending authorization state for a turn that had already ended. `requestLogin` and the surrounding auth catch relabelled it `Failed to authenticate OAuth tool` / `Authentication failed`, and the outer catch handed that to `logAxiosError` — so an ordinary Stop produced two error-level entries and returned failure text as the tool's result. Each boundary now lets an abort through unchanged, and the outer catch logs it at debug and rethrows so it stays a cancellation end to end. Deferred: waiter-only cancellation in `FlowStateManager`, so a detached `monitorFlow` stops polling instead of running out the flow's TTL. That poller is bounded at three minutes and lives exactly as long as it does for a waiter that never left, so it changes no lifetime this branch introduced; giving `FlowStateManager` a non-destructive abort mode is its own change, shared with MCP and indexSync. * 🤫 fix: Classify the Abort at the MCPManager Boundary Too Addresses the Codex review of9bcf1aa57e. `MCPManager.callTool` logs every rejection at error level before rethrowing to `createMCPTool`, so a user Stop still emitted an MCP error from the inner boundary even after the outer one learned to recognize it. The same signal-plus-abort-shape classification now applies here. The review also noted, correctly, that the existing test could not have caught this: it replaces `getMCPManager` wholesale, so it only ever exercised the outer logger. The new cases drive the real manager with a connection whose `client.request` rejects the way the SDK does once a request signal aborts, and cover both the cancellation and a genuine failure racing the Stop. * 🧹 chore: Restore Import Order in the Agents Tool Handler
300 lines
9 KiB
JavaScript
300 lines
9 KiB
JavaScript
const { logger } = require('@librechat/data-schemas');
|
|
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
|
|
const {
|
|
isEnabled,
|
|
sendEvent,
|
|
countTokens,
|
|
isAbortError,
|
|
GenerationJobManager,
|
|
recordCollectedUsage,
|
|
getTransactionsConfig,
|
|
sanitizeMessageForTransmit,
|
|
buildAbortedResponseMetadata,
|
|
} = require('@librechat/api');
|
|
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
|
const clearPendingReq = require('~/cache/clearPendingReq');
|
|
const { sendError } = require('~/server/middleware/error');
|
|
const { abortRun } = require('./abortRun');
|
|
const db = require('~/models');
|
|
|
|
/**
|
|
* Spend tokens for all models from collected usage.
|
|
* This handles both sequential and parallel agent execution.
|
|
*
|
|
* IMPORTANT: After spending, this function clears the collectedUsage array
|
|
* to prevent double-spending. The array is shared with AgentClient.collectedUsage,
|
|
* so clearing it here prevents the finally block from also spending tokens.
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} params.userId - User ID
|
|
* @param {string} params.conversationId - Conversation ID
|
|
* @param {Array<Object>} params.collectedUsage - Usage metadata from all models
|
|
* @param {string} [params.fallbackModel] - Fallback model name if not in usage
|
|
* @param {string} [params.messageId] - The response message ID for transaction correlation
|
|
* @param {AppConfig['transactions']} [params.transactions] - Resolved transactions config
|
|
*/
|
|
async function spendCollectedUsage({
|
|
userId,
|
|
conversationId,
|
|
collectedUsage,
|
|
fallbackModel,
|
|
messageId,
|
|
transactions,
|
|
}) {
|
|
if (!collectedUsage || collectedUsage.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await recordCollectedUsage(
|
|
{
|
|
spendTokens: db.spendTokens,
|
|
spendStructuredTokens: db.spendStructuredTokens,
|
|
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
|
|
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
|
|
},
|
|
{
|
|
user: userId,
|
|
conversationId,
|
|
collectedUsage,
|
|
context: 'abort',
|
|
messageId,
|
|
model: fallbackModel,
|
|
transactions,
|
|
},
|
|
);
|
|
|
|
// Clear the array to prevent double-spending from the AgentClient finally block.
|
|
// The collectedUsage array is shared by reference with AgentClient.collectedUsage,
|
|
// so clearing it here ensures recordCollectedUsage() sees an empty array and returns early.
|
|
collectedUsage.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Abort an active message generation.
|
|
* Uses GenerationJobManager for all agent requests.
|
|
* Since streamId === conversationId, we can directly abort by conversationId.
|
|
*/
|
|
async function abortMessage(req, res) {
|
|
const { abortKey, endpoint } = req.body;
|
|
|
|
if (isAssistantsEndpoint(endpoint)) {
|
|
return await abortRun(req, res);
|
|
}
|
|
|
|
const conversationId = abortKey?.split(':')?.[0] ?? req.user.id;
|
|
const userId = req.user.id;
|
|
|
|
// Use GenerationJobManager to abort the job (streamId === conversationId)
|
|
const abortResult = await GenerationJobManager.abortJob(conversationId);
|
|
|
|
if (!abortResult.success) {
|
|
if (!res.headersSent) {
|
|
return res.status(204).send({ message: 'Request not found' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { jobData, content, text, collectedUsage } = abortResult;
|
|
|
|
const completionTokens = await countTokens(text);
|
|
const promptTokens = jobData?.promptTokens ?? 0;
|
|
|
|
const responseMessage = {
|
|
messageId: jobData?.responseMessageId,
|
|
parentMessageId: jobData?.userMessage?.messageId,
|
|
conversationId: jobData?.conversationId,
|
|
content,
|
|
text,
|
|
sender: jobData?.sender ?? 'AI',
|
|
finish_reason: 'incomplete',
|
|
endpoint: jobData?.endpoint,
|
|
iconURL: jobData?.iconURL,
|
|
model: jobData?.model,
|
|
unfinished: false,
|
|
error: false,
|
|
isCreatedByUser: false,
|
|
tokenCount: completionTokens,
|
|
};
|
|
|
|
/** Persist the usage/cost rollup + context breakdown for the stopped response
|
|
* so its branch/total cost and granular rows survive a reload, matching the
|
|
* normal completion path. */
|
|
const abortMetadata = buildAbortedResponseMetadata(jobData);
|
|
if (abortMetadata) {
|
|
responseMessage.metadata = abortMetadata;
|
|
}
|
|
|
|
const transactions = getTransactionsConfig(req.config);
|
|
|
|
// Spend tokens for ALL models from collectedUsage (handles parallel agents/addedConvo)
|
|
if (collectedUsage && collectedUsage.length > 0) {
|
|
await spendCollectedUsage({
|
|
userId,
|
|
conversationId: jobData?.conversationId,
|
|
collectedUsage,
|
|
fallbackModel: jobData?.model,
|
|
messageId: jobData?.responseMessageId,
|
|
transactions,
|
|
});
|
|
} else {
|
|
// Fallback: no collected usage, use text-based token counting for primary model only
|
|
await db.spendTokens(
|
|
{ ...responseMessage, context: 'incomplete', user: userId, transactions },
|
|
{ promptTokens, completionTokens },
|
|
);
|
|
}
|
|
|
|
await db.saveMessage(
|
|
{
|
|
userId: req?.user?.id,
|
|
isTemporary: req?.body?.isTemporary,
|
|
interfaceConfig: req?.config?.interfaceConfig,
|
|
},
|
|
{ ...responseMessage, user: userId },
|
|
{ context: 'api/server/middleware/abortMiddleware.js' },
|
|
);
|
|
|
|
// Get conversation for title
|
|
const conversation = await db.getConvo(userId, conversationId);
|
|
|
|
const finalEvent = {
|
|
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
|
|
final: true,
|
|
conversation,
|
|
requestMessage: jobData?.userMessage
|
|
? sanitizeMessageForTransmit({
|
|
messageId: jobData.userMessage.messageId,
|
|
parentMessageId: jobData.userMessage.parentMessageId,
|
|
conversationId: jobData.userMessage.conversationId,
|
|
text: jobData.userMessage.text,
|
|
quotes: jobData.userMessage.quotes,
|
|
isCreatedByUser: true,
|
|
})
|
|
: null,
|
|
responseMessage,
|
|
};
|
|
|
|
logger.debug(
|
|
`[abortMessage] ID: ${userId} | ${req.user.email} | Aborted request: ${conversationId}`,
|
|
);
|
|
|
|
if (res.headersSent) {
|
|
return sendEvent(res, finalEvent);
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.send(JSON.stringify(finalEvent));
|
|
}
|
|
|
|
const handleAbort = function () {
|
|
return async function (req, res) {
|
|
try {
|
|
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
|
|
await clearPendingReq({ userId: req.user.id });
|
|
}
|
|
return await abortMessage(req, res);
|
|
} catch (err) {
|
|
logger.error('[abortMessage] handleAbort error', err);
|
|
}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Handle abort errors during generation.
|
|
* @param {ServerResponse} res
|
|
* @param {ServerRequest} req
|
|
* @param {Error | unknown} error
|
|
* @param {Partial<TMessage> & { partialText?: string }} data
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleAbortError = async (res, req, error, data) => {
|
|
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
|
|
|
|
if (error?.message?.includes('base64')) {
|
|
logger.error('[handleAbortError] Error in base64 encoding', {
|
|
...error,
|
|
stack: smartTruncateText(error?.stack, 1000),
|
|
message: truncateText(error.message, 350),
|
|
});
|
|
} else if (isAbortError(error)) {
|
|
logger.debug('[handleAbortError] AI response aborted by user', {
|
|
conversationId,
|
|
code: error?.code,
|
|
name: error?.name,
|
|
message: truncateText(error?.message ?? 'AbortError', 350),
|
|
});
|
|
} else {
|
|
logger.error('[handleAbortError] AI response error; aborting request:', error);
|
|
}
|
|
|
|
if (error?.stack && error.stack.includes('google')) {
|
|
logger.warn(
|
|
`AI Response error for conversation ${conversationId} likely caused by Google censor/filter`,
|
|
);
|
|
}
|
|
|
|
let errorText = error?.message?.includes('"type"')
|
|
? error.message
|
|
: 'An error occurred while processing your request. Please contact the Admin.';
|
|
|
|
if (error?.type === ErrorTypes.INVALID_REQUEST) {
|
|
errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
|
|
}
|
|
|
|
if (error?.message?.includes("does not support 'system'")) {
|
|
errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
|
|
}
|
|
|
|
/**
|
|
* @param {string} partialText
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const respondWithError = async (partialText) => {
|
|
const endpointOption = req.body?.endpointOption;
|
|
let options = {
|
|
sender,
|
|
messageId,
|
|
conversationId,
|
|
parentMessageId,
|
|
text: errorText,
|
|
user: req.user.id,
|
|
spec: endpointOption?.spec,
|
|
iconURL: endpointOption?.iconURL,
|
|
modelLabel: endpointOption?.modelLabel,
|
|
shouldSaveMessage: userMessageId != null,
|
|
model: endpointOption?.modelOptions?.model || req.body?.model,
|
|
};
|
|
|
|
if (req.body?.agent_id) {
|
|
options.agent_id = req.body.agent_id;
|
|
}
|
|
|
|
if (partialText) {
|
|
options = {
|
|
...options,
|
|
error: false,
|
|
unfinished: true,
|
|
text: partialText,
|
|
};
|
|
}
|
|
|
|
await sendError(req, res, options);
|
|
};
|
|
|
|
if (partialText && partialText.length > 5) {
|
|
try {
|
|
return await abortMessage(req, res);
|
|
} catch (err) {
|
|
logger.error('[handleAbortError] error while trying to abort message', err);
|
|
return respondWithError(partialText);
|
|
}
|
|
} else {
|
|
return respondWithError();
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
handleAbort,
|
|
handleAbortError,
|
|
spendCollectedUsage,
|
|
};
|