🛟 fix: Stop Agents When Code Resources Cannot Recover (#14651)

* fix: Block Agents When Code Resources Cannot Recover

* fix: Preserve Resource Recovery Failures Across Agent Paths

* fix: Centralize Fatal Agent Initialization

* chore: sort agent imports
This commit is contained in:
Danny Avila 2026-08-07 00:33:28 -04:00 committed by GitHub
parent 6d2f29266c
commit 5ff46d8c67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 629 additions and 65 deletions

View file

@ -3,7 +3,7 @@
* Tests that recordCollectedUsage is called correctly for token spending
*/
const { ResourceType } = require('librechat-data-provider');
const { ErrorTypes, ResourceType } = require('librechat-data-provider');
const mockProcessStream = jest.fn().mockResolvedValue(undefined);
const mockSpendTokens = jest.fn().mockResolvedValue({});
@ -196,8 +196,8 @@ jest.mock('~/cache', () => ({
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn().mockResolvedValue([]),
loadToolsForExecution: jest.fn().mockResolvedValue([]),
isExpectedMCPToolsUnavailableError: (error) =>
error?.code === 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
isFatalAgentInitializationError: (error) =>
['AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', 'resource_recovery_required'].includes(error?.code),
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
@ -420,6 +420,36 @@ describe('OpenAIChatCompletionController', () => {
expect(res.status).toHaveBeenCalledWith(503);
});
it('returns the resource recovery status and code before model invocation', async () => {
const { createErrorResponse, initializeAgent } = require('@librechat/api');
const { loadAgentTools } = require('~/server/services/ToolService');
const toolError = Object.assign(new Error('resource recovery required'), {
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
status: 409,
statusCode: 409,
});
loadAgentTools.mockRejectedValueOnce(toolError);
initializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
await loadTools({
req,
res,
tools: ['execute_code'],
model: agent.model,
agentId: agent.id,
provider: agent.provider,
});
});
await OpenAIChatCompletionController(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(createErrorResponse).toHaveBeenCalledWith(
'resource recovery required',
'invalid_request_error',
ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
);
});
});
describe('execution envelope', () => {

View file

@ -211,6 +211,7 @@ jest.mock('~/models', () => ({
}));
const AgentController = require('../request');
const { ErrorTypes } = require('librechat-data-provider');
const { disposeClient: mockDisposeClient } = require('~/server/cleanup');
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
@ -1749,6 +1750,73 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
});
it('returns a typed recovery conflict before acknowledging generation startup', async () => {
const recoveryError = new Error('Attached resources could not be restored');
recoveryError.code = ErrorTypes.RESOURCE_RECOVERY_REQUIRED;
mockGenerationJobManager.createJob.mockRejectedValue(recoveryError);
const req = {
user: { id: 'user-123' },
body: {
text: 'Describe the attached image.',
messageId: 'user-msg',
clientRequestId: 'req-abc',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), jest.fn(), null);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
status: 409,
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
error: 'Attached resources could not be restored',
generationProtocolVersion: 1,
});
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
});
it('preserves the recovery code in the durable error after acknowledging startup', async () => {
const recoveryError = new Error('Attached resources could not be restored');
recoveryError.code = ErrorTypes.RESOURCE_RECOVERY_REQUIRED;
const initializeClient = jest.fn().mockRejectedValue(recoveryError);
const req = {
user: { id: 'user-123' },
body: {
text: 'Describe the attached image.',
messageId: 'user-msg',
clientRequestId: 'req-abc',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
streamId: 'conversation-123',
conversationId: 'conversation-123',
generationCreatedAt: 1000,
status: 'started',
generationProtocolVersion: 1,
});
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
JSON.stringify({
status: 409,
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
error: 'Attached resources could not be restored',
}),
1000,
);
});
it('returns a recovery conflict when the atomic store rejects changed source content', async () => {
const mismatch = new Error('recovery mismatch');
mismatch.code = 'RECOVERY_PAYLOAD_MISMATCH';

View file

@ -3,7 +3,7 @@
* Tests that recordCollectedUsage is called correctly for token spending
*/
const { ResourceType } = require('librechat-data-provider');
const { ErrorTypes, ResourceType } = require('librechat-data-provider');
const mockSpendTokens = jest.fn().mockResolvedValue({});
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
@ -211,8 +211,8 @@ jest.mock('@librechat/api', () => ({
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn().mockResolvedValue([]),
loadToolsForExecution: jest.fn().mockResolvedValue([]),
isExpectedMCPToolsUnavailableError: (error) =>
error?.code === 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
isFatalAgentInitializationError: (error) =>
['AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', 'resource_recovery_required'].includes(error?.code),
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
@ -372,6 +372,38 @@ describe('createResponse controller', () => {
503,
'Expected MCP tools are unavailable',
'server_error',
'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
);
});
it('returns the resource recovery status and code before model invocation', async () => {
const { initializeAgent, sendResponsesErrorResponse } = require('@librechat/api');
const { loadAgentTools } = require('~/server/services/ToolService');
const toolError = Object.assign(new Error('resource recovery required'), {
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
status: 409,
statusCode: 409,
});
loadAgentTools.mockRejectedValueOnce(toolError);
initializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
await loadTools({
req,
res,
tools: ['execute_code'],
model: agent.model,
agentId: agent.id,
provider: agent.provider,
});
});
await createResponse(req, res);
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
res,
409,
'resource recovery required',
'invalid_request',
ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
);
});

View file

@ -49,7 +49,7 @@ const {
loadAgentTools,
loadToolsForExecution,
getAccessibleMcpServerNames,
isExpectedMCPToolsUnavailableError,
isFatalAgentInitializationError,
} = require('~/server/services/ToolService');
const {
findAccessibleResources,
@ -103,10 +103,10 @@ function createToolLoader(signal, definitionsOnly = true) {
streamId: null, // No resumable stream for OpenAI compat
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
if (isExpectedMCPToolsUnavailableError(error)) {
if (isFatalAgentInitializationError(error)) {
throw error;
}
logger.error('Error loading tools for agent ' + agentId, error);
}
};
}
@ -891,7 +891,8 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
: 500;
const errorType =
statusCode >= 400 && statusCode < 500 ? 'invalid_request_error' : 'server_error';
sendErrorResponse(res, statusCode, errorMessage, errorType);
const errorCode = typeof error?.code === 'string' ? error.code : null;
sendErrorResponse(res, statusCode, errorMessage, errorType, errorCode);
}
}
};

