mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-06 22:49:29 +00:00
* 🖥️ feat: Stream PTC Inner Tool Calls as a CLI-Style Trace Programmatic tool calling runs a whole program inside the sandbox, and the tool calls that program makes open no run step of their own. The card showed one running spinner for the entire execution, with no sign of what the code was doing. Emit a new `on_ptc_tool_call` step event for each inner invocation — once on dispatch, once on settle — and render them under the code as a terminal-style trace: status glyph, tool identity, argument preview, duration, with a failure message printed under the call that produced it. The seam is the tool map the sandbox bridge resolves inner calls against. `instrumentPtcToolMap` proxies `invoke` on each entry and leaves every other property (name, schema, mcp) passing straight through, so nothing about execution changes and emission failures can never fail a tool call. Client state is a per-tool-call Recoil atom keyed like the sandbox-starting and subagent atoms — live for the session, cleared on conversation switch so a finished program's trace stays readable. * 🩹 fix: Address Codex Review on the PTC Tool Trace Five findings, all confirmed against the source before fixing. Scope the trace atoms to a message occurrence. The hook already documents that providers repeat a tool_call_id across turns and even within one message, and `call_id` restarts at :0 for every outer call — so two programs sharing `call_0` merged into one card. Key by (response message id, tool call id) via `ptcTraceKey`, mirroring `subagentProgressKey`; the event's `runId` already carries the message id and the card reads its own from MessageContext. Prune unsettled rows on resume. Inner calls are not content parts, so the resume snapshot cannot rebuild them, and `trackReplayEvent` only persists OAuth events — a call that settled during a disconnect left a spinner that never resolved. Settled rows are real history and stay. Make the argument preview budget-aware. Iterate keys rather than entries so the budget check can actually skip work, and clip against a bounded window so a multi-megabyte value is never collapsed in full to build a 40-character preview. Catch the resumable emission promise. The synchronous try/catch around the emitter cannot observe a rejected `emitChunk`, so a failing transport raised an unhandled rejection per event instead of dropping telemetry. Announce completion to assistive technology. The check glyph is decorative and a fast call renders no duration, so a settled row previously announced no outcome; each row now carries an sr-only status and the visible cell that duplicated it is hidden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP * 🧹 fix: Repair CI Failures on the PTC Tool Trace Two failures on the previous head, both mine. `Tests: api (shard 2/3)` — 46 failures in `initialize.spec.js`, all `TypeError: createPtcProgressEmitter is not a function`. The suite mocks the callbacks module with an object literal, and wiring the new emitter into `initialize.js` without adding it there left the factory undefined at call time. Added it alongside `createAttachmentEmitter`, plus an assertion that it receives the same generation fence as every other resumable emitter — a stale epoch would leak one run's inner calls into the next. `Static checks` — import-order drift in `PtcToolTrace.tsx` and `handlers.ts`, repaired with `scripts/sort-imports.mts`. ESLint and Prettier both passed, so only the dedicated check caught it. `openai.js` and `responses.js` never take the emitter, so their specs were unaffected; verified the initialize mock now covers every name the module destructures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP * 🔐 fix: Address Second Codex Review on the PTC Tool Trace Three of five findings actioned; two answered on the thread. Respect tool-argument PII filtering (P1). Inner calls never reach `filteredToolArgumentsResult` — the sandbox bridge invokes them directly — so the trace was the one path putting their values on the wire in a deployment that had configured `filters.toolArguments.pii`. When any of the name / arguments / output fields are filtered, the emitter now omits both the argument preview and the failure message, which routinely quotes the argument that caused it. Name, status and duration still report. Drop the light/dark-specific background (P1). `dark:bg-transparent` stepped outside the semantic roles and would lose the intended separation under a custom theme. The pane now sets no background at all and inherits the card's surface, which resolves to the same color the override produced in both default themes and stays correct when a theme reassigns its roles. Bound the live trace (P2). A program looping over a large collection made every event copy an ever-growing array and rendered a row per call. The trace now keeps a rolling tail of 100 rows and counts what it evicted, surfaced as "+N earlier calls" so the cap is never silent. A settle whose row is gone — evicted, or pruned across a resume gap — no longer reappears out of order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP * ✅ test: Keep PTC Trace Tests Aligned With Caller-Capability Filtering Left out of the merge commit by a staging slip; without them `handlers.spec.ts` fails on the merged tree. `#15105` restricts the PTC tool map to tools whose `allowed_callers` admit code execution, so the existing trace test's registry entry — which declared none, defaulting to `direct` — was filtered out before the instrumentation could see it. Declare the fixture `code_execution`. Add a guard for the resolution itself: a `direct`-only tool must never appear in the instrumented map. Tracing wraps the eligible map, and this fails if a later change reorders that and lets the trace widen what the sandbox reaches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP * 🛡️ fix: Close Name Disclosure and Follow the PTC Trace Tail Two findings from the third Codex pass on `17a9ec9`. Redact filtered inner-tool names (P1). The previous gate suppressed argument and failure previews but the event still carried `name` verbatim, so a deployment whose `filters.toolArguments.pii.fields` includes `name` could see a blocked identifier disclosed through the trace — the one path inner calls take, since they never reach `filteredToolArgumentsResult`. Inner tool names are now inspected once per PTC call with the same `extractToolArgumentContent` + `inspectContent` pair the executor uses; any that trip the policy are left unwrapped, so they still execute and emit nothing. An un-inspectable name fails closed. Follow the trace tail (P2). The row list is a 200px scroller that never moved, so once a program exceeded the viewport the card sat on the oldest calls while live activity accumulated below the fold. Reuse `useFollowScroll` — the hook the code and command panes already use — which pins to the tail while calls are running and yields the moment the reader scrolls up. The host card threads its disclosure state so a collapsed pane is never scrolled invisibly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP * 📌 fix: Pin the PTC Trace Through Its Final Settle The fourth Codex pass on `4bf68e1`, one P2 finding. `useFollowScroll` returned early whenever `active` was false, so the one change it most needed to follow was the one it skipped. A failing inner call settles by appending its error line in the same commit that clears the last running row: the content grows and the stream ends together, and the pin that would have revealed that line never fired. On an expanded, bottom-pinned pane the failure — the row a reader most wants — stayed below the fold. The falling edge of `active` now pins too, but only when the content changed with it. Ending a stream on its own still leaves the pane where the reader left it, which is what the existing contract promises and what the sibling code and command panes rely on; a reader who has scrolled up is untouched either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP * 🔌 fix: Keep PTC Calls That Outlive a Reconnect Fifth Codex pass on `085a83f`; one of its two findings. Pruning rows across a resume gap deleted every `running` row, but a stream gap is not proof the call ended. A call still executing across the reconnect settles normally on the restored live stream — and `applyPtcToolCall` drops a settle whose row is gone, by design, so an evicted row cannot reappear out of order. The call therefore vanished from the trace despite having run, which is worse than the spinner the pruning existed to prevent. Rows are now marked `interrupted` instead of removed. A call whose settle was genuinely lost in the gap reports that honestly rather than spinning forever, and one that survives the gap settles onto the row it opened, reporting its real outcome and duration. `interrupted` is a client-side conclusion, so it widens the row status locally and leaves the wire contract alone. Two cases added: the gap marks rather than drops, and a post-reconnect settle lands on its marked row; plus a render case for the new outcome. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MBRQUsPXSvDBnjrSDoXWHP --------- Co-authored-by: Claude <noreply@anthropic.com>
1444 lines
53 KiB
JavaScript
1444 lines
53 KiB
JavaScript
const { logger } = require('@librechat/data-schemas');
|
|
const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents');
|
|
const {
|
|
resolveSender,
|
|
createConcurrencyLimiter,
|
|
loadSkillStates,
|
|
initializeAgent,
|
|
primeInvokedSkillsForProfiles,
|
|
validateAgentModel,
|
|
extractManualSkills,
|
|
GenerationJobManager,
|
|
getCustomEndpointConfig,
|
|
getProviderConfig,
|
|
discoverConnectedAgents,
|
|
resolveAgentTokenConfig,
|
|
resolveAgentScopedSkillIds,
|
|
resolveModelSpecSkillIds,
|
|
getAgentStartupTelemetry,
|
|
isContentFilterError,
|
|
buildAgentContextAttachmentsByAgentId,
|
|
collectCodeExecutionProfileRoutes,
|
|
getLazySubagentConfigId,
|
|
createStatefulCodeEnvironmentPolicyError,
|
|
buildSubagentThreadTaskConfig,
|
|
} = require('@librechat/api');
|
|
const {
|
|
ResourceType,
|
|
EModelEndpoint,
|
|
PermissionBits,
|
|
MAX_SUBAGENT_DEPTH,
|
|
isAgentsEndpoint,
|
|
AgentCapabilities,
|
|
Tools,
|
|
MAX_SUBAGENT_GRAPH_NODES,
|
|
MAX_SUBAGENT_RUN_CONFIGS,
|
|
isEphemeralAgentId,
|
|
resolveAllowedStatefulCodeEnvironments,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
createToolEndCallback,
|
|
createAttachmentEmitter,
|
|
createPtcProgressEmitter,
|
|
createBackgroundCodeResultHandler,
|
|
getDefaultHandlers,
|
|
} = require('~/server/controllers/agents/callbacks');
|
|
const {
|
|
loadAgentTools,
|
|
loadToolsForExecution,
|
|
getAccessibleMcpServerNames,
|
|
isFatalAgentInitializationError,
|
|
} = require('~/server/services/ToolService');
|
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
|
const {
|
|
getSkillToolDeps,
|
|
getSkillDbMethods,
|
|
canAuthorSkillFiles,
|
|
withDeploymentSkillIds,
|
|
buildAgentToolContext,
|
|
resolveMemoryAvailability,
|
|
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 subagentThreadTaskStore = require('./subagentThreadStore');
|
|
const { logViolation } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
const SUBAGENT_GRAPH_LOAD_CONCURRENCY = 4;
|
|
|
|
/**
|
|
* 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,
|
|
requestBody,
|
|
codeExecutionContext,
|
|
accessibleMcpServerNames,
|
|
}) {
|
|
const agent = { id: agentId, tools, provider, model, tool_options };
|
|
try {
|
|
return await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent,
|
|
signal,
|
|
streamId,
|
|
jobCreatedAt,
|
|
requestBody,
|
|
tool_resources,
|
|
codeExecutionContext,
|
|
definitionsOnly,
|
|
accessibleMcpServerNames,
|
|
});
|
|
} catch (error) {
|
|
if (isFatalAgentInitializationError(error) || isContentFilterError(error)) {
|
|
throw 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]
|
|
* @param {string} [params.checkpointNamespace] Immutable saver-level generation scope
|
|
* @param {import('@librechat/api').MCPRuntimeRequestBody} [params.requestBody]
|
|
*/
|
|
const initializeClient = async ({
|
|
req,
|
|
res,
|
|
signal,
|
|
endpointOption,
|
|
jobCreatedAt,
|
|
checkpointNamespace,
|
|
requestBody,
|
|
}) => {
|
|
if (!endpointOption) {
|
|
throw new Error('Endpoint option not provided');
|
|
}
|
|
const appConfig = req.config;
|
|
/** The normal controller resolves this once for timestamp anchoring. Reuse
|
|
* that trusted document for child-thread execution policy; resume and direct
|
|
* callers fall back to the same owner-scoped lookup. */
|
|
const conversationId = req.body?.conversationId;
|
|
const runtimeRequestBody = requestBody ?? req.body;
|
|
let requestConversationPromise = Promise.resolve(null);
|
|
if (Object.prototype.hasOwnProperty.call(req, 'resolvedConversation')) {
|
|
requestConversationPromise = Promise.resolve(req.resolvedConversation);
|
|
} else if (typeof conversationId === 'string' && conversationId !== '') {
|
|
requestConversationPromise = db.getConvo(req.user.id, conversationId);
|
|
}
|
|
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 artifactToolEndCallback = 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 allowedStatefulCodeEnvironments = resolveAllowedStatefulCodeEnvironments(
|
|
appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.allowedEnvironments,
|
|
);
|
|
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 = resolveMemoryAvailability({
|
|
enabledCapabilities,
|
|
memoryConfig: appConfig?.memory,
|
|
user: req.user,
|
|
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();
|
|
/** Attach only the host-resolved route for the actually executing agent.
|
|
* Runnable metadata is transport data and may contain caller-controlled
|
|
* keys, so discard any incoming route context before resolving from the
|
|
* server-owned per-agent map. This covers both traditional TOOL_END events
|
|
* and event-driven ON_TOOL_EXECUTE callbacks. */
|
|
const toolEndCallback = async (data, metadata = {}) => {
|
|
const node = typeof metadata.langgraph_node === 'string' ? metadata.langgraph_node : '';
|
|
const nodeAgentId = node.startsWith(GraphNodeKeys.TOOLS)
|
|
? node.slice(GraphNodeKeys.TOOLS.length)
|
|
: undefined;
|
|
const executingAgentId =
|
|
metadata.executingAgentId ?? metadata.agentId ?? metadata.agent_id ?? nodeAgentId;
|
|
const soleContext =
|
|
agentToolContexts.size === 1 ? agentToolContexts.values().next().value : null;
|
|
const trustedContext =
|
|
(typeof executingAgentId === 'string' ? agentToolContexts.get(executingAgentId) : null) ??
|
|
soleContext;
|
|
const callbackMetadata = { ...metadata };
|
|
delete callbackMetadata.codeExecutionContext;
|
|
if (trustedContext?.codeExecutionContext) {
|
|
callbackMetadata.codeExecutionContext = trustedContext.codeExecutionContext;
|
|
}
|
|
return artifactToolEndCallback(data, callbackMetadata);
|
|
};
|
|
/** @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
|
|
const endpointTokenConfigByAgentId = new Map();
|
|
|
|
const toolExecuteOptions = {
|
|
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
|
|
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,
|
|
conversationId,
|
|
requestBody: runtimeRequestBody,
|
|
toolNames,
|
|
agent: ctx.agent,
|
|
toolRegistry: ctx.toolRegistry,
|
|
callerCapabilityProjection,
|
|
backgroundToolNames: ctx.backgroundToolNames,
|
|
intentToolNames: ctx.intentToolNames,
|
|
mcpAvailableTools: ctx.mcpAvailableTools,
|
|
requestScopedConnections: ctx.requestScopedConnections,
|
|
userMCPAuthMap: ctx.userMCPAuthMap,
|
|
tool_resources: ctx.tool_resources,
|
|
actionsEnabled: ctx.actionsEnabled,
|
|
accessibleMcpServerNames: ctx.accessibleMcpServerNames,
|
|
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 }),
|
|
emitPtcProgress: createPtcProgressEmitter({ 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 [
|
|
memoryAvailable,
|
|
accessibleSkillIds,
|
|
editableSkillIds,
|
|
skillCreateAllowed,
|
|
{ skillStates, defaultActiveOnShare },
|
|
{ primaryAgent, modelsConfig },
|
|
requestConversation,
|
|
] = await Promise.all([
|
|
memoryAvailablePromise,
|
|
accessibleSkillIdsPromise,
|
|
editableSkillIdsPromise,
|
|
skillCreateAllowedPromise,
|
|
skillStatesPromise,
|
|
validatedPrimaryAgentPromise,
|
|
requestConversationPromise,
|
|
]);
|
|
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 | 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,
|
|
requestBody: runtimeRequestBody,
|
|
agent: primaryAgent,
|
|
endpointOption,
|
|
allowedProviders,
|
|
isInitialAgent: true,
|
|
accessibleSkillIds: primaryScopedSkillIds,
|
|
skillAuthoringAvailable: primarySkillAuthoringAvailable,
|
|
codeEnvAvailable,
|
|
backgroundToolsAvailable,
|
|
toolIntentsAvailable,
|
|
statefulSessionsAvailable,
|
|
allowedStatefulCodeEnvironments,
|
|
memoryAvailable,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
manualSkills,
|
|
},
|
|
{
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getConvoFiles: db.getConvoFiles,
|
|
getAccessibleMcpServerNames,
|
|
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,
|
|
requestBody: runtimeRequestBody,
|
|
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,
|
|
allowedStatefulCodeEnvironments,
|
|
memoryAvailable,
|
|
},
|
|
{
|
|
getAgent: db.getAgent,
|
|
checkPermission,
|
|
logViolation,
|
|
db: {
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getConvoFiles: db.getConvoFiles,
|
|
getAccessibleMcpServerNames,
|
|
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,
|
|
requestBody: runtimeRequestBody,
|
|
allowedProviders,
|
|
primaryAgentId: primaryConfig.id,
|
|
accessibleSkillIds,
|
|
editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
skillCreateAllowed,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
codeEnvAvailable,
|
|
backgroundToolsAvailable,
|
|
toolIntentsAvailable,
|
|
statefulSessionsAvailable,
|
|
memoryAvailable,
|
|
});
|
|
|
|
if (updatedMCPAuthMap) {
|
|
userMCPAuthMap = updatedMCPAuthMap;
|
|
}
|
|
userMCPAuthMap ??= {};
|
|
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 run in isolated context windows and are invoked via a dedicated
|
|
// spawn tool, not handoff edges. Explicit children are advertised as inert,
|
|
// VIEW-checked descriptors; model, tool, MCP, file, and skill initialization
|
|
// happens only when the SDK selects one.
|
|
const atSubagentThreadDepthLimit = !subagentThreadTaskStore.canCreateChildThread(
|
|
requestConversation?.subagentThread?.depth ?? 0,
|
|
);
|
|
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
|
|
const subagentsAvailableForRun = subagentsCapabilityEnabled && !atSubagentThreadDepthLimit;
|
|
/** 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 ?? []);
|
|
|
|
const lazyMetadataByAgentId = new Map();
|
|
const subagentGraphIds = new Set();
|
|
const expandedSubagentDescriptorState = { configCount: 0, rootAgentIds: [] };
|
|
|
|
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.`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const countExpandedSubagentDescriptor = (agentId) => {
|
|
expandedSubagentDescriptorState.configCount += 1;
|
|
if (expandedSubagentDescriptorState.configCount <= MAX_SUBAGENT_RUN_CONFIGS) {
|
|
return;
|
|
}
|
|
logger.warn('[initializeClient] Subagent run configuration limit exceeded', {
|
|
agentId,
|
|
expandedConfigCount: expandedSubagentDescriptorState.configCount,
|
|
maxSubagentRunConfigs: MAX_SUBAGENT_RUN_CONFIGS,
|
|
rootAgentIds: expandedSubagentDescriptorState.rootAgentIds,
|
|
});
|
|
throw new Error(
|
|
`Subagent run configuration exceeds the maximum of ${MAX_SUBAGENT_RUN_CONFIGS} expanded entries.`,
|
|
);
|
|
};
|
|
|
|
const userId = req.user?.id;
|
|
const userRole = req.user?.role;
|
|
|
|
const throwIfAborted = (abortSignal) => {
|
|
if (!abortSignal?.aborted) return;
|
|
throw abortSignal.reason instanceof Error
|
|
? abortSignal.reason
|
|
: new Error('Subagent resolution was aborted.');
|
|
};
|
|
|
|
const waitForAbort = (promise, abortSignal) => {
|
|
throwIfAborted(abortSignal);
|
|
if (!abortSignal) return promise;
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = () => {
|
|
reject(
|
|
abortSignal.reason instanceof Error
|
|
? abortSignal.reason
|
|
: new Error('Subagent resolution was aborted.'),
|
|
);
|
|
};
|
|
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
if (abortSignal.aborted) {
|
|
onAbort();
|
|
}
|
|
promise.then(resolve, reject).finally(() => {
|
|
abortSignal.removeEventListener('abort', onAbort);
|
|
});
|
|
});
|
|
};
|
|
|
|
const hasSubagentViewAccess = async (agent, agentId, abortSignal) => {
|
|
throwIfAborted(abortSignal);
|
|
if (!userId) return false;
|
|
const hasAccess = await waitForAbort(
|
|
checkPermission({
|
|
userId,
|
|
role: userRole,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermission: PermissionBits.VIEW,
|
|
}),
|
|
abortSignal,
|
|
);
|
|
throwIfAborted(abortSignal);
|
|
if (!hasAccess) {
|
|
logger.warn(
|
|
`[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`,
|
|
);
|
|
}
|
|
return hasAccess;
|
|
};
|
|
|
|
const getIncludeReasoningHistory = (agent) => {
|
|
if (!agent.provider) return undefined;
|
|
try {
|
|
return getProviderConfig({ provider: agent.provider, appConfig }).customEndpointConfig
|
|
?.customParams?.includeReasoningHistory;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const getExplicitSubagentIds = (agent) =>
|
|
Array.from(
|
|
new Set(
|
|
Array.isArray(agent.subagents?.agent_ids)
|
|
? agent.subagents.agent_ids.filter(
|
|
(id) => typeof id === 'string' && id && id !== agent.id,
|
|
)
|
|
: [],
|
|
),
|
|
);
|
|
|
|
const toLazySubagentMetadata = (agent) => {
|
|
const statefulCodeSessions =
|
|
statefulSessionsAvailable === true &&
|
|
codeEnvAvailable === true &&
|
|
agent.stateful_code_sessions === true &&
|
|
agent.tools?.includes(Tools.execute_code) === true;
|
|
const statefulCodeEnvironment = agent.stateful_code_environment ?? 'user';
|
|
if (
|
|
statefulCodeSessions &&
|
|
!allowedStatefulCodeEnvironments.includes(statefulCodeEnvironment)
|
|
) {
|
|
throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment);
|
|
}
|
|
return {
|
|
id: agent.id,
|
|
name: agent.name,
|
|
description: agent.description,
|
|
provider: agent.provider,
|
|
model: agent.model,
|
|
model_parameters: { model: agent.model_parameters?.model },
|
|
recursion_limit: agent.recursion_limit,
|
|
subagents: agent.subagents,
|
|
configId: getLazySubagentConfigId(agent),
|
|
codeEnvAvailable:
|
|
codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true,
|
|
statefulCodeSessions,
|
|
statefulCodeEnvironment,
|
|
includeReasoningHistory: getIncludeReasoningHistory(agent),
|
|
};
|
|
};
|
|
|
|
const loadSubagentMetadata = async (agentId) => {
|
|
if (skippedAgentIds.has(agentId)) return null;
|
|
const cached = lazyMetadataByAgentId.get(agentId);
|
|
if (cached) return cached;
|
|
try {
|
|
const agent = await db.getAgentWithVersionCount({ id: agentId });
|
|
if (!agent || !(await hasSubagentViewAccess(agent, agentId))) {
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
const metadata = toLazySubagentMetadata(agent);
|
|
lazyMetadataByAgentId.set(agentId, metadata);
|
|
return metadata;
|
|
} catch (error) {
|
|
if (isFatalAgentInitializationError(error)) {
|
|
throw error;
|
|
}
|
|
logger.error(`[initializeClient] Error loading subagent metadata ${agentId}:`, error);
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const loadGraphMemberCapabilityMetadata = async (agent) => {
|
|
if (!agent.subagents?.enabled) return [];
|
|
const memberIds = Array.from(
|
|
new Set((agent.subagents.graphs ?? []).flatMap((graph) => graph.agent_ids ?? [])),
|
|
).filter(
|
|
(memberId) =>
|
|
memberId !== agent.id && memberId !== primaryConfig.id && !agentConfigs.has(memberId),
|
|
);
|
|
const stagedMemberIds = memberIds.filter((memberId) => !subagentGraphIds.has(memberId));
|
|
if (subagentGraphIds.size + stagedMemberIds.length > MAX_SUBAGENT_GRAPH_NODES) {
|
|
logger.warn('[initializeClient] Subagent graph node limit exceeded', {
|
|
agentId: stagedMemberIds[0],
|
|
primaryAgentId: primaryConfig.id,
|
|
loadedSubagentCount: subagentGraphIds.size,
|
|
stagedSubagentCount: stagedMemberIds.length,
|
|
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
|
|
});
|
|
throw new Error(
|
|
`Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`,
|
|
);
|
|
}
|
|
for (const memberId of stagedMemberIds) {
|
|
subagentGraphIds.add(memberId);
|
|
}
|
|
const memberMetadata = await Promise.all(memberIds.map(loadSubagentMetadata));
|
|
return memberMetadata.filter(Boolean);
|
|
};
|
|
|
|
/**
|
|
* Resolves the selected descriptor inside the foreground request. The
|
|
* legacy initializer requires request/response objects for tool and MCP
|
|
* setup, so this intentionally remains request-scoped until AI-1597 gives
|
|
* child execution a durable runtime context.
|
|
*/
|
|
const initializeLoadedSubagent = async ({
|
|
agent,
|
|
agentId,
|
|
configId,
|
|
context,
|
|
lazyChildren,
|
|
viewAccessChecked = false,
|
|
}) => {
|
|
throwIfAborted(context.signal);
|
|
if (!agent || getLazySubagentConfigId(agent) !== configId) {
|
|
throw new Error(`Subagent ${agentId} changed before it could be initialized.`);
|
|
}
|
|
if (!viewAccessChecked && !(await hasSubagentViewAccess(agent, agentId, context.signal))) {
|
|
throw new Error(`You no longer have access to subagent ${agentId}.`);
|
|
}
|
|
const validation = await waitForAbort(
|
|
validateAgentModel({ req, res, agent, modelsConfig, logViolation }),
|
|
context.signal,
|
|
);
|
|
throwIfAborted(context.signal);
|
|
if (!validation.isValid) {
|
|
throw new Error(validation.error?.message ?? `Subagent ${agentId} failed model validation.`);
|
|
}
|
|
const scopedSkillIds = resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds: editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const config = await waitForAbort(
|
|
initializeAgent(
|
|
{
|
|
req,
|
|
res,
|
|
agent,
|
|
loadTools: createToolLoader(context.signal, streamId, true, jobCreatedAt),
|
|
requestFiles,
|
|
conversationId,
|
|
parentMessageId,
|
|
requestBody: runtimeRequestBody,
|
|
endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents },
|
|
allowedProviders,
|
|
accessibleSkillIds: scopedSkillIds,
|
|
skillAuthoringAvailable: canAuthorSkillFiles({
|
|
agent,
|
|
scopedEditableSkillIds,
|
|
skillCreateAllowed,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
codeEnvAvailable,
|
|
backgroundToolsAvailable,
|
|
toolIntentsAvailable,
|
|
statefulSessionsAvailable,
|
|
allowedStatefulCodeEnvironments,
|
|
memoryAvailable,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
},
|
|
{
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getConvoFiles: db.getConvoFiles,
|
|
getAccessibleMcpServerNames,
|
|
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,
|
|
},
|
|
),
|
|
context.signal,
|
|
);
|
|
throwIfAborted(context.signal);
|
|
config.lazySubagentConfigs = lazyChildren;
|
|
if (config.userMCPAuthMap) {
|
|
Object.assign(userMCPAuthMap, config.userMCPAuthMap);
|
|
}
|
|
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
|
|
endpointTokenConfigByAgentId.set(agentId, config.endpointTokenConfig);
|
|
return config;
|
|
};
|
|
const initializeLazySubagent = async ({ agentId, configId, context, lazyChildren }) => {
|
|
throwIfAborted(context.signal);
|
|
const agent = await waitForAbort(db.getAgentWithVersionCount({ id: agentId }), context.signal);
|
|
return initializeLoadedSubagent({ agent, agentId, configId, context, lazyChildren });
|
|
};
|
|
|
|
const buildLazySubagentDescriptors = async (agent, depth = 0, ancestors = new Set()) => {
|
|
if (!subagentsAvailableForRun || !agent.subagents?.enabled) {
|
|
return [];
|
|
}
|
|
if (agent.subagents.allowSelf !== false) {
|
|
countExpandedSubagentDescriptor(agent.id);
|
|
}
|
|
const subagentIds = getExplicitSubagentIds(agent);
|
|
if (subagentIds.length > 0 && depth >= MAX_SUBAGENT_DEPTH) {
|
|
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
|
|
agentId: agent.id,
|
|
primaryAgentId: primaryConfig.id,
|
|
depth,
|
|
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
|
|
});
|
|
throw new Error(
|
|
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${agent.id}.`,
|
|
);
|
|
}
|
|
const nextAncestors = new Set(ancestors);
|
|
nextAncestors.add(agent.id);
|
|
const descriptors = [];
|
|
for (const subagentId of subagentIds) {
|
|
if (skippedAgentIds.has(subagentId) || nextAncestors.has(subagentId)) continue;
|
|
if (subagentId !== primaryConfig.id) {
|
|
assertSubagentGraphRoom(subagentId);
|
|
}
|
|
const existing =
|
|
subagentId === primaryConfig.id ? primaryConfig : agentConfigs.get(subagentId);
|
|
if (existing) {
|
|
countExpandedSubagentDescriptor(subagentId);
|
|
if (subagentId !== primaryConfig.id) {
|
|
subagentGraphIds.add(subagentId);
|
|
}
|
|
const existingChildren = await buildLazySubagentDescriptors(
|
|
existing,
|
|
depth + 1,
|
|
nextAncestors,
|
|
);
|
|
existing.lazySubagentConfigs = existingChildren.filter((child) => child.configId);
|
|
existing.subagentAgentConfigs = existingChildren.filter((child) => !child.configId);
|
|
descriptors.push(existing);
|
|
continue;
|
|
}
|
|
const metadata = await loadSubagentMetadata(subagentId);
|
|
if (!metadata) continue;
|
|
countExpandedSubagentDescriptor(subagentId);
|
|
subagentGraphIds.add(subagentId);
|
|
const childDescriptors = await buildLazySubagentDescriptors(
|
|
metadata,
|
|
depth + 1,
|
|
nextAncestors,
|
|
);
|
|
const lazyChildren = childDescriptors.filter((child) => child.configId);
|
|
const eagerChildren = childDescriptors.filter((child) => !child.configId);
|
|
const subagentGraphMemberMetadata = await loadGraphMemberCapabilityMetadata(metadata);
|
|
descriptors.push({
|
|
id: metadata.id,
|
|
name: metadata.name,
|
|
description: metadata.description,
|
|
provider: metadata.provider,
|
|
model: metadata.model,
|
|
model_parameters: metadata.model_parameters,
|
|
recursion_limit: metadata.recursion_limit,
|
|
subagents: metadata.subagents,
|
|
configId: metadata.configId,
|
|
codeEnvAvailable: metadata.codeEnvAvailable,
|
|
statefulCodeSessions: metadata.statefulCodeSessions,
|
|
statefulCodeEnvironment: metadata.statefulCodeEnvironment,
|
|
includeReasoningHistory: metadata.includeReasoningHistory,
|
|
lazySubagentConfigs: lazyChildren,
|
|
subagentAgentConfigs: eagerChildren,
|
|
subagentGraphMemberMetadata,
|
|
resolve: async (context) =>
|
|
initializeLazySubagent({
|
|
agentId: metadata.id,
|
|
configId: metadata.configId,
|
|
context,
|
|
lazyChildren,
|
|
}).then(async (config) => {
|
|
config.subagentAgentConfigs = eagerChildren;
|
|
graphMemberConfigsById.set(config.id, config);
|
|
await resolveGraphSubagentsFor(config, context.signal);
|
|
return config;
|
|
}),
|
|
});
|
|
}
|
|
return descriptors;
|
|
};
|
|
|
|
const resolveSubagentTrees = async (rootConfigs) => {
|
|
expandedSubagentDescriptorState.rootAgentIds = rootConfigs
|
|
.filter((config) => config?.id)
|
|
.map((config) => config.id);
|
|
for (const config of rootConfigs) {
|
|
if (!config?.id) continue;
|
|
const descriptors = await buildLazySubagentDescriptors(config);
|
|
config.lazySubagentConfigs = descriptors.filter((child) => child.configId);
|
|
config.subagentAgentConfigs = descriptors.filter((child) => !child.configId);
|
|
}
|
|
};
|
|
|
|
const rootSubagentConfigs = [primaryConfig, ...agentConfigs.values()];
|
|
await resolveSubagentTrees(rootSubagentConfigs);
|
|
|
|
const graphMemberConfigsById = new Map(
|
|
rootSubagentConfigs.filter((config) => config?.id).map((config) => [config.id, config]),
|
|
);
|
|
const graphMemberLoadsById = new Map();
|
|
const initializeGraphMember = createConcurrencyLimiter(SUBAGENT_GRAPH_LOAD_CONCURRENCY);
|
|
const loadGraphMemberOnce = async (memberId) => {
|
|
throwIfAborted(signal);
|
|
const cached = graphMemberConfigsById.get(memberId);
|
|
if (cached) return cached;
|
|
if (skippedAgentIds.has(memberId)) return null;
|
|
assertSubagentGraphRoom(memberId);
|
|
subagentGraphIds.add(memberId);
|
|
const agent = await waitForAbort(db.getAgentWithVersionCount({ id: memberId }), signal);
|
|
if (!agent || !(await hasSubagentViewAccess(agent, memberId, signal))) {
|
|
skippedAgentIds.add(memberId);
|
|
return null;
|
|
}
|
|
try {
|
|
const config = await initializeLoadedSubagent({
|
|
agent,
|
|
agentId: memberId,
|
|
configId: getLazySubagentConfigId(agent),
|
|
context: { signal },
|
|
lazyChildren: [],
|
|
viewAccessChecked: true,
|
|
});
|
|
graphMemberConfigsById.set(memberId, config);
|
|
return config;
|
|
} catch (error) {
|
|
if (isFatalAgentInitializationError(error)) {
|
|
throw error;
|
|
}
|
|
logger.error(`[initializeClient] Error initializing graph member ${memberId}:`, error);
|
|
skippedAgentIds.add(memberId);
|
|
return null;
|
|
}
|
|
};
|
|
const loadGraphMember = async (memberId, graphSignal = signal) => {
|
|
throwIfAborted(graphSignal);
|
|
const cached = graphMemberConfigsById.get(memberId);
|
|
if (cached) return cached;
|
|
let pending = graphMemberLoadsById.get(memberId);
|
|
if (!pending) {
|
|
pending = loadGraphMemberOnce(memberId);
|
|
graphMemberLoadsById.set(memberId, pending);
|
|
pending.then(
|
|
() => {
|
|
if (graphMemberLoadsById.get(memberId) === pending) {
|
|
graphMemberLoadsById.delete(memberId);
|
|
}
|
|
},
|
|
() => {
|
|
if (graphMemberLoadsById.get(memberId) === pending) {
|
|
graphMemberLoadsById.delete(memberId);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
return waitForAbort(pending, graphSignal);
|
|
};
|
|
|
|
async function resolveGraphSubagentsFor(config, graphSignal = signal) {
|
|
throwIfAborted(graphSignal);
|
|
const definitions =
|
|
subagentsAvailableForRun && config.subagents?.enabled === true
|
|
? (config.subagents.graphs ?? [])
|
|
: [];
|
|
const resolvedGraphs = [];
|
|
for (const definition of definitions) {
|
|
const memberIds = [...new Set(definition.agent_ids ?? [])];
|
|
const unloadedMemberIds = memberIds.filter(
|
|
(memberId) => !graphMemberConfigsById.has(memberId) && !subagentGraphIds.has(memberId),
|
|
);
|
|
if (subagentGraphIds.size + unloadedMemberIds.length > MAX_SUBAGENT_GRAPH_NODES) {
|
|
const overflowIndex = MAX_SUBAGENT_GRAPH_NODES - subagentGraphIds.size;
|
|
logger.warn('[initializeClient] Subagent graph node limit exceeded', {
|
|
agentId: unloadedMemberIds[Math.max(overflowIndex, 0)],
|
|
primaryAgentId: primaryConfig.id,
|
|
loadedSubagentCount: subagentGraphIds.size,
|
|
stagedSubagentCount: unloadedMemberIds.length,
|
|
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
|
|
});
|
|
continue;
|
|
}
|
|
for (const memberId of unloadedMemberIds) {
|
|
subagentGraphIds.add(memberId);
|
|
}
|
|
const memberConfigs = await Promise.all(
|
|
memberIds.map((memberId) =>
|
|
initializeGraphMember(() => loadGraphMember(memberId, graphSignal)),
|
|
),
|
|
);
|
|
throwIfAborted(graphSignal);
|
|
if (memberConfigs.some((member) => member == null)) {
|
|
logger.warn('[initializeClient] Skipping incomplete graph subagent', {
|
|
parentAgentId: config.id,
|
|
graphType: definition.type,
|
|
expectedMemberCount: memberIds.length,
|
|
resolvedMemberCount: memberConfigs.filter(Boolean).length,
|
|
});
|
|
continue;
|
|
}
|
|
resolvedGraphs.push({ definition, memberConfigs });
|
|
}
|
|
config.subagentGraphConfigs = resolvedGraphs;
|
|
}
|
|
|
|
for (const config of rootSubagentConfigs) {
|
|
await resolveGraphSubagentsFor(config);
|
|
}
|
|
|
|
/** Build detached execution only for an attributable owner/thread. New
|
|
* tasks still require a spawnable child, while an existing registered live
|
|
* task keeps its poll/control seam after agent configuration changes. The
|
|
* SDK receives only this trusted host scope; models can select a child
|
|
* `threadId`, never the owner or parent-thread namespace. */
|
|
const hasSpawnableSubagent = rootSubagentConfigs.some(
|
|
(config) =>
|
|
config.subagents?.enabled === true &&
|
|
(config.subagents.allowSelf !== false ||
|
|
(config.subagentAgentConfigs?.length ?? 0) > 0 ||
|
|
(config.lazySubagentConfigs?.length ?? 0) > 0 ||
|
|
(config.subagentGraphConfigs?.length ?? 0) > 0),
|
|
);
|
|
const trustedSubagentTasks =
|
|
backgroundToolsAvailable &&
|
|
typeof req.user?.id === 'string' &&
|
|
req.user.id !== '' &&
|
|
typeof conversationId === 'string' &&
|
|
conversationId !== ''
|
|
? buildSubagentThreadTaskConfig(
|
|
subagentThreadTaskStore,
|
|
{
|
|
userId: req.user.id,
|
|
parentConversationId: conversationId,
|
|
...(typeof req.user.tenantId === 'string' && req.user.tenantId !== ''
|
|
? { tenantId: req.user.tenantId }
|
|
: {}),
|
|
},
|
|
{
|
|
completionWakeups: subagentThreadTaskStore.completionWakeupsEnabled === true,
|
|
},
|
|
)
|
|
: undefined;
|
|
let hasExistingSubagentTask = false;
|
|
if (trustedSubagentTasks != null && !(subagentsAvailableForRun && hasSpawnableSubagent)) {
|
|
try {
|
|
hasExistingSubagentTask = await subagentThreadTaskStore.hasTasks(
|
|
trustedSubagentTasks.scopeId,
|
|
);
|
|
} catch (error) {
|
|
/** Keep the poll/control tool visible when the owner directory is briefly
|
|
* unavailable. The tool then returns an honest `unavailable` status
|
|
* instead of making a live task look nonexistent. */
|
|
logger.warn('[initializeClient] Failed to inspect routed subagent tasks', error);
|
|
hasExistingSubagentTask = true;
|
|
}
|
|
}
|
|
const subagentTasks =
|
|
trustedSubagentTasks != null &&
|
|
((subagentsAvailableForRun && hasSpawnableSubagent) || hasExistingSubagentTask)
|
|
? trustedSubagentTasks
|
|
: undefined;
|
|
if (subagentTasks != null) {
|
|
toolExecuteOptions.subagentTasks = subagentTasks;
|
|
}
|
|
|
|
primaryConfig.subagents = subagentsAvailableForRun ? primaryConfig.subagents : undefined;
|
|
|
|
/** If the capability is off or this durable child is at the depth limit,
|
|
* 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. */
|
|
if (!subagentsAvailableForRun) {
|
|
primaryConfig.lazySubagentConfigs = undefined;
|
|
primaryConfig.subagentGraphConfigs = undefined;
|
|
for (const config of agentConfigs.values()) {
|
|
config.subagents = undefined;
|
|
config.subagentAgentConfigs = undefined;
|
|
config.lazySubagentConfigs = undefined;
|
|
config.subagentGraphConfigs = 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/services/Endpoints/agents/initialize.js] Error getting custom endpoint config',
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
const sender = resolveSender({
|
|
agent: primaryConfig,
|
|
specLabel: selectedModelSpec?.label,
|
|
endpointOption: {
|
|
...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. Each
|
|
* selected Code API deployment receives its own upload, and only session
|
|
* partitions routed to that deployment receive those storage pointers. */
|
|
const codeExecutionProfiles = collectCodeExecutionProfileRoutes(
|
|
[primaryConfig, ...agentConfigs.values()],
|
|
{
|
|
userId: req.user.id,
|
|
conversationId,
|
|
},
|
|
);
|
|
const handlePrimeInvokedSkills = skillsCapabilityEnabled
|
|
? (payload) =>
|
|
primeInvokedSkillsForProfiles({
|
|
req,
|
|
payload,
|
|
accessibleSkillIds,
|
|
executionProfiles: codeExecutionProfiles,
|
|
...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>} */
|
|
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 eventHandlers = getDefaultHandlers({
|
|
res,
|
|
contentParts,
|
|
stepMap,
|
|
toolInputValidationErrors,
|
|
toolExecuteOptions,
|
|
summarizationOptions,
|
|
aggregateContent,
|
|
toolEndCallback,
|
|
collectedUsage,
|
|
collectedThoughtSignatures,
|
|
streamId,
|
|
jobCreatedAt,
|
|
subagentAggregatorsByToolCallId,
|
|
usageCost,
|
|
contextUsageSink,
|
|
usageEmitSink,
|
|
});
|
|
|
|
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,
|
|
subagentTasks,
|
|
/** 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,
|
|
checkpointNamespace,
|
|
mcpRequestBody: runtimeRequestBody,
|
|
});
|
|
|
|
if (streamId) {
|
|
GenerationJobManager.setCollectedUsage(streamId, collectedUsage, jobCreatedAt);
|
|
}
|
|
|
|
return { client, userMCPAuthMap };
|
|
};
|
|
|
|
module.exports = { initializeClient };
|