mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 20:56:35 +01:00
🛂 fix: Enforce Actions Capability Gate Across All Event-Driven Tool Loading Paths (#12252)
* 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.
This commit is contained in:
parent
a26eeea592
commit
6f87b49df8
6 changed files with 372 additions and 38 deletions
|
|
@ -128,6 +128,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
toolRegistry: ctx.toolRegistry,
|
||||
userMCPAuthMap: ctx.userMCPAuthMap,
|
||||
tool_resources: ctx.tool_resources,
|
||||
actionsEnabled: ctx.actionsEnabled,
|
||||
});
|
||||
|
||||
logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`);
|
||||
|
|
@ -214,6 +215,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
toolRegistry: primaryConfig.toolRegistry,
|
||||
userMCPAuthMap: primaryConfig.userMCPAuthMap,
|
||||
tool_resources: primaryConfig.tool_resources,
|
||||
actionsEnabled: primaryConfig.actionsEnabled,
|
||||
});
|
||||
|
||||
const agent_ids = primaryConfig.agent_ids;
|
||||
|
|
@ -297,6 +299,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
toolRegistry: config.toolRegistry,
|
||||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
});
|
||||
|
||||
agentConfigs.set(agentId, config);
|
||||
|
|
@ -370,6 +373,19 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
userMCPAuthMap = updatedMCPAuthMap;
|
||||
}
|
||||
|
||||
for (const [agentId, config] of agentConfigs) {
|
||||
if (agentToolContexts.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
agentToolContexts.set(agentId, {
|
||||
agent: config,
|
||||
toolRegistry: config.toolRegistry,
|
||||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure edges is an array when we have multiple agents (multi-agent mode)
|
||||
// MultiAgentGraph.categorizeEdges requires edges to be iterable
|
||||
if (agentConfigs.size > 0 && !edges) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,26 @@ const { redactMessage } = require('~/config/parsers');
|
|||
const { findPluginAuthsByKeys } = require('~/models');
|
||||
const { getFlowStateManager } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* Resolves the set of enabled agent capabilities from endpoints config,
|
||||
* falling back to app-level or default capabilities for ephemeral agents.
|
||||
* @param {ServerRequest} req
|
||||
* @param {Object} appConfig
|
||||
* @param {string} agentId
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
async function resolveAgentCapabilities(req, appConfig, agentId) {
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
let capabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []);
|
||||
if (capabilities.size === 0 && isEphemeralAgentId(agentId)) {
|
||||
capabilities = new Set(
|
||||
appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities,
|
||||
);
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the required actions by calling the appropriate tools and returning the outputs.
|
||||
* @param {OpenAIClient} client - OpenAI or StreamRunManager Client.
|
||||
|
|
@ -445,17 +465,11 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
}
|
||||
|
||||
const appConfig = req.config;
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
let enabledCapabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []);
|
||||
|
||||
if (enabledCapabilities.size === 0 && isEphemeralAgentId(agent.id)) {
|
||||
enabledCapabilities = new Set(
|
||||
appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities,
|
||||
);
|
||||
}
|
||||
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
|
||||
|
||||
const checkCapability = (capability) => enabledCapabilities.has(capability);
|
||||
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
|
||||
const actionsEnabled = checkCapability(AgentCapabilities.actions);
|
||||
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
|
||||
|
||||
const filteredTools = agent.tools?.filter((tool) => {
|
||||
|
|
@ -468,7 +482,10 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
if (tool === Tools.web_search) {
|
||||
return checkCapability(AgentCapabilities.web_search);
|
||||
}
|
||||
if (!areToolsEnabled && !tool.includes(actionDelimiter)) {
|
||||
if (tool.includes(actionDelimiter)) {
|
||||
return actionsEnabled;
|
||||
}
|
||||
if (!areToolsEnabled) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -765,6 +782,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
toolContextMap,
|
||||
toolDefinitions,
|
||||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -808,14 +826,7 @@ async function loadAgentTools({
|
|||
}
|
||||
|
||||
const appConfig = req.config;
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
let enabledCapabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []);
|
||||
/** Edge case: use defined/fallback capabilities when the "agents" endpoint is not enabled */
|
||||
if (enabledCapabilities.size === 0 && isEphemeralAgentId(agent.id)) {
|
||||
enabledCapabilities = new Set(
|
||||
appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities,
|
||||
);
|
||||
}
|
||||
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
|
||||
const checkCapability = (capability) => {
|
||||
const enabled = enabledCapabilities.has(capability);
|
||||
if (!enabled) {
|
||||
|
|
@ -832,6 +843,7 @@ async function loadAgentTools({
|
|||
return enabled;
|
||||
};
|
||||
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
|
||||
const actionsEnabled = checkCapability(AgentCapabilities.actions);
|
||||
|
||||
let includesWebSearch = false;
|
||||
const _agentTools = agent.tools?.filter((tool) => {
|
||||
|
|
@ -842,7 +854,9 @@ async function loadAgentTools({
|
|||
} else if (tool === Tools.web_search) {
|
||||
includesWebSearch = checkCapability(AgentCapabilities.web_search);
|
||||
return includesWebSearch;
|
||||
} else if (!areToolsEnabled && !tool.includes(actionDelimiter)) {
|
||||
} else if (tool.includes(actionDelimiter)) {
|
||||
return actionsEnabled;
|
||||
} else if (!areToolsEnabled) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -947,13 +961,15 @@ async function loadAgentTools({
|
|||
|
||||
agentTools.push(...additionalTools);
|
||||
|
||||
if (!checkCapability(AgentCapabilities.actions)) {
|
||||
const hasActionTools = _agentTools.some((t) => t.includes(actionDelimiter));
|
||||
if (!hasActionTools) {
|
||||
return {
|
||||
toolRegistry,
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
toolDefinitions,
|
||||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
tools: agentTools,
|
||||
};
|
||||
}
|
||||
|
|
@ -969,6 +985,7 @@ async function loadAgentTools({
|
|||
toolContextMap,
|
||||
toolDefinitions,
|
||||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
tools: agentTools,
|
||||
};
|
||||
}
|
||||
|
|
@ -1101,6 +1118,7 @@ async function loadAgentTools({
|
|||
userMCPAuthMap,
|
||||
toolDefinitions,
|
||||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
tools: agentTools,
|
||||
};
|
||||
}
|
||||
|
|
@ -1118,9 +1136,11 @@ async function loadAgentTools({
|
|||
* @param {AbortSignal} [params.signal] - Abort signal
|
||||
* @param {Object} params.agent - The agent object
|
||||
* @param {string[]} params.toolNames - Names of tools to load
|
||||
* @param {Map} [params.toolRegistry] - Tool registry
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap] - User MCP auth map
|
||||
* @param {Object} [params.tool_resources] - Tool resources
|
||||
* @param {string|null} [params.streamId] - Stream ID for web search callbacks
|
||||
* @param {boolean} [params.actionsEnabled] - Whether the actions capability is enabled
|
||||
* @returns {Promise<{ loadedTools: Array, configurable: Object }>}
|
||||
*/
|
||||
async function loadToolsForExecution({
|
||||
|
|
@ -1133,11 +1153,17 @@ async function loadToolsForExecution({
|
|||
userMCPAuthMap,
|
||||
tool_resources,
|
||||
streamId = null,
|
||||
actionsEnabled,
|
||||
}) {
|
||||
const appConfig = req.config;
|
||||
const allLoadedTools = [];
|
||||
const configurable = { userMCPAuthMap };
|
||||
|
||||
if (actionsEnabled === undefined) {
|
||||
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id);
|
||||
actionsEnabled = enabledCapabilities.has(AgentCapabilities.actions);
|
||||
}
|
||||
|
||||
const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH);
|
||||
const isPTC = toolNames.includes(AgentConstants.PROGRAMMATIC_TOOL_CALLING);
|
||||
|
||||
|
|
@ -1194,7 +1220,6 @@ async function loadToolsForExecution({
|
|||
const actionToolNames = allToolNamesToLoad.filter((name) => name.includes(actionDelimiter));
|
||||
const regularToolNames = allToolNamesToLoad.filter((name) => !name.includes(actionDelimiter));
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
if (regularToolNames.length > 0) {
|
||||
const includesWebSearch = regularToolNames.includes(Tools.web_search);
|
||||
const webSearchCallbacks = includesWebSearch ? createOnSearchResults(res, streamId) : undefined;
|
||||
|
|
@ -1225,7 +1250,7 @@ async function loadToolsForExecution({
|
|||
}
|
||||
}
|
||||
|
||||
if (actionToolNames.length > 0 && agent) {
|
||||
if (actionToolNames.length > 0 && agent && actionsEnabled) {
|
||||
const actionTools = await loadActionToolsForExecution({
|
||||
req,
|
||||
res,
|
||||
|
|
@ -1235,6 +1260,11 @@ async function loadToolsForExecution({
|
|||
actionToolNames,
|
||||
});
|
||||
allLoadedTools.push(...actionTools);
|
||||
} else if (actionToolNames.length > 0 && agent && !actionsEnabled) {
|
||||
logger.warn(
|
||||
`[loadToolsForExecution] Capability "${AgentCapabilities.actions}" disabled. ` +
|
||||
`Skipping action tool execution. User: ${req.user.id} | Agent: ${agent.id} | Tools: ${actionToolNames.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isPTC && allLoadedTools.length > 0) {
|
||||
|
|
@ -1395,4 +1425,5 @@ module.exports = {
|
|||
loadAgentTools,
|
||||
loadToolsForExecution,
|
||||
processRequiredActions,
|
||||
resolveAgentCapabilities,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,304 @@
|
|||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
actionDelimiter,
|
||||
AgentCapabilities,
|
||||
defaultAgentCapabilities,
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Tests for ToolService capability checking logic.
|
||||
* The actual loadAgentTools function has many dependencies, so we test
|
||||
* the capability checking logic in isolation.
|
||||
*/
|
||||
describe('ToolService - Capability Checking', () => {
|
||||
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', () => {
|
||||
/**
|
||||
* Simulates the checkCapability function from loadAgentTools
|
||||
*/
|
||||
const createCheckCapability = (enabledCapabilities, logger = { warn: jest.fn() }) => {
|
||||
return (capability) => {
|
||||
const enabled = enabledCapabilities.has(capability);
|
||||
|
|
@ -124,10 +409,6 @@ describe('ToolService - Capability Checking', () => {
|
|||
});
|
||||
|
||||
describe('userMCPAuthMap gating', () => {
|
||||
/**
|
||||
* Simulates the guard condition used in both loadToolDefinitionsWrapper
|
||||
* and loadAgentTools to decide whether getUserMCPAuthMap should be called.
|
||||
*/
|
||||
const shouldFetchMCPAuth = (tools) =>
|
||||
tools?.some((t) => t.includes(Constants.mcp_delimiter)) ?? false;
|
||||
|
||||
|
|
@ -178,20 +459,17 @@ describe('ToolService - Capability Checking', () => {
|
|||
return (capability) => enabledCapabilities.has(capability);
|
||||
};
|
||||
|
||||
// When deferred_tools is in capabilities
|
||||
const withDeferred = new Set([AgentCapabilities.deferred_tools, AgentCapabilities.tools]);
|
||||
const checkWithDeferred = createCheckCapability(withDeferred);
|
||||
expect(checkWithDeferred(AgentCapabilities.deferred_tools)).toBe(true);
|
||||
|
||||
// When deferred_tools is NOT in capabilities
|
||||
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', () => {
|
||||
// Simulates the fallback behavior in loadAgentTools
|
||||
const endpointsConfig = {}; // No capabilities configured
|
||||
const endpointsConfig = {};
|
||||
const enabledCapabilities = new Set(
|
||||
endpointsConfig?.capabilities ?? defaultAgentCapabilities,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue