From 7569404a7c46b06c7d7824cef8b234585e94fa67 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:16:08 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20feat:=20Make=20Max=20Su?= =?UTF-8?q?bagents=20Configurable=20via=20librechat.yaml=20(#15023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: make max subagents configurable via endpoints.agents.maxSubagents The per-agent subagent cap was hardcoded at 10 in MAX_SUBAGENTS, leaving orchestration-heavy deployments no option but patching limits.ts and rebuilding. Add an optional endpoints.agents.maxSubagents key to librechat.yaml (default 10, hard ceiling 50) that drives request validation, model spec presets, and the agents panel UI cap. * style: fix import order in OrchestrationHub --- .../services/Config/loadCustomConfig.js | 7 ++++ .../Agents/Advanced/AgentSubagents.tsx | 16 ++++---- .../Agents/Advanced/OrchestrationHub.tsx | 11 +++++- librechat.example.yaml | 3 ++ packages/api/src/agents/validation.spec.ts | 32 +++++++++++++++ packages/api/src/agents/validation.ts | 31 +++++++++++---- .../src/endpoints/config/endpoints.spec.ts | 2 + .../api/src/endpoints/config/endpoints.ts | 3 +- .../specs/config-schemas.spec.ts | 39 +++++++++++++++++++ packages/data-provider/src/config.ts | 15 +++++++ packages/data-provider/src/limits.ts | 20 ++++++++++ packages/data-provider/src/models.ts | 23 ++++++++--- packages/data-provider/src/types.ts | 2 + 13 files changed, 180 insertions(+), 24 deletions(-) diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 2629ed1c8f..45cec14160 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -8,7 +8,9 @@ const { logger } = require('@librechat/data-schemas'); const { configSchema, paramSettings, + EModelEndpoint, EImageOutputType, + setMaxSubagents, agentParamSettings, validateSettingDefinitions, } = require('librechat-data-provider'); @@ -109,6 +111,11 @@ async function loadCustomConfig(printConfig = true) { } } + // Applied before parsing so specs validated in the same pass (whose subagent + // presets share the cap) check against the configured limit. Invalid values + // are ignored here and rejected by the schema parse below. + setMaxSubagents(customConfig?.endpoints?.[EModelEndpoint.agents]?.maxSubagents); + const result = configSchema.strict().safeParse(customConfig); if (result?.error?.errors?.some((err) => err?.path && err.path?.includes('imageOutputType'))) { throw new Error( diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx index 5409b44c75..f85ab7d732 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Switch } from '@librechat/client'; import { Network, Users } from 'lucide-react'; -import { MAX_SUBAGENTS } from 'librechat-data-provider'; import type { ControllerRenderProps } from 'react-hook-form'; import type { AgentForm } from '~/common'; import { StaticAgentRow, AddAgentSelect, ListMeta, useSelectableAgents } from './AgentList'; @@ -12,9 +11,10 @@ import { ToggleSetting } from './ui'; interface AgentSubagentsProps { field: ControllerRenderProps; currentAgentId: string; + maxSubagents: number; } -const AgentSubagents: React.FC = ({ field, currentAgentId }) => { +const AgentSubagents: React.FC = ({ field, currentAgentId, maxSubagents }) => { const localize = useLocalize(); const [newAgentId, setNewAgentId] = useState(''); @@ -66,13 +66,13 @@ const AgentSubagents: React.FC = ({ field, currentAgentId } ); useEffect(() => { - if (newAgentId && agentIds.length < MAX_SUBAGENTS && !agentIds.includes(newAgentId)) { + if (newAgentId && agentIds.length < maxSubagents && !agentIds.includes(newAgentId)) { setAgentIds([...agentIds, newAgentId]); setNewAgentId(''); } else if (newAgentId) { setNewAgentId(''); } - }, [newAgentId, agentIds, setAgentIds]); + }, [newAgentId, agentIds, maxSubagents, setAgentIds]); const removeAgentAt = (index: number) => { setAgentIds(agentIds.filter((_, i) => i !== index)); @@ -119,7 +119,7 @@ const AgentSubagents: React.FC = ({ field, currentAgentId } {agentIds.map((agentId, idx) => { @@ -137,7 +137,7 @@ const AgentSubagents: React.FC = ({ field, currentAgentId } ); })} - {agentIds.length < MAX_SUBAGENTS && ( + {agentIds.length < maxSubagents && ( = ({ field, currentAgentId } /> )} - {agentIds.length >= MAX_SUBAGENTS && ( + {agentIds.length >= maxSubagents && (

- {localize('com_ui_agent_subagents_max', { 0: MAX_SUBAGENTS })} + {localize('com_ui_agent_subagents_max', { 0: maxSubagents })}

)} diff --git a/client/src/components/SidePanel/Agents/Advanced/OrchestrationHub.tsx b/client/src/components/SidePanel/Agents/Advanced/OrchestrationHub.tsx index 1a70fa6362..87432631fc 100644 --- a/client/src/components/SidePanel/Agents/Advanced/OrchestrationHub.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/OrchestrationHub.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { AgentCapabilities } from 'librechat-data-provider'; import { useFormContext, Controller } from 'react-hook-form'; +import { AgentCapabilities, MAX_SUBAGENTS } from 'librechat-data-provider'; import type { AgentForm } from '~/common'; import { useAgentPanelContext } from '~/Providers'; import AgentSubagents from './AgentSubagents'; @@ -31,6 +31,7 @@ export default function OrchestrationHub({ currentAgentId }: OrchestrationHubPro () => agentsConfig?.capabilities.includes(AgentCapabilities.chain) ?? false, [agentsConfig], ); + const maxSubagents = agentsConfig?.maxSubagents ?? MAX_SUBAGENTS; return (
@@ -43,7 +44,13 @@ export default function OrchestrationHub({ currentAgentId }: OrchestrationHubPro } + render={({ field }) => ( + + )} /> )} { expect(result.success).toBe(true); }); + it('accepts above the default cap when the configured limit is raised', () => { + setMaxSubagents(MAX_SUBAGENTS + 10); + const raised = Array.from({ length: MAX_SUBAGENTS + 5 }, (_, i) => `agent_${i}`); + const result = agentSubagentsSchema.safeParse({ + enabled: true, + agent_ids: raised, + }); + setMaxSubagents(undefined); + expect(result.success).toBe(true); + }); + + it('rejects above the raised cap and resets on invalid configured values', () => { + const oversized = Array.from({ length: MAX_SUBAGENTS + 11 }, (_, i) => `agent_${i}`); + + setMaxSubagents(MAX_SUBAGENTS + 10); + const overRaised = agentSubagentsSchema.safeParse({ + enabled: true, + agent_ids: oversized, + }); + + setMaxSubagents(MAX_SUBAGENTS + 100); + const afterInvalid = agentSubagentsSchema.safeParse({ + enabled: true, + agent_ids: oversized, + }); + + setMaxSubagents(undefined); + expect(overRaised.success).toBe(false); + expect(afterInvalid.success).toBe(false); + }); + it('accepts an explicit bounded graph subagent', () => { expect( agentSubagentsSchema.safeParse({ enabled: true, allowSelf: false, graphs: [graph] }).success, diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index 4a3c198491..ba1a94c696 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { MemoryScope, - MAX_SUBAGENTS, + getMaxSubagents, ViolationTypes, ErrorTypes, MAX_SUBAGENT_GRAPH_NODES, @@ -181,10 +181,12 @@ export const agentToolOptionsSchema: z.ZodOptional< > = z.record(z.string(), toolOptionsSchema).optional(); /** - * Subagent spawning configuration for an agent. `agent_ids` is capped at - * `Constants.MAX_SUBAGENTS` so a crafted API request cannot trigger hundreds - * of `processAgent` calls (DB lookup + permission check + tool loading). - * The UI enforces the same cap, so legitimate payloads never hit the bound. + * Subagent spawning configuration for an agent. `agent_ids` and `graphs` are + * capped at the effective subagents limit (10 by default, configurable via + * `endpoints.agents.maxSubagents`) so a crafted API request cannot trigger + * hundreds of `processAgent` calls (DB lookup + permission check + tool + * loading). The UI enforces the same cap, so legitimate payloads never hit + * the bound. */ const graphSubagentEdgeSchema = z .object({ @@ -346,10 +348,25 @@ export const agentSubagentsSchema: z.ZodOptional .object({ enabled: z.boolean().optional(), allowSelf: z.boolean().optional(), - agent_ids: z.array(z.string()).max(MAX_SUBAGENTS).optional(), - graphs: z.array(graphSubagentSchema).max(MAX_SUBAGENTS).optional(), + agent_ids: z.array(z.string()).optional(), + graphs: z.array(graphSubagentSchema).optional(), }) .superRefine((subagents, ctx) => { + const maxSubagents = getMaxSubagents(); + if ((subagents.agent_ids?.length ?? 0) > maxSubagents) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['agent_ids'], + message: `agent_ids must contain at most ${maxSubagents} item(s)`, + }); + } + if ((subagents.graphs?.length ?? 0) > maxSubagents) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['graphs'], + message: `graphs must contain at most ${maxSubagents} item(s)`, + }); + } const reservedTypes = new Set(subagents.agent_ids ?? []); const configuredAgentIds = new Set(subagents.agent_ids ?? []); if (subagents.allowSelf !== false) { diff --git a/packages/api/src/endpoints/config/endpoints.spec.ts b/packages/api/src/endpoints/config/endpoints.spec.ts index fdeb341f17..dfaf1db232 100644 --- a/packages/api/src/endpoints/config/endpoints.spec.ts +++ b/packages/api/src/endpoints/config/endpoints.spec.ts @@ -164,6 +164,7 @@ describe('createEndpointsConfigService', () => { [EModelEndpoint.agents]: { allowedProviders: ['openAI', 'anthropic'], capabilities: [AgentCapabilities.execute_code], + maxSubagents: 20, }, }, }), @@ -173,6 +174,7 @@ describe('createEndpointsConfigService', () => { const result = await getEndpointsConfig(fakeReq()); expect(result?.[EModelEndpoint.agents]?.allowedProviders).toEqual(['openAI', 'anthropic']); + expect(result?.[EModelEndpoint.agents]?.maxSubagents).toBe(20); }); it('exposes the deployment stateful environment allowlist', async () => { diff --git a/packages/api/src/endpoints/config/endpoints.ts b/packages/api/src/endpoints/config/endpoints.ts index b7f58985fe..12c38ea0f9 100644 --- a/packages/api/src/endpoints/config/endpoints.ts +++ b/packages/api/src/endpoints/config/endpoints.ts @@ -70,7 +70,7 @@ export function createEndpointsConfigService(deps: EndpointsConfigDeps): { } if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) { - const { disableBuilder, capabilities, allowedProviders, statefulCodeSessions } = + const { disableBuilder, capabilities, allowedProviders, statefulCodeSessions, maxSubagents } = appConfig.endpoints[EModelEndpoint.agents]; mergedConfig[EModelEndpoint.agents] = { ...mergedConfig[EModelEndpoint.agents], @@ -78,6 +78,7 @@ export function createEndpointsConfigService(deps: EndpointsConfigDeps): { disableBuilder, capabilities, statefulCodeSessions, + maxSubagents, }; } diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 4d3d3161cf..d3ab111480 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -13,6 +13,8 @@ import { summarizationConfigSchema, retainRecentConfigSchema, MAX_SUBAGENTS, + MAX_SUBAGENTS_CEILING, + setMaxSubagents, } from '../src/config'; import { tModelSpecPresetSchema, @@ -413,6 +415,26 @@ describe('agentsEndpointSchema', () => { expect(result.success).toBe(true); }); + it('defaults maxSubagents to MAX_SUBAGENTS and validates its bounds', () => { + const omitted = agentsEndpointSchema.safeParse({}); + expect(omitted.success).toBe(true); + if (omitted.success) { + expect(omitted.data.maxSubagents).toBe(MAX_SUBAGENTS); + } + + const raised = agentsEndpointSchema.safeParse({ maxSubagents: MAX_SUBAGENTS + 10 }); + expect(raised.success).toBe(true); + if (raised.success) { + expect(raised.data.maxSubagents).toBe(MAX_SUBAGENTS + 10); + } + + expect(agentsEndpointSchema.safeParse({ maxSubagents: 0 }).success).toBe(false); + expect(agentsEndpointSchema.safeParse({ maxSubagents: 2.5 }).success).toBe(false); + expect( + agentsEndpointSchema.safeParse({ maxSubagents: MAX_SUBAGENTS_CEILING + 1 }).success, + ).toBe(false); + }); + it('rejects empty or unknown stateful code environment allowlists', () => { expect( agentsEndpointSchema.safeParse({ @@ -1322,6 +1344,23 @@ describe('specsConfigSchema', () => { }); expect(result.success).toBe(false); }); + + it('validates model spec subagent ids against the configured cap', () => { + const raised = Array.from({ length: MAX_SUBAGENTS + 5 }, (_, i) => `agent_${i}`); + setMaxSubagents(MAX_SUBAGENTS + 10); + const withinRaised = specsConfigSchema.safeParse({ + list: [ + { + name: 'spec-1', + label: 'Spec 1', + preset: { endpoint: EModelEndpoint.openAI }, + subagents: { enabled: true, agent_ids: raised }, + }, + ], + }); + setMaxSubagents(undefined); + expect(withinRaised.success).toBe(true); + }); }); describe('configSchema langfuse', () => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 4559ec9789..bc97dcd1a1 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -9,6 +9,7 @@ import { eReasoningResponseKeySchema, } from './schemas'; import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; +import { MAX_SUBAGENTS, MAX_SUBAGENTS_CEILING } from './limits'; import { STATEFUL_CODE_ENVIRONMENTS } from './stateful-code'; import { specsConfigSchema, TSpecsConfig } from './models'; import { REFILL_INTERVAL_UNITS } from './balance'; @@ -18,6 +19,9 @@ import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; export { MAX_SUBAGENTS, + MAX_SUBAGENTS_CEILING, + getMaxSubagents, + setMaxSubagents, MAX_GRAPH_SUBAGENT_MEMBERS, MAX_CHAT_PROJECT_NAME_LENGTH, MAX_CHAT_PROJECT_DESCRIPTION_LENGTH, @@ -1002,6 +1006,16 @@ export const agentsEndpointSchema = baseEndpointSchema maxCitations: z.number().min(1).max(50).optional().default(30), maxCitationsPerFile: z.number().min(1).max(10).optional().default(7), minRelevanceScore: z.number().min(0.0).max(1.0).optional().default(0.45), + /** Maximum explicit subagents per agent (`agent_ids` and `graphs`); raised from + * the shipped default of 10 for orchestration-heavy deployments, bounded by + * `MAX_SUBAGENTS_CEILING`. */ + maxSubagents: z + .number() + .int() + .min(1) + .max(MAX_SUBAGENTS_CEILING) + .optional() + .default(MAX_SUBAGENTS), allowedProviders: z.array(z.union([z.string(), eModelEndpointSchema])).optional(), capabilities: z .array(z.nativeEnum(AgentCapabilities)) @@ -1033,6 +1047,7 @@ export const agentsEndpointSchema = baseEndpointSchema maxCitations: 30, maxCitationsPerFile: 7, minRelevanceScore: 0.45, + maxSubagents: MAX_SUBAGENTS, }); export type TAgentsEndpoint = z.infer; diff --git a/packages/data-provider/src/limits.ts b/packages/data-provider/src/limits.ts index 1392e6881c..8ff3f3250b 100644 --- a/packages/data-provider/src/limits.ts +++ b/packages/data-provider/src/limits.ts @@ -1,6 +1,26 @@ /** Maximum number of explicit subagents per parent agent. UI + Zod schema share this. */ export const MAX_SUBAGENTS = 10; +/** Hard upper bound for `endpoints.agents.maxSubagents`, keeping the request-validation + * cap bounded no matter what the config file says. */ +export const MAX_SUBAGENTS_CEILING = 50; + +let maxSubagents = MAX_SUBAGENTS; + +/** Effective subagents-per-agent cap; initialized from `endpoints.agents.maxSubagents` at startup. */ +export const getMaxSubagents = (): number => maxSubagents; + +/** Applies a configured cap; any missing or out-of-range value resets to the default. */ +export const setMaxSubagents = (value: number | undefined): void => { + maxSubagents = + typeof value === 'number' && + Number.isInteger(value) && + value >= 1 && + value <= MAX_SUBAGENTS_CEILING + ? value + : MAX_SUBAGENTS; +}; + /** Chat project field limits. The dialogs and the persistence layer share these, * so the inputs stop at the same point the server would otherwise truncate. */ export const MAX_CHAT_PROJECT_NAME_LENGTH = 100; diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index e0ca18545b..17f14edef1 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -8,7 +8,7 @@ import { AuthType, authTypeSchema, } from './schemas'; -import { MAX_SUBAGENTS } from './limits'; +import { getMaxSubagents } from './limits'; type ModelSpecSubagentsConfig = Omit; @@ -84,11 +84,22 @@ export type TModelSpec = { subagents?: ModelSpecSubagentsConfig; }; -export const modelSpecSubagentsSchema = z.object({ - enabled: z.boolean().optional(), - allowSelf: z.boolean().optional(), - agent_ids: z.array(z.string()).max(MAX_SUBAGENTS).optional(), -}); +export const modelSpecSubagentsSchema = z + .object({ + enabled: z.boolean().optional(), + allowSelf: z.boolean().optional(), + agent_ids: z.array(z.string()).optional(), + }) + .superRefine((subagents, ctx) => { + const maxSubagents = getMaxSubagents(); + if ((subagents.agent_ids?.length ?? 0) > maxSubagents) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['agent_ids'], + message: `agent_ids must contain at most ${maxSubagents} item(s)`, + }); + } + }); /** * The endpoint a spec targets. Only the agents endpoint can serve a preset that diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 6e67a03517..861c67fb3c 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -547,6 +547,8 @@ export type TConfig = { statefulCodeSessions?: { allowedEnvironments: StatefulCodeEnvironment[]; }; + /** Effective subagents-per-agent cap served from `endpoints.agents.maxSubagents`. */ + maxSubagents?: number; customParams?: { defaultParamsEndpoint?: string; reasoningFormat?: ReasoningParameterFormat;