🧩 feat: Enable Model Spec Subagents (#13598)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/client` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled

This commit is contained in:
Danny Avila 2026-06-08 11:43:08 -04:00 committed by GitHub
parent cb6dbc8f60
commit fb87abe773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 303 additions and 19 deletions

View file

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

View file

@ -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<string, unknown>,
modelSpec: Pick<TModelSpec, 'subagents'> | 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<Agent | null>;
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<string>(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;

View file

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

View file

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

View file

@ -203,6 +203,21 @@ export function sanitizeModelSpecs<T extends Partial<TSpecsConfig> | 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;
}

View file

@ -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',