mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-29 19:01:38 +00:00
* 🧠 feat: Memory Agent Capability with Inline Tools and Ephemeral Badge
Add `AgentCapabilities.memory`, which expands into the inline set_memory/delete_memory tool pair (mirroring the execute_code expansion via registerMemoryTools) when a run-level memoryAvailable gate holds: capability enabled, memory configured, MEMORIES.USE permission, and personalization not opted out. Surfaces the memory artifact as an attachment in the agents tool-end callback.
Adds the ephemeral path (TEphemeralAgent.memory, load/added agent tool injection), a fully-gated memory badge plus tools-dropdown entry, the agent-builder Memory toggle with form round-trip, and a mock e2e test asserting the badge reaches the request payload. Additive to and independent of the existing post-turn memory extraction agent.
* 🩹 fix: Address Codex review on memory capability (gating, validKeys, usage guard)
- Strip the memory capability from the served agents capabilities when memory is not configured/enabled, so the badge, tools dropdown, agent-builder toggle, and backend capability gate stay consistent instead of exposing an inert toggle on default installs (where MEMORIES.USE defaults true).
- Surface configured memory.validKeys in the inline tool definitions so the model is told the allowed keys up front, matching the runtime createMemoryTool schema.
- Append a strict explicit-request usage guard to the agent instructions when inline memory tools are registered, preserving the memory-agent's privacy behavior.
- Add AppService tests covering memory-capability stripping.
* ✅ test: Update AppService capability snapshots for memory strip
AppService now strips the memory capability from the served agents defaults when no memory block is configured; update the spec's expected capability lists to defaultAgentCapabilitiesWithoutMemory for the no-memory-config cases.
* 🛡️ fix: Address Codex re-review on memory capability (round 2)
- Strip the memory capability from the FINAL served agents config, not just defaults; loadEndpoints reparses any endpoints.agents block, so memory was still exposed in that common shape (packages/data-schemas/src/app/service.ts) + regression test.
- Re-check the full memory gate (config, opt-out, MEMORIES.USE) inside handleTools before constructing set_memory/delete_memory, so an unsolicited tool call from a model/custom endpoint can't bypass the runtime gates (api/app/clients/tools/util/handleTools.js).
- Restore the persisted memory toggle for model-spec conversations via applyModelSpecEphemeralAgent (client/src/utils/endpoints.ts).
- Clear LAST_MEMORY_TOGGLE_ on logout and clear-all-chats so a stale memory preference can't leak across users on a shared browser (client/src/utils/localStorage.ts).
* 🧠 fix: Address Codex re-review on memory capability (round 3)
- Serialize set_memory writes and advance a running token total inside createMemoryTool, so parallel batched calls in one event-driven turn can't each pass the limit check against a stale total and collectively exceed memory.tokenLimit (packages/api/src/agents/memory.ts) + tests.
- Inject the keyed memory context (withKeys) instead of withoutKeys when the running agent has the inline memory capability, so delete_memory has a visible key to target (api/server/controllers/agents/client.js).
* 🔐 fix: Address Codex re-review on memory capability (round 4)
- Detect inline memory by tool NAME (set_memory/delete_memory) across an initialized agent's tools + toolDefinitions, since the 'memory' marker is expanded at init and the prior string check never matched; inject the keyed memory context for any primary OR sub-agent that carries the inline memory tools (api/server/controllers/agents/client.js).
- Enforce memory WRITE permissions in the inline tool gate: set_memory requires CREATE+UPDATE and delete_memory requires UPDATE (matching the REST memory routes), so a USE-only role can't mutate/delete memories via agent tool calls (api/app/clients/tools/util/handleTools.js).
* 🔒 fix: Address Codex re-review on memory capability (round 5)
- Gate inline memory registration (memoryAvailable) on the memory WRITE permissions (USE+CREATE+UPDATE), so a read-only-memory role no longer has set_memory/delete_memory shown to the model only for the runtime loader to refuse them (api/server/services/Endpoints/agents/initialize.js).
- Enforce the per-agent memory opt-in at execution: handleTools now refuses to construct set_memory/delete_memory unless the agent actually declared them (toolDefinitions/tools), blocking hallucinated/undeclared memory tool calls from mutating memory.
- Fail closed when getFormattedMemories errors with a configured tokenLimit, instead of writing as if storage were empty and bypassing the cap (api/app/clients/tools/util/handleTools.js).
* 🩹 fix: Address Codex re-review on memory capability (round 6)
- Fix a P1 regression from the prior round: the execution-context agent keeps the raw 'memory' capability marker (not the expanded set_memory/delete_memory names), so the opt-in check now matches the marker. This restores memory writes/deletes AND avoids hijacking an MCP tool that merely shares the set_memory/delete_memory name (api/app/clients/tools/util/handleTools.js).
- Count repeated set_memory writes to the same key as replacements, not additions, against tokenLimit — set_memory upserts, so a same-key rewrite swaps its prior token contribution instead of double-counting (packages/api/src/agents/memory.ts) + test.
- Gate the memory badge, tools dropdown, and agent-builder toggle on the full memory write permissions (USE+CREATE+UPDATE) via a shared useHasMemoryAccess hook, so a read-only-memory role no longer sees an enabled Memory control the backend would refuse to wire up.
* 🧷 fix: Address Codex re-review on memory capability (round 7)
- Recognize inline memory across both execution-context agent shapes: initializeAgent now sets a LibreChat-only memoryToolsRegistered flag on the InitializedAgent, and the opt-in/detection checks accept that flag OR the raw 'memory' marker. Fixes memory failing for processAddedConvo agents (which store the initialized config, marker already expanded) while staying MCP-name-collision-safe (api/app/clients/tools/util/handleTools.js, packages/api/src/agents/initialize.ts, api/server/controllers/agents/client.js).
- Scope keyed memory context to memory-enabled agents only: useMemory now returns both keyed and unkeyed contexts, and buildMessages injects the keyed one (memory keys + token metadata) only to agents that can call delete_memory, while the primary/post-turn path keeps the unkeyed values — so a primary without memory tools no longer sees memory keys it doesn't need.
* 🔏 fix: Address Codex re-review on memory capability (round 8)
- Enforce memory size limits on inline writes: createMemoryTool now rejects keys over 1000 chars and values over memory.charLimit, matching the REST memory routes, so an inline-memory agent can't persist blobs the memory UI/API would reject (packages/api/src/agents/memory.ts, api/app/clients/tools/util/handleTools.js) + test.
- Recheck the agents 'memory' endpoint capability at execution time, so a stale/hallucinated set_memory/delete_memory call can't mutate memory after an admin removes the capability while the agent document still carries the marker (api/app/clients/tools/util/handleTools.js).
* ♻️ refactor: Move inline-memory backend logic into packages/api + share memory load
Workspace boundary: the inline-memory gating/detection logic that had crept into /api now lives in packages/api/src/agents/memory.ts (TS), with /api kept as thin wrappers.
- Add agentHasInlineMemoryTools, isMemoryToolAllowed, and buildInlineMemoryTool to packages/api; handleTools.js now calls buildInlineMemoryTool instead of constructing/gating the tools inline, and client.js imports agentHasInlineMemoryTools instead of redefining it.
- Optimize repeated memory loads: getRequestMemories memoizes getFormattedMemories per request (WeakMap keyed by req), so the run's memory-context load and every memory-enabled agent's set_memory token-usage load share a single DB fetch instead of one per agent.
* 🧠 fix: Invalidate request memory cache after inline writes
Inline set_memory/delete_memory now invalidate the request-scoped
getFormattedMemories cache on a successful write, so a later tool round
in the same response is seeded with the post-write usage total instead
of the stale pre-write one (multi-round writes no longer collectively
exceed tokenLimit, and a set after a delete is not over-counted). The
within-round sharing across multiple memory-enabled agents is preserved.
* 🧠 fix: Persist memory capability on saved agents; honor registration flag
- Add Tools.memory to the v1 systemTools allowlist so filterAuthorizedTools
no longer silently drops the memory marker when an agent with the Memory
capability is created/updated/duplicated through the builder (previously
the capability only worked for ephemeral chats, not persisted agents).
- agentHasInlineMemoryTools now honors an explicit memoryToolsRegistered
boolean before falling back to the raw `memory` marker, so an initialized
config whose registration was denied (memoryAvailable false) is not given
keyed memory context just because the marker survives in tools.
* 🧩 fix: Bring memory tool to parity with other ephemeral tools
- Add `memory` to the model-spec schema/type and honor `modelSpec.memory`
in both ephemeral paths (load.ts, added.ts) and the frontend spec
application, so admins can pre-enable Memory from a model spec exactly
like webSearch/fileSearch/executeCode.
- Add LAST_MEMORY_TOGGLE_ to the timestamped-storage cleanup list so stale
per-conversation memory toggles are purged on startup like the others.
- Hide the agent-builder Memory toggle for users who disabled memory in
personalization (memories === false), mirroring the chat badge's opt-out
gate, so the setting isn't shown as inert/misleading.
* ✅ test: Cover memory in applyModelSpecEphemeralAgent spec defaults
Update the exact-object assertions to include the new `memory` field and
add positive coverage that `modelSpec.memory` maps to the ephemeral
agent's `memory` flag. Fixes the shard 2/4 failure from 672a03b05.
1791 lines
58 KiB
JavaScript
1791 lines
58 KiB
JavaScript
const { logger, redactMessage } = require('@librechat/data-schemas');
|
|
const { tool: toolFn, DynamicStructuredTool } = require('@librechat/agents/langchain/tools');
|
|
const {
|
|
sleep,
|
|
createToolSearch,
|
|
createBashExecutionTool,
|
|
Constants: AgentConstants,
|
|
createBashProgrammaticToolCallingTool,
|
|
} = require('@librechat/agents');
|
|
const {
|
|
sendEvent,
|
|
getToolkitKey,
|
|
getUserMCPAuthMap,
|
|
loadToolDefinitions,
|
|
GenerationJobManager,
|
|
isActionDomainAllowed,
|
|
buildWebSearchContext,
|
|
buildImageToolContext,
|
|
buildToolClassification,
|
|
getMissingCustomUserVars,
|
|
buildWebSearchDynamicContext,
|
|
getCodeApiAuthHeaders,
|
|
getReplayablePendingMCPOAuthStart,
|
|
getMCPServerNamesFromTools,
|
|
buildMCPAuthToolCall,
|
|
buildMCPAuthStepId,
|
|
buildMCPAuthRunStepEvent,
|
|
buildMCPAuthRunStepDeltaEvent,
|
|
buildMCPAuthRunStepCompletedEvent,
|
|
isFileAuthoringToolDefinition,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Time,
|
|
Tools,
|
|
Constants,
|
|
CacheKeys,
|
|
ErrorTypes,
|
|
ContentTypes,
|
|
imageGenTools,
|
|
EModelEndpoint,
|
|
EToolResources,
|
|
isActionTool,
|
|
actionDelimiter,
|
|
ImageVisionTool,
|
|
openapiToFunction,
|
|
AgentCapabilities,
|
|
isEphemeralAgentId,
|
|
validateActionDomain,
|
|
actionDomainSeparator,
|
|
defaultAgentCapabilities,
|
|
validateAndParseOpenAPISpec,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
createActionTool,
|
|
legacyDomainEncode,
|
|
decryptMetadata,
|
|
loadActionSets,
|
|
domainParser,
|
|
} = require('./ActionService');
|
|
const {
|
|
getEndpointsConfig,
|
|
getMCPServerTools,
|
|
getCachedTools,
|
|
} = require('~/server/services/Config');
|
|
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
|
const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileSearch');
|
|
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
|
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
|
|
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
|
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
|
const { createMCPPermissionContext, resolveConfigServers } = require('~/server/services/MCP');
|
|
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
|
|
const { recordUsage } = require('~/server/services/Threads');
|
|
const { loadTools } = require('~/app/clients/tools/util');
|
|
const { findPluginAuthsByKeys } = require('~/models');
|
|
const { getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
const domainSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
|
|
|
|
/**
|
|
* Collapse every `actionDomainSeparator` sequence in the encoded-domain
|
|
* suffix of a fully-qualified action tool name to an underscore. Agents
|
|
* can store tool names in the raw `domainParser(..., true)` output,
|
|
* which for short hostnames is a `---`-separated string (e.g.
|
|
* `medium---com`). The lookup maps below are always keyed with the
|
|
* `_`-collapsed domain, so every read must normalize that suffix or
|
|
* short-hostname tools silently fail to resolve.
|
|
*
|
|
* The operationId portion (everything before the last `actionDelimiter`)
|
|
* is deliberately left untouched: `openapiToFunction` preserves hyphens
|
|
* in generated operationIds, so two specs can legitimately produce
|
|
* operationIds that differ only in hyphens-vs-underscores (e.g.
|
|
* `get_foo---bar` vs `get_foo_bar`). Collapsing the operationId would
|
|
* merge those into a single map slot and silently drop one tool.
|
|
*/
|
|
const normalizeActionToolName = (toolName) => {
|
|
const delimiterIndex = toolName.lastIndexOf(actionDelimiter);
|
|
if (delimiterIndex === -1) {
|
|
return toolName;
|
|
}
|
|
const prefixEnd = delimiterIndex + actionDelimiter.length;
|
|
const encodedDomain = toolName.slice(prefixEnd);
|
|
return toolName.slice(0, prefixEnd) + encodedDomain.replace(domainSeparatorRegex, '_');
|
|
};
|
|
|
|
/**
|
|
* Populate a `toolToAction` map with one slot per fully-qualified tool
|
|
* name (`<operationId><actionDelimiter><encoded-domain>`). Both the new
|
|
* and the legacy encodings of the domain are registered for every
|
|
* function so agents whose stored tool names predate the current
|
|
* encoding still resolve correctly.
|
|
*
|
|
* Indexing on the full tool name instead of the encoded domain alone is
|
|
* what makes multi-action agents work when two actions share a hostname:
|
|
* the operationId disambiguates them, so neither overwrites the other.
|
|
*
|
|
* Two actions that additionally share the same operationId still
|
|
* collide (nothing in the key distinguishes them). That case is
|
|
* pathological — `sanitizeOperationId` plus OpenAPI's own uniqueness
|
|
* requirement make it very unlikely — but when it does happen we log
|
|
* a warning so the silent-overwrite mode from the original bug cannot
|
|
* reappear under a different disguise.
|
|
*/
|
|
const registerActionTools = ({
|
|
toolToAction,
|
|
functionSignatures,
|
|
normalizedDomain,
|
|
legacyNormalized,
|
|
makeEntry,
|
|
}) => {
|
|
const setKey = (key, entry) => {
|
|
if (toolToAction.has(key)) {
|
|
logger.warn(
|
|
`[Actions] operationId collision: "${key}" already registered; ` +
|
|
`action "${entry.action?.action_id}" overwrites the previous entry. ` +
|
|
`Two actions share both the operationId and the encoded hostname.`,
|
|
);
|
|
}
|
|
toolToAction.set(key, entry);
|
|
};
|
|
|
|
for (const sig of functionSignatures) {
|
|
const entry = makeEntry(sig);
|
|
// Use `sig.name` verbatim: `openapiToFunction` keeps hyphens in
|
|
// generated operationIds, so `get_foo---bar` and `get_foo_bar` are
|
|
// distinct operations on the same spec. `normalizeActionToolName`
|
|
// only touches the encoded-domain suffix at lookup time, so map
|
|
// keys and lookups stay consistent without merging distinct
|
|
// operationIds into the same slot.
|
|
setKey(`${sig.name}${actionDelimiter}${normalizedDomain}`, entry);
|
|
if (legacyNormalized !== normalizedDomain) {
|
|
setKey(`${sig.name}${actionDelimiter}${legacyNormalized}`, entry);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
* @param {RequiredAction} requiredActions - The current required action.
|
|
* @returns {Promise<ToolOutput>} The outputs of the tools.
|
|
*/
|
|
const processVisionRequest = async (client, currentAction) => {
|
|
if (!client.visionPromise) {
|
|
return {
|
|
tool_call_id: currentAction.toolCallId,
|
|
output: 'No image details found.',
|
|
};
|
|
}
|
|
|
|
/** @type {ChatCompletion | undefined} */
|
|
const completion = await client.visionPromise;
|
|
if (completion && completion.usage) {
|
|
recordUsage({
|
|
user: client.req.user.id,
|
|
model: client.req.body.model,
|
|
conversationId: (client.responseMessage ?? client.finalMessage).conversationId,
|
|
...completion.usage,
|
|
});
|
|
}
|
|
const output = completion?.choices?.[0]?.message?.content ?? 'No image details found.';
|
|
return {
|
|
tool_call_id: currentAction.toolCallId,
|
|
output,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Processes return required actions from run.
|
|
* @param {OpenAIClient | StreamRunManager} client - OpenAI (legacy) or StreamRunManager Client.
|
|
* @param {RequiredAction[]} requiredActions - The required actions to submit outputs for.
|
|
* @returns {Promise<ToolOutputs>} The outputs of the tools.
|
|
*/
|
|
async function processRequiredActions(client, requiredActions) {
|
|
logger.debug(
|
|
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
|
requiredActions,
|
|
);
|
|
const appConfig = client.req.config;
|
|
const toolDefinitions = (await getCachedTools()) ?? {};
|
|
const seenToolkits = new Set();
|
|
const tools = requiredActions
|
|
.map((action) => {
|
|
const toolName = action.tool;
|
|
const toolDef = toolDefinitions[toolName];
|
|
if (toolDef && !manifestToolMap[toolName]) {
|
|
for (const toolkit of toolkits) {
|
|
if (seenToolkits.has(toolkit.pluginKey)) {
|
|
return;
|
|
} else if (toolName.startsWith(`${toolkit.pluginKey}_`)) {
|
|
seenToolkits.add(toolkit.pluginKey);
|
|
return toolkit.pluginKey;
|
|
}
|
|
}
|
|
}
|
|
return toolName;
|
|
})
|
|
.filter((toolName) => !!toolName);
|
|
|
|
const { loadedTools } = await loadTools({
|
|
user: client.req.user.id,
|
|
model: client.req.body.model ?? 'gpt-4o-mini',
|
|
tools,
|
|
functions: true,
|
|
endpoint: client.req.body.endpoint,
|
|
options: {
|
|
processFileURL,
|
|
req: client.req,
|
|
uploadImageBuffer,
|
|
openAIApiKey: client.apiKey,
|
|
returnMetadata: true,
|
|
},
|
|
webSearch: appConfig.webSearch,
|
|
fileStrategy: appConfig.fileStrategy,
|
|
imageOutputType: appConfig.imageOutputType,
|
|
});
|
|
|
|
const ToolMap = loadedTools.reduce((map, tool) => {
|
|
map[tool.name] = tool;
|
|
return map;
|
|
}, {});
|
|
|
|
const promises = [];
|
|
|
|
let actionSetsData = null;
|
|
let isActionTool = false;
|
|
const ActionToolMap = {};
|
|
const ActionBuildersMap = {};
|
|
|
|
for (let i = 0; i < requiredActions.length; i++) {
|
|
const currentAction = requiredActions[i];
|
|
if (currentAction.tool === ImageVisionTool.function.name) {
|
|
promises.push(processVisionRequest(client, currentAction));
|
|
continue;
|
|
}
|
|
let tool = ToolMap[currentAction.tool] ?? ActionToolMap[currentAction.tool];
|
|
|
|
const handleToolOutput = async (output) => {
|
|
requiredActions[i].output = output;
|
|
|
|
/** @type {FunctionToolCall & PartMetadata} */
|
|
const toolCall = {
|
|
function: {
|
|
name: currentAction.tool,
|
|
arguments: JSON.stringify(currentAction.toolInput),
|
|
output,
|
|
},
|
|
id: currentAction.toolCallId,
|
|
type: 'function',
|
|
progress: 1,
|
|
action: isActionTool,
|
|
};
|
|
|
|
const toolCallIndex = client.mappedOrder.get(toolCall.id);
|
|
|
|
if (imageGenTools.has(currentAction.tool)) {
|
|
const imageOutput = output;
|
|
toolCall.function.output = `${currentAction.tool} displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.`;
|
|
|
|
// Streams the "Finished" state of the tool call in the UI
|
|
client.addContentData({
|
|
[ContentTypes.TOOL_CALL]: toolCall,
|
|
index: toolCallIndex,
|
|
type: ContentTypes.TOOL_CALL,
|
|
});
|
|
|
|
await sleep(500);
|
|
|
|
/** @type {ImageFile} */
|
|
const imageDetails = {
|
|
...imageOutput,
|
|
...currentAction.toolInput,
|
|
};
|
|
|
|
const image_file = {
|
|
[ContentTypes.IMAGE_FILE]: imageDetails,
|
|
type: ContentTypes.IMAGE_FILE,
|
|
// Replace the tool call output with Image file
|
|
index: toolCallIndex,
|
|
};
|
|
|
|
client.addContentData(image_file);
|
|
|
|
// Update the stored tool call
|
|
client.seenToolCalls && client.seenToolCalls.set(toolCall.id, toolCall);
|
|
|
|
return {
|
|
tool_call_id: currentAction.toolCallId,
|
|
output: toolCall.function.output,
|
|
};
|
|
}
|
|
|
|
client.seenToolCalls && client.seenToolCalls.set(toolCall.id, toolCall);
|
|
client.addContentData({
|
|
[ContentTypes.TOOL_CALL]: toolCall,
|
|
index: toolCallIndex,
|
|
type: ContentTypes.TOOL_CALL,
|
|
// TODO: to append tool properties to stream, pass metadata rest to addContentData
|
|
// result: tool.result,
|
|
});
|
|
|
|
return {
|
|
tool_call_id: currentAction.toolCallId,
|
|
output,
|
|
};
|
|
};
|
|
|
|
if (!tool) {
|
|
// throw new Error(`Tool ${currentAction.tool} not found.`);
|
|
|
|
if (!actionSetsData) {
|
|
/** @type {Action[]} */
|
|
const actionSets =
|
|
(await loadActionSets({
|
|
assistant_id: client.req.body.assistant_id,
|
|
})) ?? [];
|
|
|
|
// See registerActionTools for the key-shape rationale.
|
|
const toolToAction = new Map();
|
|
|
|
for (const action of actionSets) {
|
|
const domain = await domainParser(action.metadata.domain, true);
|
|
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
|
|
const legacyDomain = legacyDomainEncode(action.metadata.domain);
|
|
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
|
|
|
|
const isDomainAllowed = await isActionDomainAllowed(
|
|
action.metadata.domain,
|
|
appConfig?.actions?.allowedDomains,
|
|
appConfig?.actions?.allowedAddresses,
|
|
);
|
|
if (!isDomainAllowed) {
|
|
continue;
|
|
}
|
|
|
|
// Validate and parse OpenAPI spec
|
|
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
|
if (!validationResult.spec || !validationResult.serverUrl) {
|
|
throw new Error(
|
|
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
|
);
|
|
}
|
|
|
|
// SECURITY: Validate the domain from the spec matches the stored domain
|
|
// This is defense-in-depth to prevent any stored malicious actions
|
|
const domainValidation = validateActionDomain(
|
|
action.metadata.domain,
|
|
validationResult.serverUrl,
|
|
);
|
|
if (!domainValidation.isValid) {
|
|
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
|
userId: client.req.user.id,
|
|
action_id: action.action_id,
|
|
});
|
|
continue; // Skip this action rather than failing the entire request
|
|
}
|
|
|
|
// Process the OpenAPI spec
|
|
const { requestBuilders, functionSignatures } = openapiToFunction(validationResult.spec);
|
|
|
|
// Store encrypted values for OAuth flow
|
|
const encrypted = {
|
|
oauth_client_id: action.metadata.oauth_client_id,
|
|
oauth_client_secret: action.metadata.oauth_client_secret,
|
|
};
|
|
|
|
// Decrypt metadata
|
|
const decryptedAction = { ...action };
|
|
decryptedAction.metadata = await decryptMetadata(action.metadata);
|
|
|
|
registerActionTools({
|
|
toolToAction,
|
|
functionSignatures,
|
|
normalizedDomain,
|
|
legacyNormalized,
|
|
makeEntry: (sig) => ({
|
|
action: decryptedAction,
|
|
requestBuilder: requestBuilders[sig.name],
|
|
encrypted,
|
|
}),
|
|
});
|
|
|
|
// Store builders for reuse
|
|
ActionBuildersMap[action.metadata.domain] = requestBuilders;
|
|
}
|
|
|
|
actionSetsData = toolToAction;
|
|
}
|
|
|
|
const entry = actionSetsData.get(normalizeActionToolName(currentAction.tool));
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
|
|
const { action, requestBuilder, encrypted } = entry;
|
|
|
|
// We've already decrypted the metadata, so we can pass it directly
|
|
const _allowedDomains = appConfig?.actions?.allowedDomains;
|
|
const _allowedAddresses = appConfig?.actions?.allowedAddresses;
|
|
tool = await createActionTool({
|
|
userId: client.req.user.id,
|
|
res: client.res,
|
|
action,
|
|
requestBuilder,
|
|
// Note: intentionally not passing zodSchema, name, and description for assistants API
|
|
encrypted, // Pass the encrypted values for OAuth flow
|
|
useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0,
|
|
allowedAddresses: _allowedAddresses,
|
|
});
|
|
if (!tool) {
|
|
logger.warn(
|
|
`Invalid action: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id} | toolName: ${currentAction.tool}`,
|
|
);
|
|
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
|
|
}
|
|
isActionTool = !!tool;
|
|
ActionToolMap[currentAction.tool] = tool;
|
|
}
|
|
|
|
if (currentAction.tool === 'calculator') {
|
|
currentAction.toolInput = currentAction.toolInput.input;
|
|
}
|
|
|
|
const handleToolError = (error) => {
|
|
logger.error(
|
|
`tool_call_id: ${currentAction.toolCallId} | Error processing tool ${currentAction.tool}`,
|
|
error,
|
|
);
|
|
return {
|
|
tool_call_id: currentAction.toolCallId,
|
|
output: `Error processing tool ${currentAction.tool}: ${redactMessage(error.message, 256)}`,
|
|
};
|
|
};
|
|
|
|
try {
|
|
const promise = tool
|
|
._call(currentAction.toolInput)
|
|
.then(handleToolOutput)
|
|
.catch(handleToolError);
|
|
promises.push(promise);
|
|
} catch (error) {
|
|
const toolOutputError = handleToolError(error);
|
|
promises.push(Promise.resolve(toolOutputError));
|
|
}
|
|
}
|
|
|
|
return {
|
|
tool_outputs: await Promise.all(promises),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Processes the runtime tool calls and returns the tool classes.
|
|
* @param {Object} params - Run params containing user and request information.
|
|
* @param {ServerRequest} params.req - The request object.
|
|
* @param {ServerResponse} params.res - The request object.
|
|
* @param {AbortSignal} params.signal
|
|
* @param {Pick<Agent, 'id' | 'provider' | 'model' | 'tools'} params.agent - The agent to load tools for.
|
|
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
|
|
* @returns {Promise<{
|
|
* tools?: StructuredTool[];
|
|
* toolContextMap?: Record<string, unknown>;
|
|
* dynamicToolContextMap?: Record<string, unknown>;
|
|
* userMCPAuthMap?: Record<string, Record<string, string>>;
|
|
* toolRegistry?: Map<string, import('~/utils/toolClassification').LCTool>;
|
|
* hasDeferredTools?: boolean;
|
|
* }>} The agent tools and registry.
|
|
*/
|
|
/** Native LibreChat tools that are not in the manifest */
|
|
const nativeTools = new Set([
|
|
Tools.execute_code,
|
|
Tools.file_search,
|
|
Tools.web_search,
|
|
Tools.memory,
|
|
]);
|
|
|
|
/** Checks if a tool name is a known built-in tool */
|
|
const isBuiltInTool = (toolName) =>
|
|
Boolean(
|
|
manifestToolMap[toolName] ||
|
|
toolkits.some((t) => t.pluginKey === toolName) ||
|
|
nativeTools.has(toolName),
|
|
);
|
|
|
|
/**
|
|
* Loads only tool definitions without creating tool instances.
|
|
* This is the efficient path for event-driven mode where tools are loaded on-demand.
|
|
*
|
|
* @param {Object} params
|
|
* @param {ServerRequest} params.req - The request object
|
|
* @param {ServerResponse} [params.res] - The response object for SSE events
|
|
* @param {Object} params.agent - The agent configuration
|
|
* @param {string|null} [params.streamId] - Stream ID for resumable mode
|
|
* @returns {Promise<{
|
|
* toolDefinitions?: import('@librechat/api').LCTool[];
|
|
* toolRegistry?: Map<string, import('@librechat/api').LCTool>;
|
|
* mcpAvailableTools?: Record<string, import('@librechat/api').LCAvailableTools>;
|
|
* userMCPAuthMap?: Record<string, Record<string, string>>;
|
|
* hasDeferredTools?: boolean;
|
|
* }>}
|
|
*/
|
|
async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, tool_resources }) {
|
|
if (!agent.tools || agent.tools.length === 0) {
|
|
return { toolDefinitions: [] };
|
|
}
|
|
|
|
if (
|
|
agent.tools.length === 1 &&
|
|
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
|
|
) {
|
|
return { toolDefinitions: [] };
|
|
}
|
|
|
|
const appConfig = req.config;
|
|
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 programmaticToolsEnabled = enabledCapabilities.has(AgentCapabilities.programmatic_tools);
|
|
const codeExecutionEnabled =
|
|
agent.tools?.includes(Tools.execute_code) === true &&
|
|
enabledCapabilities.has(AgentCapabilities.execute_code);
|
|
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
|
|
|
|
const filteredTools = agent.tools?.filter((tool) => {
|
|
if (tool === Tools.file_search) {
|
|
return checkCapability(AgentCapabilities.file_search);
|
|
}
|
|
if (tool === Tools.execute_code) {
|
|
return checkCapability(AgentCapabilities.execute_code);
|
|
}
|
|
if (tool === Tools.web_search) {
|
|
return checkCapability(AgentCapabilities.web_search);
|
|
}
|
|
if (tool === Tools.memory) {
|
|
return checkCapability(AgentCapabilities.memory);
|
|
}
|
|
if (isActionTool(tool)) {
|
|
return actionsEnabled;
|
|
}
|
|
if (tool?.includes(Constants.mcp_delimiter)) {
|
|
return areToolsEnabled && canUseMCP;
|
|
}
|
|
if (!areToolsEnabled) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!filteredTools || filteredTools.length === 0) {
|
|
return { toolDefinitions: [] };
|
|
}
|
|
|
|
/** @type {Record<string, Record<string, string>>} */
|
|
let userMCPAuthMap;
|
|
if (filteredTools?.some((t) => t.includes(Constants.mcp_delimiter))) {
|
|
userMCPAuthMap = await getUserMCPAuthMap({
|
|
tools: filteredTools,
|
|
userId: req.user.id,
|
|
findPluginAuthsByKeys,
|
|
});
|
|
}
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
const configServers = await resolveConfigServers(req);
|
|
const pendingOAuthServers = new Set();
|
|
const pendingOAuthStarts = new Map();
|
|
const emittedOAuthStarts = new Map();
|
|
const oauthToolCallIds = new Map();
|
|
const oauthStepIndexes = new Map();
|
|
/** @type {Record<string, import('@librechat/api').LCAvailableTools>} */
|
|
const mcpAvailableTools = {};
|
|
const requestScopedConnections = getMCPRequestContext(req, res);
|
|
const rememberMCPAvailableTools = (serverName, availableTools) => {
|
|
if (!availableTools || Object.keys(availableTools).length === 0) {
|
|
return;
|
|
}
|
|
mcpAvailableTools[serverName] = availableTools;
|
|
};
|
|
|
|
const createOAuthEmitter = (serverName, index) => {
|
|
return async (authURL, options) => {
|
|
if (emittedOAuthStarts.get(serverName) === authURL) {
|
|
return;
|
|
}
|
|
emittedOAuthStarts.set(serverName, authURL);
|
|
|
|
const flowId =
|
|
oauthToolCallIds.get(serverName) ?? `${req.user.id}:${serverName}:${Date.now()}`;
|
|
const stepId = buildMCPAuthStepId(serverName);
|
|
oauthToolCallIds.set(serverName, flowId);
|
|
oauthStepIndexes.set(serverName, index);
|
|
const toolCall = buildMCPAuthToolCall({
|
|
id: flowId,
|
|
serverName,
|
|
});
|
|
|
|
const runStepEvent = buildMCPAuthRunStepEvent({ stepId, toolCall, index });
|
|
const runStepDeltaEvent = buildMCPAuthRunStepDeltaEvent({
|
|
authURL,
|
|
stepId,
|
|
toolCall,
|
|
options,
|
|
});
|
|
|
|
if (streamId) {
|
|
await GenerationJobManager.emitChunk(streamId, runStepEvent);
|
|
await GenerationJobManager.emitChunk(streamId, runStepDeltaEvent);
|
|
} else if (res && !res.writableEnded) {
|
|
sendEvent(res, runStepEvent);
|
|
sendEvent(res, runStepDeltaEvent);
|
|
} else {
|
|
logger.warn(
|
|
`[Tool Definitions] Cannot emit OAuth event for ${serverName}: no streamId and res not available`,
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
const createOAuthEndEmitter = (serverName) => {
|
|
return async () => {
|
|
const stepId = buildMCPAuthStepId(serverName);
|
|
const toolCall = buildMCPAuthToolCall({
|
|
id: oauthToolCallIds.get(serverName),
|
|
args: '',
|
|
output: 'OAuth authentication completed',
|
|
serverName,
|
|
type: 'tool_call',
|
|
});
|
|
const runStepCompletedEvent = buildMCPAuthRunStepCompletedEvent({
|
|
stepId,
|
|
toolCall,
|
|
index: oauthStepIndexes.get(serverName) ?? 0,
|
|
});
|
|
|
|
if (streamId) {
|
|
await GenerationJobManager.emitChunk(streamId, runStepCompletedEvent);
|
|
} else if (res && !res.writableEnded) {
|
|
sendEvent(res, runStepCompletedEvent);
|
|
} else {
|
|
logger.warn(
|
|
`[Tool Definitions] Cannot emit OAuth completion for ${serverName}: no streamId and res not available`,
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
const getPendingOAuthStartForEmit = async (serverName) => {
|
|
const cachedOAuthStart = pendingOAuthStarts.get(serverName);
|
|
if (cachedOAuthStart?.options?.expiresAt != null) {
|
|
return cachedOAuthStart;
|
|
}
|
|
|
|
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
|
|
flowManager,
|
|
userId: req.user.id,
|
|
serverName,
|
|
});
|
|
if (!pendingOAuthStart) {
|
|
return cachedOAuthStart;
|
|
}
|
|
|
|
if (!cachedOAuthStart || pendingOAuthStart.authURL === cachedOAuthStart.authURL) {
|
|
pendingOAuthStarts.set(serverName, pendingOAuthStart);
|
|
return pendingOAuthStart;
|
|
}
|
|
|
|
return cachedOAuthStart;
|
|
};
|
|
|
|
const getOrFetchMCPServerTools = async (userId, serverName) => {
|
|
const addPendingOAuthServer = async () => {
|
|
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
|
|
flowManager,
|
|
userId,
|
|
serverName,
|
|
});
|
|
if (!pendingOAuthStart) {
|
|
return false;
|
|
}
|
|
|
|
pendingOAuthServers.add(serverName);
|
|
pendingOAuthStarts.set(serverName, pendingOAuthStart);
|
|
return true;
|
|
};
|
|
|
|
let serverConfig;
|
|
try {
|
|
serverConfig =
|
|
configServers?.[serverName] ??
|
|
(await getMCPServersRegistry().getServerConfig(serverName, userId, configServers));
|
|
} catch (err) {
|
|
logger.warn(
|
|
`[Tool Definitions] MCP registry unavailable while resolving '${serverName}': ${
|
|
err?.message ?? err
|
|
}. Skipping MCP tool exposure for this lookup.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!serverConfig) {
|
|
logger.warn(
|
|
`[Tool Definitions] Skipping MCP server '${serverName}': no server config found (server may have been removed).`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
|
const missingUserVars = getMissingCustomUserVars(serverConfig, customUserVars);
|
|
if (missingUserVars.length > 0) {
|
|
logger.warn(
|
|
`[Tool Definitions] Skipping MCP server '${serverName}': required user-provided variable(s) not set: ${missingUserVars.join(
|
|
', ',
|
|
)}. Tools will not be exposed until the user configures them.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (mcpAvailableTools[serverName]) {
|
|
return mcpAvailableTools[serverName];
|
|
}
|
|
|
|
const cached = await getMCPServerTools(userId, serverName, serverConfig);
|
|
if (cached) {
|
|
rememberMCPAvailableTools(serverName, cached);
|
|
await addPendingOAuthServer();
|
|
return cached;
|
|
}
|
|
|
|
if (await addPendingOAuthServer()) {
|
|
return null;
|
|
}
|
|
|
|
const oauthStart = async (authURL, options) => {
|
|
pendingOAuthServers.add(serverName);
|
|
if (typeof authURL === 'string' && authURL.length > 0) {
|
|
pendingOAuthStarts.set(serverName, { authURL, options });
|
|
}
|
|
};
|
|
|
|
const result = await reinitMCPServer({
|
|
user: req.user,
|
|
oauthStart,
|
|
flowManager,
|
|
serverName,
|
|
configServers,
|
|
userMCPAuthMap,
|
|
requestBody: req.body,
|
|
requestScopedConnections,
|
|
});
|
|
|
|
rememberMCPAvailableTools(serverName, result?.availableTools);
|
|
return result?.availableTools || null;
|
|
};
|
|
|
|
const getActionToolDefinitions = async (agentId, actionToolNames) => {
|
|
const actionSets = (await loadActionSets({ agent_id: agentId })) ?? [];
|
|
if (actionSets.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const definitions = [];
|
|
const allowedDomains = appConfig?.actions?.allowedDomains;
|
|
const allowedAddresses = appConfig?.actions?.allowedAddresses;
|
|
const normalizedToolNames = new Set(
|
|
actionToolNames.map((n) => n.replace(domainSeparatorRegex, '_')),
|
|
);
|
|
|
|
for (const action of actionSets) {
|
|
const domain = await domainParser(action.metadata.domain, true);
|
|
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
|
|
|
|
const legacyDomain = legacyDomainEncode(action.metadata.domain);
|
|
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
|
|
|
|
const isDomainAllowed = await isActionDomainAllowed(
|
|
action.metadata.domain,
|
|
allowedDomains,
|
|
allowedAddresses,
|
|
);
|
|
if (!isDomainAllowed) {
|
|
logger.warn(
|
|
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
|
|
`Add it to librechat.yaml actions.allowedDomains to enable this action.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
|
if (!validationResult.spec || !validationResult.serverUrl) {
|
|
logger.warn(`[Actions] Invalid OpenAPI spec for domain: ${domain}`);
|
|
continue;
|
|
}
|
|
|
|
const { functionSignatures } = openapiToFunction(validationResult.spec, true);
|
|
|
|
for (const sig of functionSignatures) {
|
|
const toolName = `${sig.name}${actionDelimiter}${normalizedDomain}`;
|
|
const legacyToolName = `${sig.name}${actionDelimiter}${legacyNormalized}`;
|
|
if (!normalizedToolNames.has(toolName) && !normalizedToolNames.has(legacyToolName)) {
|
|
continue;
|
|
}
|
|
|
|
definitions.push({
|
|
name: toolName,
|
|
description: sig.description,
|
|
parameters: sig.parameters,
|
|
});
|
|
}
|
|
}
|
|
|
|
return definitions;
|
|
};
|
|
|
|
let { toolDefinitions, toolRegistry, hasDeferredTools } = await loadToolDefinitions(
|
|
{
|
|
userId: req.user.id,
|
|
agentId: agent.id,
|
|
tools: filteredTools,
|
|
toolOptions: agent.tool_options,
|
|
deferredToolsEnabled,
|
|
programmaticToolsEnabled,
|
|
codeExecutionEnabled,
|
|
provider: agent.provider,
|
|
},
|
|
{
|
|
isBuiltInTool,
|
|
getOrFetchMCPServerTools,
|
|
getActionToolDefinitions,
|
|
},
|
|
);
|
|
|
|
for (const serverName of getMCPServerNamesFromTools(filteredTools)) {
|
|
if (pendingOAuthServers.has(serverName)) {
|
|
continue;
|
|
}
|
|
|
|
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
|
|
flowManager,
|
|
userId: req.user.id,
|
|
serverName,
|
|
});
|
|
if (pendingOAuthStart) {
|
|
pendingOAuthServers.add(serverName);
|
|
pendingOAuthStarts.set(serverName, pendingOAuthStart);
|
|
}
|
|
}
|
|
|
|
if (pendingOAuthServers.size > 0 && (res || streamId)) {
|
|
const serverNames = Array.from(pendingOAuthServers);
|
|
logger.info(
|
|
`[Tool Definitions] OAuth required for ${serverNames.length} server(s): ${serverNames.join(', ')}. Emitting events and waiting.`,
|
|
);
|
|
|
|
const oauthWaitPromises = serverNames.map(async (serverName, index) => {
|
|
try {
|
|
const pendingOAuthStart = await getPendingOAuthStartForEmit(serverName);
|
|
const oauthStart = createOAuthEmitter(serverName, index);
|
|
if (pendingOAuthStart) {
|
|
await oauthStart(pendingOAuthStart.authURL, pendingOAuthStart.options);
|
|
}
|
|
|
|
const result = await reinitMCPServer({
|
|
user: req.user,
|
|
serverName,
|
|
configServers,
|
|
userMCPAuthMap,
|
|
flowManager,
|
|
requestBody: req.body,
|
|
returnOnOAuth: false,
|
|
oauthStart,
|
|
oauthEnd: createOAuthEndEmitter(serverName),
|
|
connectionTimeout: Time.TWO_MINUTES,
|
|
});
|
|
|
|
if (result?.availableTools) {
|
|
rememberMCPAvailableTools(serverName, result.availableTools);
|
|
logger.info(`[Tool Definitions] OAuth completed for ${serverName}, tools available`);
|
|
return { serverName, success: true };
|
|
}
|
|
return { serverName, success: false };
|
|
} catch (error) {
|
|
logger.debug(`[Tool Definitions] OAuth wait failed for ${serverName}:`, error?.message);
|
|
return { serverName, success: false };
|
|
}
|
|
});
|
|
|
|
const results = await Promise.allSettled(oauthWaitPromises);
|
|
const successfulServers = results
|
|
.filter((r) => r.status === 'fulfilled' && r.value.success)
|
|
.map((r) => r.value.serverName);
|
|
|
|
if (successfulServers.length > 0) {
|
|
logger.info(
|
|
`[Tool Definitions] Reloading tools after OAuth for: ${successfulServers.join(', ')}`,
|
|
);
|
|
const reloadResult = await loadToolDefinitions(
|
|
{
|
|
userId: req.user.id,
|
|
agentId: agent.id,
|
|
tools: filteredTools,
|
|
toolOptions: agent.tool_options,
|
|
deferredToolsEnabled,
|
|
programmaticToolsEnabled,
|
|
codeExecutionEnabled,
|
|
provider: agent.provider,
|
|
},
|
|
{
|
|
isBuiltInTool,
|
|
getOrFetchMCPServerTools,
|
|
getActionToolDefinitions,
|
|
},
|
|
);
|
|
toolDefinitions = reloadResult.toolDefinitions;
|
|
toolRegistry = reloadResult.toolRegistry;
|
|
hasDeferredTools = reloadResult.hasDeferredTools;
|
|
}
|
|
}
|
|
|
|
/** @type {Record<string, string>} */
|
|
const toolContextMap = {};
|
|
/** @type {Record<string, string>} */
|
|
const dynamicToolContextMap = {};
|
|
const hasWebSearch = filteredTools.includes(Tools.web_search);
|
|
const hasFileSearch = filteredTools.includes(Tools.file_search);
|
|
const hasExecuteCode = filteredTools.includes(Tools.execute_code);
|
|
|
|
if (hasWebSearch) {
|
|
toolContextMap[Tools.web_search] = buildWebSearchContext();
|
|
dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(
|
|
req.conversationCreatedAt,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `files` carry the upload session_ids; we surface them so client.js can
|
|
* seed `Graph.sessions[EXECUTE_CODE]` before run start. Without that seed,
|
|
* the agents-side `ToolNode.getCodeSessionContext` returns undefined on
|
|
* call #1, `_injected_files` is never set on the tool call, and the
|
|
* sandbox can't see the prior turn's generated artifacts on first read.
|
|
*/
|
|
let primedCodeFiles;
|
|
if (hasExecuteCode && tool_resources) {
|
|
try {
|
|
const { toolContext, files } = await primeCodeFiles({
|
|
req,
|
|
tool_resources,
|
|
agentId: agent.id,
|
|
});
|
|
if (toolContext) {
|
|
dynamicToolContextMap[Tools.execute_code] = toolContext;
|
|
}
|
|
if (files?.length) {
|
|
primedCodeFiles = files;
|
|
}
|
|
} catch (error) {
|
|
logger.error('[loadToolDefinitionsWrapper] Error priming code files:', error);
|
|
}
|
|
}
|
|
|
|
if (hasFileSearch && tool_resources) {
|
|
try {
|
|
const { toolContext } = await primeSearchFiles({
|
|
req,
|
|
tool_resources,
|
|
agentId: agent.id,
|
|
});
|
|
if (toolContext) {
|
|
dynamicToolContextMap[Tools.file_search] = toolContext;
|
|
}
|
|
} catch (error) {
|
|
logger.error('[loadToolDefinitionsWrapper] Error priming search files:', error);
|
|
}
|
|
}
|
|
|
|
const imageFiles = tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
|
if (imageFiles.length > 0) {
|
|
const hasOaiImageGen = filteredTools.includes('image_gen_oai');
|
|
const hasGeminiImageGen = filteredTools.includes('gemini_image_gen');
|
|
|
|
if (hasOaiImageGen) {
|
|
const toolContext = buildImageToolContext({
|
|
imageFiles,
|
|
toolName: `${EToolResources.image_edit}_oai`,
|
|
contextDescription: 'image editing',
|
|
});
|
|
if (toolContext) {
|
|
dynamicToolContextMap.image_edit_oai = toolContext;
|
|
}
|
|
}
|
|
|
|
if (hasGeminiImageGen) {
|
|
const toolContext = buildImageToolContext({
|
|
imageFiles,
|
|
toolName: 'gemini_image_gen',
|
|
contextDescription: 'image context',
|
|
});
|
|
if (toolContext) {
|
|
dynamicToolContextMap.gemini_image_gen = toolContext;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
toolRegistry,
|
|
mcpAvailableTools,
|
|
requestScopedConnections,
|
|
userMCPAuthMap,
|
|
toolContextMap,
|
|
dynamicToolContextMap,
|
|
toolDefinitions,
|
|
hasDeferredTools,
|
|
actionsEnabled,
|
|
primedCodeFiles,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Loads agent tools for initialization or execution.
|
|
* @param {Object} params
|
|
* @param {ServerRequest} params.req - The request object
|
|
* @param {ServerResponse} params.res - The response object
|
|
* @param {Object} params.agent - The agent configuration
|
|
* @param {AbortSignal} [params.signal] - Abort signal
|
|
* @param {Object} [params.tool_resources] - Tool resources
|
|
* @param {string} [params.openAIApiKey] - OpenAI API key
|
|
* @param {string|null} [params.streamId] - Stream ID for resumable mode
|
|
* @param {boolean} [params.definitionsOnly=true] - When true, returns only serializable
|
|
* tool definitions without creating full tool instances. Use for event-driven mode
|
|
* where tools are loaded on-demand during execution.
|
|
*/
|
|
async function loadAgentTools({
|
|
req,
|
|
res,
|
|
agent,
|
|
signal,
|
|
tool_resources,
|
|
openAIApiKey,
|
|
streamId = null,
|
|
definitionsOnly = true,
|
|
}) {
|
|
if (definitionsOnly) {
|
|
return loadToolDefinitionsWrapper({ req, res, agent, streamId, tool_resources });
|
|
}
|
|
|
|
if (!agent.tools || agent.tools.length === 0) {
|
|
return { toolDefinitions: [] };
|
|
} else if (
|
|
agent.tools &&
|
|
agent.tools.length === 1 &&
|
|
/** Legacy handling for `ocr` as may still exist in existing Agents */
|
|
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
|
|
) {
|
|
return { toolDefinitions: [] };
|
|
}
|
|
|
|
const appConfig = req.config;
|
|
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
|
|
const checkCapability = (capability) => {
|
|
const enabled = enabledCapabilities.has(capability);
|
|
if (!enabled) {
|
|
const isToolCapability = [
|
|
AgentCapabilities.file_search,
|
|
AgentCapabilities.execute_code,
|
|
AgentCapabilities.web_search,
|
|
].includes(capability);
|
|
const suffix = isToolCapability ? ' despite configured tool.' : '.';
|
|
logger.warn(
|
|
`Capability "${capability}" disabled${suffix} User: ${req.user.id} | Agent: ${agent.id}`,
|
|
);
|
|
}
|
|
return enabled;
|
|
};
|
|
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
|
|
const actionsEnabled = checkCapability(AgentCapabilities.actions);
|
|
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
|
|
|
|
let includesWebSearch = false;
|
|
const _agentTools = agent.tools?.filter((tool) => {
|
|
if (tool === Tools.file_search) {
|
|
return checkCapability(AgentCapabilities.file_search);
|
|
} else if (tool === Tools.execute_code) {
|
|
return checkCapability(AgentCapabilities.execute_code);
|
|
} else if (tool === Tools.web_search) {
|
|
includesWebSearch = checkCapability(AgentCapabilities.web_search);
|
|
return includesWebSearch;
|
|
} else if (tool === Tools.memory) {
|
|
return checkCapability(AgentCapabilities.memory);
|
|
} else if (isActionTool(tool)) {
|
|
return actionsEnabled;
|
|
} else if (tool?.includes(Constants.mcp_delimiter)) {
|
|
return areToolsEnabled && canUseMCP;
|
|
} else if (!areToolsEnabled) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!_agentTools || _agentTools.length === 0) {
|
|
return {};
|
|
}
|
|
/** @type {ReturnType<typeof createOnSearchResults>} */
|
|
let webSearchCallbacks;
|
|
if (includesWebSearch) {
|
|
webSearchCallbacks = createOnSearchResults(res, streamId);
|
|
}
|
|
|
|
/** @type {Record<string, Record<string, string>>} */
|
|
let userMCPAuthMap;
|
|
if (_agentTools?.some((t) => t.includes(Constants.mcp_delimiter))) {
|
|
userMCPAuthMap = await getUserMCPAuthMap({
|
|
tools: _agentTools,
|
|
userId: req.user.id,
|
|
findPluginAuthsByKeys,
|
|
});
|
|
}
|
|
|
|
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
|
|
agent,
|
|
signal,
|
|
userMCPAuthMap,
|
|
functions: true,
|
|
user: req.user.id,
|
|
tools: _agentTools,
|
|
options: {
|
|
req,
|
|
res,
|
|
openAIApiKey,
|
|
tool_resources,
|
|
processFileURL,
|
|
uploadImageBuffer,
|
|
returnMetadata: true,
|
|
mcpPermissionContext,
|
|
requestScopedConnections: getMCPRequestContext(req, res),
|
|
[Tools.web_search]: webSearchCallbacks,
|
|
},
|
|
webSearch: appConfig.webSearch,
|
|
fileStrategy: appConfig.fileStrategy,
|
|
imageOutputType: appConfig.imageOutputType,
|
|
});
|
|
|
|
/** Build tool registry from MCP tools and create PTC/tool search tools if configured */
|
|
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
|
|
const programmaticToolsEnabled = enabledCapabilities.has(AgentCapabilities.programmatic_tools);
|
|
const codeExecutionEnabled =
|
|
agent.tools?.includes(Tools.execute_code) === true &&
|
|
enabledCapabilities.has(AgentCapabilities.execute_code);
|
|
const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools } =
|
|
await buildToolClassification({
|
|
loadedTools,
|
|
userId: req.user.id,
|
|
agentId: agent.id,
|
|
agentToolOptions: agent.tool_options,
|
|
deferredToolsEnabled,
|
|
programmaticToolsEnabled,
|
|
codeExecutionEnabled,
|
|
authHeaders: () => getCodeApiAuthHeaders(req),
|
|
});
|
|
|
|
const agentTools = [];
|
|
for (let i = 0; i < loadedTools.length; i++) {
|
|
const tool = loadedTools[i];
|
|
if (tool.name && (tool.name === Tools.execute_code || tool.name === Tools.file_search)) {
|
|
agentTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
if (!areToolsEnabled) {
|
|
continue;
|
|
}
|
|
|
|
if (tool.mcp === true) {
|
|
agentTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
if (tool instanceof DynamicStructuredTool) {
|
|
agentTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
const toolDefinition = {
|
|
name: tool.name,
|
|
schema: tool.schema,
|
|
description: tool.description,
|
|
};
|
|
|
|
if (imageGenTools.has(tool.name)) {
|
|
toolDefinition.responseFormat = 'content_and_artifact';
|
|
}
|
|
|
|
const toolInstance = toolFn(async (...args) => {
|
|
return tool['_call'](...args);
|
|
}, toolDefinition);
|
|
|
|
agentTools.push(toolInstance);
|
|
}
|
|
|
|
const ToolMap = loadedTools.reduce((map, tool) => {
|
|
map[tool.name] = tool;
|
|
return map;
|
|
}, {});
|
|
|
|
agentTools.push(...additionalTools);
|
|
|
|
const hasActionTools = _agentTools.some((t) => isActionTool(t));
|
|
if (!hasActionTools) {
|
|
return {
|
|
toolRegistry,
|
|
requestScopedConnections: getMCPRequestContext(req, res),
|
|
userMCPAuthMap,
|
|
toolContextMap,
|
|
dynamicToolContextMap,
|
|
toolDefinitions,
|
|
hasDeferredTools,
|
|
actionsEnabled,
|
|
tools: agentTools,
|
|
primedCodeFiles,
|
|
};
|
|
}
|
|
|
|
const actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
|
|
if (actionSets.length === 0) {
|
|
if (_agentTools.length > 0 && agentTools.length === 0) {
|
|
logger.warn(`No tools found for the specified tool calls: ${_agentTools.join(', ')}`);
|
|
}
|
|
return {
|
|
toolRegistry,
|
|
requestScopedConnections: getMCPRequestContext(req, res),
|
|
userMCPAuthMap,
|
|
toolContextMap,
|
|
dynamicToolContextMap,
|
|
toolDefinitions,
|
|
hasDeferredTools,
|
|
actionsEnabled,
|
|
tools: agentTools,
|
|
primedCodeFiles,
|
|
};
|
|
}
|
|
|
|
// See registerActionTools for the key-shape rationale.
|
|
const toolToAction = new Map();
|
|
|
|
for (const action of actionSets) {
|
|
const domain = await domainParser(action.metadata.domain, true);
|
|
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
|
|
const legacyDomain = legacyDomainEncode(action.metadata.domain);
|
|
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
|
|
|
|
const isDomainAllowed = await isActionDomainAllowed(
|
|
action.metadata.domain,
|
|
appConfig?.actions?.allowedDomains,
|
|
appConfig?.actions?.allowedAddresses,
|
|
);
|
|
if (!isDomainAllowed) {
|
|
continue;
|
|
}
|
|
|
|
// Validate and parse OpenAPI spec once per action set
|
|
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
|
if (!validationResult.spec || !validationResult.serverUrl) {
|
|
continue;
|
|
}
|
|
|
|
// SECURITY: Validate the domain from the spec matches the stored domain
|
|
// This is defense-in-depth to prevent any stored malicious actions
|
|
const domainValidation = validateActionDomain(
|
|
action.metadata.domain,
|
|
validationResult.serverUrl,
|
|
);
|
|
if (!domainValidation.isValid) {
|
|
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
|
userId: req.user.id,
|
|
agent_id: agent.id,
|
|
action_id: action.action_id,
|
|
});
|
|
continue; // Skip this action rather than failing the entire request
|
|
}
|
|
|
|
const encrypted = {
|
|
oauth_client_id: action.metadata.oauth_client_id,
|
|
oauth_client_secret: action.metadata.oauth_client_secret,
|
|
};
|
|
|
|
// Decrypt metadata once per action set
|
|
const decryptedAction = { ...action };
|
|
decryptedAction.metadata = await decryptMetadata(action.metadata);
|
|
|
|
// Process the OpenAPI spec once per action set
|
|
const { requestBuilders, functionSignatures, zodSchemas } = openapiToFunction(
|
|
validationResult.spec,
|
|
true,
|
|
);
|
|
|
|
registerActionTools({
|
|
toolToAction,
|
|
functionSignatures,
|
|
normalizedDomain,
|
|
legacyNormalized,
|
|
makeEntry: (sig) => ({
|
|
action: decryptedAction,
|
|
requestBuilder: requestBuilders[sig.name],
|
|
zodSchema: zodSchemas[sig.name],
|
|
functionSignature: sig,
|
|
encrypted,
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Now map tools to the processed action sets
|
|
const ActionToolMap = {};
|
|
|
|
for (const toolName of _agentTools) {
|
|
if (ToolMap[toolName]) {
|
|
continue;
|
|
}
|
|
|
|
const entry = toolToAction.get(normalizeActionToolName(toolName));
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
|
|
const { action, encrypted, zodSchema, requestBuilder, functionSignature } = entry;
|
|
const _allowedDomains = appConfig?.actions?.allowedDomains;
|
|
const _allowedAddresses = appConfig?.actions?.allowedAddresses;
|
|
const tool = await createActionTool({
|
|
userId: req.user.id,
|
|
res,
|
|
action,
|
|
requestBuilder,
|
|
zodSchema,
|
|
encrypted,
|
|
name: toolName,
|
|
description: functionSignature.description,
|
|
streamId,
|
|
useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0,
|
|
allowedAddresses: _allowedAddresses,
|
|
});
|
|
|
|
if (!tool) {
|
|
logger.warn(
|
|
`Invalid action: user: ${req.user.id} | agent_id: ${agent.id} | toolName: ${toolName}`,
|
|
);
|
|
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
|
|
}
|
|
|
|
agentTools.push(tool);
|
|
ActionToolMap[toolName] = tool;
|
|
}
|
|
|
|
if (_agentTools.length > 0 && agentTools.length === 0) {
|
|
logger.warn(`No tools found for the specified tool calls: ${_agentTools.join(', ')}`);
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
toolRegistry,
|
|
requestScopedConnections: getMCPRequestContext(req, res),
|
|
toolContextMap,
|
|
dynamicToolContextMap,
|
|
userMCPAuthMap,
|
|
toolDefinitions,
|
|
hasDeferredTools,
|
|
actionsEnabled,
|
|
tools: agentTools,
|
|
primedCodeFiles,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Loads tools for event-driven execution (ON_TOOL_EXECUTE handler).
|
|
* This function encapsulates all dependencies needed for tool loading,
|
|
* so callers don't need to import processFileURL, uploadImageBuffer, etc.
|
|
*
|
|
* Handles both regular tools (MCP, built-in) and action tools.
|
|
*
|
|
* @param {Object} params
|
|
* @param {ServerRequest} params.req - The request object
|
|
* @param {ServerResponse} params.res - The response object
|
|
* @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, import('@librechat/api').LCAvailableTools>} [params.mcpAvailableTools] - Run-scoped MCP tool definitions
|
|
* @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] - Run-scoped MCP connections
|
|
* @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({
|
|
req,
|
|
res,
|
|
signal,
|
|
agent,
|
|
toolNames,
|
|
toolRegistry,
|
|
mcpAvailableTools,
|
|
requestScopedConnections,
|
|
userMCPAuthMap,
|
|
tool_resources,
|
|
streamId = null,
|
|
actionsEnabled,
|
|
}) {
|
|
const appConfig = req.config;
|
|
const allLoadedTools = [];
|
|
const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res);
|
|
const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections };
|
|
|
|
const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH);
|
|
const ptcToolNames = [
|
|
AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING,
|
|
AgentConstants.PROGRAMMATIC_TOOL_CALLING,
|
|
].filter((name) => toolNames.includes(name));
|
|
const isPTCRequested = ptcToolNames.length > 0;
|
|
const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL);
|
|
const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code);
|
|
const isCodeExecutionToolRequested = isBashToolRequested || isLegacyExecuteCodeRequested;
|
|
|
|
let enabledCapabilities;
|
|
if (actionsEnabled === undefined || isPTCRequested || isCodeExecutionToolRequested) {
|
|
enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id);
|
|
}
|
|
if (actionsEnabled === undefined) {
|
|
actionsEnabled = enabledCapabilities.has(AgentCapabilities.actions);
|
|
}
|
|
const codeExecutionEnabled =
|
|
enabledCapabilities?.has(AgentCapabilities.execute_code) === true &&
|
|
agent?.tools?.includes(Tools.execute_code) === true;
|
|
|
|
const isPTC =
|
|
isPTCRequested &&
|
|
enabledCapabilities.has(AgentCapabilities.programmatic_tools) &&
|
|
codeExecutionEnabled;
|
|
|
|
logger.debug(
|
|
`[loadToolsForExecution] isToolSearch: ${isToolSearch}, toolRegistry: ${toolRegistry?.size ?? 'undefined'}`,
|
|
);
|
|
|
|
if (isToolSearch && toolRegistry) {
|
|
const toolSearchTool = createToolSearch({
|
|
mode: 'local',
|
|
toolRegistry,
|
|
});
|
|
allLoadedTools.push(toolSearchTool);
|
|
configurable.toolRegistry = toolRegistry;
|
|
}
|
|
|
|
if (isPTC && toolRegistry) {
|
|
configurable.toolRegistry = toolRegistry;
|
|
try {
|
|
/**
|
|
* LibreChat threads per-request Code API auth through the agents
|
|
* library so PTC calls share the same managed auth context.
|
|
*/
|
|
for (const name of ptcToolNames) {
|
|
const ptcTool = createBashProgrammaticToolCallingTool({
|
|
authHeaders: () => getCodeApiAuthHeaders(req),
|
|
});
|
|
ptcTool.name = name;
|
|
allLoadedTools.push(ptcTool);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[loadToolsForExecution] Error creating PTC tool:', error);
|
|
}
|
|
}
|
|
|
|
const isBashTool =
|
|
isBashToolRequested &&
|
|
codeExecutionEnabled &&
|
|
toolRegistry?.has(AgentConstants.BASH_TOOL) === true;
|
|
if (isBashToolRequested && !isBashTool) {
|
|
logger.warn(
|
|
`[loadToolsForExecution] Skipping unregistered or unauthorized ${AgentConstants.BASH_TOOL}. ` +
|
|
`User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`,
|
|
);
|
|
}
|
|
if (isBashTool) {
|
|
try {
|
|
const bashTool = createBashExecutionTool({
|
|
authHeaders: () => getCodeApiAuthHeaders(req),
|
|
});
|
|
allLoadedTools.push(bashTool);
|
|
} catch (error) {
|
|
logger.error('[loadToolsForExecution] Failed to create bash_tool', error);
|
|
}
|
|
}
|
|
|
|
const fileAuthoringToolNames = new Set(
|
|
toolRegistry
|
|
? Array.from(toolRegistry.values())
|
|
.filter((definition) => isFileAuthoringToolDefinition(definition))
|
|
.map((definition) => definition.name)
|
|
: [],
|
|
);
|
|
const specialToolNames = new Set([
|
|
AgentConstants.TOOL_SEARCH,
|
|
AgentConstants.PROGRAMMATIC_TOOL_CALLING,
|
|
AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING,
|
|
AgentConstants.BASH_TOOL,
|
|
AgentConstants.SKILL_TOOL,
|
|
AgentConstants.READ_FILE,
|
|
...fileAuthoringToolNames,
|
|
]);
|
|
|
|
let ptcOrchestratedToolNames = [];
|
|
if (isPTC && toolRegistry) {
|
|
ptcOrchestratedToolNames = Array.from(toolRegistry.keys()).filter(
|
|
(name) => !specialToolNames.has(name),
|
|
);
|
|
}
|
|
|
|
const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name));
|
|
const allowedNonSpecialToolNames = requestedNonSpecialToolNames.filter((name) => {
|
|
if (name !== Tools.execute_code) {
|
|
return true;
|
|
}
|
|
const allowed = codeExecutionEnabled && toolRegistry?.has(Tools.execute_code) === true;
|
|
if (!allowed) {
|
|
logger.warn(
|
|
`[loadToolsForExecution] Skipping unregistered or unauthorized ${Tools.execute_code}. ` +
|
|
`User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`,
|
|
);
|
|
}
|
|
return allowed;
|
|
});
|
|
const allToolNamesToLoad = isPTC
|
|
? [...new Set([...allowedNonSpecialToolNames, ...ptcOrchestratedToolNames])]
|
|
: allowedNonSpecialToolNames;
|
|
|
|
const actionToolNames = [];
|
|
const regularToolNames = [];
|
|
for (const name of allToolNamesToLoad) {
|
|
(isActionTool(name) ? actionToolNames : regularToolNames).push(name);
|
|
}
|
|
|
|
if (regularToolNames.length > 0) {
|
|
const includesWebSearch = regularToolNames.includes(Tools.web_search);
|
|
const webSearchCallbacks = includesWebSearch ? createOnSearchResults(res, streamId) : undefined;
|
|
|
|
const { loadedTools } = await loadTools({
|
|
agent,
|
|
signal,
|
|
userMCPAuthMap,
|
|
functions: true,
|
|
tools: regularToolNames,
|
|
user: req.user.id,
|
|
options: {
|
|
req,
|
|
res,
|
|
tool_resources,
|
|
processFileURL,
|
|
uploadImageBuffer,
|
|
returnMetadata: true,
|
|
mcpAvailableTools,
|
|
requestScopedConnections: mcpRequestScopedConnections,
|
|
[Tools.web_search]: webSearchCallbacks,
|
|
},
|
|
webSearch: appConfig?.webSearch,
|
|
fileStrategy: appConfig?.fileStrategy,
|
|
imageOutputType: appConfig?.imageOutputType,
|
|
});
|
|
|
|
if (loadedTools) {
|
|
allLoadedTools.push(...loadedTools);
|
|
}
|
|
}
|
|
|
|
if (actionToolNames.length > 0 && agent && actionsEnabled) {
|
|
const actionTools = await loadActionToolsForExecution({
|
|
req,
|
|
res,
|
|
agent,
|
|
appConfig,
|
|
streamId,
|
|
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) {
|
|
const ptcToolMap = new Map();
|
|
for (const tool of allLoadedTools) {
|
|
if (
|
|
tool.name &&
|
|
tool.name !== AgentConstants.PROGRAMMATIC_TOOL_CALLING &&
|
|
tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING
|
|
) {
|
|
ptcToolMap.set(tool.name, tool);
|
|
}
|
|
}
|
|
configurable.ptcToolMap = ptcToolMap;
|
|
}
|
|
|
|
return {
|
|
configurable,
|
|
loadedTools: allLoadedTools,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Loads action tools for event-driven execution.
|
|
* @param {Object} params
|
|
* @param {ServerRequest} params.req - The request object
|
|
* @param {ServerResponse} params.res - The response object
|
|
* @param {Object} params.agent - The agent object
|
|
* @param {Object} params.appConfig - App configuration
|
|
* @param {string|null} params.streamId - Stream ID
|
|
* @param {string[]} params.actionToolNames - Action tool names to load
|
|
* @returns {Promise<Array>} Loaded action tools
|
|
*/
|
|
async function loadActionToolsForExecution({
|
|
req,
|
|
res,
|
|
agent,
|
|
appConfig,
|
|
streamId,
|
|
actionToolNames,
|
|
}) {
|
|
const loadedActionTools = [];
|
|
|
|
const actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
|
|
if (actionSets.length === 0) {
|
|
return loadedActionTools;
|
|
}
|
|
|
|
// See registerActionTools for the key-shape rationale.
|
|
const toolToAction = new Map();
|
|
const allowedDomains = appConfig?.actions?.allowedDomains;
|
|
const allowedAddresses = appConfig?.actions?.allowedAddresses;
|
|
|
|
for (const action of actionSets) {
|
|
const domain = await domainParser(action.metadata.domain, true);
|
|
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
|
|
const legacyDomain = legacyDomainEncode(action.metadata.domain);
|
|
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
|
|
|
|
const isDomainAllowed = await isActionDomainAllowed(
|
|
action.metadata.domain,
|
|
allowedDomains,
|
|
allowedAddresses,
|
|
);
|
|
if (!isDomainAllowed) {
|
|
logger.warn(
|
|
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
|
|
`Add it to librechat.yaml actions.allowedDomains to enable this action.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
|
if (!validationResult.spec || !validationResult.serverUrl) {
|
|
logger.warn(`[Actions] Invalid OpenAPI spec for domain: ${domain}`);
|
|
continue;
|
|
}
|
|
|
|
const domainValidation = validateActionDomain(
|
|
action.metadata.domain,
|
|
validationResult.serverUrl,
|
|
);
|
|
if (!domainValidation.isValid) {
|
|
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
|
userId: req.user.id,
|
|
agent_id: agent.id,
|
|
action_id: action.action_id,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const encrypted = {
|
|
oauth_client_id: action.metadata.oauth_client_id,
|
|
oauth_client_secret: action.metadata.oauth_client_secret,
|
|
};
|
|
|
|
const decryptedAction = { ...action };
|
|
decryptedAction.metadata = await decryptMetadata(action.metadata);
|
|
|
|
const { requestBuilders, functionSignatures, zodSchemas } = openapiToFunction(
|
|
validationResult.spec,
|
|
true,
|
|
);
|
|
|
|
registerActionTools({
|
|
toolToAction,
|
|
functionSignatures,
|
|
normalizedDomain,
|
|
legacyNormalized,
|
|
makeEntry: (sig) => ({
|
|
action: decryptedAction,
|
|
requestBuilder: requestBuilders[sig.name],
|
|
zodSchema: zodSchemas[sig.name],
|
|
functionSignature: sig,
|
|
encrypted,
|
|
}),
|
|
});
|
|
}
|
|
|
|
for (const toolName of actionToolNames) {
|
|
const entry = toolToAction.get(normalizeActionToolName(toolName));
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
|
|
const { action, encrypted, zodSchema, requestBuilder, functionSignature } = entry;
|
|
const tool = await createActionTool({
|
|
userId: req.user.id,
|
|
res,
|
|
action,
|
|
streamId,
|
|
zodSchema,
|
|
encrypted,
|
|
requestBuilder,
|
|
name: toolName,
|
|
description: functionSignature.description,
|
|
useSSRFProtection: !Array.isArray(allowedDomains) || allowedDomains.length === 0,
|
|
allowedAddresses,
|
|
});
|
|
|
|
if (!tool) {
|
|
logger.warn(`[Actions] Failed to create action tool: ${toolName}`);
|
|
continue;
|
|
}
|
|
|
|
loadedActionTools.push(tool);
|
|
}
|
|
|
|
return loadedActionTools;
|
|
}
|
|
|
|
module.exports = {
|
|
loadTools,
|
|
isBuiltInTool,
|
|
getToolkitKey,
|
|
loadAgentTools,
|
|
loadToolsForExecution,
|
|
processRequiredActions,
|
|
resolveAgentCapabilities,
|
|
};
|