mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 20:56:35 +01:00
* fix: gate action tools by actions capability in all code paths Extract resolveAgentCapabilities helper to eliminate 3x-duplicated capability resolution. Apply early action-tool filtering in both loadToolDefinitionsWrapper and loadAgentTools non-definitions path. Gate loadActionToolsForExecution in loadToolsForExecution behind an actionsEnabled parameter with a cache-based fallback. Replace the late capability guard in loadAgentTools with a hasActionTools check to avoid unnecessary loadActionSets DB calls and duplicate warnings. * fix: thread actionsEnabled through InitializedAgent type Add actionsEnabled to the loadTools callback return type, InitializedAgent, and the initializeAgent destructuring/return so callers can forward the resolved value to loadToolsForExecution without redundant getEndpointsConfig cache lookups. * fix: pass actionsEnabled from callers to loadToolsForExecution Thread actionsEnabled through the agentToolContexts map in initialize.js (primary and handoff agents) and through primaryConfig in the openai.js and responses.js controllers, avoiding per-tool-call capability re-resolution on the hot path. * test: add regression tests for action capability gating Test the real exported functions (resolveAgentCapabilities, loadAgentTools, loadToolsForExecution) with mocked dependencies instead of shadow re-implementations. Covers definition filtering, execution gating, actionsEnabled param forwarding, and fallback capability resolution. * test: use Constants.EPHEMERAL_AGENT_ID in ephemeral fallback test Replaces a string guess with the canonical constant to avoid fragility if the ephemeral detection heuristic changes. * fix: populate agentToolContexts for addedConvo parallel agents After processAddedConvo returns, backfill agentToolContexts for any agents in agentConfigs not already present, so ON_TOOL_EXECUTE for added-convo agents receives actionsEnabled instead of falling back to a per-call cache lookup.
480 lines
17 KiB
JavaScript
480 lines
17 KiB
JavaScript
const {
|
|
Tools,
|
|
Constants,
|
|
EModelEndpoint,
|
|
actionDelimiter,
|
|
AgentCapabilities,
|
|
defaultAgentCapabilities,
|
|
} = require('librechat-data-provider');
|
|
|
|
const mockGetEndpointsConfig = jest.fn();
|
|
const mockGetMCPServerTools = jest.fn();
|
|
const mockGetCachedTools = jest.fn();
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
|
|
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
|
|
getCachedTools: (...args) => mockGetCachedTools(...args),
|
|
}));
|
|
|
|
const mockLoadToolDefinitions = jest.fn();
|
|
const mockGetUserMCPAuthMap = jest.fn();
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
loadToolDefinitions: (...args) => mockLoadToolDefinitions(...args),
|
|
getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args),
|
|
}));
|
|
|
|
const mockLoadToolsUtil = jest.fn();
|
|
jest.mock('~/app/clients/tools/util', () => ({
|
|
loadTools: (...args) => mockLoadToolsUtil(...args),
|
|
}));
|
|
|
|
const mockLoadActionSets = jest.fn();
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
jest.mock('~/server/services/Tools/search', () => ({
|
|
createOnSearchResults: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Tools/mcp', () => ({
|
|
reinitMCPServer: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processFileURL: jest.fn(),
|
|
uploadImageBuffer: jest.fn(),
|
|
}));
|
|
jest.mock('~/app/clients/tools/util/fileSearch', () => ({
|
|
primeFiles: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
primeFiles: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
jest.mock('../ActionService', () => ({
|
|
loadActionSets: (...args) => mockLoadActionSets(...args),
|
|
decryptMetadata: jest.fn(),
|
|
createActionTool: jest.fn(),
|
|
domainParser: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Threads', () => ({
|
|
recordUsage: jest.fn(),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
findPluginAuthsByKeys: jest.fn(),
|
|
}));
|
|
jest.mock('~/config', () => ({
|
|
getFlowStateManager: jest.fn(() => ({})),
|
|
}));
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(() => ({})),
|
|
}));
|
|
|
|
const {
|
|
loadAgentTools,
|
|
loadToolsForExecution,
|
|
resolveAgentCapabilities,
|
|
} = require('../ToolService');
|
|
|
|
function createMockReq(capabilities) {
|
|
return {
|
|
user: { id: 'user_123' },
|
|
config: {
|
|
endpoints: {
|
|
[EModelEndpoint.agents]: {
|
|
capabilities,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createEndpointsConfig(capabilities) {
|
|
return {
|
|
[EModelEndpoint.agents]: { capabilities },
|
|
};
|
|
}
|
|
|
|
describe('ToolService - Action Capability Gating', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockLoadToolDefinitions.mockResolvedValue({
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
hasDeferredTools: false,
|
|
});
|
|
mockLoadToolsUtil.mockResolvedValue({ loadedTools: [], toolContextMap: {} });
|
|
mockLoadActionSets.mockResolvedValue([]);
|
|
});
|
|
|
|
describe('resolveAgentCapabilities', () => {
|
|
it('should return capabilities from endpoints config', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await resolveAgentCapabilities(req, req.config, 'agent_123');
|
|
|
|
expect(result).toBeInstanceOf(Set);
|
|
expect(result.has(AgentCapabilities.tools)).toBe(true);
|
|
expect(result.has(AgentCapabilities.actions)).toBe(true);
|
|
expect(result.has(AgentCapabilities.web_search)).toBe(false);
|
|
});
|
|
|
|
it('should fall back to default capabilities for ephemeral agents with empty config', async () => {
|
|
const req = createMockReq(defaultAgentCapabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue({});
|
|
|
|
const result = await resolveAgentCapabilities(req, req.config, Constants.EPHEMERAL_AGENT_ID);
|
|
|
|
for (const cap of defaultAgentCapabilities) {
|
|
expect(result.has(cap)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should return empty set when no capabilities and not ephemeral', async () => {
|
|
const req = createMockReq([]);
|
|
mockGetEndpointsConfig.mockResolvedValue({});
|
|
|
|
const result = await resolveAgentCapabilities(req, req.config, 'agent_123');
|
|
|
|
expect(result.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('loadAgentTools (definitionsOnly=true) — action tool filtering', () => {
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const regularTool = 'calculator';
|
|
|
|
it('should exclude action tools from definitions when actions capability is disabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
expect(callArgs.tools).not.toContain(actionToolName);
|
|
});
|
|
|
|
it('should include action tools in definitions when actions capability is enabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
|
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
|
expect(callArgs.tools).toContain(regularTool);
|
|
expect(callArgs.tools).toContain(actionToolName);
|
|
});
|
|
|
|
it('should return actionsEnabled in the result', async () => {
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
const result = await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool] },
|
|
definitionsOnly: true,
|
|
});
|
|
|
|
expect(result.actionsEnabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadAgentTools (definitionsOnly=false) — action tool filtering', () => {
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const regularTool = 'calculator';
|
|
|
|
it('should not load action sets when actions capability is disabled', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load action sets when actions capability is enabled and action tools present', async () => {
|
|
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadAgentTools({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123', tools: [regularTool, actionToolName] },
|
|
definitionsOnly: false,
|
|
});
|
|
|
|
expect(mockLoadActionSets).toHaveBeenCalledWith({ agent_id: 'agent_123' });
|
|
});
|
|
});
|
|
|
|
describe('loadToolsForExecution — action tool gating', () => {
|
|
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
|
const regularTool = Tools.web_search;
|
|
|
|
it('should skip action tool loading when actionsEnabled=false', async () => {
|
|
const req = createMockReq([]);
|
|
req.config = {};
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [regularTool, actionToolName],
|
|
actionsEnabled: false,
|
|
});
|
|
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
expect(result.loadedTools).toBeDefined();
|
|
});
|
|
|
|
it('should load action tools when actionsEnabled=true', async () => {
|
|
const req = createMockReq([AgentCapabilities.actions]);
|
|
req.config = {};
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [actionToolName],
|
|
actionsEnabled: true,
|
|
});
|
|
|
|
expect(mockLoadActionSets).toHaveBeenCalledWith({ agent_id: 'agent_123' });
|
|
});
|
|
|
|
it('should resolve actionsEnabled from capabilities when not explicitly provided', async () => {
|
|
const capabilities = [AgentCapabilities.tools];
|
|
const req = createMockReq(capabilities);
|
|
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [actionToolName],
|
|
});
|
|
|
|
expect(mockGetEndpointsConfig).toHaveBeenCalled();
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not call loadActionSets when there are no action tools', async () => {
|
|
const req = createMockReq([AgentCapabilities.actions]);
|
|
req.config = {};
|
|
|
|
await loadToolsForExecution({
|
|
req,
|
|
res: {},
|
|
agent: { id: 'agent_123' },
|
|
toolNames: [regularTool],
|
|
actionsEnabled: true,
|
|
});
|
|
|
|
expect(mockLoadActionSets).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('checkCapability logic', () => {
|
|
const createCheckCapability = (enabledCapabilities, logger = { warn: jest.fn() }) => {
|
|
return (capability) => {
|
|
const enabled = enabledCapabilities.has(capability);
|
|
if (!enabled) {
|
|
const isToolCapability = [
|
|
AgentCapabilities.file_search,
|
|
AgentCapabilities.execute_code,
|
|
AgentCapabilities.web_search,
|
|
].includes(capability);
|
|
const suffix = isToolCapability ? ' despite configured tool.' : '.';
|
|
logger.warn(`Capability "${capability}" disabled${suffix}`);
|
|
}
|
|
return enabled;
|
|
};
|
|
};
|
|
|
|
it('should return true when capability is enabled', () => {
|
|
const enabledCapabilities = new Set([AgentCapabilities.deferred_tools]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities);
|
|
|
|
expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(true);
|
|
});
|
|
|
|
it('should return false when capability is not enabled', () => {
|
|
const enabledCapabilities = new Set([]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities);
|
|
|
|
expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(false);
|
|
});
|
|
|
|
it('should log warning with "despite configured tool" for tool capabilities', () => {
|
|
const logger = { warn: jest.fn() };
|
|
const enabledCapabilities = new Set([]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
|
|
|
checkCapability(AgentCapabilities.file_search);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.execute_code);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.web_search);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
|
});
|
|
|
|
it('should log warning without "despite configured tool" for non-tool capabilities', () => {
|
|
const logger = { warn: jest.fn() };
|
|
const enabledCapabilities = new Set([]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
|
|
|
checkCapability(AgentCapabilities.deferred_tools);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Capability "deferred_tools" disabled.'),
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('despite configured tool'),
|
|
);
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.tools);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Capability "tools" disabled.'),
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('despite configured tool'),
|
|
);
|
|
|
|
logger.warn.mockClear();
|
|
checkCapability(AgentCapabilities.actions);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Capability "actions" disabled.'),
|
|
);
|
|
});
|
|
|
|
it('should not log warning when capability is enabled', () => {
|
|
const logger = { warn: jest.fn() };
|
|
const enabledCapabilities = new Set([
|
|
AgentCapabilities.deferred_tools,
|
|
AgentCapabilities.file_search,
|
|
]);
|
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
|
|
|
checkCapability(AgentCapabilities.deferred_tools);
|
|
checkCapability(AgentCapabilities.file_search);
|
|
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('defaultAgentCapabilities', () => {
|
|
it('should include deferred_tools capability by default', () => {
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.deferred_tools);
|
|
});
|
|
|
|
it('should include all expected default capabilities', () => {
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.execute_code);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.file_search);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.web_search);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.artifacts);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.actions);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.context);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.tools);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.chain);
|
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.ocr);
|
|
});
|
|
});
|
|
|
|
describe('userMCPAuthMap gating', () => {
|
|
const shouldFetchMCPAuth = (tools) =>
|
|
tools?.some((t) => t.includes(Constants.mcp_delimiter)) ?? false;
|
|
|
|
it('should return true when agent has MCP tools', () => {
|
|
const tools = ['web_search', `search${Constants.mcp_delimiter}my-mcp-server`, 'calculator'];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(true);
|
|
});
|
|
|
|
it('should return false when agent has no MCP tools', () => {
|
|
const tools = ['web_search', 'calculator', 'code_interpreter'];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(false);
|
|
});
|
|
|
|
it('should return false when tools is empty', () => {
|
|
expect(shouldFetchMCPAuth([])).toBe(false);
|
|
});
|
|
|
|
it('should return false when tools is undefined', () => {
|
|
expect(shouldFetchMCPAuth(undefined)).toBe(false);
|
|
});
|
|
|
|
it('should return false when tools is null', () => {
|
|
expect(shouldFetchMCPAuth(null)).toBe(false);
|
|
});
|
|
|
|
it('should detect MCP tools with different server names', () => {
|
|
const tools = [
|
|
`listFiles${Constants.mcp_delimiter}file-server`,
|
|
`query${Constants.mcp_delimiter}db-server`,
|
|
];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(true);
|
|
});
|
|
|
|
it('should return true even when only one tool is MCP', () => {
|
|
const tools = [
|
|
'web_search',
|
|
'calculator',
|
|
'code_interpreter',
|
|
`echo${Constants.mcp_delimiter}test-server`,
|
|
];
|
|
expect(shouldFetchMCPAuth(tools)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('deferredToolsEnabled integration', () => {
|
|
it('should correctly determine deferredToolsEnabled from capabilities set', () => {
|
|
const createCheckCapability = (enabledCapabilities) => {
|
|
return (capability) => enabledCapabilities.has(capability);
|
|
};
|
|
|
|
const withDeferred = new Set([AgentCapabilities.deferred_tools, AgentCapabilities.tools]);
|
|
const checkWithDeferred = createCheckCapability(withDeferred);
|
|
expect(checkWithDeferred(AgentCapabilities.deferred_tools)).toBe(true);
|
|
|
|
const withoutDeferred = new Set([AgentCapabilities.tools, AgentCapabilities.actions]);
|
|
const checkWithoutDeferred = createCheckCapability(withoutDeferred);
|
|
expect(checkWithoutDeferred(AgentCapabilities.deferred_tools)).toBe(false);
|
|
});
|
|
|
|
it('should use defaultAgentCapabilities when no capabilities configured', () => {
|
|
const endpointsConfig = {};
|
|
const enabledCapabilities = new Set(
|
|
endpointsConfig?.capabilities ?? defaultAgentCapabilities,
|
|
);
|
|
|
|
expect(enabledCapabilities.has(AgentCapabilities.deferred_tools)).toBe(true);
|
|
});
|
|
});
|
|
});
|