Skip to content

Commit 29971f1

Browse files
danny-avilaclaude
andauthored
🧺 fix: Skip Priming Denied File Search Resources (#15699)
#15691 paired every read of `AgentCapabilities.execute_code` / `file_search` with its role grant, and skipped `execute_code` from the resend-file priming when `RUN_CODE` was denied. It left the `file_search` half: the grant was not available inside `initializeAgent`, so a role denied `FILE_SEARCH` still had its prior-turn search files re-hydrated on every resend. Priming is not free. The files are fetched, `updateFilesUsage` bumps their counters, and `primeResources` builds `tool_resources.file_search` — all for a tool the loader is about to drop. A deployment with the capability off paid the same cost. `InitializeAgentParams.fileSearchAvailable` carries the capability AND the grant, resolved from the same single `resolveToolRoleGrants` read that already answers `codeEnvAvailable`, so pairing the second flag costs no extra role lookup. Every initializer that resolves grants forwards it: the chat path and its handoff, added-convo and discovery hand-offs, both API routes, and the OpenAI-compatible embedder route. Absent leaves priming unconditional, so an embedder that resolves no grant keeps its current behavior. The loop that selected the resend tool resources becomes `resolveResendToolResources`, which makes both halves of the gate testable rather than reachable only through a full agent initialization. The memory agent is untouched: it passes no `conversationId`, so it never reaches this priming. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f27726 commit 29971f1

14 files changed

Lines changed: 421 additions & 42 deletions

File tree

api/server/controllers/agents/__tests__/openai.spec.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,4 +1657,68 @@ describe('OpenAIChatCompletionController', () => {
16571657
});
16581658
});
16591659
});
1660+
1661+
describe('file search role gating', () => {
1662+
const setCapabilities = (capabilities) => {
1663+
req.config.endpoints.agents.capabilities = capabilities;
1664+
};
1665+
1666+
it('reports file search available when the capability and the grant agree', async () => {
1667+
const { initializeAgent } = require('@librechat/api');
1668+
setCapabilities(['file_search']);
1669+
1670+
await OpenAIChatCompletionController(req, res);
1671+
1672+
expect(initializeAgent).toHaveBeenCalledWith(
1673+
expect.objectContaining({ fileSearchAvailable: true }),
1674+
expect.anything(),
1675+
);
1676+
});
1677+
1678+
/** `initializeAgent` re-hydrates prior-turn `file_search` files from this
1679+
* flag, so a denied role must reach it — dropping the tool downstream still
1680+
* leaves the files read, their usage bumped and their resources primed. */
1681+
it('withholds it when the role is denied FILE_SEARCH', async () => {
1682+
const { initializeAgent, resolveToolRoleGrants } = require('@librechat/api');
1683+
resolveToolRoleGrants.mockResolvedValueOnce({ runCode: true, fileSearch: false });
1684+
setCapabilities(['file_search']);
1685+
1686+
await OpenAIChatCompletionController(req, res);
1687+
1688+
expect(initializeAgent).toHaveBeenCalledWith(
1689+
expect.objectContaining({ fileSearchAvailable: false }),
1690+
expect.anything(),
1691+
);
1692+
});
1693+
1694+
/** Both flags are false without their capability, so the role read would be
1695+
* pure load on every request. */
1696+
it('reads no role at all when neither capability is enabled', async () => {
1697+
const { initializeAgent, resolveToolRoleGrants } = require('@librechat/api');
1698+
setCapabilities([]);
1699+
1700+
await OpenAIChatCompletionController(req, res);
1701+
1702+
expect(resolveToolRoleGrants).not.toHaveBeenCalled();
1703+
expect(initializeAgent).toHaveBeenCalledWith(
1704+
expect.objectContaining({ fileSearchAvailable: false, codeEnvAvailable: false }),
1705+
expect.anything(),
1706+
);
1707+
});
1708+
1709+
/** One lookup answers both grants, so enabling either capability pays for
1710+
* the other's pairing too. */
1711+
it('pairs both flags from a single role read', async () => {
1712+
const { initializeAgent, resolveToolRoleGrants } = require('@librechat/api');
1713+
setCapabilities(['file_search', 'execute_code']);
1714+
1715+
await OpenAIChatCompletionController(req, res);
1716+
1717+
expect(resolveToolRoleGrants).toHaveBeenCalledTimes(1);
1718+
expect(initializeAgent).toHaveBeenCalledWith(
1719+
expect.objectContaining({ fileSearchAvailable: true, codeEnvAvailable: true }),
1720+
expect.anything(),
1721+
);
1722+
});
1723+
});
16601724
});

api/server/controllers/agents/__tests__/responses.unit.spec.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2198,4 +2198,68 @@ describe('createResponse controller', () => {
21982198
});
21992199
});
22002200
});
2201+
2202+
describe('file search role gating', () => {
2203+
const setCapabilities = (capabilities) => {
2204+
req.config.endpoints.agents.capabilities = capabilities;
2205+
};
2206+
2207+
it('reports file search available when the capability and the grant agree', async () => {
2208+
const { initializeAgent } = require('@librechat/api');
2209+
setCapabilities(['file_search']);
2210+
2211+
await createResponse(req, res);
2212+
2213+
expect(initializeAgent).toHaveBeenCalledWith(
2214+
expect.objectContaining({ fileSearchAvailable: true }),
2215+
expect.anything(),
2216+
);
2217+
});
2218+
2219+
/** `initializeAgent` re-hydrates prior-turn `file_search` files from this
2220+
* flag, so a denied role must reach it — dropping the tool downstream still
2221+
* leaves the files read, their usage bumped and their resources primed. */
2222+
it('withholds it when the role is denied FILE_SEARCH', async () => {
2223+
const { initializeAgent, resolveToolRoleGrants } = require('@librechat/api');
2224+
resolveToolRoleGrants.mockResolvedValueOnce({ runCode: true, fileSearch: false });
2225+
setCapabilities(['file_search']);
2226+
2227+
await createResponse(req, res);
2228+
2229+
expect(initializeAgent).toHaveBeenCalledWith(
2230+
expect.objectContaining({ fileSearchAvailable: false }),
2231+
expect.anything(),
2232+
);
2233+
});
2234+
2235+
/** Both flags are false without their capability, so the role read would be
2236+
* pure load on every request. */
2237+
it('reads no role at all when neither capability is enabled', async () => {
2238+
const { initializeAgent, resolveToolRoleGrants } = require('@librechat/api');
2239+
setCapabilities([]);
2240+
2241+
await createResponse(req, res);
2242+
2243+
expect(resolveToolRoleGrants).not.toHaveBeenCalled();
2244+
expect(initializeAgent).toHaveBeenCalledWith(
2245+
expect.objectContaining({ fileSearchAvailable: false, codeEnvAvailable: false }),
2246+
expect.anything(),
2247+
);
2248+
});
2249+
2250+
/** One lookup answers both grants, so enabling either capability pays for
2251+
* the other's pairing too. */
2252+
it('pairs both flags from a single role read', async () => {
2253+
const { initializeAgent, resolveToolRoleGrants } = require('@librechat/api');
2254+
setCapabilities(['file_search', 'execute_code']);
2255+
2256+
await createResponse(req, res);
2257+
2258+
expect(resolveToolRoleGrants).toHaveBeenCalledTimes(1);
2259+
expect(initializeAgent).toHaveBeenCalledWith(
2260+
expect.objectContaining({ fileSearchAvailable: true, codeEnvAvailable: true }),
2261+
expect.anything(),
2262+
);
2263+
});
2264+
});
22012265
});

api/server/controllers/agents/openai.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -491,13 +491,16 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
491491

