mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
feat: gate ask_user_question behind its own agent capability
Add a first-class AgentCapabilities.ask_user_question (in defaultAgentCapabilities, on by default) so admins can enable/disable questions independently via endpoints.agents.capabilities, exactly like execute_code / web_search — not lumped under the generic tools capability. - ToolService: both filteredTools predicates (definitions-only and instance loaders) gate ask_user_question on checkCapability(ask_user_question) before the generic tools fallthrough. When off, the tool is dropped from toolDefinitions/toolRegistry, so run.ts's agentRequestsAskUserQuestion (which keys on the loaded surface) declines to install it and attach a checkpointer — the capability is enforced end-to-end at the loader, no run.ts change needed. - Tools dialog catalog: surface the ask builtin under its own capability rather than the generic tools one, so the UI matches the backend gate. - Tests: ToolService capability on/off filtering + defaults membership; catalog builtin visibility keyed on the dedicated capability.
This commit is contained in:
parent
e5e55d1012
commit
5c9f7ac789
5 changed files with 63 additions and 11 deletions
|
|
@ -28,6 +28,7 @@ const {
|
|||
buildMCPAuthRunStepDeltaEvent,
|
||||
buildMCPAuthRunStepCompletedEvent,
|
||||
isFileAuthoringToolDefinition,
|
||||
ASK_USER_QUESTION_TOOL_NAME,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
|
|
@ -579,6 +580,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
if (tool === Tools.memory) {
|
||||
return checkCapability(AgentCapabilities.memory);
|
||||
}
|
||||
if (tool === ASK_USER_QUESTION_TOOL_NAME) {
|
||||
return checkCapability(AgentCapabilities.ask_user_question);
|
||||
}
|
||||
if (isActionTool(tool)) {
|
||||
return actionsEnabled;
|
||||
}
|
||||
|
|
@ -1133,6 +1137,8 @@ async function loadAgentTools({
|
|||
return includesWebSearch;
|
||||
} else if (tool === Tools.memory) {
|
||||
return checkCapability(AgentCapabilities.memory);
|
||||
} else if (tool === ASK_USER_QUESTION_TOOL_NAME) {
|
||||
return checkCapability(AgentCapabilities.ask_user_question);
|
||||
} else if (isActionTool(tool)) {
|
||||
return actionsEnabled;
|
||||
} else if (tool?.includes(Constants.mcp_delimiter)) {
|
||||
|
|
|
|||
|
|
@ -252,6 +252,44 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
expect(callArgs.tools).toContain(actionToolName);
|
||||
});
|
||||
|
||||
it('should exclude ask_user_question when its capability is disabled (even if tools is enabled)', async () => {
|
||||
// ask_user_question is gated by its OWN capability, like execute_code —
|
||||
// NOT the generic `tools` capability. Here `tools` is on but the ask
|
||||
// capability is not, so the tool must be filtered out.
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
await loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_123', tools: [regularTool, 'ask_user_question'] },
|
||||
definitionsOnly: true,
|
||||
});
|
||||
|
||||
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
||||
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
||||
expect(callArgs.tools).toContain(regularTool);
|
||||
expect(callArgs.tools).not.toContain('ask_user_question');
|
||||
});
|
||||
|
||||
it('should include ask_user_question when its capability is enabled', async () => {
|
||||
const capabilities = [AgentCapabilities.tools, AgentCapabilities.ask_user_question];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
await loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_123', tools: [regularTool, 'ask_user_question'] },
|
||||
definitionsOnly: true,
|
||||
});
|
||||
|
||||
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
||||
const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
|
||||
expect(callArgs.tools).toContain('ask_user_question');
|
||||
});
|
||||
|
||||
it('should not filter MCP tools whose name contains _action (cross-delimiter collision)', async () => {
|
||||
const mcpToolWithAction = `get_action${Constants.mcp_delimiter}myserver`;
|
||||
const capabilities = [AgentCapabilities.tools];
|
||||
|
|
@ -1268,6 +1306,7 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.artifacts);
|
||||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.actions);
|
||||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.context);
|
||||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.ask_user_question);
|
||||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.tools);
|
||||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.chain);
|
||||
expect(defaultAgentCapabilities).toContain(AgentCapabilities.ocr);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ const toolInputs: BuildCatalogInputs = {
|
|||
agentsConfig: { capabilities: [AgentCapabilities.tools] },
|
||||
};
|
||||
|
||||
const askInputs: BuildCatalogInputs = {
|
||||
...emptyInputs,
|
||||
agentsConfig: { capabilities: [AgentCapabilities.ask_user_question] },
|
||||
};
|
||||
|
||||
describe('buildCatalog', () => {
|
||||
test('returns empty when nothing is enabled', () => {
|
||||
expect(buildCatalog(emptyInputs)).toEqual([]);
|
||||
|
|
@ -59,7 +64,7 @@ describe('buildCatalog', () => {
|
|||
|
||||
test('surfaces ask_user_question as a BUILTIN (not a plugin) when the server lists it', () => {
|
||||
const askPlugin = makePlugin({ pluginKey: 'ask_user_question', name: 'Ask User' });
|
||||
const items = buildCatalog({ ...toolInputs, regularTools: [askPlugin] });
|
||||
const items = buildCatalog({ ...askInputs, regularTools: [askPlugin] });
|
||||
const builtin = items.find((i) => i.kind === 'builtin' && i.id === 'ask_user_question');
|
||||
expect(builtin).toBeDefined();
|
||||
expect(builtin?.iconKey).toBe('ask_user_question');
|
||||
|
|
@ -67,15 +72,15 @@ describe('buildCatalog', () => {
|
|||
expect(items.find((i) => i.kind === 'tool' && i.id === 'ask_user_question')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('omits the ask_user_question builtin when the server filtered it or tools are off', () => {
|
||||
test('gates the ask_user_question builtin on its OWN capability, not the generic tools one', () => {
|
||||
const askPlugin = makePlugin({ pluginKey: 'ask_user_question', name: 'Ask User' });
|
||||
// admin filtered (not in regularTools)
|
||||
// admin filtered (not in regularTools) despite the capability being on
|
||||
expect(
|
||||
buildCatalog(toolInputs).find((i) => i.kind === 'builtin' && i.id === 'ask_user_question'),
|
||||
buildCatalog(askInputs).find((i) => i.kind === 'builtin' && i.id === 'ask_user_question'),
|
||||
).toBeUndefined();
|
||||
// tools capability off
|
||||
// ask_user_question capability off — the generic `tools` capability does NOT stand in for it
|
||||
expect(
|
||||
buildCatalog({ ...emptyInputs, regularTools: [askPlugin] }).find(
|
||||
buildCatalog({ ...toolInputs, regularTools: [askPlugin] }).find(
|
||||
(i) => i.kind === 'builtin' && i.id === 'ask_user_question',
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -121,13 +121,13 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] {
|
|||
/**
|
||||
* Native tool presented with the builtins (it ships with the app and pauses
|
||||
* the run like a first-class feature), while remaining an `agent.tools`
|
||||
* entry mechanically. Availability = the plugin listing itself: it appears
|
||||
* only when the `tools` capability is on AND the server lists the plugin
|
||||
* (i.e. the admin didn't filter it) — the same gates as the plugin section
|
||||
* it graduated from.
|
||||
* entry mechanically. Availability is its OWN capability (like execute_code /
|
||||
* web_search), NOT the generic `tools` one — the admin gates questions
|
||||
* independently via `endpoints.agents.capabilities` — AND the server must
|
||||
* still list the plugin (admin didn't filter it out).
|
||||
*/
|
||||
if (
|
||||
enabled.has(AgentCapabilities.tools) &&
|
||||
enabled.has(AgentCapabilities.ask_user_question) &&
|
||||
inputs.regularTools.some((plugin) => plugin.pluginKey === 'ask_user_question')
|
||||
) {
|
||||
items.push({
|
||||
|
|
|
|||
|
|
@ -573,6 +573,7 @@ export enum AgentCapabilities {
|
|||
context = 'context',
|
||||
skills = 'skills',
|
||||
memory = 'memory',
|
||||
ask_user_question = 'ask_user_question',
|
||||
tools = 'tools',
|
||||
chain = 'chain',
|
||||
ocr = 'ocr',
|
||||
|
|
@ -689,6 +690,7 @@ export const defaultAgentCapabilities = [
|
|||
AgentCapabilities.context,
|
||||
AgentCapabilities.skills,
|
||||
AgentCapabilities.memory,
|
||||
AgentCapabilities.ask_user_question,
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.chain,
|
||||
AgentCapabilities.ocr,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue