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;