492492
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
493493
const codeCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.execute_code);
494+
const fileSearchCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.file_search);
494495
/** Started before the memory read rather than awaited on its own line, so
495496
* the role lookup overlaps that query instead of preceding it. Skipped
496-
* when the deployment has the capability off — the flag is false either
497-
* way, so the read would be pure load on every request. */
498-
const toolRoleGrants = codeCapabilityEnabled
499-
? resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })
500-
: null;
497+
* when the deployment has both capabilities off — both flags are false
498+
* either way, so the read would be pure load on every request. One
499+
* lookup answers both. */
500+
const toolRoleGrants =
501+
codeCapabilityEnabled || fileSearchCapabilityEnabled
502+
? resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })
503+
: null;
501504
const memoryAvailable = await resolveMemoryAvailability({
502505
enabledCapabilities,
503506
memoryConfig: appConfig?.memory,
@@ -508,6 +511,11 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
508511
* `bash_tool`, `read_file` and the workspace file tools from this flag,
509512
* and forwards the code-environment context to their handlers. */
510513
const codeEnvAvailable = codeCapabilityEnabled && (await toolRoleGrants)?.runCode === true;
514+
/** The same pairing for the other gated tool, read only by the resend-file
515+
* priming: `false` skips re-hydrating prior-turn `file_search` files for
516+
* a tool the loader is about to drop. */
517+
const fileSearchAvailable =
518+
fileSearchCapabilityEnabled && (await toolRoleGrants)?.fileSearch === true;
511519
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
512520
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
513521
const accessibleSkillIds = skillsCapabilityEnabled
@@ -573,6 +581,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
573581
ephemeralSkillsToggle,
574582
}),
575583
codeEnvAvailable,
584+
fileSearchAvailable,
576585
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
577586
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
578587
statefulSessionsAvailable: enabledCapabilities.has(
@@ -652,6 +661,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
652661
skillStates,
653662
defaultActiveOnShare,
654663
codeEnvAvailable,
664+
fileSearchAvailable,
655665
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
656666
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
657667
statefulSessionsAvailable: enabledCapabilities.has(

api/server/controllers/agents/responses.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -738,13 +738,16 @@ const executeResponse = async (envelope, { req, res }) => {
738738

739739
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
740740
const codeCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.execute_code);
741+
const fileSearchCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.file_search);
741742
/** Started before the memory read rather than awaited on its own line, so
742743
* the role lookup overlaps that query instead of preceding it. Skipped
743-
* when the deployment has the capability off — the flag is false either
744-
* way, so the read would be pure load on every request. */
745-
const toolRoleGrants = codeCapabilityEnabled
746-
? resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })
747-
: null;
744+
* when the deployment has both capabilities off — both flags are false
745+
* either way, so the read would be pure load on every request. One
746+
* lookup answers both. */
747+
const toolRoleGrants =
748+
codeCapabilityEnabled || fileSearchCapabilityEnabled
749+
? resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })
750+
: null;
748751
const memoryAvailable = await resolveMemoryAvailability({
749752
enabledCapabilities,
750753
memoryConfig: appConfig?.memory,
@@ -755,6 +758,11 @@ const executeResponse = async (envelope, { req, res }) => {
755758
* `bash_tool`, `read_file` and the workspace file tools from this flag,
756759
* and forwards the code-environment context to their handlers. */
757760
const codeEnvAvailable = codeCapabilityEnabled && (await toolRoleGrants)?.runCode === true;
761+
/** The same pairing for the other gated tool, read only by the resend-file
762+
* priming: `false` skips re-hydrating prior-turn `file_search` files for
763+
* a tool the loader is about to drop. */
764+
const fileSearchAvailable =
765+
fileSearchCapabilityEnabled && (await toolRoleGrants)?.fileSearch === true;
758766
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
759767
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
760768
const accessibleSkillIds = skillsCapabilityEnabled
@@ -820,6 +828,7 @@ const executeResponse = async (envelope, { req, res }) => {
820828
ephemeralSkillsToggle,
821829
}),
822830
codeEnvAvailable,
831+
fileSearchAvailable,
823832
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
824833
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
825834
statefulSessionsAvailable: enabledCapabilities.has(
@@ -899,6 +908,7 @@ const executeResponse = async (envelope, { req, res }) => {
899908
skillStates,
900909
defaultActiveOnShare,
901910
codeEnvAvailable,
911+
fileSearchAvailable,
902912
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
903913
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
904914
statefulSessionsAvailable: enabledCapabilities.has(

api/server/services/Endpoints/agents/addedConvo.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ const loadAddedAgent = (params) =>
6161
* @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag;
6262
* forwarded verbatim to the added agent's `initializeAgent`. @see
6363
* InitializeAgentParams.codeEnvAvailable for full semantics.
64+
* @param {boolean} [params.fileSearchAvailable] - `file_search` capability AND
65+
* the caller's `FILE_SEARCH` grant; forwarded verbatim alongside
66+
* `codeEnvAvailable`. @see InitializeAgentParams.fileSearchAvailable.
6467
* @param {boolean} [params.statefulSessionsAvailable] - `stateful_code_sessions`
6568
* capability flag; forwarded verbatim alongside `codeEnvAvailable`.
6669
* @returns {Promise<{userMCPAuthMap: Object|undefined}>} The updated userMCPAuthMap
@@ -89,6 +92,7 @@ const processAddedConvo = async ({
8992
skillStates,
9093
defaultActiveOnShare,
9194
codeEnvAvailable,
95+
fileSearchAvailable,
9296
backgroundToolsAvailable,
9397
toolIntentsAvailable,
9498
statefulSessionsAvailable,
@@ -189,6 +193,7 @@ const processAddedConvo = async ({
189193
ephemeralSkillsToggle,
190194
}),
191195
codeEnvAvailable,
196+
fileSearchAvailable,
192197
backgroundToolsAvailable,
193198
toolIntentsAvailable,
194199
statefulSessionsAvailable,

api/server/services/Endpoints/agents/addedConvo.spec.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ describe('processAddedConvo', () => {
119119
);
120120
});
121121

122+
/** The added convo re-hydrates the same conversation's prior-turn files, so a
123+
* denied `FILE_SEARCH` grant has to travel with it — otherwise the parallel
124+
* agent primes the search files the primary just skipped. `undefined` stays
125+
* `undefined`, which leaves priming unconditional for callers that never
126+
* resolved the grant. */
127+
it.each([true, false, undefined])(
128+
'forwards fileSearchAvailable=%s verbatim to the added-convo initializeAgent call',
129+
async (fileSearchAvailable) => {
130+
await processAddedConvo(baseParams({ fileSearchAvailable }));
131+
132+
expect(mockInitializeAgent).toHaveBeenCalledWith(
133+
expect.objectContaining({ fileSearchAvailable }),
134+
expect.anything(),
135+
);
136+
},
137+
);
138+
122139
it('forwards codeEnvAvailable=false verbatim (not coerced to undefined)', async () => {
123140
/* Symmetric coverage: if the runtime gate is off for the primary, the
124141
parallel agent must not accidentally re-enable code execution via a

api/server/services/Endpoints/agents/initialize.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -228,14 +228,16 @@ const initializeClient = async ({
228228
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
229229
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
230230
const codeCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.execute_code);
231+
const fileSearchCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.file_search);
231232
/** Started here but joined into the startup `Promise.all` below rather than
232-
* awaited inline: `codeEnvAvailable` is not read until agent construction, so
233-
* the role lookup overlaps the memory, skill and conversation queries instead
234-
* of delaying them. Skipped entirely when the deployment has the capability
235-
* off, since the flag is false either way. */
236-
const toolRoleGrantsPromise = codeCapabilityEnabled
237-
? resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName, context: 'initializeClient' })
238-
: null;
233+
* awaited inline: neither flag is read until agent construction, so the role
234+
* lookup overlaps the memory, skill and conversation queries instead of
235+
* delaying them. Skipped entirely when the deployment has both capabilities
236+
* off, since both flags are false either way. One lookup answers both. */
237+
const toolRoleGrantsPromise =
238+
codeCapabilityEnabled || fileSearchCapabilityEnabled
239+
? resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName, context: 'initializeClient' })
240+
: null;
239241
const backgroundToolsAvailable = enabledCapabilities.has(AgentCapabilities.run_in_background);
240242
const toolIntentsAvailable = enabledCapabilities.has(AgentCapabilities.tool_intents);
241243
const deferredToolsAvailable = enabledCapabilities.has(AgentCapabilities.deferred_tools);
@@ -537,6 +539,11 @@ const initializeClient = async ({
537539
* forwards the code-environment context to their handlers — so the grant has
538540
* to travel with the flag, not just with the tool list. */
539541
const codeEnvAvailable = codeCapabilityEnabled && toolRoleGrants?.runCode === true;
542+
/** The same pairing for the other gated tool. Read only by the resend-file
543+
* priming inside `initializeAgent`: `false` skips re-hydrating prior-turn
544+
* `file_search` files, whose usage counters would otherwise be bumped and
545+
* whose resources primed for a tool the loader is about to drop. */
546+
const fileSearchAvailable = fileSearchCapabilityEnabled && toolRoleGrants?.fileSearch === true;
540547

541548
const agentConfigs = new Map();
542549
const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders);
@@ -621,6 +628,7 @@ const initializeClient = async ({
621628
accessibleSkillIds: primaryScopedSkillIds,
622629
skillAuthoringAvailable: primarySkillAuthoringAvailable,
623630
codeEnvAvailable,
631+
fileSearchAvailable,
624632
backgroundToolsAvailable,
625633
toolIntentsAvailable,
626634
statefulSessionsAvailable,
@@ -703,6 +711,7 @@ const initializeClient = async ({
703711
skillStates,
704712
defaultActiveOnShare,
705713
codeEnvAvailable,
714+
fileSearchAvailable,
706715
backgroundToolsAvailable,
707716
toolIntentsAvailable,
708717
statefulSessionsAvailable,
@@ -781,6 +790,7 @@ const initializeClient = async ({
781790
skillStates,
782791
defaultActiveOnShare,
783792
codeEnvAvailable,
793+
fileSearchAvailable,
784794
backgroundToolsAvailable,
785795
toolIntentsAvailable,
786796
statefulSessionsAvailable,
@@ -1149,6 +1159,7 @@ const initializeClient = async ({
11491159
ephemeralSkillsToggle,
11501160
}),
11511161
codeEnvAvailable,
1162+
fileSearchAvailable,
11521163
backgroundToolsAvailable,
11531164
toolIntentsAvailable,
11541165
statefulSessionsAvailable,

api/server/services/__tests__/toolCapabilityGates.spec.js

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,24 @@ const CLASSIFIED = {
2626
// -- Gates: capability AND grant --------------------------------------------
2727
/** 7 gates + 2 map entries + 2 warning-list entries. */
2828
'api/server/services/ToolService.js': 11,
29-
/** `codeEnvAvailable`, paired after the startup batch. */
30-
'api/server/services/Endpoints/agents/initialize.js': 1,
31-
/** `codeEnvAvailable` for the OpenAI-compatible route. */
32-
'api/server/controllers/agents/openai.js': 1,
33-
/** `codeEnvAvailable` for the Responses route. */
34-
'api/server/controllers/agents/responses.js': 1,
29+
/** `codeEnvAvailable` and `fileSearchAvailable`, both paired after the
30+
* startup batch from the one grant read that batch joins. */
31+
'api/server/services/Endpoints/agents/initialize.js': 2,
32+
/** `codeEnvAvailable` and `fileSearchAvailable` for the OpenAI-compatible
33+
* route, both paired with the shared grant read. */
34+
'api/server/controllers/agents/openai.js': 2,
35+
/** `codeEnvAvailable` and `fileSearchAvailable` for the Responses route,
36+
* both paired with the shared grant read. */
37+
'api/server/controllers/agents/responses.js': 2,
3538
/** `codeEnvAvailable` for the memory-agent initializer. */
3639
'api/server/controllers/agents/client.js': 1,
3740
/** Upload processing: the code-environment and file-search branches. */
3841
'api/server/services/Files/process.js': 2,
3942
/** Agent-management upload purposes. */
4043
'api/server/routes/agents/management.js': 2,
41-
/** One gate, paired when the embedder wires `getRoleByName`; one in a comment. */
42-
'packages/api/src/agents/openai/service.ts': 2,
44+
/** Two gates, each paired when the embedder wires `getRoleByName`; one in a
45+
* comment. */
46+
'packages/api/src/agents/openai/service.ts': 3,
4347

4448
// -- Not gates ---------------------------------------------------------------
4549
/** The upload-resource map entry, and one mention in a doc comment. */

0 commit comments

Comments
 (0)