View file

@ -3,6 +3,7 @@ const { v5: uuidv5 } = require('uuid');
const {
Constants,
EModelEndpoint,
ErrorTypes,
ViolationTypes,
isEphemeralAgentId,
} = require('librechat-data-provider');
@ -50,6 +51,18 @@ function sendGenerationJson(res, status, body, generationProtocolVersion) {
return res.status(status).json({ ...body, generationProtocolVersion });
}
function getResourceRecoveryFailure(error) {
if (error?.code !== ErrorTypes.RESOURCE_RECOVERY_REQUIRED) {
return null;
}
return {
status: 409,
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
error: error.message || 'Attached resources must be restored before retrying.',
};
}
function createCloseHandler(abortController) {
return function (manual) {
if (!manual) {
@ -1837,6 +1850,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
});
} catch (error) {
logger.error('[ResumableAgentController] Initialization error:', error);
const resourceRecoveryFailure = getResourceRecoveryFailure(error);
try {
if (!res.headersSent) {
if (error?.code === 'GENERATION_PREDECESSOR_MISMATCH') {
@ -1877,6 +1891,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
},
generationProtocolVersion,
);
} else if (resourceRecoveryFailure) {
sendGenerationJson(
res,
resourceRecoveryFailure.status,
resourceRecoveryFailure,
generationProtocolVersion,
);
} else {
sendGenerationJson(
res,
@ -1905,7 +1926,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// and the concurrency slot leaks — so swallow its error. (A failed completeJob did not
// finalize anything, so releasing afterward can't let it abort a later replacement.)
if (jobCreatedAt != null) {
const initializationError = error.message || 'Failed to start generation';
const initializationError = resourceRecoveryFailure
? JSON.stringify(resourceRecoveryFailure)
: error.message || 'Failed to start generation';
await GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt).catch(
(completeErr) => {
logger.warn(

View file

@ -60,7 +60,7 @@ const {
const {
loadAgentTools,
loadToolsForExecution,
isExpectedMCPToolsUnavailableError,
isFatalAgentInitializationError,
} = require('~/server/services/ToolService');
const {
findAccessibleResources,
@ -116,10 +116,10 @@ function createToolLoader(signal, definitionsOnly = true) {
streamId: null,
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
if (isExpectedMCPToolsUnavailableError(error)) {
if (isFatalAgentInitializationError(error)) {
throw error;
}
logger.error('Error loading tools for agent ' + agentId, error);
}
};
}
@ -1093,7 +1093,12 @@ const executeResponse = async (envelope, { req, res }) => {
? error.status
: 500;
const errorType = statusCode >= 400 && statusCode < 500 ? 'invalid_request' : 'server_error';
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
const errorCode = typeof error?.code === 'string' ? error.code : undefined;
if (errorCode === undefined) {
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
} else {
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType, errorCode);
}
}
}
};