LibreChat/api/server/services/Endpoints/agents/initialize.js
2026-05-11 09:25:08 -04:00

869 lines
31 KiB
JavaScript

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<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,
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<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 = [];
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<string, {
* userMCPAuthMap?: Record<string, Record<string, string>>,
* 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<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 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<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;
}
}
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 };