LibreChat/api/server/controllers/PluginController.js
Danny Avila e35b539e64
fix: adversarial-review findings — in-graph execution, orphan prunes, endpoint scoping, real kill switch
Pre-PR multi-agent review confirmed 5 defects in the initial commit; all fixed:

1. CRITICAL — the tool never paused on the real agents endpoint: production
   loads tools definitions-only, flipping the SDK ToolNode to event-driven
   dispatch, and the host ON_TOOL_EXECUTE handler runs outside the Pregel task
   frame (under runOutsideTracing), where interrupt() throws and becomes an
   error ToolMessage. Reworked: the ask tool never rides toolDefinitions/
   toolRegistry — on HITL-capable top-level agents a real instance is supplied
   via AgentInputs.graphTools (agents#289, requires @librechat/agents > 3.2.57),
   the SDK's in-graph direct-tool seam; new production-shape e2e pins the
   event-driven mode end to end.
2. CRITICAL — ask-only runs left orphaned interrupted checkpoints (silent
   context duplication on every later turn): both orphan prunes were gated on
   toolApproval.enabled. The pre-turn prune now also fires for ask-capable
   agents (exported agentRequestsAskUserQuestion), and the abort-route prune
   fires when the aborted job carries a pendingAction.
3. MAJOR — self-spawned subagents bypassed the strip (self config resolves from
   the parent's _sourceInputs): fixed SDK-side (buildChildInputs clears
   graphTools) and the tool is now never present on child surfaces host-side.
4. MINOR — the manifest entry leaked into the Assistants tools dialog and the
   legacy plugins endpoint, where tools execute with no run to pause: new
   agentsOnly manifest flag, scoped out of both listings.
5. MINOR — filteredTools/includedTools only hid the tool from the dialog:
   now enforced at run build (strip + no checkpointer), making the admin
   filter a real kill switch for already-saved agents.
2026-07-07 07:41:50 -04:00

114 lines
3.9 KiB
JavaScript

const { logger } = require('@librechat/data-schemas');
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getAppConfig } = require('~/server/services/Config');
const getAvailablePluginsController = async (req, res) => {
try {
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const { filteredTools = [], includedTools = [] } = appConfig;
const uniquePlugins = filterUniquePlugins(availableTools);
const includeSet = new Set(includedTools);
const filterSet = new Set(filteredTools);
/** includedTools takes precedence — filteredTools ignored when both are set. */
const plugins = [];
for (const plugin of uniquePlugins) {
/** Agents-runtime-only tools (e.g. ask_user_question) never work on the
* legacy plugins endpoint — no run to pause, no resume surface. */
if (plugin.agentsOnly === true) {
continue;
}
if (includeSet.size > 0) {
if (!includeSet.has(plugin.pluginKey)) {
continue;
}
} else if (filterSet.has(plugin.pluginKey)) {
continue;
}
plugins.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
}
res.status(200).json(plugins);
} catch (error) {
res.status(500).json({ message: error.message });
}
};
const getAvailableTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
let toolDefinitions = await getCachedTools();
if (toolDefinitions == null && appConfig?.availableTools != null) {
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
await setCachedTools(appConfig.availableTools);
toolDefinitions = appConfig.availableTools;
}
const uniquePlugins = filterUniquePlugins(availableTools);
const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null;
const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null;
/**
* `getAvailableTools` serves BOTH tool dialogs — /api/agents/tools and
* /api/assistants/tools. Tools flagged `agentsOnly` in the manifest (e.g.
* ask_user_question, which pauses an agents run via a LangGraph interrupt)
* cannot work on the assistants runtime: it executes tools directly with no
* run to pause and no resume surface, so attaching one there guarantees a
* permanent tool error. Scope them out of the assistants listing by route.
*/
const isAssistantsRoute = req.baseUrl?.includes('/assistants') === true;
const toolsOutput = [];
for (const plugin of uniquePlugins) {
if (plugin.agentsOnly === true && isAssistantsRoute) {
continue;
}
const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true;
const isToolkit =
plugin.toolkit === true &&
toolDefKeysList != null &&
toolDefKeysList.some(
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
);
if (!isToolDefined && !isToolkit) {
continue;
}
toolsOutput.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
}
res.status(200).json(toolsOutput);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });
}
};
module.exports = {
getAvailableTools,
getAvailablePluginsController,
};