diff --git a/client/src/utils/__tests__/applyModelSpecEphemeralAgent.test.ts b/client/src/utils/__tests__/applyModelSpecEphemeralAgent.test.ts index 44bfbb82f7..821df6aa87 100644 --- a/client/src/utils/__tests__/applyModelSpecEphemeralAgent.test.ts +++ b/client/src/utils/__tests__/applyModelSpecEphemeralAgent.test.ts @@ -118,6 +118,16 @@ describe('applyModelSpecEphemeralAgent', () => { const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent; expect(agent.artifacts).toBe('custom-renderer'); }); + + it('should not copy model spec subagents into client ephemeral agent state', () => { + const subagents = { enabled: true, allowSelf: true, agent_ids: ['agent_private'] }; + const modelSpec = createModelSpec({ subagents }); + + applyModelSpecEphemeralAgent({ convoId: null, modelSpec, updateEphemeralAgent }); + + const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent; + expect(agent.subagents).toBeUndefined(); + }); }); // ─── Existing Conversations: Per-Conversation Persistence ────────── diff --git a/librechat.example.yaml b/librechat.example.yaml index 748a2a1ab1..7fb2081e9e 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -616,6 +616,12 @@ endpoints: # # accessible skill catalog, false to force skills off, or a name list as # # a strict allowlist for catalog, manual, and always-apply resolution. # # skills: ["brand-guidelines", "code-review"] +# # Subagents can be enabled per model spec. `allowSelf: true` lets the +# # ephemeral agent spawn a fresh isolated copy of itself for focused work. +# # subagents: +# # enabled: true +# # allowSelf: true +# # agent_ids: [] # # # Example 2: Nested under a custom endpoint (grouped with groq endpoint) # - name: "llama3-70b-8192" diff --git a/packages/api/src/agents/__tests__/load.spec.ts b/packages/api/src/agents/__tests__/load.spec.ts index 0bc93e8336..b2b9b6b3e9 100644 --- a/packages/api/src/agents/__tests__/load.spec.ts +++ b/packages/api/src/agents/__tests__/load.spec.ts @@ -1,8 +1,8 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { Constants, FileSources } from 'librechat-data-provider'; import { agentSchema, createMethods } from '@librechat/data-schemas'; +import { Constants, FileSources, MAX_SUBAGENTS } from 'librechat-data-provider'; import type { Agent as LibreChatAgent, AgentModelParameters, @@ -265,7 +265,9 @@ describe('loadAgent', () => { { req: { user: { id: 'user123' }, - body: {}, + body: { + ephemeralAgent: { subagents: { enabled: false, agent_ids: ['agent_tampered'] } }, + }, config: { config: {}, fileStrategy: FileSources.local, @@ -330,6 +332,124 @@ describe('loadAgent', () => { expect(result?.skills).toEqual([]); }); + test('should apply subagent config for ephemeral model specs', async () => { + const { EPHEMERAL_AGENT_ID } = Constants; + const subagents = { enabled: true, allowSelf: true, agent_ids: [] }; + + const result = await loadAgent( + { + req: { + user: { id: 'user123' }, + body: {}, + config: { + config: {}, + fileStrategy: FileSources.local, + imageOutputType: 'png', + modelSpecs: { + list: [ + { + name: 'self-spawn', + label: 'Self Spawn', + preset: { endpoint: 'openai', model: 'gpt-4' }, + subagents, + }, + ], + }, + }, + }, + spec: 'self-spawn', + agent_id: EPHEMERAL_AGENT_ID as string, + endpoint: 'openai', + model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters, + }, + deps, + ); + + expect(result?.subagents).toEqual(subagents); + }); + + test('should discard oversized request subagent ids for ephemeral agents', async () => { + const { EPHEMERAL_AGENT_ID } = Constants; + const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, index) => `agent_${index}`); + + const result = await loadAgent( + { + req: { + user: { id: 'user123' }, + body: { + ephemeralAgent: { + subagents: { enabled: true, allowSelf: false, agent_ids: oversized }, + }, + }, + }, + agent_id: EPHEMERAL_AGENT_ID as string, + endpoint: 'openai', + model_parameters: { model: 'gpt-4' } as unknown as AgentModelParameters, + }, + deps, + ); + + expect(result?.subagents).toEqual({ enabled: true, allowSelf: false }); + }); + + test('should preserve request subagents when added agent mirrors ephemeral primary tools', async () => { + const { EPHEMERAL_AGENT_ID } = Constants; + const subagents = { enabled: true, allowSelf: true, agent_ids: [] }; + + const result = await loadAddedAgent( + { + req: { + user: { id: 'user123' }, + config: { + config: {}, + fileStrategy: FileSources.local, + imageOutputType: 'png', + }, + }, + conversation: { + endpoint: 'openai', + model: 'gpt-4', + ephemeralAgent: { subagents }, + } as unknown as TConversation, + primaryAgent: { id: EPHEMERAL_AGENT_ID as string, tools: ['web_search'] } as LibreChatAgent, + }, + deps, + ); + + expect(result?.tools).toEqual(['web_search']); + expect(result?.subagents).toEqual(subagents); + }); + + test('should discard oversized request subagent ids for mirrored added agents', async () => { + const { EPHEMERAL_AGENT_ID } = Constants; + const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, index) => `agent_${index}`); + + const result = await loadAddedAgent( + { + req: { + user: { id: 'user123' }, + config: { + config: {}, + fileStrategy: FileSources.local, + imageOutputType: 'png', + }, + }, + conversation: { + endpoint: 'openai', + model: 'gpt-4', + ephemeralAgent: { + subagents: { enabled: true, allowSelf: false, agent_ids: oversized }, + }, + } as unknown as TConversation, + primaryAgent: { id: EPHEMERAL_AGENT_ID as string, tools: ['web_search'] } as LibreChatAgent, + }, + deps, + ); + + expect(result?.tools).toEqual(['web_search']); + expect(result?.subagents).toEqual({ enabled: true, allowSelf: false }); + }); + test('should enable full skill scope for added ephemeral model spec with skills true', async () => { const result = await loadAddedAgent( { @@ -398,8 +518,44 @@ describe('loadAgent', () => { expect(result?.skills).toEqual([]); }); + test('should apply subagent config for added ephemeral model specs', async () => { + const subagents = { enabled: true, allowSelf: true, agent_ids: [] }; + + const result = await loadAddedAgent( + { + req: { + user: { id: 'user123' }, + config: { + config: {}, + fileStrategy: FileSources.local, + imageOutputType: 'png', + modelSpecs: { + list: [ + { + name: 'added-self-spawn', + label: 'Added Self Spawn', + preset: { endpoint: 'openai', model: 'gpt-4' }, + subagents, + }, + ], + }, + }, + }, + conversation: { + endpoint: 'openai', + model: 'gpt-4', + spec: 'added-self-spawn', + } as unknown as TConversation, + }, + deps, + ); + + expect(result?.subagents).toEqual(subagents); + }); + test('should apply model spec skills when added agent mirrors ephemeral primary tools', async () => { const { EPHEMERAL_AGENT_ID } = Constants; + const subagents = { enabled: true, allowSelf: true, agent_ids: [] }; const result = await loadAddedAgent( { @@ -416,6 +572,7 @@ describe('loadAgent', () => { label: 'Mirrored Scoped Skills', preset: { endpoint: 'openai', model: 'gpt-4' }, skills: ['brand-writer'], + subagents, }, ], }, @@ -434,6 +591,7 @@ describe('loadAgent', () => { expect(result?.tools).toEqual(['web_search']); expect(result?.skills_enabled).toBe(true); expect(result?.skills).toEqual([]); + expect(result?.subagents).toEqual(subagents); }); test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => { diff --git a/packages/api/src/agents/added.ts b/packages/api/src/agents/added.ts index 64b92938ca..8c440156c5 100644 --- a/packages/api/src/agents/added.ts +++ b/packages/api/src/agents/added.ts @@ -1,5 +1,4 @@ import { logger } from '@librechat/data-schemas'; -import type { AppConfig } from '@librechat/data-schemas'; import { Tools, Constants, @@ -8,8 +7,15 @@ import { appendAgentIdSuffix, encodeEphemeralAgentId, } from 'librechat-data-provider'; -import type { Agent, TConversation, TModelSpec } from 'librechat-data-provider'; +import type { + Agent, + TConversation, + TModelSpec, + AgentSubagentsConfig, +} from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; import { getCustomEndpointConfig } from '~/app/config'; +import { sanitizeRequestSubagents } from './subagents'; const { mcp_all, mcp_delimiter } = Constants; @@ -34,6 +40,17 @@ function applyModelSpecSkills( } } +function applyModelSpecSubagents( + result: Record, + modelSpec: Pick | null | undefined, + ephemeralAgent?: { subagents?: AgentSubagentsConfig }, +): void { + const subagents = modelSpec?.subagents ?? sanitizeRequestSubagents(ephemeralAgent?.subagents); + if (subagents) { + result.subagents = subagents; + } +} + export interface LoadAddedAgentDeps { getAgent: (searchParameter: { id: string }) => Promise; getMCPServerTools: ( @@ -88,6 +105,7 @@ export async function loadAddedAgent( file_search?: boolean; web_search?: boolean; artifacts?: unknown; + subagents?: AgentSubagentsConfig; }; [key: string]: unknown; }; @@ -98,6 +116,16 @@ export async function loadAddedAgent( } const appConfig = req.config as AppConfig | undefined; + const ephemeralAgent = rest.ephemeralAgent as + | { + mcp?: string[]; + execute_code?: boolean; + file_search?: boolean; + web_search?: boolean; + artifacts?: unknown; + subagents?: AgentSubagentsConfig; + } + | undefined; const primaryIsEphemeral = primaryAgent && isEphemeralAgentId(primaryAgent.id); if (primaryIsEphemeral && Array.isArray(primaryAgent.tools)) { @@ -132,18 +160,10 @@ export async function loadAddedAgent( tools: [...primaryAgent.tools], }; applyModelSpecSkills(result, modelSpec); + applyModelSpecSubagents(result, modelSpec, ephemeralAgent); return result as unknown as Agent; } - const ephemeralAgent = rest.ephemeralAgent as - | { - mcp?: string[]; - execute_code?: boolean; - file_search?: boolean; - web_search?: boolean; - artifacts?: unknown; - } - | undefined; const mcpServers = new Set(ephemeralAgent?.mcp); const userId = req.user?.id ?? ''; @@ -234,6 +254,7 @@ export async function loadAddedAgent( if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) { result.artifacts = ephemeralAgent.artifacts; } + applyModelSpecSubagents(result, modelSpec, ephemeralAgent); applyModelSpecSkills(result, modelSpec); return result as unknown as Agent; diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index ec23b8e62c..3d85fb2b74 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -1,5 +1,4 @@ import { logger } from '@librechat/data-schemas'; -import type { AppConfig } from '@librechat/data-schemas'; import { Tools, Constants, @@ -13,7 +12,9 @@ import type { TModelSpec, Agent, } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; import { getCustomEndpointConfig } from '~/app/config'; +import { sanitizeRequestSubagents } from './subagents'; const { mcp_all, mcp_delimiter } = Constants; type ModelParametersWithPromptPrefix = AgentModelParameters & { promptPrefix?: string | null }; @@ -135,6 +136,14 @@ export async function loadEphemeralAgent( if (ephemeralAgent?.artifacts) { result.artifacts = ephemeralAgent.artifacts; } + if (modelSpec?.subagents) { + result.subagents = modelSpec.subagents; + } else { + const requestSubagents = sanitizeRequestSubagents(ephemeralAgent?.subagents); + if (requestSubagents) { + result.subagents = requestSubagents; + } + } if (modelSpec && Object.prototype.hasOwnProperty.call(modelSpec, 'skills')) { if (modelSpec.skills === true) { result.skills_enabled = true; diff --git a/packages/api/src/agents/subagents.ts b/packages/api/src/agents/subagents.ts new file mode 100644 index 0000000000..0353397781 --- /dev/null +++ b/packages/api/src/agents/subagents.ts @@ -0,0 +1,27 @@ +import { MAX_SUBAGENTS } from 'librechat-data-provider'; +import type { AgentSubagentsConfig } from 'librechat-data-provider'; + +export function sanitizeRequestSubagents( + subagents?: AgentSubagentsConfig | null, +): AgentSubagentsConfig | undefined { + if (!subagents || typeof subagents !== 'object') { + return undefined; + } + + const sanitized: AgentSubagentsConfig = {}; + if (typeof subagents.enabled === 'boolean') { + sanitized.enabled = subagents.enabled; + } + if (typeof subagents.allowSelf === 'boolean') { + sanitized.allowSelf = subagents.allowSelf; + } + if ( + Array.isArray(subagents.agent_ids) && + subagents.agent_ids.length <= MAX_SUBAGENTS && + subagents.agent_ids.every((agentId) => typeof agentId === 'string') + ) { + sanitized.agent_ids = subagents.agent_ids; + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} diff --git a/packages/api/src/modelSpecs/index.ts b/packages/api/src/modelSpecs/index.ts index 4f33f914df..30c895d89c 100644 --- a/packages/api/src/modelSpecs/index.ts +++ b/packages/api/src/modelSpecs/index.ts @@ -203,6 +203,21 @@ export function sanitizeModelSpecs | null | unde const preset = modelSpec?.preset; const sanitizedModelSpec = { ...modelSpec }; delete (sanitizedModelSpec as { skills?: unknown }).skills; + const subagents = sanitizedModelSpec.subagents; + if (subagents && typeof subagents === 'object') { + const sanitizedSubagents: TModelSpec['subagents'] = {}; + if (subagents.enabled !== undefined) { + sanitizedSubagents.enabled = subagents.enabled; + } + if (subagents.allowSelf !== undefined) { + sanitizedSubagents.allowSelf = subagents.allowSelf; + } + if (Object.keys(sanitizedSubagents).length > 0) { + sanitizedModelSpec.subagents = sanitizedSubagents; + } else { + delete sanitizedModelSpec.subagents; + } + } if (!preset || typeof preset !== 'object') { return sanitizedModelSpec; } diff --git a/packages/api/src/modelSpecs/modelSpecs.test.ts b/packages/api/src/modelSpecs/modelSpecs.test.ts index 284dca75a2..e8a723d8f3 100644 --- a/packages/api/src/modelSpecs/modelSpecs.test.ts +++ b/packages/api/src/modelSpecs/modelSpecs.test.ts @@ -18,6 +18,7 @@ describe('modelSpecs helpers', () => { name: 'guarded-spec', label: 'Guarded Spec', skills: ['private-skill'], + subagents: { enabled: true, allowSelf: true, agent_ids: ['agent_private'] }, preset: { endpoint: EModelEndpoint.openAI, model: 'gpt-4o', @@ -34,6 +35,10 @@ describe('modelSpecs helpers', () => { }; const sanitizedModelSpecs = sanitizeModelSpecs(modelSpecs); + expect(sanitizedModelSpecs.list[0].subagents).toEqual({ + enabled: true, + allowSelf: true, + }); expect(sanitizedModelSpecs.list[0].preset).toEqual({ endpoint: EModelEndpoint.openAI, model: 'gpt-4o', diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 3a63fb3b52..a285aebe0b 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -10,6 +10,7 @@ import { fileStrategiesSchema, summarizationTriggerSchema, summarizationConfigSchema, + MAX_SUBAGENTS, } from '../src/config'; import { tModelSpecPresetSchema, @@ -838,6 +839,7 @@ describe('specsConfigSchema', () => { hideBadgeRow: true, softDefault: true, preset: { endpoint: EModelEndpoint.openAI }, + subagents: { enabled: true, allowSelf: true, agent_ids: ['agent_researcher'] }, }, ], }); @@ -845,6 +847,11 @@ describe('specsConfigSchema', () => { if (result.success) { expect(result.data.list[0].hideBadgeRow).toBe(true); expect(result.data.list[0].softDefault).toBe(true); + expect(result.data.list[0].subagents).toEqual({ + enabled: true, + allowSelf: true, + agent_ids: ['agent_researcher'], + }); } }); @@ -852,4 +859,19 @@ describe('specsConfigSchema', () => { const result = specsConfigSchema.safeParse({ list: null }); expect(result.success).toBe(false); }); + + it('rejects model spec subagent ids above the shared cap', () => { + const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, i) => `agent_${i}`); + const result = specsConfigSchema.safeParse({ + list: [ + { + name: 'spec-1', + label: 'Spec 1', + preset: { endpoint: EModelEndpoint.openAI }, + subagents: { enabled: true, agent_ids: oversized }, + }, + ], + }); + expect(result.success).toBe(false); + }); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 238a5bde6c..69647a1201 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -10,11 +10,12 @@ import { } from './schemas'; import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; import { specsConfigSchema, TSpecsConfig } from './models'; +import { REFILL_INTERVAL_UNITS } from './balance'; import { fileConfigSchema } from './file-config'; import { apiBaseUrl } from './api-endpoints'; import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; -import { REFILL_INTERVAL_UNITS } from './balance'; +export { MAX_SUBAGENTS } from './limits'; export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml']; @@ -2288,9 +2289,6 @@ export enum Constants { SUBAGENT = 'subagent', } -/** Maximum number of explicit subagents per parent agent. UI + Zod schema share this. */ -export const MAX_SUBAGENTS = 10; - /** Maximum explicit subagent hops allowed from any root agent at runtime. */ export const MAX_SUBAGENT_DEPTH = 5; diff --git a/packages/data-provider/src/limits.ts b/packages/data-provider/src/limits.ts new file mode 100644 index 0000000000..7dbabf6d2c --- /dev/null +++ b/packages/data-provider/src/limits.ts @@ -0,0 +1,2 @@ +/** Maximum number of explicit subagents per parent agent. UI + Zod schema share this. */ +export const MAX_SUBAGENTS = 10; diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index 430f228bbf..0bfc68e067 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { AgentSubagentsConfig } from './types/assistants'; import type { TModelSpecPreset } from './schemas'; import { EModelEndpoint, @@ -7,6 +8,7 @@ import { AuthType, authTypeSchema, } from './schemas'; +import { MAX_SUBAGENTS } from './limits'; export type TModelSpec = { name: string; @@ -41,8 +43,15 @@ export type TModelSpec = { artifacts?: string | boolean; mcpServers?: string[]; skills?: boolean | string[]; + subagents?: AgentSubagentsConfig; }; +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 tModelSpecSchema = z.object({ name: z.string(), label: z.string(), @@ -64,6 +73,7 @@ export const tModelSpecSchema = z.object({ artifacts: z.union([z.string(), z.boolean()]).optional(), mcpServers: z.array(z.string()).optional(), skills: z.union([z.boolean(), z.array(z.string())]).optional(), + subagents: modelSpecSubagentsSchema.optional(), }); export const specsConfigSchema = z.object({ diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index ac069b210d..a7f51a5ee6 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -10,11 +10,11 @@ import type { ReasoningResponseKey, ReasoningParameterFormat, } from './schemas'; +import type { Agent, AgentSubagentsConfig } from './types/assistants'; import type { RefillIntervalUnit } from './balance'; import type { SettingDefinition } from './generate'; import type { TMinimalFeedback } from './feedback'; import type { ContentTypes } from './types/runs'; -import type { Agent } from './types/assistants'; export * from './schemas'; @@ -107,6 +107,7 @@ export type TEphemeralAgent = { execute_code?: boolean; artifacts?: string; skills?: boolean; + subagents?: AgentSubagentsConfig; }; export type TPayload = Partial &