const { logger } = require('@librechat/data-schemas'); const { createContentAggregator } = require('@librechat/agents'); const { loadSkillStates, initializeAgent, primeInvokedSkills, validateAgentModel, extractManualSkills, GenerationJobManager, getCustomEndpointConfig, discoverConnectedAgents, resolveAgentScopedSkillIds, } = require('@librechat/api'); const { ResourceType, EModelEndpoint, PermissionBits, MAX_SUBAGENT_DEPTH, isAgentsEndpoint, getResponseSender, AgentCapabilities, MAX_SUBAGENT_GRAPH_NODES, isEphemeralAgentId, } = require('librechat-data-provider'); const { createToolEndCallback, getDefaultHandlers, } = require('~/server/controllers/agents/callbacks'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { getSkillToolDeps, enrichWithSkillConfigurable, buildSkillPrimedIdsByName, } = 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) */ function createToolLoader(signal, streamId = null, definitionsOnly = false) { /** * @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, * toolDefinitions?: import('@librechat/agents').LCTool[], * userMCPAuthMap?: Record>, * 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, 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 */ const initializeClient = async ({ req, res, signal, endpointOption }) => { if (!endpointOption) { throw new Error('Endpoint option not provided'); } const appConfig = req.config; /** @type {string | null} */ const streamId = req._resumableStreamId || null; /** @type {Array} */ 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} */ const collectedThoughtSignatures = {}; /** @type {ArtifactPromises} */ const artifactPromises = []; const { contentParts, aggregateContent } = createContentAggregator(); const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId }); /** 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 → 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 ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ userId: req.user.id, role: req.user.role, resourceType: ResourceType.SKILL, requiredPermissions: PermissionBits.VIEW, }) : []; const { skillStates, defaultActiveOnShare } = await loadSkillStates({ userId: req.user.id, appConfig, getUserById: db.getUserById, accessibleSkillIds, }); /** * Agent context store - populated after initialization, accessed by callback via closure. * Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey } * @type {Map>, * agent?: object, * tool_resources?: object, * toolRegistry?: import('@librechat/agents').LCToolRegistry, * 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, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); 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 enrichWithSkillConfigurable( result, req, ctx.accessibleSkillIds, ctx.codeEnvAvailable === true, ctx.skillPrimedIdsByName, ctx.activeSkillNames, ); }, toolEndCallback, ...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(); const eventHandlers = getDefaultHandlers({ res, toolExecuteOptions, summarizationOptions, aggregateContent, toolEndCallback, collectedUsage, collectedThoughtSignatures, streamId, subagentAggregatorsByToolCallId, }); if (!endpointOption.agent) { throw new Error('No agent promise provided'); } const primaryAgent = await endpointOption.agent; delete endpointOption.agent; if (!primaryAgent) { throw new Error('Agent not found'); } const modelsConfig = await getModelsConfig(req); const validationResult = await validateAgentModel({ req, res, modelsConfig, logViolation, agent: primaryAgent, }); if (!validationResult.isValid) { throw new Error(validationResult.error?.message); } 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); /** @type {Array} */ 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 primaryScopedSkillIds = resolveAgentScopedSkillIds({ agent: primaryAgent, accessibleSkillIds, skillsCapabilityEnabled, ephemeralSkillsToggle, }); const primaryConfig = await initializeAgent( { req, res, loadTools, requestFiles, conversationId, parentMessageId, agent: primaryAgent, endpointOption, allowedProviders, isInitialAgent: true, accessibleSkillIds: primaryScopedSkillIds, codeEnvAvailable, 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: db.listSkillsByAccess, listAlwaysApplySkills: db.listAlwaysApplySkills, getSkillByName: db.getSkillByName, }, ); logger.debug( `[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`, ); /** Maps each primed skill name (manual `$` or always-apply) to the * `_id` of the exact doc that was primed. Plumbed to * `enrichWithSkillConfigurable` so the read_file handler can pin * same-name collision lookups to the resolver's chosen doc AND relax * the disable-model-invocation gate for skills whose body is already * in this turn's context. */ const skillPrimedIdsByName = buildSkillPrimedIdsByName( primaryConfig.manualSkillPrimes, primaryConfig.alwaysApplySkillPrimes, ); agentToolContexts.set(primaryConfig.id, { agent: primaryAgent, toolRegistry: primaryConfig.toolRegistry, userMCPAuthMap: primaryConfig.userMCPAuthMap, tool_resources: primaryConfig.tool_resources, actionsEnabled: primaryConfig.actionsEnabled, accessibleSkillIds: primaryConfig.accessibleSkillIds, activeSkillNames: primaryConfig.activeSkillNames, codeEnvAvailable: primaryConfig.codeEnvAvailable, skillPrimedIdsByName, }); 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, }), skillStates, defaultActiveOnShare, codeEnvAvailable, }, { 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: db.listSkillsByAccess, listAlwaysApplySkills: db.listAlwaysApplySkills, getSkillByName: db.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. // // Handoff agents get the same `skillPrimedIdsByName` plumbing as the // primary so `read_file` can pin same-name collisions to the exact // primed doc AND relax the `disable-model-invocation: true` gate for // skills whose body is already in this turn's context — matters for // handoff agents that have their own always-apply skills bound or // that the user `$`-invokes within the handoff flow. onAgentInitialized: (agentId, agent, config) => { agentToolContexts.set(agentId, { agent, toolRegistry: config.toolRegistry, userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, accessibleSkillIds: config.accessibleSkillIds, activeSkillNames: config.activeSkillNames, codeEnvAvailable: config.codeEnvAvailable, skillPrimedIdsByName: buildSkillPrimedIdsByName( config.manualSkillPrimes, config.alwaysApplySkillPrimes, ), }); }, // 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, codeEnvAvailable, }); if (updatedMCPAuthMap) { userMCPAuthMap = updatedMCPAuthMap; } for (const [agentId, config] of agentConfigs) { if (agentToolContexts.has(agentId)) { continue; } agentToolContexts.set(agentId, { agent: config, toolRegistry: config.toolRegistry, userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, accessibleSkillIds: config.accessibleSkillIds, activeSkillNames: config.activeSkillNames, codeEnvAvailable: config.codeEnvAvailable, }); } // `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 config = await initializeAgent( { req, res, agent, loadTools, requestFiles, conversationId, parentMessageId, endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, allowedProviders, accessibleSkillIds: resolveAgentScopedSkillIds({ agent, accessibleSkillIds, 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, 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: db.listSkillsByAccess, listAlwaysApplySkills: db.listAlwaysApplySkills, getSkillByName: db.getSkillByName, }, ); agentConfigs.set(agentId, config); agentToolContexts.set(agentId, { agent, toolRegistry: config.toolRegistry, userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, accessibleSkillIds: config.accessibleSkillIds, activeSkillNames: config.activeSkillNames, codeEnvAvailable: config.codeEnvAvailable, skillPrimedIdsByName: buildSkillPrimedIdsByName( config.manualSkillPrimes, config.alwaysApplySkillPrimes, ), }); 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} */ 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; } } 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; const client = new AgentClient({ req, res, sender, contentParts, agentConfigs, eventHandlers, collectedUsage, collectedThoughtSignatures, aggregateContent, artifactPromises, primeInvokedSkills: handlePrimeInvokedSkills, agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, attachments: primaryConfig.attachments, endpointType: endpointOption.endpointType, resendFiles: primaryConfig.resendFiles ?? true, maxContextTokens: primaryConfig.maxContextTokens, endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, subagentAggregatorsByToolCallId, }); if (streamId) { GenerationJobManager.setCollectedUsage(streamId, collectedUsage); } return { client, userMCPAuthMap }; }; module.exports = { initializeClient };