LibreChat/api/server/services/ToolService.js
Danny Avila 520af663bc
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
🧵 feat: Background Tool Calls for Agents & Model Specs (#14197)
* 🧵 feat: Background Tool Calls for Agents & Model Specs

Opt-in, poll-based background tool execution. The model marks an eligible tool
call with `run_in_background: true`; the host executor registers a task, returns
a handle immediately (so the graph turn resolves), runs the tool as a detached
promise, and the model retrieves the result via a new `check_background_task`
poll tool. Host-side only — no `@librechat/agents` change.

- Opt-in mirrors `deferred_tools`: admin capability `run_in_background`
  (off by default) + per-tool `tool_options.run_in_background`.
- Model specs / ephemeral agents: `TModelSpec.runInBackground` /
  `TEphemeralAgent.run_in_background` synthesize per-tool options; both paths
  converge at `initializeAgent`.
- In-process task registry: scoped per user+conversation, idempotent by
  toolCallId (safe across resume/replay), capped, TTL-swept.
- Excludes direct-path / host-special / code-session tools. Subagents and push
  notifications are deferred follow-ups.

* 🩹 fix: Harden background tool calls (Codex review)

- Reliable per-agent execution gate: thread the injected `run_in_background`
  tool names from `initializeAgent` through `configurable.backgroundToolNames`
  (`toolRegistry` only reaches the executor for PTC/tool_search), fixing the
  silent no-op + unstripped-arg leak for ordinary event-driven tools.
- Enforce the per-tool opt-in at execution (`backgroundToolSet.has(name)`) so a
  non-opted-in tool can't be backgrounded via an extra arg.
- Gate the `check_background_task` interception on the run actually enabling
  background, so a user tool sharing that name still executes.
- Forward `backgroundToolsAvailable` to added-convo (multi-convo) agents.
- Exclude `web_search`/`file_search` from eligibility — their results are turned
  into user-visible attachments/citations only by the foreground toolEndCallback.

* 🩹 fix: Address Codex round 2 on background tool calls

- Idempotency scoped to run+turn: provider tool-call ids repeat across turns
  (e.g. `call_0`), so key the dedupe map by `runId::toolCallId` and sweep
  orphaned mappings — a later turn no longer collides with a retained task.
- Artifacts preserved: a backgrounded tool's artifact is processed through the
  same `toolEndCallback` as the foreground path (images/files/citations no
  longer silently dropped), best-effort/guarded.
- Forward the `run_in_background` capability to connected-agent discovery and
  subagent `processAgent` init, so a child agent's own event-driven tools work
  the same as when it runs as primary.
- Strip the injected flag on foreground calls of background-capable tools
  (the model may emit it as `false`) so strict MCP/action schemas don't reject.
- `check_background_task` list path returns metadata only (result_available /
  result_chars), never full results — prevents context overflow; the full
  result is returned only when a specific id is requested.

* 🩹 fix: Address Codex round 3 on background tool calls

- Exclude background-capable tools from eager execution (run.ts): a speculative
  eager dispatch of a `run_in_background` call could launch the detached task
  with partial/stale args, and that side effect can't be canceled.
- Reserve the `check_background_task` name: overwrite a colliding user/MCP tool
  with the host poll schema (with a warning) so the advertised schema matches
  the executor's interception instead of hijacking a mismatched tool.
- Don't inject background schemas into pure subagents (spawn-tool child graphs)
  whose tools don't reach the host interceptor; keep it for primary/added/
  connected agents. Subagent background is the durable follow-up.
- Thread `backgroundToolsAvailable` + `backgroundToolNames` through the
  OpenAI-compatible and Responses agent routes (was chat-only), so the same
  agent/model spec behaves consistently across surfaces.
- Exclude image-generation built-ins (dalle/flux/gemini_image_gen/image_gen_oai/
  image_edit_oai) — artifact-first tools whose files can't reliably attach to an
  already-saved turn when backgrounded.

* 🩹 fix: Address Codex round 4 on background tool calls

- Sanitize self-spawn subagent inputs: strip `run_in_background` + the
  `check_background_task` def from the parent AgentInputs reused for self-spawn,
  so the isolated child (direct/child-graph path) doesn't advertise a background
  schema it can't honor. The SDK resolver keeps a provided `agentInputs` even
  with `self: true`.
- Exclude `check_background_task` from PTC (`run_tools_with_code`) tool
  definitions — it's host-only and not callable from generated code.
- Parse stringified JSON args before deciding background dispatch and before
  stripping the flag, so string-delivered `run_in_background` is honored and
  never leaks to strict object-schema tools.
- Skip injection for tools that already declare their own `run_in_background`
  param (would otherwise hijack/strip it), and for non-object (string-input)
  schemas (would otherwise rewrite the input contract).

* 🩹 fix: Address Codex round 5 on background tool calls

- check_background_task now parses stringified JSON args, so providers that
  deliver args as a string can retrieve a specific task by id (not just list).
- Include agentId in the background dedupe key (`agentId::runId::toolCallId`):
  two agents in the same run emitting the same provider id (e.g. `call_0`) now
  launch independent tasks instead of colliding.
- Self-spawn sanitization also strips the background entries from the reused
  toolRegistry (not just toolDefinitions), so a child using tool_search/deferred
  loading can't rediscover the host-only run_in_background / check_background_task.

* 🩹 fix: Strip run_in_background from PTC target tool schemas (Codex round 6)

The PTC path already filtered out the host-only check_background_task poll tool
but still exposed target tool schemas with the injected `run_in_background` param
(the shared toolRegistry entries were mutated by applyBackgroundToolCalls). PTC
codegen doesn't go through the host background interceptor, so it could pass the
flag to an MCP/action tool (strict-schema rejection or silent foreground with no
poll). Sanitize the PTC toolDefs like the self-spawn path does.

* 🩹 fix: Sanitize background from explicit subagent inputs (Codex round 7)

A child agent reachable as a top-level/handoff agent is initialized WITH the
background capability, then reused as an explicit subagent via buildSubagentConfigs.
Round 4 only sanitized the self-spawn case; this now applies the same
stripBackgroundFromToolDefinitions/Registry to explicit child agentInputs when
`child.backgroundToolNames` is non-empty, so an isolated child graph doesn't
advertise a run_in_background / check_background_task contract it can't honor.

* 🩹 fix: Reap stuck/expired background tasks (Codex round 8)

- get() now sweeps before returning, so repeatedly polling a known
  background_task_id can't keep an expired completed task (and its retained
  result, up to 100k chars) alive past the one-hour completed TTL.
