mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 19:41:32 +00:00
🛟 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:
parent
6d2f29266c
commit
5ff46d8c67
24 changed files with 629 additions and 65 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue