🎛️ feat: Make Max Subagents Configurable via librechat.yaml (#15023)

* 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
This commit is contained in:
Marco Beretta 2026-08-20 17:16:08 +02:00 committed by GitHub
parent 986b1218ac
commit 7569404a7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 180 additions and 24 deletions

View file

@ -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(

View file

@ -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<AgentForm, 'subagents'>;
currentAgentId: string;
maxSubagents: number;
}
const AgentSubagents: React.FC<AgentSubagentsProps> = ({ field, currentAgentId }) => {
const AgentSubagents: React.FC<AgentSubagentsProps> = ({ field, currentAgentId, maxSubagents }) => {
const localize = useLocalize();
const [newAgentId, setNewAgentId] = useState('');
@ -66,13 +66,13 @@ const AgentSubagents: React.FC<AgentSubagentsProps> = ({ 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<AgentSubagentsProps> = ({ field, currentAgentId }
<ListMeta
label={localize('com_ui_agent_subagents_agents')}
count={agentIds.length}
max={MAX_SUBAGENTS}
max={maxSubagents}
/>
{agentIds.map((agentId, idx) => {
@ -137,7 +137,7 @@ const AgentSubagents: React.FC<AgentSubagentsProps> = ({ field, currentAgentId }
);
})}
{agentIds.length < MAX_SUBAGENTS && (
{agentIds.length < maxSubagents && (
<AddAgentSelect
options={options}
onSelect={setNewAgentId}
@ -146,9 +146,9 @@ const AgentSubagents: React.FC<AgentSubagentsProps> = ({ field, currentAgentId }
/>
)}
{agentIds.length >= MAX_SUBAGENTS && (
{agentIds.length >= maxSubagents && (
<p className="pt-1 text-center text-xs italic text-text-tertiary">
{localize('com_ui_agent_subagents_max', { 0: MAX_SUBAGENTS })}
{localize('com_ui_agent_subagents_max', { 0: maxSubagents })}
</p>
)}
</div>

View file

@ -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 (
<section className="flex flex-col gap-1">
@ -43,7 +44,13 @@ export default function OrchestrationHub({ currentAgentId }: OrchestrationHubPro
<Controller
name="subagents"
control={control}
render={({ field }) => <AgentSubagents field={field} currentAgentId={currentAgentId} />}
render={({ field }) => (
<AgentSubagents
field={field}
currentAgentId={currentAgentId}
maxSubagents={maxSubagents}
/>
)}
/>
)}
<Controller

View file

@ -523,6 +523,9 @@ endpoints:
# # (optional) Minimum relevance score for sources to be included in responses, defaults to 0.45 (45% relevance threshold)
# # Set to 0.0 to show all sources (no filtering), or higher like 0.7 for stricter filtering
# minRelevanceScore: 0.45
# # (optional) Maximum explicit subagents per agent, for both the flat list and
# # graph definitions. Defaults to 10; hard cap 50.
# maxSubagents: 20
# # (optional) Cap the number of active accessible skills shown in the model-visible catalog.
# # Useful for large organizations where many department-specific skills may be available.
# skills:

View file

@ -1,5 +1,6 @@
import {
MAX_SUBAGENTS,
setMaxSubagents,
MAX_SUBAGENT_GRAPH_NODES,
MAX_GRAPH_SUBAGENT_MEMBERS,
} from 'librechat-data-provider';
@ -48,6 +49,37 @@ describe('agentSubagentsSchema', () => {
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,

View file

@ -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<z.ZodType<AgentSubagentsConfig>
.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) {

View file

@ -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 () => {

View file

@ -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,
};
}

View file

@ -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', () => {

View file

@ -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<typeof agentsEndpointSchema>;

View file

@ -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;

View file

@ -8,7 +8,7 @@ import {
AuthType,
authTypeSchema,
} from './schemas';
import { MAX_SUBAGENTS } from './limits';
import { getMaxSubagents } from './limits';
type ModelSpecSubagentsConfig = Omit<AgentSubagentsConfig, 'graphs'>;
@ -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

View file

@ -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;