From 49083d0adfc5e230a547c8d89ad66386c042a4d1 Mon Sep 17 00:00:00 2001 From: dymux <91779374+putramkti@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:04:09 +0700 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=82=EF=B8=8F=20fix(agents):=20warn=20?= =?UTF-8?q?when=20skill=20descriptions=20are=20truncated=20in=20the=20mode?= =?UTF-8?q?l=20catalog=20(#14878)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatSkillCatalog caps every catalog entry at 250 characters and truncates silently, while the import validator allows descriptions up to 1024 chars. Skill authors therefore get no signal that the trigger phrases at the end of a long description will never reach the model, and the skill silently stops firing on the prompts it was written for. Log a warning per truncated skill so operators can see which descriptions need tightening, and pass the cap explicitly so the warning and the catalog stay in sync if @librechat/agents changes its default. Closes #14657 Co-authored-by: dymux --- .../api/src/agents/__tests__/skills.test.ts | 32 +++++++++++++++++++ packages/api/src/agents/skills.ts | 19 ++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/api/src/agents/__tests__/skills.test.ts b/packages/api/src/agents/__tests__/skills.test.ts index 87c849fc8f6..6d48466921f 100644 --- a/packages/api/src/agents/__tests__/skills.test.ts +++ b/packages/api/src/agents/__tests__/skills.test.ts @@ -897,6 +897,38 @@ describe('injectSkillCatalog', () => { expect(agent.additional_instructions).toContain('desc-my-skill'); }); + it('warns when a skill description exceeds the catalog entry cap', async () => { + const { logger } = await import('@librechat/data-schemas'); + const warnSpy = jest.spyOn(logger, 'warn'); + const longDesc = 'x'.repeat(400); + const longSkill: PageSkill = { + ...makeSkill('long-skill', userObjectId), + description: longDesc, + }; + const shortSkill = makeSkill('short-skill', userObjectId); + const listSkillsByAccess = buildPager([[longSkill, shortSkill]]); + const agent = makeAgent(); + await injectSkillCatalog(baseParams({ listSkillsByAccess, agent })); + + const truncWarns = warnSpy.mock.calls + .map((call) => String(call[0])) + .filter((msg) => msg.includes('truncated to')); + expect(truncWarns).toHaveLength(1); + expect(truncWarns[0]).toContain('"long-skill"'); + expect(truncWarns[0]).toContain('was 400'); + /* Short description is not flagged. */ + expect( + warnSpy.mock.calls + .map((call) => String(call[0])) + .filter((msg) => msg.includes('"short-skill"') && msg.includes('truncated')), + ).toHaveLength(0); + + /* The catalog still reaches the model — the warning is additive. */ + expect(agent.additional_instructions).toContain('long-skill'); + expect(agent.additional_instructions).toContain('short-skill'); + warnSpy.mockRestore(); + }); + it('honors a configured maxCatalogSkills below the default hard limit', async () => { const first = makeSkill('first-skill', userObjectId); const second = makeSkill('second-skill', userObjectId); diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index f12fcf75e0b..44495f120d9 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -89,6 +89,13 @@ const MIN_SKILL_CATALOG_LIMIT = 1; const MAX_CATALOG_PAGES = 10; /** Page size used when paginating to fill the active-skill quota. */ const CATALOG_PAGE_SIZE = 100; +/** + * Per-entry description cap applied by `formatSkillCatalog` before the + * catalog is injected into agent context. `@librechat/agents` truncates + * silently, so this mirrors the default so we can warn when authors' skill + * descriptions will not reach the model verbatim. + */ +const SKILL_CATALOG_MAX_ENTRY_CHARS = 250; /** Hard ceiling on skill names a model spec can request by config. */ const MAX_MODEL_SPEC_SKILLS = SKILL_CATALOG_LIMIT; /** @@ -620,9 +627,19 @@ export async function injectSkillCatalog( * and those reads would otherwise be impossible. */ if (catalogVisibleSkills.length > 0) { + for (const s of catalogVisibleSkills) { + if (s.description.length > SKILL_CATALOG_MAX_ENTRY_CHARS) { + logger.warn( + `[injectSkillCatalog] skill "${s.name}" description truncated to ${SKILL_CATALOG_MAX_ENTRY_CHARS} chars for the model catalog (was ${s.description.length})`, + ); + } + } const catalog = formatSkillCatalog( catalogVisibleSkills.map((s) => ({ name: s.name, description: s.description })), - { contextWindowTokens: contextWindowTokens || 200_000 }, + { + contextWindowTokens: contextWindowTokens || 200_000, + maxEntryChars: SKILL_CATALOG_MAX_ENTRY_CHARS, + }, ); if (catalog) { agent.additional_instructions = agent.additional_instructions From 986b1218ac00e70c8c2a08c8244004641340eeae Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 20 Aug 2026 11:14:27 -0400 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=94=93=20fix:=20Unblock=20Detached=20?= =?UTF-8?q?Subagent=20Preparation=20(#15016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: retain detached subagent execution lifetime * test: verify detached timer lifecycle * fix: settle Meilisearch query middleware * fix: preserve detached child failure diagnostics * test: select active preparation watchdog * test: keep watchdog assertion target-compatible --- .../api/src/agents/subagentThreads.spec.ts | 19 ++++++++++++ packages/api/src/agents/subagentThreads.ts | 29 +++++++++++++++---- .../src/models/plugins/mongoMeili.spec.ts | 16 ++++++++++ .../src/models/plugins/mongoMeili.ts | 10 +++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/api/src/agents/subagentThreads.spec.ts b/packages/api/src/agents/subagentThreads.spec.ts index e513f12e2ae..e9390160cb3 100644 --- a/packages/api/src/agents/subagentThreads.spec.ts +++ b/packages/api/src/agents/subagentThreads.spec.ts @@ -877,6 +877,10 @@ describe('SubagentThreadTaskStore', () => { await waitForSettled(firstWorker, config.scopeId, initial); slowThreadId = requireThreadId(initial); blockNextRead = true; + const intervalSpy = jest.spyOn(global, 'setInterval'); + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); const firstRun = jest.fn(taskRequest(config.scopeId).run); const first = firstWorker.start( @@ -887,6 +891,15 @@ describe('SubagentThreadTaskStore', () => { }), ); await preparing; + const heartbeatCall = intervalSpy.mock.calls.find(([, delay]) => delay === 50); + const heartbeatIndex = + heartbeatCall == null ? -1 : intervalSpy.mock.calls.indexOf(heartbeatCall); + const heartbeat = intervalSpy.mock.results[heartbeatIndex]?.value as NodeJS.Timeout | undefined; + const warningIndex = timeoutSpy.mock.calls.length - 1; + expect(timeoutSpy.mock.calls[warningIndex]?.[1]).toBe(5_000); + const warning = timeoutSpy.mock.results[warningIndex]?.value as NodeJS.Timeout | undefined; + expect(heartbeat?.hasRef()).toBe(true); + expect(warning?.hasRef()).toBe(true); /** Wait for evidence rather than a fixed delay: a renewal that succeeds after the * acquired lease's own deadline proves the heartbeat carried it past expiry. */ await waitUntil(() => renewedPastDeadline, 'the shared lease to outlive its original deadline'); @@ -905,6 +918,12 @@ describe('SubagentThreadTaskStore', () => { releasePreparation(); await waitForSettled(firstWorker, config.scopeId, first); expect(firstRun).toHaveBeenCalledTimes(1); + expect(clearIntervalSpy).toHaveBeenCalledWith(heartbeat); + expect(clearTimeoutSpy).toHaveBeenCalledWith(warning); + intervalSpy.mockRestore(); + timeoutSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); }); it('cancels a child when its lease renewal only commits after expiry', async () => { diff --git a/packages/api/src/agents/subagentThreads.ts b/packages/api/src/agents/subagentThreads.ts index eb3f21fcac7..b2b7199b71a 100644 --- a/packages/api/src/agents/subagentThreads.ts +++ b/packages/api/src/agents/subagentThreads.ts @@ -388,7 +388,6 @@ async function observeSlowPreparation( const warning = setTimeout(() => { logger.warn('[subagentThreads] Child-thread preparation is still waiting', context); }, SLOW_PREPARATION_WARN_MS); - warning.unref?.(); try { return await operation; } finally { @@ -631,10 +630,11 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { } throw error; } - logger.error( - '[subagentThreads] Child-thread execution failed', - publicFailureDetail(error), - ); + logger.error('[subagentThreads] Child-thread execution failed', { + detail: publicFailureDetail(error), + errorName: error instanceof Error ? error.name : typeof error, + ...(error instanceof Error && error.stack != null ? { stack: error.stack } : {}), + }); if (mayPersist) { await this.persistFailure( scope, @@ -1408,7 +1408,6 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { shared.heartbeatInFlight = renewal; }; shared.heartbeat = setInterval(heartbeat, this.leaseHeartbeatMs); - shared.heartbeat.unref?.(); } private async renewSharedLease( @@ -1593,10 +1592,18 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { lost: false, expiresAt: now.getTime() + this.leaseTtlMs, }; + /** A detached child has no request or stream handle after its parent returns. + * Keep the lease heartbeat referenced until settlement so Node cannot retire + * the execution context while its provider promise is still pending. */ this.startSharedLeaseHeartbeat(scopeId, scope, threadId, lease); /** Account deletion can fence the owner after the optimistic probe but before * this lease exists. Once the lease is visible, revalidate so deletion either * observes and drains us or wins before any provider work can begin. */ + logger.debug('[subagentThreads] Child-thread preparation entered stage', { + stage: 'owner_recheck', + taskId, + threadId, + }); if ( !(await observeSlowPreparation(this.isOwnerActive(scope.userId), { stage: 'owner_recheck', @@ -1606,6 +1613,11 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { ) { throw new SubagentThreadDeletedError('The thread owner is unavailable.'); } + logger.debug('[subagentThreads] Child-thread preparation entered stage', { + stage: 'transcript_read', + taskId, + threadId, + }); const allMessages = (await observeSlowPreparation( this.methods.getMessages( { conversationId: threadId, user: scope.userId }, @@ -1725,6 +1737,11 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { break; } } + logger.debug('[subagentThreads] Child-thread preparation entered stage', { + stage: 'seed_write', + taskId, + threadId, + }); const savedUserMessage = await observeSlowPreparation( this.methods.saveMessage( { userId: scope.userId }, diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts index 421c15c14db..2556873728b 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts @@ -114,6 +114,22 @@ describe('Meilisearch Mongoose plugin', () => { process.env = OLD_ENV; }); + test('settles query updates and deletes when no document hook is available', async () => { + const modelName = `QueryMiddlewareResult${Date.now()}`; + const Model = createDynamicMeiliModel(modelName); + try { + await Model.create({ docId: 'query-result', user: 'user', title: 'Before' }); + await expect( + Model.updateOne({ docId: 'query-result' }, { $set: { title: 'After' } }), + ).resolves.toMatchObject({ matchedCount: 1, modifiedCount: 1 }); + await expect(Model.deleteOne({ docId: 'query-result' })).resolves.toMatchObject({ + deletedCount: 1, + }); + } finally { + mongoose.deleteModel(modelName); + } + }); + test('saving conversation indexes w/ meilisearch', async () => { await createConversationModel(mongoose).create({ conversationId: new mongoose.Types.ObjectId(), diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 405d32268f7..5c5bea44c51 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -730,11 +730,17 @@ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions): }); schema.post('updateOne', function (doc: DocumentWithMeiliIndex, next) { - doc.postUpdateHook?.(next); + if (typeof doc.postUpdateHook === 'function') { + return doc.postUpdateHook(next); + } + return next(); }); schema.post('deleteOne', function (doc: DocumentWithMeiliIndex, next) { - doc.postRemoveHook?.(next); + if (typeof doc.postRemoveHook === 'function') { + return doc.postRemoveHook(next); + } + return next(); }); // Pre-deleteMany hook: remove corresponding documents from MeiliSearch when multiple documents are deleted. 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 3/9] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20feat:=20Make=20Max?= =?UTF-8?q?=20Subagents=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 2629ed1c8f0..45cec141607 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 5409b44c751..f85ab7d732d 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 1a70fa63620..87432631fc0 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 4a3c198491d..ba1a94c696b 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 fdeb341f17e..dfaf1db2326 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 b7f58985fe3..12c38ea0f9f 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 4d3d3161cf7..d3ab111480c 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 4559ec9789c..bc97dcd1a13 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 1392e6881c5..8ff3f3250bb 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 e0ca18545be..17f14edef1f 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 6e67a03517f..861c67fb3ce 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; From f1fbaeb6d8a4e92db0b9495aec4f7b05b87a9297 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:17:52 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=94=97=20feat:=20Open=20Footer=20Site?= =?UTF-8?q?=20Links=20in=20a=20New=20Tab=20(#15019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer's LibreChat site link navigated away from the chat in the same tab. Footer content links (default version link and custom footer markdown) now open in a new tab with noopener noreferrer. Privacy policy and terms of service links keep same-tab navigation, following the WCAG decision in #10997. --- client/src/components/Chat/Footer.tsx | 3 +- .../components/Chat/__tests__/Footer.spec.tsx | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Chat/__tests__/Footer.spec.tsx diff --git a/client/src/components/Chat/Footer.tsx b/client/src/components/Chat/Footer.tsx index 90d78737b94..79413c7f68c 100644 --- a/client/src/components/Chat/Footer.tsx +++ b/client/src/components/Chat/Footer.tsx @@ -63,7 +63,8 @@ function Footer({ className, startupConfig }: FooterProps) { {children} diff --git a/client/src/components/Chat/__tests__/Footer.spec.tsx b/client/src/components/Chat/__tests__/Footer.spec.tsx new file mode 100644 index 00000000000..c855b746f08 --- /dev/null +++ b/client/src/components/Chat/__tests__/Footer.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import Footer from '../Footer'; + +jest.mock('react-gtm-module', () => ({ + __esModule: true, + default: { initialize: jest.fn() }, +})); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: jest.fn(() => ({ data: undefined, isFetching: false, error: null })), +})); + +const mockTranslations: Record = { + com_ui_latest_footer: 'Every AI for Everyone.', + com_ui_privacy_policy: 'Privacy policy', + com_ui_terms_of_service: 'Terms of service', +}; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => mockTranslations[key] ?? key, +})); + +describe('Footer', () => { + test('opens the default LibreChat site link in a new tab', () => { + render(