- sweep() now reaps `running` tasks older than a 30-min running TTL, marking
  them errored. Previously a detached call that never settled (hung network /
  lost MCP connection) held a running slot forever, exhausting the
  per-conversation cap and rejecting every later dispatch.

* 🩹 fix: Evict oldest settled tasks instead of blocking at the cap (Codex round 9)

Only the running-task cap gates dispatch now. The total-tasks cap
(MAX_TASKS_PER_BUCKET) bounds memory but no longer rejects new background calls:
when full, it evicts the oldest settled (completed/error) tasks to make room.
Previously 200 quick background calls in one conversation would block all new
dispatches for up to the completed-task TTL, since polling doesn't remove settled
tasks. Running is already capped, so room always frees.

* 📝 docs: Frame background tool calls as within-turn (Codex P1 contract)

Codex escalated the request-lifecycle findings to P1 on the grounds that the
advertised "poll later" contract can't be honored for genuinely long-running
calls (request-scoped MCP connections + the run abort signal are torn down at
turn end). Align the model-facing contract with what the same-run implementation
actually delivers: the run_in_background param, check_background_task, and the
dispatch handle now instruct the model to collect the result WITHIN THE SAME TURN
(backgrounded work isn't guaranteed to survive past the turn). This is
within-turn parallelism; cross-turn survival of long-running calls remains the
deliberate durable subagent follow-up. Copy/comment-only; no behavior change.

* ♻️ refactor: Cross-turn background tool calls, leak-free

Extend background tool calls from within-turn to cross-turn on a single
process, since the mechanism already supports it: the run's abort signal
never reaches the detached invoke (the graph forwards only configurable/
metadata to the tool-execute handler), so the floating promise keeps
running past turn completion and its result stays in the in-process
registry for a later turn to poll (get/list key only on
user::conversation + id, never the dispatch run/turn).

Guarantee no connection leak: ephemeral request-scoped MCP tools (runtime
{{LIBRECHAT_BODY_*}} placeholders) capture their request-scoped store at
creation and fall back to it, so config manipulation can't redirect them;
their connection is torn down at request end. Tag such tools in
createToolInstance and run them in the foreground instead of backgrounding
them. Pooled/app-level MCP and structured tools are unaffected and survive
cross-turn via their managed pools.

Reword the model-facing contract (run_in_background, check_background_task,
handle message, fileoverview) from within-turn to cross-turn on this server
(not across restart/replica, which stays the durable follow-up).

Tests: cross-turn poll retrieval; ephemeral MCP tool runs foreground.

* 🐛 fix: Guard ephemeral MCP tag against a null server config

createToolInstance can be reached with a null/stale capturedServerConfig
(cached availableTools + getServerConfig returns null, as several MCP unit
tests construct tools). The new unconditional requiresEphemeralUserConnection
call then dereferenced config.source and threw during tool construction
(CI: Tests api shard 2/3). Guard with the same serverConfig ? ... : false
pattern the other callers use; a missing config is not request-scoped.

* 🎨 fix: Deliver backgrounded tool artifacts on the poll turn

A slow backgrounded MCP/action tool resolves after its dispatch turn is
finalized: createToolEndCallback only appends to that turn's artifactPromises
(already awaited) and writes to a closed stream, so the artifact (file/citation/
UI resource) was silently dropped — check_background_task recorded only the
hasArtifact boolean. The cross-turn contract made this the common case.

Hold the artifact on the task and deliver it through the LIVE poll turn's
toolEndCallback the first time check_background_task collects that id (once,
then cleared to free memory), attributed to the original tool. Same-turn and
cross-turn now share this path since the model must poll to collect any result.

Tests: registry claim-once; artifact delivered on poll not dispatch, idempotent.

*  feat: Agent-builder toggle for background tool calls + cap tool descriptions

Add a per-MCP-tool "run in background" toggle in the agent builder, mirroring
the programmatic/deferred pattern: gated on the admin `run_in_background`
capability via useAgentCapabilities, read/written on tool_options[id]
.run_in_background through useMCPToolOptions (per-tool + bulk mark-all), and
rendered as a Zap toggle in MCPToolItem and McpSection with new locale keys.

Also cap the section tool/server descriptions (McpSection, ToolSection,
SkillSection) with max-h-40 overflow-y-auto so a long description scrolls
instead of overflowing the dialog, matching MCPToolItem's existing cap.

Tests: MCPToolItem renders/toggles the background button only when enabled.

* 🧪 fix: Mock new background hook functions in McpSection spec

* 🎨 fix: Restore background artifact when poll-turn delivery fails

* 🛡️ fix: Harden background tool call edges from review findings

- Error immediately (matching foreground) when a background-requested tool
  failed to load, instead of returning a success handle for a dead task
- Exclude ephemeral request-scoped MCP tools at injection time so the model
  never sees a run_in_background param the executor would silently downgrade;
  flip the execute-time tag to fail closed on a missing server config
- Source image-tool background exclusions from the shared imageGenTools set
  (adds missing stable-diffusion, an artifact-first live tool) instead of a
  hand-copied list
- Add check_background_task to the eager-execution exclusion list: artifact
  collection is a one-shot claim that must not fire from a speculative
  snapshot the SDK may discard
- Strip an imitated run_in_background arg on tools the executing agent never
  opted in (multi-agent history bleed), unless the tool's own schema declares
  the parameter
- Truncate oversized stored results with an explicit marker via the shared
  truncateMiddle (moved to utils/text) instead of a silent slice
- Document the at-most-once artifact delivery semantics honestly (the
  callback's downstream persistence is fire-and-forget, as in foreground)

* ♻️ refactor: Deduplicate background tool-call plumbing and tighten types

- Use the SDK's JsonSchemaType instead of a local duplicate; drop all
  as-unknown casts and type the poll-tool serializer explicitly
- Drop derivable BackgroundTask state (progress, hasArtifact) and the dead
  `enabled` param/return on applyBackgroundToolCalls (guarded at the call
  site), which also skips the defs pass when nothing opted in
- Fold the enable expression into synthesizeBackgroundToolOptions so the
  three load/added call sites can't drift
- Throttle the registry's all-buckets sweep and always sweep the accessed
  bucket, so a hot poll loop is no longer O(total tasks server-wide); bound
  retained artifact memory with a size cap
- Single-pass stripBackgroundFromToolDefinitions; pass metadata through to
  the poll-turn callback instead of a no-op reconstruction
- Collapse the client's copy-pasted boolean option families into a keyed
  factory (also removes the shared-object mutation in the bulk toggles) and
  the six toggle-button copies into one OptionToggle component

* 🧪 test: e2e coverage for cross-turn background tool calls

Proves the full contract through the real pipeline (mock harness): an agent
opts an MCP tool in via tool_options.run_in_background, the model dispatches
it detached and receives the synthetic handle while the tool is still running
(status=running in the rendered ack — the non-blocking guarantee without
timing assertions), the tool completes after its turn finalized, and a later
user turn recovers the task id from replayed history, polls
check_background_task, and renders the collected result.

- fake-mcp-server: slow_echo fixture tool (delayed echo)
- fake-model: E2E_BACKGROUND_DISPATCH / E2E_BACKGROUND_COLLECT markers
- e2e yaml: agents capabilities = defaults + run_in_background

* 🔧 fix: Close two background capability gaps from review

- Thread backgroundToolsAvailable through the OpenAI-compatible service
  (derived from app capabilities like codeEnvAvailable/statefulSessions),
  so agents with tool_options.run_in_background keep the feature on that
  route; fold the three capability derivations into one helper
- Index ephemeral MCP servers by normalizeServerName when excluding tools
  from background injection: tool names embed the normalized server name
  while mcpConfig keys the original, so exotic server names previously
  escaped the injection-time exclusion

* 🛂 fix: Fall back to configurable user identity for background task scoping

The in-repo routes merge req into the tool-execute configurable, but external
hosts of the exported OpenAI-compatible service inject their own loadTools and
may not — tasks would then register under an empty user id, collapsing
registry isolation to conversationId alone. Resolve the scoping id from
req.user.id, then configurable.user_id / user, and cover the isolation with a
foreign-user not_found test.

* 🧹 chore: Apply repo import sorter to PR-touched files
2026-07-13 12:51:36 -04:00

1818 lines
60 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,
ASK_USER_QUESTION_TOOL_NAME,
} = 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 (tool === ASK_USER_QUESTION_TOOL_NAME) {
return checkCapability(AgentCapabilities.ask_user_question);
}
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 (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)) {
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,
provider: agent.provider,
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,
backgroundToolNames,
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 };
/** Per-agent set of tools that received the injected `run_in_background`
* param; the event-driven executor gates background dispatch and the
* `check_background_task` poll tool on this reliable per-agent channel. */
if (backgroundToolNames?.length) {
configurable.backgroundToolNames = backgroundToolNames;
}
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;
/**
* Opt bash_tool into the hedged stateful-session description. Gated on code
* execution being enabled AND the admin `stateful_code_sessions` capability
* AND the agent's own builder opt-in; off by default. Sets prompt text only
* (the wire hint is set at run config). PTC keeps its stateless prompt in
* v1. Older @librechat/agents ignore the param.
*/
const statefulCodeSessions =
codeExecutionEnabled &&
enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true &&
agent?.stateful_code_sessions === 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),
statefulSessions: statefulCodeSessions,
});
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,
};