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:
Danny Avila 2026-07-07 15:57:14 -04:00
parent e5e55d1012
commit 5c9f7ac789
5 changed files with 63 additions and 11 deletions

View file

@ -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)) {

View file

@ -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);

View file

@ -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();

View file

@ -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({

View file

@ -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,