mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 11:33:44 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* feat: surface interrupt-steer escalation on waiting messages The interrupt & steer feature shipped reachable only through the composer chord, the send-button hovercard, and the composer button; a message already waiting (queued for after the run, or steered and parked at the next tool boundary) had no path to it. Both waiting surfaces now carry one: - Queued rows get an icon-only ZapOff escalation button beside the existing Steer primary. It routes through sendQueuedNow, which now takes a preempt option on its live-run path. The tooltip teaches the composer chord, derived through resolveComposerKeyDown so a rebound or yielded chord is never advertised. - In-flight steer bubbles get an "Interrupt now" overflow entry with the same race rules as Edit: reclaim first, and only a `reclaimed` outcome resubmits (via retrySteer with preempt, swapping the chip for an interrupting one). `applied` and run-ended-mid-reclaim outcomes stop at the existing informational toasts, so the words can never land twice. Not offered on a steer already preempting. - Every during-run overflow menu gains an "Always interrupt instead" toggle for steerInterruptsByDefault, next to the existing steer/queue default toggle. MenuEntry supports disabled for the new entries. Only one interrupt can be unresolved at a time: while one preempt is pending (or the run is paused on approval, where the server 409s), every escalation control disables instead of racing the same seal. Ten new tests across both surfaces; 381 green in the affected suites. * fix: lock escalation across its reclaim window, keep the paused control visible, label as steer Codex round 1, all three findings. P2, escalation race. The single-interrupt invariant had a window between clicking "Interrupt now" and the reclaim resolving, where no preempt chip existed for the chip-derived gate to see: two bubbles escalated back-to-back could both resubmit. A shared escalating flag (Jotai, per-conversation) now covers the window and disables every escalation control on both surfaces, and a fresh recheck before resubmitting catches an interrupt armed elsewhere meanwhile (composer chord, queued row); those words re-home to the queue with an informational toast instead of breaking the invariant. P2, unreachable paused state. canSteer is defined as hasRealConvoId && !pausedOnApproval, so gating the button on canSteer removed it exactly when it was meant to render disabled; the test only passed on an impossible stub combination. The render gate is now duringRunActive && (canSteer || pausedOnApproval), and the test uses the real invariant. P2, label semantics. "Interrupt & send now" borrowed the name of the hard-abort action; this one preserves the partial answer and steers. Renamed to "Interrupt & steer now" (com_ui_interrupt_steer_now). Both behavior fixes counterfactually verified; 384 tests green across the affected suites. * fix: disable bubble escalation while the run cannot accept a steer Codex round 2, one P2. Answer mode (ask_user_question) sets duringRunActive false while pausedOnApproval stays false, since that flag only detects approval-bearing tool calls. The bubble's escalation entry stayed enabled there, so clicking it cancelled a healthy waiting steer and the preempt resubmission bounced off RUN_PAUSED, degrading the words to the queue. The entry now also disables on !duringRunActive, matching the queued-row control's gate. Counterfactually verified: reverting the gate fails the new answer-mode test. * fix: recheck live run state after the reclaim, not just at the click Codex round 3, one P2, and it is the round-1 recheck principle applied one level deeper: the entry-time disable cannot see a run that pauses (tool approval, answer mode) while the reclaim round-trip is in flight, and the .then closure held the render's stale steering controls, so the resubmit would fire into a RUN_PAUSED rejection after the reclaim had already surrendered the steer's boundary slot. The escalation continuation now reads the LIVE controls through a latest-ref: if the run can no longer accept a steer, the words re-home to the queue with an informational toast instead of resubmitting, and the resubmit itself also goes through the live controls. Counterfactually verified: reading the stale closure instead of the ref fails the new mid-reclaim pause test. * refactor: make escalation one atomic server-side arm, in place Codex round 4: four P2s, every one an interleaving of the same window — escalation as reclaim-then-repost is a compound, non-atomic operation whose continuation must revalidate the world (FIFO position lost, ref assigned too late, no run fence, competing bubble actions). Rounds 1-3 patched that window with a lock and rechecks; round 4 shows the window itself is the defect, so this removes it instead of guarding it again. Escalation is now POST /chat/steer/arm: the server flips preempt on the EXISTING queued item in one atomic store op (new IJobStore.armSteer; a decode-patch-encode LSET Lua on Redis, an in-place mutation in memory), fenced to the validated generation and refused once the queue closes. The handler mirrors the steer POST's preempt contract exactly: durable flag gated on the owner's recorded capability, volatile requestPreempt fire-and-forget because the durable flag is the truth resume/handover re-arm from. By construction this resolves all four findings: FIFO survives (the item never moves; the whole queue still drains in instruction order at the seal), there is no continuation to hold stale controls, the store op is fenced to the original run, and a competing Edit/Queue/Cancel either beats the arm (armed:false, chip untouched) or operates on the armed item, whose cancel already disarms. The client escalation entry becomes one mutation: armed:true relabels the chip in place (same steerId, same position), PREEMPT_UNSUPPORTED and lost races toast honestly, and the round 1-3 machinery — the escalating lock atom, the latest-ref, the post-reclaim rechecks and their two toast strings — is deleted rather than extended. Verified: 7 new handler tests on the real in-memory manager (including FIFO preservation and the stale-generation fence), 2 Redis integration tests against real Redis (in-place arm keeps order and every field; missing/stale/closed all refuse), client suites 396 green. * fix: decide capability inside the atomic arm, neutralize the lost-race toast Codex round 5, both findings, both edges of the new arm design rather than its mechanism. P2, capability TOCTOU. A HITL resume on a rolling deploy rewrites preemptCapable for the SAME generation, so the handler's read could go stale between validation and the flag flip, arming a steer the live owner cannot seal. armSteer now returns armed | missing | incapable, with the owner's live capability part of the same atomic predicate as the generation fence (HGET preemptCapable inside the Lua; the flat job field, not a metadata blob — the in-memory store reads the same field). The handler's pre-check is deleted rather than kept alongside; the store predicate is the single source. New handler test rewrites the capability after queueing and expects PREEMPT_UNSUPPORTED with the item left unflagged; the Redis guards test now asserts the incapable refusal against real Redis. P2, ambiguous toast. armed:false covers injected, cancelled, re-homed, and run-over alike, so telling the user the message "already reached the agent" claimed one specific outcome. The lost-race branch now uses a neutral message (com_ui_steer_arm_lost_race) and defers to the events for what actually happened. * fix: flip the escalation lock synchronously before the arm request Codex round 6, one P2. Round 4 deleted the escalating flag along with the reclaim continuation it guarded, but that left the one-interrupt gate blind during the arm request's own round trip: the chip-derived check cannot see an arm until its response relabels the chip, so on a slow connection two bubbles could both arm before either response landed. Double-arm is harmless server-side now (the run seals once and drains the whole queue in order), but every escalation control advertises "one interrupt at a time" by disabling, and the controls must tell the truth. The per-conversation escalating flag returns as a pure UX gate: set synchronously at click, before the mutation, cleared on settlement, and folded into interruptPending on both surfaces. Unlike its round 1-3 ancestor there is no continuation behind it to guard and no recheck to pair with it. Counterfactually verified: without the synchronous set, the two-bubble race test arms twice. 207 tests green across the Chat Input suites. * test(e2e): cover escalation of waiting messages through the real seal Three mock-harness tests on E2E_SLOW_REPLY, a 160-chunk stream with no tool boundary, so an in-thread steer part can ONLY come from a genuine mid-stream seal — which makes each test a behavioral proof rather than a UI check: - Queued row escalation: the ZapOff button turns a waiting queued message into a preempt-armed steer (202 echoes preempt: true) that seals and injects, where the sibling steering.spec test proves the unescalated path waits for run end instead. - Bubble in-place arm: an ordinary steer (202 with no preempt echo) waits as a bubble, POST /chat/steer/arm answers armed: true, the bubble relabels in place (same single bubble, same text, escalation no longer offered on reopen), and the armed steer seals mid-stream. - Always-interrupt toggle: flipped from a waiting row's overflow menu, plain Enter now produces a preempt: true steer that seals in the SAME run, and the menu offers the way back. An afterEach clears the localStorage preference so a mid-test failure cannot leak preempt-by-default into the rest of the serial suite. All three verified locally through the full harness (real backend, mock LLM, seeded DB): 3 passed in 27s. * feat: dedicated escalation arrow + shortcut, menu split into actions and preferences The escalation was still half-hidden: the bubble only offered it inside the overflow menu, and the tooltip taught the composer chord, which does a different thing (interrupts with typed text, not this chip). Three changes make it a first-class command: - A shared EscalateNowButton (circular arrow, ghost-bordered like the composer's interrupt control) is always visible on BOTH surfaces: beside each queued row's Steer primary and on every waiting steer bubble next to its menu. It disappears once a steer is interrupting. - A dedicated registry shortcut, escalateSteer (Cmd/Ctrl+Shift+.), editing-allowed and rebindable like every other action. Deliberately NOT an Enter chord: the composer owns every Enter chord, and the yield design rests on no default binding using Enter besides submit. Its handler clicks the newest enabled arrow control (bubbles beat queued rows), so the shortcut can never diverge from the button, and the arrow's tooltip teaches THIS command via the registry display. - The overflow menus separate one-off actions from sticky behavior changes: Edit, Cancel, Queue, then a smaller "Preferences" section holding the queueing and always-interrupt toggles, each with the standard InfoHoverCard reusing the Settings panel's descriptions. "Interrupt & steer now" leaves the menu entirely. 386 client tests green, including a menu-structure test locking the order and the absence of the escalation entry; bubble escalation tests drive the visible arrow. The e2e spec's bubble test now clicks the arrow, and a fourth test drives the dedicated shortcut end to end through a real mid-stream seal. * style: bind the escalation arrow to its message (variant A anatomy) Two same-weight circles in a row read as one control group, leaving the arrow's ownership ambiguous, and a floating arrow stops meaning anything once several messages stack. The shared control now carries variant A's anatomy: a thin divider binds a small SOLID arrow (filled, inverted) to the message region on its left, and the menu ellipsis stays a bare glyph, so the two affordances can no longer blur together — and the divider+arrow pairing repeats cleanly per chip at N messages. * chore: drop the unused within import CI lint caught * fix: advertise the escalation shortcut only while the control is live Codex on the e2e head, one P2: the tooltip appended the chord hint even while the button was disabled, advertising a shortcut that does nothing during an approval pause. The flagged control (InterruptNowButton) was since replaced by the shared EscalateNowButton, which inherited the pattern; the successor now omits the chord whenever the control is disabled, matching the rule the during-run hovercard already follows. * fix: harden steer escalation lifecycle and recovery * test(e2e): disambiguate accessible steer preferences * test: align abort persistence coverage with prerequisites * chore(i18n): remove obsolete steer race message * chore: normalize imports across steering changes * test: exercise stream integration on Redis Cluster * test: scope HITL checkpoints to generation * test: fix cluster cleanup and locale policy * fix: keep escalation visible during ask pauses * fix: fence recovery downgrade and stale predecessors * fix: require generation owner abort acknowledgement * fix: validate delayed preempt arms * test: align final escalation fixtures * fix: preserve in-memory predecessor abort handoff * fix: restore controls for recovered queued messages * test: cover recovered queue controls * fix: close final steering review gaps
1078 lines
39 KiB
JavaScript
1078 lines
39 KiB
JavaScript
const { logger } = require('@librechat/data-schemas');
|
|
const { createContentAggregator } = require('@librechat/agents');
|
|
const {
|
|
checkAccess,
|
|
loadSkillStates,
|
|
initializeAgent,
|
|
isMemoryEnabled,
|
|
primeInvokedSkills,
|
|
validateAgentModel,
|
|
extractManualSkills,
|
|
GenerationJobManager,
|
|
getCustomEndpointConfig,
|
|
discoverConnectedAgents,
|
|
resolveAgentTokenConfig,
|
|
resolveAgentScopedSkillIds,
|
|
resolveModelSpecSkillIds,
|
|
getAgentStartupTelemetry,
|
|
buildAgentContextAttachmentsByAgentId,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Permissions,
|
|
ResourceType,
|
|
EModelEndpoint,
|
|
PermissionBits,
|
|
PermissionTypes,
|
|
MAX_SUBAGENT_DEPTH,
|
|
isAgentsEndpoint,
|
|
getResponseSender,
|
|
AgentCapabilities,
|
|
MAX_SUBAGENT_GRAPH_NODES,
|
|
isEphemeralAgentId,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
createToolEndCallback,
|
|
createAttachmentEmitter,
|
|
createBackgroundCodeResultHandler,
|
|
getDefaultHandlers,
|
|
} = require('~/server/controllers/agents/callbacks');
|
|
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
|
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
|
const {
|
|
getSkillToolDeps,
|
|
getSkillDbMethods,
|
|
canAuthorSkillFiles,
|
|
withDeploymentSkillIds,
|
|
buildAgentToolContext,
|
|
enrichLoadedToolsWithAgentContext,
|
|
} = require('./skillDeps');
|
|
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
|
const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService');
|
|
const AgentClient = require('~/server/controllers/agents/client');
|
|
const { processAddedConvo } = require('./addedConvo');
|
|
const { logViolation } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
/**
|
|
* Creates a tool loader function for the agent.
|
|
* @param {AbortSignal} signal - The abort signal
|
|
* @param {string | null} [streamId] - The stream ID for resumable mode
|
|
* @param {boolean} [definitionsOnly=false] - When true, returns only serializable
|
|
* tool definitions without creating full tool instances (for event-driven mode)
|
|
* @param {number} [jobCreatedAt] - The generation epoch that owns emitted tool events
|
|
*/
|
|
function createToolLoader(signal, streamId = null, definitionsOnly = false, jobCreatedAt) {
|
|
/**
|
|
* @param {object} params
|
|
* @param {ServerRequest} params.req
|
|
* @param {ServerResponse} params.res
|
|
* @param {string} params.agentId
|
|
* @param {string[]} params.tools
|
|
* @param {string} params.provider
|
|
* @param {string} params.model
|
|
* @param {AgentToolResources} params.tool_resources
|
|
* @returns {Promise<{
|
|
* tools?: StructuredTool[],
|
|
* toolContextMap: Record<string, unknown>,
|
|
* toolDefinitions?: import('@librechat/agents').LCTool[],
|
|
* userMCPAuthMap?: Record<string, Record<string, string>>,
|
|
* toolRegistry?: import('@librechat/agents').LCToolRegistry
|
|
* } | undefined>}
|
|
*/
|
|
return async function loadTools({
|
|
req,
|
|
res,
|
|
tools,
|
|
model,
|
|
agentId,
|
|
provider,
|
|
tool_options,
|
|
tool_resources,
|
|
}) {
|
|
const agent = { id: agentId, tools, provider, model, tool_options };
|
|
try {
|
|
return await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent,
|
|
signal,
|
|
streamId,
|
|
jobCreatedAt,
|
|
tool_resources,
|
|
definitionsOnly,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error loading tools for agent ' + agentId, error);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initializes the AgentClient for a given request/response cycle.
|
|
* @param {Object} params
|
|
* @param {Express.Request} params.req
|
|
* @param {Express.Response} params.res
|
|
* @param {AbortSignal} params.signal
|
|
* @param {Object} params.endpointOption
|
|
* @param {number} [params.jobCreatedAt]
|
|
* @param {string} [params.checkpointNamespace] Immutable saver-level generation scope
|
|
*/
|
|
const initializeClient = async ({
|
|
req,
|
|
res,
|
|
signal,
|
|
endpointOption,
|
|
jobCreatedAt,
|
|
checkpointNamespace,
|
|
}) => {
|
|
if (!endpointOption) {
|
|
throw new Error('Endpoint option not provided');
|
|
}
|
|
const appConfig = req.config;
|
|
const startupTelemetry = getAgentStartupTelemetry(req);
|
|
|
|
/** @type {string | null} */
|
|
const streamId = req._resumableStreamId || null;
|
|
|
|
/** @type {Array<UsageMetadata>} */
|
|
const collectedUsage = [];
|
|
/**
|
|
* Vertex Gemini 3 thought signatures captured from `chat_model_end` events,
|
|
* keyed by `tool_call_id`. Persisted on
|
|
* `responseMessage.metadata.thoughtSignatures` so subsequent conversation
|
|
* turns can restore each signature onto the right reconstructed AIMessage's
|
|
* `additional_kwargs.signatures` and avoid 400s when resuming after a tool
|
|
* round-trip without a final text reply. Always allocated; capture path
|
|
* is a no-op for providers that don't emit signatures (OpenAI, Anthropic,
|
|
* Bedrock, etc.).
|
|
* @type {Record<string, string>}
|
|
*/
|
|
const collectedThoughtSignatures = {};
|
|
/** @type {ArtifactPromises} */
|
|
const artifactPromises = [];
|
|
/** @type {Map<string, import('@librechat/api').ToolInputValidationError>} */
|
|
const toolInputValidationErrors = new Map();
|
|
const { contentParts, aggregateContent, stepMap } = createContentAggregator();
|
|
const toolEndCallback = createToolEndCallback({
|
|
req,
|
|
res,
|
|
artifactPromises,
|
|
streamId,
|
|
jobCreatedAt,
|
|
});
|
|
|
|
/** Query accessible skill IDs once per run (shared across all agents).
|
|
* Skills activate under strict opt-in semantics — see
|
|
* `resolveAgentScopedSkillIds` for the per-agent activation predicate:
|
|
* - Ephemeral agent → model-spec `skills` config first, otherwise the
|
|
* per-conversation skills badge toggle (full catalog).
|
|
* - Persisted agent → `agent.skills_enabled === true`. Optional
|
|
* `agent.skills` allowlist narrows the catalog; empty/undefined
|
|
* allowlist with the toggle on = full accessible catalog. */
|
|
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
|
|
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
|
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
|
|
const backgroundToolsAvailable = enabledCapabilities.has(AgentCapabilities.run_in_background);
|
|
const toolIntentsAvailable = enabledCapabilities.has(AgentCapabilities.tool_intents);
|
|
const statefulSessionsAvailable = enabledCapabilities.has(
|
|
AgentCapabilities.stateful_code_sessions,
|
|
);
|
|
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
|
const skillDbMethods = getSkillDbMethods();
|
|
|
|
if (!endpointOption.agent) {
|
|
throw new Error('No agent promise provided');
|
|
}
|
|
|
|
/** Run-level gate for inline memory tools: the `memory` capability must be
|
|
* enabled, memory must be configured, and the user must not have opted out.
|
|
* Requires the memory WRITE permissions (CREATE + UPDATE) — both inline tools
|
|
* mutate memory — so the tools aren't registered (and shown to the model) for
|
|
* read-only-memory roles that the runtime loader would then refuse to build.
|
|
* Agents (or the ephemeral memory badge) opt in per-agent via the `memory`
|
|
* marker on `tools`. */
|
|
const memoryAvailablePromise =
|
|
enabledCapabilities.has(AgentCapabilities.memory) &&
|
|
isMemoryEnabled(appConfig?.memory) &&
|
|
req.user?.personalization?.memories !== false &&
|
|
checkAccess({
|
|
user: req.user,
|
|
permissionType: PermissionTypes.MEMORIES,
|
|
permissions: [Permissions.USE, Permissions.CREATE, Permissions.UPDATE],
|
|
getRoleByName: db.getRoleByName,
|
|
});
|
|
|
|
const accessibleSkillIdsPromise = skillsCapabilityEnabled
|
|
? findAccessibleResources({
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
}).then(withDeploymentSkillIds)
|
|
: Promise.resolve([]);
|
|
const editableSkillIdsPromise = skillsCapabilityEnabled
|
|
? findAccessibleResources({
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.EDIT,
|
|
})
|
|
: Promise.resolve([]);
|
|
const skillCreateAllowedPromise = skillsCapabilityEnabled
|
|
? getSkillToolDeps().canCreateSkill({ req })
|
|
: Promise.resolve(false);
|
|
const skillStatesPromise = accessibleSkillIdsPromise.then((accessibleSkillIds) =>
|
|
loadSkillStates({
|
|
userId: req.user.id,
|
|
appConfig,
|
|
getUserById: db.getUserById,
|
|
accessibleSkillIds,
|
|
}),
|
|
);
|
|
const primaryAgentPromise = endpointOption.agent;
|
|
const modelsConfigPromise = getModelsConfig(req);
|
|
const validatedPrimaryAgentPromise = Promise.all([primaryAgentPromise, modelsConfigPromise]).then(
|
|
async ([primaryAgent, modelsConfig]) => {
|
|
if (!primaryAgent) {
|
|
throw new Error('Agent not found');
|
|
}
|
|
|
|
const validationResult = await validateAgentModel({
|
|
req,
|
|
res,
|
|
modelsConfig,
|
|
logViolation,
|
|
agent: primaryAgent,
|
|
});
|
|
if (!validationResult.isValid) {
|
|
throw new Error(validationResult.error?.message);
|
|
}
|
|
|
|
return { primaryAgent, modelsConfig };
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Agent context store - populated after initialization, accessed by callback via closure.
|
|
* Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey }
|
|
* @type {Map<string, {
|
|
* userMCPAuthMap?: Record<string, Record<string, string>>,
|
|
* agent?: object,
|
|
* tool_resources?: object,
|
|
* toolRegistry?: import('@librechat/agents').LCToolRegistry,
|
|
* requestScopedConnections?: import('@librechat/api').RequestScopedMCPConnectionStore,
|
|
* openAIApiKey?: string
|
|
* }>}
|
|
*/
|
|
const agentToolContexts = new Map();
|
|
|
|
const toolExecuteOptions = {
|
|
loadTools: async (toolNames, agentId) => {
|
|
const ctx = agentToolContexts.get(agentId) ?? {};
|
|
logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`);
|
|
logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`);
|
|
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res,
|
|
signal,
|
|
streamId,
|
|
toolNames,
|
|
agent: ctx.agent,
|
|
toolRegistry: ctx.toolRegistry,
|
|
backgroundToolNames: ctx.backgroundToolNames,
|
|
intentToolNames: ctx.intentToolNames,
|
|
mcpAvailableTools: ctx.mcpAvailableTools,
|
|
requestScopedConnections: ctx.requestScopedConnections,
|
|
userMCPAuthMap: ctx.userMCPAuthMap,
|
|
tool_resources: ctx.tool_resources,
|
|
actionsEnabled: ctx.actionsEnabled,
|
|
jobCreatedAt,
|
|
});
|
|
|
|
logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`);
|
|
/** Per-agent narrowed flag (admin capability AND agent.tools
|
|
* includes execute_code), captured in `agentToolContexts` when
|
|
* the agent initialized. Falls back to `false` on any stray
|
|
* ctx miss so a skills-only agent never gains sandbox access
|
|
* even if capability lookup somehow skips. */
|
|
return enrichLoadedToolsWithAgentContext({
|
|
result,
|
|
req,
|
|
ctx,
|
|
});
|
|
},
|
|
toolEndCallback,
|
|
persistBackgroundCodeResult: createBackgroundCodeResultHandler({
|
|
req,
|
|
updateToolCallResult: db.updateToolCallResult,
|
|
}),
|
|
emitAttachment: createAttachmentEmitter({ res, streamId, jobCreatedAt }),
|
|
...getSkillToolDeps(),
|
|
};
|
|
|
|
const summarizationOptions =
|
|
appConfig?.summarization?.enabled === false ? { enabled: false } : { enabled: true };
|
|
|
|
/**
|
|
* Per-request map of per-subagent `createContentAggregator` instances
|
|
* keyed by the parent's `tool_call_id`. The handler in `callbacks.js`
|
|
* lazily creates an aggregator for each distinct `parentToolCallId`
|
|
* and folds every `ON_SUBAGENT_UPDATE` event into it as they stream
|
|
* in. `AgentClient` pulls each aggregator's `contentParts` at message
|
|
* save time and attaches them to the matching `subagent` tool_call so
|
|
* the child's reasoning / tool calls / final text survive a page
|
|
* refresh — the client-side Recoil atom is best-effort live-only.
|
|
*/
|
|
const subagentAggregatorsByToolCallId = new Map();
|
|
|
|
/** Backend prices each model call authoritatively (premium tiers, cache
|
|
* rates) and emits the cost on on_token_usage when contextCost is on, so
|
|
* the gauge sums real costs instead of re-deriving from base rates.
|
|
* `endpointTokenConfig` is filled in once `primaryConfig` resolves below so
|
|
* custom-endpoint agents price with their configured rates, not defaults. */
|
|
const usageCost = {
|
|
enabled: appConfig?.interfaceConfig?.contextCost === true,
|
|
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
|
|
};
|
|
|
|
/** Latest visible context snapshot + every emitted usage payload for this
|
|
* response, captured by the handlers and persisted on the response message's
|
|
* metadata so the breakdown and branch/total cost survive a reload.
|
|
* @type {{ latest: import('librechat-data-provider').TContextUsageEvent | null, count: number }} */
|
|
const contextUsageSink = { latest: null, count: 0 };
|
|
/** @type {Array<import('librechat-data-provider').TTokenUsageEvent>} */
|
|
const usageEmitSink = [];
|
|
|
|
const eventHandlers = getDefaultHandlers({
|
|
res,
|
|
contentParts,
|
|
stepMap,
|
|
toolInputValidationErrors,
|
|
toolExecuteOptions,
|
|
summarizationOptions,
|
|
aggregateContent,
|
|
toolEndCallback,
|
|
collectedUsage,
|
|
collectedThoughtSignatures,
|
|
streamId,
|
|
jobCreatedAt,
|
|
subagentAggregatorsByToolCallId,
|
|
usageCost,
|
|
contextUsageSink,
|
|
usageEmitSink,
|
|
});
|
|
|
|
const [
|
|
memoryAvailable,
|
|
accessibleSkillIds,
|
|
editableSkillIds,
|
|
skillCreateAllowed,
|
|
{ skillStates, defaultActiveOnShare },
|
|
{ primaryAgent, modelsConfig },
|
|
] = await Promise.all([
|
|
memoryAvailablePromise,
|
|
accessibleSkillIdsPromise,
|
|
editableSkillIdsPromise,
|
|
skillCreateAllowedPromise,
|
|
skillStatesPromise,
|
|
validatedPrimaryAgentPromise,
|
|
]);
|
|
delete endpointOption.agent;
|
|
|
|
const agentConfigs = new Map();
|
|
const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders);
|
|
|
|
/** Event-driven mode: only load tool definitions, not full instances */
|
|
const loadTools = createToolLoader(signal, streamId, true, jobCreatedAt);
|
|
/** @type {Array<MongoFile>} */
|
|
const requestFiles = req.body.files ?? [];
|
|
/** @type {string} */
|
|
const conversationId = req.body.conversationId;
|
|
/** @type {string | undefined} */
|
|
const parentMessageId = req.body.parentMessageId;
|
|
/**
|
|
* Skill names the user invoked via the `$` popover for this turn. Only flows
|
|
* to the primary agent — handoff agents are follow-up turns that don't see
|
|
* the user's per-submission `$` selections. `extractManualSkills` also
|
|
* drops non-string / empty elements so a crafted payload can't reach the
|
|
* `getSkillByName` DB query with nonsense values.
|
|
* @type {string[] | undefined}
|
|
*/
|
|
const manualSkills = extractManualSkills(req.body);
|
|
|
|
const selectedModelSpec =
|
|
endpointOption.spec && Array.isArray(appConfig?.modelSpecs?.list)
|
|
? appConfig.modelSpecs.list.find((modelSpec) => modelSpec.name === endpointOption.spec)
|
|
: null;
|
|
|
|
if (
|
|
primaryAgent &&
|
|
isEphemeralAgentId(primaryAgent.id) &&
|
|
selectedModelSpec &&
|
|
Object.hasOwn(selectedModelSpec, 'skills')
|
|
) {
|
|
if (selectedModelSpec.skills === true) {
|
|
primaryAgent.skills_enabled = true;
|
|
delete primaryAgent.skills;
|
|
} else if (selectedModelSpec.skills === false) {
|
|
primaryAgent.skills_enabled = false;
|
|
primaryAgent.skills = [];
|
|
} else if (Array.isArray(selectedModelSpec.skills)) {
|
|
const resolvedSkillIds = await resolveModelSpecSkillIds({
|
|
names: selectedModelSpec.skills,
|
|
accessibleSkillIds,
|
|
getSkillByName: skillDbMethods.getSkillByName,
|
|
});
|
|
primaryAgent.skills_enabled = true;
|
|
primaryAgent.skills = resolvedSkillIds.map((id) => id.toString());
|
|
}
|
|
}
|
|
|
|
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
|
|
agent: primaryAgent,
|
|
accessibleSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({
|
|
agent: primaryAgent,
|
|
accessibleSkillIds: editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const primarySkillAuthoringAvailable = canAuthorSkillFiles({
|
|
agent: primaryAgent,
|
|
scopedEditableSkillIds: primaryScopedEditableSkillIds,
|
|
skillCreateAllowed,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
|
|
const primaryConfig = await initializeAgent(
|
|
{
|
|
req,
|
|
res,
|
|
loadTools,
|
|
requestFiles,
|
|
conversationId,
|
|
parentMessageId,
|
|
agent: primaryAgent,
|
|
endpointOption,
|
|
allowedProviders,
|
|
isInitialAgent: true,
|
|
accessibleSkillIds: primaryScopedSkillIds,
|
|
skillAuthoringAvailable: primarySkillAuthoringAvailable,
|
|
codeEnvAvailable,
|
|
backgroundToolsAvailable,
|
|
toolIntentsAvailable,
|
|
statefulSessionsAvailable,
|
|
memoryAvailable,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
manualSkills,
|
|
},
|
|
{
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getConvoFiles: db.getConvoFiles,
|
|
updateFilesUsage: db.updateFilesUsage,
|
|
getUserKeyValues: db.getUserKeyValues,
|
|
getUserCodeFiles: db.getUserCodeFiles,
|
|
getToolFilesByIds: db.getToolFilesByIds,
|
|
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
|
|
filterFilesByAgentAccess,
|
|
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
|
|
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
|
|
getSkillByName: skillDbMethods.getSkillByName,
|
|
},
|
|
);
|
|
|
|
/** Price emitted usage with the primary agent's resolved endpoint config so
|
|
* custom-endpoint agents reflect configured rates (mirrors the AgentClient
|
|
* spending path, which reads the same config). */
|
|
usageCost.endpointTokenConfig = primaryConfig.endpointTokenConfig;
|
|
|
|
logger.debug(
|
|
`[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`,
|
|
);
|
|
agentToolContexts.set(
|
|
primaryConfig.id,
|
|
buildAgentToolContext({ agent: primaryAgent, config: primaryConfig }),
|
|
);
|
|
|
|
const {
|
|
agentConfigs: discoveredConfigs,
|
|
edges: discoveredEdges,
|
|
userMCPAuthMap: discoveredMCPAuthMap,
|
|
skippedAgentIds: discoveredSkippedIds,
|
|
} = await discoverConnectedAgents(
|
|
{
|
|
req,
|
|
res,
|
|
primaryConfig,
|
|
agent_ids: primaryConfig.agent_ids,
|
|
endpointOption,
|
|
allowedProviders,
|
|
modelsConfig,
|
|
loadTools,
|
|
requestFiles,
|
|
conversationId,
|
|
parentMessageId,
|
|
computeAccessibleSkillIds: (agent) =>
|
|
resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
computeSkillAuthoringAvailable: (agent) =>
|
|
canAuthorSkillFiles({
|
|
agent,
|
|
scopedEditableSkillIds: resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds: editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
skillCreateAllowed,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
codeEnvAvailable,
|
|
backgroundToolsAvailable,
|
|
toolIntentsAvailable,
|
|
statefulSessionsAvailable,
|
|
memoryAvailable,
|
|
},
|
|
{
|
|
getAgent: db.getAgent,
|
|
checkPermission,
|
|
logViolation,
|
|
db: {
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getConvoFiles: db.getConvoFiles,
|
|
updateFilesUsage: db.updateFilesUsage,
|
|
getUserKeyValues: db.getUserKeyValues,
|
|
getUserCodeFiles: db.getUserCodeFiles,
|
|
getToolFilesByIds: db.getToolFilesByIds,
|
|
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
|
|
filterFilesByAgentAccess,
|
|
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
|
|
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
|
|
getSkillByName: skillDbMethods.getSkillByName,
|
|
},
|
|
// The callback fires during BFS, before the helper prunes agents
|
|
// whose edges end up filtered. Don't populate `agentConfigs` here —
|
|
// `discoveredConfigs` (returned below) is the authoritative pruned
|
|
// set. The per-agent tool context map is OK to keep populated even
|
|
// for pruned ids: it's only read by closure in ON_TOOL_EXECUTE,
|
|
// stale entries are unreachable at runtime.
|
|
onAgentInitialized: (agentId, agent, config) => {
|
|
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
|
|
},
|
|
// Pass through the `@librechat/api` exports so that tests which
|
|
// `jest.mock('@librechat/api')` can override the initializer/validator.
|
|
initializeAgent,
|
|
validateAgentModel,
|
|
},
|
|
);
|
|
|
|
// Copy the pruned discovery result into the outer map. Anything the
|
|
// helper dropped (skipped or unreachable after edge filtering) is
|
|
// intentionally absent. `processAddedConvo` below may still add more
|
|
// entries for parallel multi-convo execution.
|
|
for (const [agentId, config] of discoveredConfigs) {
|
|
agentConfigs.set(agentId, config);
|
|
}
|
|
|
|
let userMCPAuthMap = discoveredMCPAuthMap;
|
|
let edges = discoveredEdges;
|
|
|
|
/** Multi-Convo: Process addedConvo for parallel agent execution */
|
|
const { userMCPAuthMap: updatedMCPAuthMap } = await processAddedConvo({
|
|
req,
|
|
res,
|
|
loadTools,
|
|
logViolation,
|
|
modelsConfig,
|
|
requestFiles,
|
|
agentConfigs,
|
|
primaryAgent,
|
|
endpointOption,
|
|
userMCPAuthMap,
|
|
conversationId,
|
|
parentMessageId,
|
|
allowedProviders,
|
|
primaryAgentId: primaryConfig.id,
|
|
accessibleSkillIds,
|
|
editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
skillCreateAllowed,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
codeEnvAvailable,
|
|
backgroundToolsAvailable,
|
|
toolIntentsAvailable,
|
|
statefulSessionsAvailable,
|
|
memoryAvailable,
|
|
});
|
|
|
|
if (updatedMCPAuthMap) {
|
|
userMCPAuthMap = updatedMCPAuthMap;
|
|
}
|
|
|
|
for (const [agentId, config] of agentConfigs) {
|
|
if (agentToolContexts.has(agentId)) {
|
|
continue;
|
|
}
|
|
agentToolContexts.set(agentId, buildAgentToolContext({ agent: config, config }));
|
|
}
|
|
|
|
// `discoverConnectedAgents` always returns a concrete array, so no
|
|
// further normalization is needed before handing this to `createRun`.
|
|
primaryConfig.edges = edges;
|
|
|
|
// Subagents: load any explicit subagent configs. Subagents run in isolated
|
|
// context windows and are invoked via a dedicated spawn tool (not handoff
|
|
// edges). An agent that is ONLY referenced as a subagent is dropped from
|
|
// `agentConfigs` so the LangGraph pipeline doesn't treat it as a
|
|
// parallel/handoff node, but it is KEPT in `agentToolContexts` — the child's
|
|
// `ON_TOOL_EXECUTE` dispatches resolve tool execution context (agent,
|
|
// tool_resources, skill ACLs, ...) from that map, so removing it would leave
|
|
// action tools skipped and resource-scoped tools running without their
|
|
// configured resources.
|
|
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
|
|
/** Track skipped ids locally so repeated failures short-circuit within
|
|
* the subagent loading loop. Seeded from the discovery helper's skip
|
|
* list so agents that already failed handoff loading don't get retried. */
|
|
const skippedAgentIds = new Set(discoveredSkippedIds ?? []);
|
|
|
|
/** All agent ids referenced on any edge (source OR target). Used by
|
|
* `loadSubagentsFor` to decide whether an agent that's only a subagent
|
|
* can be safely dropped from `agentConfigs` — LangGraph doesn't treat
|
|
* pure subagents as parallel/handoff nodes. */
|
|
const edgeAgentIds = new Set([primaryConfig.id]);
|
|
for (const edge of edges ?? []) {
|
|
const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
|
|
const targets = Array.isArray(edge.to) ? edge.to : [edge.to];
|
|
for (const id of sources) {
|
|
if (typeof id === 'string') edgeAgentIds.add(id);
|
|
}
|
|
for (const id of targets) {
|
|
if (typeof id === 'string') edgeAgentIds.add(id);
|
|
}
|
|
}
|
|
|
|
/** Lazy per-id agent loader used for subagents that weren't reachable
|
|
* via the handoff edge graph (so `discoverConnectedAgents` didn't
|
|
* initialize them). Mirrors the helper's internal `processAgent`:
|
|
* DB lookup + VIEW check + `initializeAgent`, then inserts into
|
|
* `agentConfigs` and `agentToolContexts`. Returns `null` on any
|
|
* failure so the caller can skip gracefully. */
|
|
const loadAgentById = async (agentId) => {
|
|
if (skippedAgentIds.has(agentId)) return null;
|
|
const existing = agentConfigs.get(agentId);
|
|
if (existing) return existing;
|
|
|
|
try {
|
|
const agent = await db.getAgent({ id: agentId });
|
|
if (!agent) {
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
const userId = req.user?.id;
|
|
if (!userId) {
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
const hasAccess = await checkPermission({
|
|
userId,
|
|
role: req.user?.role,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermission: PermissionBits.VIEW,
|
|
});
|
|
if (!hasAccess) {
|
|
logger.warn(
|
|
`[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`,
|
|
);
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
const validation = await validateAgentModel({
|
|
req,
|
|
res,
|
|
agent,
|
|
modelsConfig,
|
|
logViolation,
|
|
});
|
|
if (!validation.isValid) {
|
|
logger.warn(
|
|
`[processAgent] Subagent ${agentId} failed model validation: ${validation.error?.message}`,
|
|
);
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
const scopedSkillIds = resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds: editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const config = await initializeAgent(
|
|
{
|
|
req,
|
|
res,
|
|
agent,
|
|
loadTools,
|
|
requestFiles,
|
|
conversationId,
|
|
parentMessageId,
|
|
endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents },
|
|
allowedProviders,
|
|
accessibleSkillIds: scopedSkillIds,
|
|
skillAuthoringAvailable: canAuthorSkillFiles({
|
|
agent,
|
|
scopedEditableSkillIds,
|
|
skillCreateAllowed,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
/** Match the primary / handoff / addedConvo paths: forward the
|
|
* endpoint-level admin flag so `initializeAgent` can compute the
|
|
* per-agent narrowing (admin AND agent.tools includes
|
|
* execute_code) into `InitializedAgent.codeEnvAvailable`. Without
|
|
* this, a code-enabled subagent loaded only through
|
|
* `subagentAgentConfigs` initializes with `codeEnvAvailable:
|
|
* false`, so `bash_tool` / `read_file` sandbox fallback are
|
|
* silently gated off even though the seed walk found it. */
|
|
codeEnvAvailable,
|
|
/* Background tool calls are intentionally NOT enabled for pure
|
|
* subagents (spawn-tool child graphs): their tools don't reach the
|
|
* host ON_TOOL_EXECUTE background interceptor, so injecting the
|
|
* schema would advertise a poll tool the host can't honor. Backgrounding
|
|
* subagent work is the durable follow-up. */
|
|
statefulSessionsAvailable,
|
|
memoryAvailable,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
},
|
|
{
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getConvoFiles: db.getConvoFiles,
|
|
updateFilesUsage: db.updateFilesUsage,
|
|
getUserKeyValues: db.getUserKeyValues,
|
|
getUserCodeFiles: db.getUserCodeFiles,
|
|
getToolFilesByIds: db.getToolFilesByIds,
|
|
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
|
|
filterFilesByAgentAccess,
|
|
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
|
|
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
|
|
getSkillByName: skillDbMethods.getSkillByName,
|
|
},
|
|
);
|
|
agentConfigs.set(agentId, config);
|
|
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
|
|
return config;
|
|
} catch (err) {
|
|
logger.error(`[processAgent] Error processing subagent ${agentId}:`, err);
|
|
skippedAgentIds.add(agentId);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/** Collected during resolution; applied to `agentConfigs` only after
|
|
* every config has had its subagents resolved. Eager pruning would
|
|
* hide pure-subagent ids from the subsequent `loadSubagentsFor`
|
|
* loop, which would leave *their* `subagentAgentConfigs` empty and
|
|
* silently break nested delegation like A → B → C where B is only
|
|
* a subagent of A. */
|
|
const pureSubagentIds = new Set();
|
|
const subagentGraphIds = new Set();
|
|
const loadedSubagentConfigIds = new Set();
|
|
|
|
const assertSubagentGraphRoom = (agentId) => {
|
|
if (subagentGraphIds.has(agentId)) {
|
|
return;
|
|
}
|
|
if (subagentGraphIds.size >= MAX_SUBAGENT_GRAPH_NODES) {
|
|
logger.warn('[initializeClient] Subagent graph node limit exceeded', {
|
|
agentId,
|
|
primaryAgentId: primaryConfig.id,
|
|
loadedSubagentCount: subagentGraphIds.size,
|
|
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
|
|
});
|
|
throw new Error(
|
|
`Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`,
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Loads `subagentAgentConfigs` for a single agent config. Shared
|
|
* between the primary agent and handoff-target agents (and pure
|
|
* subagents, transitively) so an agent used via handoff or
|
|
* nested-subagent that has its own explicit `subagents.agent_ids`
|
|
* gets them honored at runtime. Self-spawn works regardless (no DB
|
|
* lookup needed). Pruning decisions are deferred to `pureSubagentIds`.
|
|
*/
|
|
const loadSubagentsFor = async (config, depth = 0) => {
|
|
const sub = config.subagents;
|
|
if (!subagentsCapabilityEnabled || !sub?.enabled) {
|
|
config.subagentAgentConfigs = [];
|
|
return;
|
|
}
|
|
|
|
if (loadedSubagentConfigIds.has(config.id)) {
|
|
if ((config.subagentAgentConfigs?.length ?? 0) > 0 && depth >= MAX_SUBAGENT_DEPTH) {
|
|
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
|
|
agentId: config.id,
|
|
primaryAgentId: primaryConfig.id,
|
|
depth,
|
|
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
|
|
childCount: config.subagentAgentConfigs.length,
|
|
});
|
|
throw new Error(
|
|
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/** Dedupe and filter in one pass — a crafted payload could
|
|
* legitimately include the same ID twice; the backend shouldn't
|
|
* create duplicate SubagentConfig entries for the LLM to see as
|
|
* separate spawn targets. */
|
|
const explicitSubagentIds = Array.from(
|
|
new Set(
|
|
Array.isArray(sub.agent_ids)
|
|
? sub.agent_ids.filter((id) => typeof id === 'string' && id && id !== config.id)
|
|
: [],
|
|
),
|
|
);
|
|
|
|
if (explicitSubagentIds.length > 0 && depth >= MAX_SUBAGENT_DEPTH) {
|
|
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
|
|
agentId: config.id,
|
|
primaryAgentId: primaryConfig.id,
|
|
depth,
|
|
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
|
|
childCount: explicitSubagentIds.length,
|
|
});
|
|
throw new Error(
|
|
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
|
|
);
|
|
}
|
|
|
|
loadedSubagentConfigIds.add(config.id);
|
|
|
|
/** @type {Array<Object>} */
|
|
const resolved = [];
|
|
for (const subagentId of explicitSubagentIds) {
|
|
if (skippedAgentIds.has(subagentId)) continue;
|
|
|
|
/** Cycle guard: a configuration like A ↔ B (B lists A as its
|
|
* subagent) would otherwise trigger `loadAgentById` on the
|
|
* primary — inserting a second config for the same primary id,
|
|
* which downstream duplicates in the agent array. Reuse the
|
|
* existing primary config when a subagent ref points back at it. */
|
|
if (subagentId === primaryConfig.id) {
|
|
resolved.push(primaryConfig);
|
|
continue;
|
|
}
|
|
|
|
assertSubagentGraphRoom(subagentId);
|
|
const subagentConfig = await loadAgentById(subagentId);
|
|
if (!subagentConfig) continue;
|
|
|
|
subagentGraphIds.add(subagentConfig.id ?? subagentId);
|
|
resolved.push(subagentConfig);
|
|
|
|
if (!edgeAgentIds.has(subagentId)) {
|
|
pureSubagentIds.add(subagentId);
|
|
}
|
|
}
|
|
|
|
config.subagentAgentConfigs = resolved;
|
|
};
|
|
|
|
const maxResolvedDepthByConfigId = new Map();
|
|
|
|
/** BFS across subagent trees so nested chains like A → B → C get
|
|
* resolved before any pruning. Agent configs are loaded once, but
|
|
* overlapping roots can still be revisited at deeper path depths so
|
|
* the depth guard observes the deepest reachable subagent path. */
|
|
const resolveSubagentTrees = async (rootConfigs) => {
|
|
const pending = rootConfigs.map((cfg) => ({ cfg, depth: 0 }));
|
|
for (let index = 0; index < pending.length; index++) {
|
|
const { cfg, depth } = pending[index];
|
|
if (!cfg?.id) continue;
|
|
const previousDepth = maxResolvedDepthByConfigId.get(cfg.id);
|
|
if (previousDepth != null && previousDepth >= depth) continue;
|
|
maxResolvedDepthByConfigId.set(cfg.id, depth);
|
|
await loadSubagentsFor(cfg, depth);
|
|
for (const child of cfg.subagentAgentConfigs ?? []) {
|
|
const childDepth = depth + 1;
|
|
const previousChildDepth = child?.id ? maxResolvedDepthByConfigId.get(child.id) : undefined;
|
|
if (child?.id && (previousChildDepth == null || previousChildDepth < childDepth)) {
|
|
pending.push({ cfg: child, depth: childDepth });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
await resolveSubagentTrees([primaryConfig, ...agentConfigs.values()]);
|
|
|
|
/** Drop pure-subagent entries now that every reachable config has
|
|
* had its subagents resolved. They stay in `agentToolContexts` so
|
|
* their tools still execute with the right scoping. */
|
|
for (const id of pureSubagentIds) {
|
|
agentConfigs.delete(id);
|
|
}
|
|
|
|
primaryConfig.subagents = subagentsCapabilityEnabled ? primaryConfig.subagents : undefined;
|
|
|
|
/** If the capability is off at the endpoint level, strip `subagents` on
|
|
* every loaded config — not just the primary. `run.ts` calls
|
|
* `buildSubagentConfigs` for every agent in the array, so a handoff
|
|
* agent with `subagents.enabled: true` persisted on its document would
|
|
* otherwise still expose self-spawn at runtime even though the admin
|
|
* has disabled the capability globally. */
|
|
if (!subagentsCapabilityEnabled) {
|
|
for (const config of agentConfigs.values()) {
|
|
config.subagents = undefined;
|
|
config.subagentAgentConfigs = undefined;
|
|
}
|
|
}
|
|
|
|
const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId([
|
|
primaryConfig,
|
|
...agentConfigs.values(),
|
|
]);
|
|
|
|
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
|
|
if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) {
|
|
try {
|
|
endpointConfig = getCustomEndpointConfig({
|
|
endpoint: primaryConfig.endpoint,
|
|
appConfig,
|
|
});
|
|
} catch (err) {
|
|
logger.error(
|
|
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
const sender =
|
|
primaryAgent.name ??
|
|
getResponseSender({
|
|
...endpointOption,
|
|
model: endpointOption.model_parameters.model,
|
|
modelDisplayLabel: endpointConfig?.modelDisplayLabel,
|
|
modelLabel: endpointOption.model_parameters.modelLabel,
|
|
});
|
|
|
|
/** History priming uses the user's full ACL-accessible skill set (not
|
|
* per-agent scoped) because prior turns may reference skills no longer
|
|
* in any active agent's scope; the ACL check is the security gate.
|
|
* `codeEnvAvailable` comes from `primaryConfig` — @see
|
|
* `InitializedAgent.codeEnvAvailable` for the per-agent narrowing. */
|
|
const handlePrimeInvokedSkills = skillsCapabilityEnabled
|
|
? (payload) =>
|
|
primeInvokedSkills({
|
|
req,
|
|
payload,
|
|
accessibleSkillIds,
|
|
codeEnvAvailable: primaryConfig.codeEnvAvailable === true,
|
|
...getSkillToolDeps(),
|
|
})
|
|
: undefined;
|
|
|
|
/** Per-agent resolved endpoint token config, keyed by agent id. Built from
|
|
* `agentToolContexts` (the one map holding every agent, including pure
|
|
* subagents pruned from `agentConfigs`) so usage billed/emitted for a
|
|
* connected or subagent on a different custom endpoint is priced with THAT
|
|
* agent's configured rates instead of the primary's. Every known agent is
|
|
* recorded — even with an `undefined` config — so the resolver can tell a
|
|
* known non-custom agent (built-in pricing) from an untagged/unknown one
|
|
* (primary fallback).
|
|
* @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
|
|
const endpointTokenConfigByAgentId = new Map();
|
|
for (const [agentId, ctx] of agentToolContexts) {
|
|
endpointTokenConfigByAgentId.set(agentId, ctx?.endpointTokenConfig);
|
|
}
|
|
/** Price emitted usage per producing agent too, so the streamed/persisted
|
|
* `metadata.usage.cost` matches the per-agent balance transaction. */
|
|
usageCost.resolveEndpointTokenConfig = (usage) =>
|
|
resolveAgentTokenConfig({
|
|
agentId: usage?.agentId,
|
|
byAgentId: endpointTokenConfigByAgentId,
|
|
fallback: usageCost.endpointTokenConfig,
|
|
});
|
|
|
|
const client = new AgentClient({
|
|
req,
|
|
res,
|
|
sender,
|
|
contentParts,
|
|
stepMap,
|
|
agentConfigs,
|
|
eventHandlers,
|
|
collectedUsage,
|
|
collectedThoughtSignatures,
|
|
aggregateContent,
|
|
artifactPromises,
|
|
primeInvokedSkills: handlePrimeInvokedSkills,
|
|
agent: primaryConfig,
|
|
spec: endpointOption.spec,
|
|
iconURL: endpointOption.iconURL,
|
|
chatProjectId: endpointOption.chatProjectId,
|
|
attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments,
|
|
agentContextAttachmentsByAgentId,
|
|
endpointType: endpointOption.endpointType,
|
|
resendFiles: primaryConfig.resendFiles ?? true,
|
|
maxContextTokens: primaryConfig.maxContextTokens,
|
|
endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents,
|
|
subagentAggregatorsByToolCallId,
|
|
/** Resolved endpoint token/pricing config so spending and cost reflect
|
|
* configured rates for custom-endpoint agents instead of defaults. */
|
|
endpointTokenConfig: primaryConfig.endpointTokenConfig,
|
|
/** Per-agent override of the above for multi-endpoint graphs (connected
|
|
* agents + subagents); falls back to the primary config when an agent
|
|
* isn't present or has no configured rates. */
|
|
endpointTokenConfigByAgentId,
|
|
/** Capture sinks the handlers fill during the run; `sendCompletion` reads
|
|
* them to persist the breakdown + usage rollup on the response message. */
|
|
contextUsageSink,
|
|
usageEmitSink,
|
|
startupTelemetry,
|
|
toolInputValidationErrors,
|
|
jobCreatedAt,
|
|
checkpointNamespace,
|
|
});
|
|
|
|
if (streamId) {
|
|
GenerationJobManager.setCollectedUsage(streamId, collectedUsage, jobCreatedAt);
|
|
}
|
|
|
|
return { client, userMCPAuthMap };
|
|
};
|
|
|
|
module.exports = { initializeClient };
|