mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal (#6726)
* fix: tool capability checks in loadAgentTools function * fix: enhance atomicity in removing agent resource files and add concurrency tests, improve documentdb compatibility
This commit is contained in:
parent
953e9732d9
commit
cff392e578
3 changed files with 165 additions and 41 deletions
|
@ -126,16 +126,17 @@ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Removes multiple resource files from an agent in a single update.
|
||||
* Removes multiple resource files from an agent using atomic operations.
|
||||
* @param {object} params
|
||||
* @param {string} params.agent_id
|
||||
* @param {Array<{tool_resource: string, file_id: string}>} params.files
|
||||
* @returns {Promise<Agent>} The updated agent.
|
||||
* @throws {Error} If the agent is not found or update fails.
|
||||
*/
|
||||
const removeAgentResourceFiles = async ({ agent_id, files }) => {
|
||||
const searchParameter = { id: agent_id };
|
||||
|
||||
// associate each tool resource with the respective file ids array
|
||||
// Group files to remove by resource
|
||||
const filesByResource = files.reduce((acc, { tool_resource, file_id }) => {
|
||||
if (!acc[tool_resource]) {
|
||||
acc[tool_resource] = [];
|
||||
|
@ -144,42 +145,35 @@ const removeAgentResourceFiles = async ({ agent_id, files }) => {
|
|||
return acc;
|
||||
}, {});
|
||||
|
||||
// build the update aggregation pipeline wich removes file ids from tool resources array
|
||||
// and eventually deletes empty tool resources
|
||||
const updateData = [];
|
||||
Object.entries(filesByResource).forEach(([resource, fileIds]) => {
|
||||
const toolResourcePath = `tool_resources.${resource}`;
|
||||
const fileIdsPath = `${toolResourcePath}.file_ids`;
|
||||
// Step 1: Atomically remove file IDs using $pull
|
||||
const pullOps = {};
|
||||
const resourcesToCheck = new Set();
|
||||
for (const [resource, fileIds] of Object.entries(filesByResource)) {
|
||||
const fileIdsPath = `tool_resources.${resource}.file_ids`;
|
||||
pullOps[fileIdsPath] = { $in: fileIds };
|
||||
resourcesToCheck.add(resource);
|
||||
}
|
||||
|
||||
// file ids removal stage
|
||||
updateData.push({
|
||||
$set: {
|
||||
[fileIdsPath]: {
|
||||
$filter: {
|
||||
input: `$${fileIdsPath}`,
|
||||
cond: { $not: [{ $in: ['$$this', fileIds] }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatePullData = { $pull: pullOps };
|
||||
const agentAfterPull = await Agent.findOneAndUpdate(searchParameter, updatePullData, {
|
||||
new: true,
|
||||
}).lean();
|
||||
|
||||
// empty tool resource deletion stage
|
||||
updateData.push({
|
||||
$set: {
|
||||
[toolResourcePath]: {
|
||||
$cond: [{ $eq: [`$${fileIdsPath}`, []] }, '$$REMOVE', `$${toolResourcePath}`],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// return the updated agent or throw if no agent matches
|
||||
const updatedAgent = await updateAgent(searchParameter, updateData);
|
||||
if (updatedAgent) {
|
||||
return updatedAgent;
|
||||
} else {
|
||||
if (!agentAfterPull) {
|
||||
// Agent might have been deleted concurrently, or never existed.
|
||||
// Check if it existed before trying to throw.
|
||||
const agentExists = await getAgent(searchParameter);
|
||||
if (!agentExists) {
|
||||
throw new Error('Agent not found for removing resource files');
|
||||
}
|
||||
// If it existed but findOneAndUpdate returned null, something else went wrong.
|
||||
throw new Error('Failed to update agent during file removal (pull step)');
|
||||
}
|
||||
|
||||
// Return the agent state directly after the $pull operation.
|
||||
// Skipping the $unset step for now to simplify and test core $pull atomicity.
|
||||
// Empty arrays might remain, but the removal itself should be correct.
|
||||
return agentAfterPull;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -157,4 +157,134 @@ describe('Agent Resource File Operations', () => {
|
|||
expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle concurrent duplicate additions', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileId = uuidv4();
|
||||
|
||||
// Concurrent additions of the same file
|
||||
const additionPromises = Array.from({ length: 5 }).map(() =>
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(additionPromises);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||
// Should only contain one instance of the fileId
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(1);
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids[0]).toBe(fileId);
|
||||
});
|
||||
|
||||
test('should handle concurrent add and remove of the same file', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileId = uuidv4();
|
||||
|
||||
// First, ensure the file exists (or test might be trivial if remove runs first)
|
||||
await addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
});
|
||||
|
||||
// Concurrent add (which should be ignored) and remove
|
||||
const operations = [
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
}),
|
||||
removeAgentResourceFiles({
|
||||
agent_id: agent.id,
|
||||
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||
}),
|
||||
];
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
// The final state should ideally be that the file is removed,
|
||||
// but the key point is consistency (not duplicated or error state).
|
||||
// Depending on execution order, the file might remain if the add operation's
|
||||
// findOneAndUpdate runs after the remove operation completes.
|
||||
// A more robust check might be that the length is <= 1.
|
||||
// Given the remove uses an update pipeline, it might be more likely to win.
|
||||
// The final state depends on race condition timing (add or remove might "win").
|
||||
// The critical part is that the state is consistent (no duplicates, no errors).
|
||||
// Assert that the fileId is either present exactly once or not present at all.
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||
const finalFileIds = updatedAgent.tool_resources.test_tool.file_ids;
|
||||
const count = finalFileIds.filter((id) => id === fileId).length;
|
||||
expect(count).toBeLessThanOrEqual(1); // Should be 0 or 1, never more
|
||||
// Optional: Check overall length is consistent with the count
|
||||
if (count === 0) {
|
||||
expect(finalFileIds).toHaveLength(0);
|
||||
} else {
|
||||
expect(finalFileIds).toHaveLength(1);
|
||||
expect(finalFileIds[0]).toBe(fileId);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle concurrent duplicate removals', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileId = uuidv4();
|
||||
|
||||
// Add the file first
|
||||
await addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
});
|
||||
|
||||
// Concurrent removals of the same file
|
||||
const removalPromises = Array.from({ length: 5 }).map(() =>
|
||||
removeAgentResourceFiles({
|
||||
agent_id: agent.id,
|
||||
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(removalPromises);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
// Check if the array is empty or the tool resource itself is removed
|
||||
const fileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
|
||||
expect(fileIds).toHaveLength(0);
|
||||
expect(fileIds).not.toContain(fileId);
|
||||
});
|
||||
|
||||
test('should handle concurrent removals of different files', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileIds = Array.from({ length: 10 }, () => uuidv4());
|
||||
|
||||
// Add all files first
|
||||
await Promise.all(
|
||||
fileIds.map((fileId) =>
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Concurrently remove all files
|
||||
const removalPromises = fileIds.map((fileId) =>
|
||||
removeAgentResourceFiles({
|
||||
agent_id: agent.id,
|
||||
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(removalPromises);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
// Check if the array is empty or the tool resource itself is removed
|
||||
const finalFileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
|
||||
expect(finalFileIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -473,10 +473,10 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
|
|||
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
|
||||
|
||||
const _agentTools = agent.tools?.filter((tool) => {
|
||||
if (tool === Tools.file_search && !checkCapability(AgentCapabilities.file_search)) {
|
||||
return false;
|
||||
} else if (tool === Tools.execute_code && !checkCapability(AgentCapabilities.execute_code)) {
|
||||
return false;
|
||||
if (tool === Tools.file_search) {
|
||||
return checkCapability(AgentCapabilities.file_search);
|
||||
} else if (tool === Tools.execute_code) {
|
||||
return checkCapability(AgentCapabilities.execute_code);
|
||||
} else if (!areToolsEnabled && !tool.includes(actionDelimiter)) {
|
||||
return false;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue