🏷️ fix: Scope File Search entity_id to Agent Knowledge-Base Files Only (#13693)

User-attached files are embedded by the RAG API under the user id (no
entity), while only agent knowledge-base files are embedded under the
agent's entity_id. Sending entity_id in every /query request made the
RAG API's entity filter return no results for user attachments — with a
shared agent, files attached to the message were effectively invisible
to the file_search tool, while knowledge-base files kept working (which
masked the bug).

primeFiles now tags each file with fromAgent (whether it belongs to the
agent's file_search.file_ids) and createQueryBody only includes
entity_id when fromAgent === true — the safe default for callers that
omit the flag is to query without entity scoping. Tests cover KB files,
user attachments, the omitted-flag default, and restore RAG_API_URL.
This commit is contained in:
Matheus Serpa 2026-06-20 11:18:25 -03:00 committed by GitHub
parent 3926fda234
commit 21d98b85bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 4 deletions

View file

@ -25,7 +25,7 @@ const fileSearchJsonSchema = {
* @param {Agent['tool_resources']} options.tool_resources
* @param {string} [options.agentId] - The agent ID for file access control
* @returns {Promise<{
* files: Array<{ file_id: string; filename: string }>,
* files: Array<{ file_id: string; filename: string; fromAgent: boolean }>,
* toolContext: string
* }>}
*/
@ -70,6 +70,7 @@ const primeFiles = async (options) => {
files.push({
file_id: file.file_id,
filename: file.filename,
fromAgent: agentResourceIds.has(file.file_id),
});
}
@ -80,7 +81,7 @@ const primeFiles = async (options) => {
*
* @param {Object} options
* @param {string} options.userId
* @param {Array<{ file_id: string; filename: string }>} options.files
* @param {Array<{ file_id: string; filename: string; fromAgent?: boolean }>} options.files
* @param {string} [options.entity_id]
* @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
* @returns
@ -97,7 +98,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
}
/**
* @param {import('librechat-data-provider').TFile} file
* @param {import('librechat-data-provider').TFile & { fromAgent?: boolean }} file
* @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
*/
const createQueryBody = (file) => {
@ -106,7 +107,14 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
query,
k: 5,
};
if (!entity_id) {
// User-attached files are embedded under the user id (no entity);
// only agent knowledge-base files carry the agent's entity_id.
// Sending entity_id for user attachments makes the RAG API's entity
// filter return no results for them. When files are provided by
// primeFiles, fromAgent is always set; for callers that pass files
// directly without the flag, the safe default is unscoped (no
// entity_id).
if (!entity_id || file.fromAgent !== true) {
return body;
}
body.entity_id = entity_id;

View file

@ -230,3 +230,63 @@ describe('fileSearch.js - tuple return validation', () => {
});
});
});
describe('entity_id scoping by file origin', () => {
const ORIGINAL_RAG_API_URL = process.env.RAG_API_URL;
beforeEach(() => {
jest.clearAllMocks();
process.env.RAG_API_URL = 'http://localhost:8000';
generateShortLivedToken.mockReturnValue('mock-jwt-token');
axios.post.mockResolvedValue({ data: [] });
});
afterEach(() => {
if (ORIGINAL_RAG_API_URL === undefined) {
delete process.env.RAG_API_URL;
} else {
process.env.RAG_API_URL = ORIGINAL_RAG_API_URL;
}
});
function bodiesSent() {
return axios.post.mock.calls
.filter(([url]) => String(url).endsWith('/query'))
.map(([, body]) => body);
}
it('sends entity_id only for agent knowledge-base files', async () => {
const tool = await createFileSearchTool({
userId: 'user1',
entity_id: 'agent_123',
files: [
{ file_id: 'kb-1', filename: 'kb.pdf', fromAgent: true },
{ file_id: 'user-1', filename: 'attachment.txt', fromAgent: false },
],
});
await tool.func({ query: 'q' });
const bodies = bodiesSent();
expect(bodies.find((b) => b.file_id === 'kb-1').entity_id).toBe('agent_123');
expect(bodies.find((b) => b.file_id === 'user-1').entity_id).toBeUndefined();
});
it('omits entity_id when fromAgent is not set (safe default)', async () => {
const tool = await createFileSearchTool({
userId: 'user1',
entity_id: 'agent_123',
files: [{ file_id: 'legacy-1', filename: 'legacy.pdf' }],
});
await tool.func({ query: 'q' });
expect(bodiesSent()[0].entity_id).toBeUndefined();
});
it('sends no entity_id when none is provided', async () => {
const tool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'f1', filename: 'a.txt', fromAgent: true }],
});
await tool.func({ query: 'q' });
expect(bodiesSent()[0].entity_id).toBeUndefined();
});
});