mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🎛️ 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:
parent
986b1218ac
commit
7569404a7c
13 changed files with 180 additions and 24 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue