LibreChat/api/server/services/Endpoints/agents/initialize.js
Danny Avila cc813f430e
🎯 feat: Tool Intent Label Capability (tool_intents) (#14499)
* 🎯 feat: Tool Intent Label Capability (tool_intents)

Adds the fourth member of the per-tool capability family (defer_loading,
allowed_callers, run_in_background): an admin capability
AgentCapabilities.tool_intents plus a per-tool
tool_options[name].describe_intent flag. Opted-in tools get an optional
intent string injected as the FIRST property of their schema — one
model-authored sentence per call, streamed to the client as the call's
live status label (args already reach the client verbatim, so no new
event plumbing). Native host tools (web_search, create_file/edit_file,
set_memory/delete_memory, ask_user_question) default on while the
capability is enabled; explicit false opts out. SDK-native intent
schemas (@librechat/agents coding suite) are recognized and left alone.

- packages/api/src/agents/intent.ts: structural sibling of
  background.ts — first-key non-mutating injection with registry
  parity (covers deferred/tool_search discovery), eligibility and
  PTC-only skips, arg read/strip helpers, self-spawn strip for defs and
  registry, ephemeral/model-spec synthesis with a tool_options merge so
  the background and intent toggles compose.
- handlers.ts: intent runs BEFORE background injection so the label
  stays the first streamed key when a tool carries both (pinned by
  test); the arg is stripped before invocation unless the tool's own
  schema declares it, on both the foreground and background-dispatch
  paths; PTC target schemas are sanitized like background's.
- Capability plumbing through all four routes (endpoint initialize,
  openai + responses controllers, the exported OpenAI-compatible
  service) plus handoff discovery and added-convo agents, and the
  intentToolNames execution channel via configurable.
- describe_intent on toolOptionsSchema (all three written-out Zod
  annotations), ToolOptions, TEphemeralAgent, TModelSpec (+ zod), and
  data-schemas doc comments (tool_options is Mixed — no migration).
- intent.spec.ts: 28 tests cloned from background.spec.ts structure,
  including the intent+background key-order composition.

* 🧯 fix: Codex Review — Opt-Out Strips SDK-Native Intent, Skip mcp_all Placeholders

- An explicit describe_intent: false now REMOVES an SDK-native intent
  property from the definition and registry entry, so the per-tool
  opt-out actually disables the arg's token cost for tools like
  web_search that carry the schema natively (SDK bodies tolerate its
  absence). Previously the early return left the property in place.
- synthesizeIntentToolOptions skips lazily-expanded mcp_all
  placeholders instead of recording options under names that
  applyIntentLabels' exact-name matching can never match, and documents
  the limitation (parity with synthesizeBackgroundToolOptions).

The P1 about the client not rendering the label is the documented
slicing: the UI streaming-label PR follows once #14391's ToolCallGroup
changes merge — args already reach the client, so that slice is purely
rendering.

* 🧯 fix: Codex Re-Review — Label Marker Guard, Capability Kill Switch, Late Defs, Service Threading

- removeIntentParam is now marker-guarded (the label contract's opening
  instruction discriminates it), so an MCP/action tool's own business
  `intent` parameter is never stripped by an opt-out or the disabled
  path — previously an explicit false could remove a real, possibly
  required argument.
- New sanitizeIntentLabels pass runs AFTER every registration step
  (the skill catalog appends its SDK definition post-injection): with
  tool_intents disabled it strips SDK-native intent labels from all
  definitions and registry entries, making the capability a real kill
  switch over their token cost; with it enabled it enforces explicit
  per-tool opt-outs on late-registered definitions.
- ask_user_question removed from the native default-on set: its graph
  tool is rebuilt in run.ts from its own Zod schema (also the HITL
  card's wire shape), so definition-level injection never reached the
  model. Its intent support lands with the HITL slice, which threads
  the label into the interrupt payload deliberately.
- The exported OpenAI-compatible service now threads intentToolNames
  into the run configurable, so the executor's PTC path can strip
  host-injected intent schemas on that route like the in-repo
  controllers do.

* 🧯 fix: Codex Round 2 — Post-Skill Injection, PTC Native Strip, Service Boundary, Honest Docs

- Intent injection now runs LAST in initializeAgent, after the skill
  catalog — which both appends its own definition and REPLACES upgraded
  ones (skill-aware read_file), clobbering an earlier injection while
  intentToolNames still listed the tool. Injection PREPENDS while
  background APPENDS, so intent stays the first schema property under
  the new ordering (pinned by a reverse-order composition test).
- The PTC target-schema strip is now marker-guarded strip-ALL: SDK-
  native intent labels (which are deliberately never in intentToolNames)
  are removed from sandbox-advertised schemas alongside host-injected
  ones; business intent params survive.
- toolIntentsAvailable on the exported service documents the loader
  boundary: a custom LoadToolsFn returning only structured instances
  bypasses definition/registry injection and sanitize by construction.
- librechat.example.yaml describes tool_intents as backend groundwork
  with UI rendering in an upcoming release rather than promising a live
  label today.

* 📦 chore: bump `@librechat/agents` to v3.3.6

Brings in the SDK half of tool intent labels (danny-avila/agents#347,
#349): intent-first schemas on the coding suite across all three
engines, plus web_search / subagent / skill / tool_search, and the
outcome / outcome_patch result channel.

Activates three host paths that were inert while no SDK tool shipped an
`intent` property — verified against the real 3.3.6 schemas:
- capability OFF now strips SDK-native labels (a real admin kill switch)
- explicit `describe_intent: false` removes them per tool
- host injection stays idempotent against an SDK schema, keeping
  `intent` first and never double-injecting

* 🔬 test: Real-Provider Verification for Tool Intent Labels

Adds the live check the unit tests structurally cannot perform: whether a
real model actually authors the injected arg, places it FIRST, and gives
sibling calls to one tool distinct labels. Reuses the existing
real-provider harness (in-memory Mongo, seeded user, credential
neutralizer) and the existing stdio MCP fixture as a genuine tool, so no
external service is involved.

- e2e/config/librechat.real.yaml: adds the e2e-memory MCP server and the
  tool_intents capability, giving the real model something to call. The
  sibling spec asserts only relative token growth, so the extra schemas
  do not perturb it.
- e2e/playwright.config.real.ts: optional Langfuse passthrough. The
  LANGFUSE_* keys match the credential-neutralizer pattern and were being
  blanked before the server booted; they are preserved explicitly, read
  from the invoking environment only, and never written to the generated
  config.
- e2e/specs/real/tool-intents.spec.ts: two facts stored in one turn, both
  through the same tool, asserting intent is the first key of each call
  and that the two labels differ. Args are read from persistence rather
  than the DOM deliberately — no UI renders the label yet, and
  persistence is what a reloaded conversation and the trace both read.

First run against claude-haiku-4-5 produced 'Recording the location of
the OAuth callback router' and 'Recording the location of the MCP
connection pool configuration' — distinct, first-position, no tool name.

Also updates tool-intent-spec.md: records the 3.3.7 removal of the tense
verb map with the evidence that motivated it, the trimmed description and
the marker's role as an API, and a new mandatory requirement that
client-side label rendering be gated on a server-sent signal rather than
the presence of an intent key (a tool's own business 'intent' parameter
would otherwise render as a status label).

* 📦 chore: bump `@librechat/agents` to v3.3.7 and dedupe the intent contract

Picks up danny-avila/agents#353: the tense verb map is gone (a bare
intent now displays unchanged, with completion carried by UI state), the
model-facing description is trimmed 502 → 289 chars, and both the marker
and the description are exported.

Stops redeclaring the SDK contract here:
- INTENT_LABEL_MARKER is imported instead of duplicated as a string
  literal. Every removal path in this module keys on it, and a local copy
  that drifted from the SDK's would make them all stop recognizing
  SDK-native labels — failing OPEN, with labels left in schemas and
  per-tool opt-outs silently inert.
- INTENT_DESCRIPTION is imported too, so host-injected tools and
  SDK-native tools present the model with one identical instruction.
  Keeping the old local copy would also have meant host-injected tools
  still paying ~126 tokens per schema while SDK tools paid ~72.

Verified live against real Anthropic after the trim: two sibling calls to
one MCP tool produced 'Storing the OAuth callback router file location'
and 'Storing the MCP connection pool configuration file location' —
first-position and distinct, so the shorter description holds compliance.
2026-07-29 15:40:52 -04:00

1069 lines
39 KiB
JavaScript

const { logger } = require('@librechat/data-schemas');
const { createContentAggregator } = require('@librechat/agents');
const {
checkAccess,
loadSkillStates,
initializeAgent,
isMemoryEnabled,
primeInvokedSkills,
validateAgentModel,
extractManualSkills,
GenerationJobManager,
getCustomEndpointConfig,
discoverConnectedAgents,
resolveAgentTokenConfig,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
getAgentStartupTelemetry,
buildAgentContextAttachmentsByAgentId,
} = require('@librechat/api');
const {
Permissions,
ResourceType,
EModelEndpoint,
PermissionBits,
PermissionTypes,
MAX_SUBAGENT_DEPTH,
isAgentsEndpoint,
getResponseSender,
AgentCapabilities,
MAX_SUBAGENT_GRAPH_NODES,
isEphemeralAgentId,
} = require('librechat-data-provider');
const {
createToolEndCallback,
createAttachmentEmitter,
createBackgroundCodeResultHandler,
getDefaultHandlers,
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const {
getSkillToolDeps,
getSkillDbMethods,
canAuthorSkillFiles,
withDeploymentSkillIds,
buildAgentToolContext,
enrichLoadedToolsWithAgentContext,
} = require('./skillDeps');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService');
const AgentClient = require('~/server/controllers/agents/client');
const { processAddedConvo } = require('./addedConvo');
const { logViolation } = require('~/cache');
const db = require('~/models');
/**
* Creates a tool loader function for the agent.
* @param {AbortSignal} signal - The abort signal
* @param {string | null} [streamId] - The stream ID for resumable mode
* @param {boolean} [definitionsOnly=false] - When true, returns only serializable
* tool definitions without creating full tool instances (for event-driven mode)
* @param {number} [jobCreatedAt] - The generation epoch that owns emitted tool events
*/
function createToolLoader(signal, streamId = null, definitionsOnly = false, jobCreatedAt) {
/**
* @param {object} params
* @param {ServerRequest} params.req
* @param {ServerResponse} params.res
* @param {string} params.agentId
* @param {string[]} params.tools
* @param {string} params.provider
* @param {string} params.model
* @param {AgentToolResources} params.tool_resources
* @returns {Promise<{
* tools?: StructuredTool[],
* toolContextMap: Record<string, unknown>,
* toolDefinitions?: import('@librechat/agents').LCTool[],
* userMCPAuthMap?: Record<string, Record<string, string>>,
* toolRegistry?: import('@librechat/agents').LCToolRegistry
* } | undefined>}
*/
return async function loadTools({
req,
res,
tools,
model,
agentId,
provider,
tool_options,
tool_resources,
}) {
const agent = { id: agentId, tools, provider, model, tool_options };
try {
return await loadAgentTools({
req,
res,
agent,
signal,
streamId,
jobCreatedAt,
tool_resources,
definitionsOnly,
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
}
};
}
/**
* Initializes the AgentClient for a given request/response cycle.
* @param {Object} params
* @param {Express.Request} params.req
* @param {Express.Response} params.res
* @param {AbortSignal} params.signal
* @param {Object} params.endpointOption
* @param {number} [params.jobCreatedAt]
*/
const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt }) => {
if (!endpointOption) {
throw new Error('Endpoint option not provided');
}
const appConfig = req.config;
const startupTelemetry = getAgentStartupTelemetry(req);
/** @type {string | null} */
const streamId = req._resumableStreamId || null;
/** @type {Array<UsageMetadata>} */
const collectedUsage = [];
/**
* Vertex Gemini 3 thought signatures captured from `chat_model_end` events,
* keyed by `tool_call_id`. Persisted on
* `responseMessage.metadata.thoughtSignatures` so subsequent conversation
* turns can restore each signature onto the right reconstructed AIMessage's
* `additional_kwargs.signatures` and avoid 400s when resuming after a tool
* round-trip without a final text reply. Always allocated; capture path
* is a no-op for providers that don't emit signatures (OpenAI, Anthropic,
* Bedrock, etc.).
* @type {Record<string, string>}
*/
const collectedThoughtSignatures = {};
/** @type {ArtifactPromises} */
const artifactPromises = [];
/** @type {Map<string, import('@librechat/api').ToolInputValidationError>} */
const toolInputValidationErrors = new Map();
const { contentParts, aggregateContent, stepMap } = createContentAggregator();
const toolEndCallback = createToolEndCallback({
req,
res,
artifactPromises,
streamId,
jobCreatedAt,
});
/** Query accessible skill IDs once per run (shared across all agents).
* Skills activate under strict opt-in semantics — see
* `resolveAgentScopedSkillIds` for the per-agent activation predicate:
* - Ephemeral agent → model-spec `skills` config first, otherwise the
* per-conversation skills badge toggle (full catalog).
* - Persisted agent → `agent.skills_enabled === true`. Optional
* `agent.skills` allowlist narrows the catalog; empty/undefined
* allowlist with the toggle on = full accessible catalog. */
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
const backgroundToolsAvailable = enabledCapabilities.has(AgentCapabilities.run_in_background);
const toolIntentsAvailable = enabledCapabilities.has(AgentCapabilities.tool_intents);
const statefulSessionsAvailable = enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
);
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
const skillDbMethods = getSkillDbMethods();
if (!endpointOption.agent) {
throw new Error('No agent promise provided');
}
/** Run-level gate for inline memory tools: the `memory` capability must be
* enabled, memory must be configured, and the user must not have opted out.
* Requires the memory WRITE permissions (CREATE + UPDATE) — both inline tools
* mutate memory — so the tools aren't registered (and shown to the model) for
* read-only-memory roles that the runtime loader would then refuse to build.
* Agents (or the ephemeral memory badge) opt in per-agent via the `memory`
* marker on `tools`. */
const memoryAvailablePromise =
enabledCapabilities.has(AgentCapabilities.memory) &&
isMemoryEnabled(appConfig?.memory) &&
req.user?.personalization?.memories !== false &&
checkAccess({
user: req.user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.UPDATE],
getRoleByName: db.getRoleByName,
});
const accessibleSkillIdsPromise = skillsCapabilityEnabled
? findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}).then(withDeploymentSkillIds)
: Promise.resolve([]);
const editableSkillIdsPromise = skillsCapabilityEnabled
? findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.EDIT,
})
: Promise.resolve([]);
const skillCreateAllowedPromise = skillsCapabilityEnabled
? getSkillToolDeps().canCreateSkill({ req })
: Promise.resolve(false);
const skillStatesPromise = accessibleSkillIdsPromise.then((accessibleSkillIds) =>
loadSkillStates({
userId: req.user.id,
appConfig,
getUserById: db.getUserById,
accessibleSkillIds,
}),
);
const primaryAgentPromise = endpointOption.agent;
const modelsConfigPromise = getModelsConfig(req);
const validatedPrimaryAgentPromise = Promise.all([primaryAgentPromise, modelsConfigPromise]).then(
async ([primaryAgent, modelsConfig]) => {
if (!primaryAgent) {
throw new Error('Agent not found');
}
const validationResult = await validateAgentModel({
req,
res,
modelsConfig,
logViolation,
agent: primaryAgent,
});
if (!validationResult.isValid) {
throw new Error(validationResult.error?.message);
}
return { primaryAgent, modelsConfig };
},
);
/**
* Agent context store - populated after initialization, accessed by callback via closure.
* Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey }
* @type {Map<string, {
* userMCPAuthMap?: Record<string, Record<string, string>>,
* agent?: object,
* tool_resources?: object,
* toolRegistry?: import('@librechat/agents').LCToolRegistry,
* requestScopedConnections?: import('@librechat/api').RequestScopedMCPConnectionStore,
* openAIApiKey?: string
* }>}
*/
const agentToolContexts = new Map();
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
const ctx = agentToolContexts.get(agentId) ?? {};
logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`);
logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`);
const result = await loadToolsForExecution({
req,
res,
signal,
streamId,
toolNames,
agent: ctx.agent,
toolRegistry: ctx.toolRegistry,
backgroundToolNames: ctx.backgroundToolNames,
intentToolNames: ctx.intentToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
requestScopedConnections: ctx.requestScopedConnections,
userMCPAuthMap: ctx.userMCPAuthMap,
tool_resources: ctx.tool_resources,
actionsEnabled: ctx.actionsEnabled,
jobCreatedAt,
});
logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`);
/** Per-agent narrowed flag (admin capability AND agent.tools
* includes execute_code), captured in `agentToolContexts` when
* the agent initialized. Falls back to `false` on any stray
* ctx miss so a skills-only agent never gains sandbox access
* even if capability lookup somehow skips. */
return enrichLoadedToolsWithAgentContext({
result,
req,
ctx,
});
},
toolEndCallback,
persistBackgroundCodeResult: createBackgroundCodeResultHandler({
req,
updateToolCallResult: db.updateToolCallResult,
}),
emitAttachment: createAttachmentEmitter({ res, streamId, jobCreatedAt }),
...getSkillToolDeps(),
};
const summarizationOptions =
appConfig?.summarization?.enabled === false ? { enabled: false } : { enabled: true };
/**
* Per-request map of per-subagent `createContentAggregator` instances
* keyed by the parent's `tool_call_id`. The handler in `callbacks.js`
* lazily creates an aggregator for each distinct `parentToolCallId`
* and folds every `ON_SUBAGENT_UPDATE` event into it as they stream
* in. `AgentClient` pulls each aggregator's `contentParts` at message
* save time and attaches them to the matching `subagent` tool_call so
* the child's reasoning / tool calls / final text survive a page
* refresh — the client-side Recoil atom is best-effort live-only.
*/
const subagentAggregatorsByToolCallId = new Map();
/** Backend prices each model call authoritatively (premium tiers, cache
* rates) and emits the cost on on_token_usage when contextCost is on, so
* the gauge sums real costs instead of re-deriving from base rates.
* `endpointTokenConfig` is filled in once `primaryConfig` resolves below so
* custom-endpoint agents price with their configured rates, not defaults. */
const usageCost = {
enabled: appConfig?.interfaceConfig?.contextCost === true,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
};
/** Latest visible context snapshot + every emitted usage payload for this
* response, captured by the handlers and persisted on the response message's
* metadata so the breakdown and branch/total cost survive a reload.
* @type {{ latest: import('librechat-data-provider').TContextUsageEvent | null, count: number }} */
const contextUsageSink = { latest: null, count: 0 };
/** @type {Array<import('librechat-data-provider').TTokenUsageEvent>} */
const usageEmitSink = [];
const eventHandlers = getDefaultHandlers({
res,
contentParts,
stepMap,
toolInputValidationErrors,
toolExecuteOptions,
summarizationOptions,
aggregateContent,
toolEndCallback,
collectedUsage,
collectedThoughtSignatures,
streamId,
jobCreatedAt,
subagentAggregatorsByToolCallId,
usageCost,
contextUsageSink,
usageEmitSink,
});
const [
memoryAvailable,
accessibleSkillIds,
editableSkillIds,
skillCreateAllowed,
{ skillStates, defaultActiveOnShare },
{ primaryAgent, modelsConfig },
] = await Promise.all([
memoryAvailablePromise,
accessibleSkillIdsPromise,
editableSkillIdsPromise,
skillCreateAllowedPromise,
skillStatesPromise,
validatedPrimaryAgentPromise,
]);
delete endpointOption.agent;
const agentConfigs = new Map();
const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders);
/** Event-driven mode: only load tool definitions, not full instances */
const loadTools = createToolLoader(signal, streamId, true, jobCreatedAt);
/** @type {Array<MongoFile>} */
const requestFiles = req.body.files ?? [];
/** @type {string} */
const conversationId = req.body.conversationId;
/** @type {string | undefined} */
const parentMessageId = req.body.parentMessageId;
/**
* Skill names the user invoked via the `$` popover for this turn. Only flows
* to the primary agent — handoff agents are follow-up turns that don't see
* the user's per-submission `$` selections. `extractManualSkills` also
* drops non-string / empty elements so a crafted payload can't reach the
* `getSkillByName` DB query with nonsense values.
* @type {string[] | undefined}
*/
const manualSkills = extractManualSkills(req.body);
const selectedModelSpec =
endpointOption.spec && Array.isArray(appConfig?.modelSpecs?.list)
? appConfig.modelSpecs.list.find((modelSpec) => modelSpec.name === endpointOption.spec)
: null;
if (
primaryAgent &&
isEphemeralAgentId(primaryAgent.id) &&
selectedModelSpec &&
Object.hasOwn(selectedModelSpec, 'skills')
) {
if (selectedModelSpec.skills === true) {
primaryAgent.skills_enabled = true;
delete primaryAgent.skills;
} else if (selectedModelSpec.skills === false) {
primaryAgent.skills_enabled = false;
primaryAgent.skills = [];
} else if (Array.isArray(selectedModelSpec.skills)) {
const resolvedSkillIds = await resolveModelSpecSkillIds({
names: selectedModelSpec.skills,
accessibleSkillIds,
getSkillByName: skillDbMethods.getSkillByName,
});
primaryAgent.skills_enabled = true;
primaryAgent.skills = resolvedSkillIds.map((id) => id.toString());
}
}
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
agent: primaryAgent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({
agent: primaryAgent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const primarySkillAuthoringAvailable = canAuthorSkillFiles({
agent: primaryAgent,
scopedEditableSkillIds: primaryScopedEditableSkillIds,
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const primaryConfig = await initializeAgent(
{
req,
res,
loadTools,
requestFiles,
conversationId,
parentMessageId,
agent: primaryAgent,
endpointOption,
allowedProviders,
isInitialAgent: true,
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: primarySkillAuthoringAvailable,
codeEnvAvailable,
backgroundToolsAvailable,
toolIntentsAvailable,
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
manualSkills,
},
{
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
getConvoFiles: db.getConvoFiles,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
);
/** Price emitted usage with the primary agent's resolved endpoint config so
* custom-endpoint agents reflect configured rates (mirrors the AgentClient
* spending path, which reads the same config). */
usageCost.endpointTokenConfig = primaryConfig.endpointTokenConfig;
logger.debug(
`[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`,
);
agentToolContexts.set(
primaryConfig.id,
buildAgentToolContext({ agent: primaryAgent, config: primaryConfig }),
);
const {
agentConfigs: discoveredConfigs,
edges: discoveredEdges,
userMCPAuthMap: discoveredMCPAuthMap,
skippedAgentIds: discoveredSkippedIds,
} = await discoverConnectedAgents(
{
req,
res,
primaryConfig,
agent_ids: primaryConfig.agent_ids,
endpointOption,
allowedProviders,
modelsConfig,
loadTools,
requestFiles,
conversationId,
parentMessageId,
computeAccessibleSkillIds: (agent) =>
resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
computeSkillAuthoringAvailable: (agent) =>
canAuthorSkillFiles({
agent,
scopedEditableSkillIds: resolveAgentScopedSkillIds({
agent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
toolIntentsAvailable,
statefulSessionsAvailable,
memoryAvailable,
},
{
getAgent: db.getAgent,
checkPermission,
logViolation,
db: {
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
getConvoFiles: db.getConvoFiles,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
// The callback fires during BFS, before the helper prunes agents
// whose edges end up filtered. Don't populate `agentConfigs` here —
// `discoveredConfigs` (returned below) is the authoritative pruned
// set. The per-agent tool context map is OK to keep populated even
// for pruned ids: it's only read by closure in ON_TOOL_EXECUTE,
// stale entries are unreachable at runtime.
onAgentInitialized: (agentId, agent, config) => {
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
},
// Pass through the `@librechat/api` exports so that tests which
// `jest.mock('@librechat/api')` can override the initializer/validator.
initializeAgent,
validateAgentModel,
},
);
// Copy the pruned discovery result into the outer map. Anything the
// helper dropped (skipped or unreachable after edge filtering) is
// intentionally absent. `processAddedConvo` below may still add more
// entries for parallel multi-convo execution.
for (const [agentId, config] of discoveredConfigs) {
agentConfigs.set(agentId, config);
}
let userMCPAuthMap = discoveredMCPAuthMap;
let edges = discoveredEdges;
/** Multi-Convo: Process addedConvo for parallel agent execution */
const { userMCPAuthMap: updatedMCPAuthMap } = await processAddedConvo({
req,
res,
loadTools,
logViolation,
modelsConfig,
requestFiles,
agentConfigs,
primaryAgent,
endpointOption,
userMCPAuthMap,
conversationId,
parentMessageId,
allowedProviders,
primaryAgentId: primaryConfig.id,
accessibleSkillIds,
editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
skillCreateAllowed,
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
backgroundToolsAvailable,
toolIntentsAvailable,
statefulSessionsAvailable,
memoryAvailable,
});
if (updatedMCPAuthMap) {
userMCPAuthMap = updatedMCPAuthMap;
}
for (const [agentId, config] of agentConfigs) {
if (agentToolContexts.has(agentId)) {
continue;
}
agentToolContexts.set(agentId, buildAgentToolContext({ agent: config, config }));
}
// `discoverConnectedAgents` always returns a concrete array, so no
// further normalization is needed before handing this to `createRun`.
primaryConfig.edges = edges;
// Subagents: load any explicit subagent configs. Subagents run in isolated
// context windows and are invoked via a dedicated spawn tool (not handoff
// edges). An agent that is ONLY referenced as a subagent is dropped from
// `agentConfigs` so the LangGraph pipeline doesn't treat it as a
// parallel/handoff node, but it is KEPT in `agentToolContexts` — the child's
// `ON_TOOL_EXECUTE` dispatches resolve tool execution context (agent,
// tool_resources, skill ACLs, ...) from that map, so removing it would leave
// action tools skipped and resource-scoped tools running without their
// configured resources.
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
/** Track skipped ids locally so repeated failures short-circuit within
* the subagent loading loop. Seeded from the discovery helper's skip
* list so agents that already failed handoff loading don't get retried. */
const skippedAgentIds = new Set(discoveredSkippedIds ?? []);
/** All agent ids referenced on any edge (source OR target). Used by
* `loadSubagentsFor` to decide whether an agent that's only a subagent
* can be safely dropped from `agentConfigs` — LangGraph doesn't treat
* pure subagents as parallel/handoff nodes. */
const edgeAgentIds = new Set([primaryConfig.id]);
for (const edge of edges ?? []) {
const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
const targets = Array.isArray(edge.to) ? edge.to : [edge.to];
for (const id of sources) {
if (typeof id === 'string') edgeAgentIds.add(id);
}
for (const id of targets) {
if (typeof id === 'string') edgeAgentIds.add(id);
}
}
/** Lazy per-id agent loader used for subagents that weren't reachable
* via the handoff edge graph (so `discoverConnectedAgents` didn't
* initialize them). Mirrors the helper's internal `processAgent`:
* DB lookup + VIEW check + `initializeAgent`, then inserts into
* `agentConfigs` and `agentToolContexts`. Returns `null` on any
* failure so the caller can skip gracefully. */
const loadAgentById = async (agentId) => {
if (skippedAgentIds.has(agentId)) return null;
const existing = agentConfigs.get(agentId);
if (existing) return existing;
try {
const agent = await db.getAgent({ id: agentId });
if (!agent) {
skippedAgentIds.add(agentId);
return null;
}
const userId = req.user?.id;
if (!userId) {
skippedAgentIds.add(agentId);
return null;
}
const hasAccess = await checkPermission({
userId,
role: req.user?.role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.VIEW,
});
if (!hasAccess) {
logger.warn(
`[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`,
);
skippedAgentIds.add(agentId);
return null;
}
const validation = await validateAgentModel({
req,
res,
agent,
modelsConfig,
logViolation,
});
if (!validation.isValid) {
logger.warn(
`[processAgent] Subagent ${agentId} failed model validation: ${validation.error?.message}`,
);
skippedAgentIds.add(agentId);
return null;
}
const scopedSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const config = await initializeAgent(
{
req,
res,
agent,
loadTools,
requestFiles,
conversationId,
parentMessageId,
endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents },
allowedProviders,
accessibleSkillIds: scopedSkillIds,
skillAuthoringAvailable: canAuthorSkillFiles({
agent,
scopedEditableSkillIds,
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
/** Match the primary / handoff / addedConvo paths: forward the
* endpoint-level admin flag so `initializeAgent` can compute the
* per-agent narrowing (admin AND agent.tools includes
* execute_code) into `InitializedAgent.codeEnvAvailable`. Without
* this, a code-enabled subagent loaded only through
* `subagentAgentConfigs` initializes with `codeEnvAvailable:
* false`, so `bash_tool` / `read_file` sandbox fallback are
* silently gated off even though the seed walk found it. */
codeEnvAvailable,
/* Background tool calls are intentionally NOT enabled for pure
* subagents (spawn-tool child graphs): their tools don't reach the
* host ON_TOOL_EXECUTE background interceptor, so injecting the
* schema would advertise a poll tool the host can't honor. Backgrounding
* subagent work is the durable follow-up. */
statefulSessionsAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
},
{
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
getConvoFiles: db.getConvoFiles,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
);
agentConfigs.set(agentId, config);
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
return config;
} catch (err) {
logger.error(`[processAgent] Error processing subagent ${agentId}:`, err);
skippedAgentIds.add(agentId);
return null;
}
};
/** Collected during resolution; applied to `agentConfigs` only after
* every config has had its subagents resolved. Eager pruning would
* hide pure-subagent ids from the subsequent `loadSubagentsFor`
* loop, which would leave *their* `subagentAgentConfigs` empty and
* silently break nested delegation like A → B → C where B is only
* a subagent of A. */
const pureSubagentIds = new Set();
const subagentGraphIds = new Set();
const loadedSubagentConfigIds = new Set();
const assertSubagentGraphRoom = (agentId) => {
if (subagentGraphIds.has(agentId)) {
return;
}
if (subagentGraphIds.size >= MAX_SUBAGENT_GRAPH_NODES) {
logger.warn('[initializeClient] Subagent graph node limit exceeded', {
agentId,
primaryAgentId: primaryConfig.id,
loadedSubagentCount: subagentGraphIds.size,
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
});
throw new Error(
`Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`,
);
}
};
/**
* Loads `subagentAgentConfigs` for a single agent config. Shared
* between the primary agent and handoff-target agents (and pure
* subagents, transitively) so an agent used via handoff or
* nested-subagent that has its own explicit `subagents.agent_ids`
* gets them honored at runtime. Self-spawn works regardless (no DB
* lookup needed). Pruning decisions are deferred to `pureSubagentIds`.
*/
const loadSubagentsFor = async (config, depth = 0) => {
const sub = config.subagents;
if (!subagentsCapabilityEnabled || !sub?.enabled) {
config.subagentAgentConfigs = [];
return;
}
if (loadedSubagentConfigIds.has(config.id)) {
if ((config.subagentAgentConfigs?.length ?? 0) > 0 && depth >= MAX_SUBAGENT_DEPTH) {
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
agentId: config.id,
primaryAgentId: primaryConfig.id,
depth,
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
childCount: config.subagentAgentConfigs.length,
});
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
);
}
return;
}
/** Dedupe and filter in one pass — a crafted payload could
* legitimately include the same ID twice; the backend shouldn't
* create duplicate SubagentConfig entries for the LLM to see as
* separate spawn targets. */
const explicitSubagentIds = Array.from(
new Set(
Array.isArray(sub.agent_ids)
? sub.agent_ids.filter((id) => typeof id === 'string' && id && id !== config.id)
: [],
),
);
if (explicitSubagentIds.length > 0 && depth >= MAX_SUBAGENT_DEPTH) {
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
agentId: config.id,
primaryAgentId: primaryConfig.id,
depth,
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
childCount: explicitSubagentIds.length,
});
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
);
}
loadedSubagentConfigIds.add(config.id);
/** @type {Array<Object>} */
const resolved = [];
for (const subagentId of explicitSubagentIds) {
if (skippedAgentIds.has(subagentId)) continue;
/** Cycle guard: a configuration like A ↔ B (B lists A as its
* subagent) would otherwise trigger `loadAgentById` on the
* primary — inserting a second config for the same primary id,
* which downstream duplicates in the agent array. Reuse the
* existing primary config when a subagent ref points back at it. */
if (subagentId === primaryConfig.id) {
resolved.push(primaryConfig);
continue;
}
assertSubagentGraphRoom(subagentId);
const subagentConfig = await loadAgentById(subagentId);
if (!subagentConfig) continue;
subagentGraphIds.add(subagentConfig.id ?? subagentId);
resolved.push(subagentConfig);
if (!edgeAgentIds.has(subagentId)) {
pureSubagentIds.add(subagentId);
}
}
config.subagentAgentConfigs = resolved;
};
const maxResolvedDepthByConfigId = new Map();
/** BFS across subagent trees so nested chains like A → B → C get
* resolved before any pruning. Agent configs are loaded once, but
* overlapping roots can still be revisited at deeper path depths so
* the depth guard observes the deepest reachable subagent path. */
const resolveSubagentTrees = async (rootConfigs) => {
const pending = rootConfigs.map((cfg) => ({ cfg, depth: 0 }));
for (let index = 0; index < pending.length; index++) {
const { cfg, depth } = pending[index];
if (!cfg?.id) continue;
const previousDepth = maxResolvedDepthByConfigId.get(cfg.id);
if (previousDepth != null && previousDepth >= depth) continue;
maxResolvedDepthByConfigId.set(cfg.id, depth);
await loadSubagentsFor(cfg, depth);
for (const child of cfg.subagentAgentConfigs ?? []) {
const childDepth = depth + 1;
const previousChildDepth = child?.id ? maxResolvedDepthByConfigId.get(child.id) : undefined;
if (child?.id && (previousChildDepth == null || previousChildDepth < childDepth)) {
pending.push({ cfg: child, depth: childDepth });
}
}
}
};
await resolveSubagentTrees([primaryConfig, ...agentConfigs.values()]);
/** Drop pure-subagent entries now that every reachable config has
* had its subagents resolved. They stay in `agentToolContexts` so
* their tools still execute with the right scoping. */
for (const id of pureSubagentIds) {
agentConfigs.delete(id);
}
primaryConfig.subagents = subagentsCapabilityEnabled ? primaryConfig.subagents : undefined;
/** If the capability is off at the endpoint level, strip `subagents` on
* every loaded config — not just the primary. `run.ts` calls
* `buildSubagentConfigs` for every agent in the array, so a handoff
* agent with `subagents.enabled: true` persisted on its document would
* otherwise still expose self-spawn at runtime even though the admin
* has disabled the capability globally. */
if (!subagentsCapabilityEnabled) {
for (const config of agentConfigs.values()) {
config.subagents = undefined;
config.subagentAgentConfigs = undefined;
}
}
const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId([
primaryConfig,
...agentConfigs.values(),
]);
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) {
try {
endpointConfig = getCustomEndpointConfig({
endpoint: primaryConfig.endpoint,
appConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
err,
);
}
}
const sender =
primaryAgent.name ??
getResponseSender({
...endpointOption,
model: endpointOption.model_parameters.model,
modelDisplayLabel: endpointConfig?.modelDisplayLabel,
modelLabel: endpointOption.model_parameters.modelLabel,
});
/** History priming uses the user's full ACL-accessible skill set (not
* per-agent scoped) because prior turns may reference skills no longer
* in any active agent's scope; the ACL check is the security gate.
* `codeEnvAvailable` comes from `primaryConfig` — @see
* `InitializedAgent.codeEnvAvailable` for the per-agent narrowing. */
const handlePrimeInvokedSkills = skillsCapabilityEnabled
? (payload) =>
primeInvokedSkills({
req,
payload,
accessibleSkillIds,
codeEnvAvailable: primaryConfig.codeEnvAvailable === true,
...getSkillToolDeps(),
})
: undefined;
/** Per-agent resolved endpoint token config, keyed by agent id. Built from
* `agentToolContexts` (the one map holding every agent, including pure
* subagents pruned from `agentConfigs`) so usage billed/emitted for a
* connected or subagent on a different custom endpoint is priced with THAT
* agent's configured rates instead of the primary's. Every known agent is
* recorded — even with an `undefined` config — so the resolver can tell a
* known non-custom agent (built-in pricing) from an untagged/unknown one
* (primary fallback).
* @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
const endpointTokenConfigByAgentId = new Map();
for (const [agentId, ctx] of agentToolContexts) {
endpointTokenConfigByAgentId.set(agentId, ctx?.endpointTokenConfig);
}
/** Price emitted usage per producing agent too, so the streamed/persisted
* `metadata.usage.cost` matches the per-agent balance transaction. */
usageCost.resolveEndpointTokenConfig = (usage) =>
resolveAgentTokenConfig({
agentId: usage?.agentId,
byAgentId: endpointTokenConfigByAgentId,
fallback: usageCost.endpointTokenConfig,
});
const client = new AgentClient({
req,
res,
sender,
contentParts,
stepMap,
agentConfigs,
eventHandlers,
collectedUsage,
collectedThoughtSignatures,
aggregateContent,
artifactPromises,
primeInvokedSkills: handlePrimeInvokedSkills,
agent: primaryConfig,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
chatProjectId: endpointOption.chatProjectId,
attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments,
agentContextAttachmentsByAgentId,
endpointType: endpointOption.endpointType,
resendFiles: primaryConfig.resendFiles ?? true,
maxContextTokens: primaryConfig.maxContextTokens,
endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents,
subagentAggregatorsByToolCallId,
/** Resolved endpoint token/pricing config so spending and cost reflect
* configured rates for custom-endpoint agents instead of defaults. */
endpointTokenConfig: primaryConfig.endpointTokenConfig,
/** Per-agent override of the above for multi-endpoint graphs (connected
* agents + subagents); falls back to the primary config when an agent
* isn't present or has no configured rates. */
endpointTokenConfigByAgentId,
/** Capture sinks the handlers fill during the run; `sendCompletion` reads
* them to persist the breakdown + usage rollup on the response message. */
contextUsageSink,
usageEmitSink,
startupTelemetry,
toolInputValidationErrors,
jobCreatedAt,
});
if (streamId) {
GenerationJobManager.setCollectedUsage(streamId, collectedUsage, jobCreatedAt);
}
return { client, userMCPAuthMap };
};
module.exports = { initializeClient };