From 5c9f7ac789bce0b33fe1fe33d8cb9bb270f2e290 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 7 Jul 2026 15:57:14 -0400 Subject: [PATCH] feat: gate ask_user_question behind its own agent capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/server/services/ToolService.js | 6 +++ .../services/__tests__/ToolService.spec.js | 39 +++++++++++++++++++ .../Tools/items/__tests__/catalog.spec.ts | 17 +++++--- .../SidePanel/Agents/Tools/items/catalog.ts | 10 ++--- packages/data-provider/src/config.ts | 2 + 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 082217e783..b111a150d5 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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)) { diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 9f496c5f3a..db8c2c8cb1 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -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); diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts index ae70ccdec2..1733561f0c 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts @@ -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(); diff --git a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts index 1a3f874b31..e321ceecfa 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts @@ -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({ diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index f130e465c6..5a753ab1ac 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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,