🧭 feat: Scope Model Spec Skills (#13522)

* feat: scope model spec skills

* style: format skill catalog limit

* fix: serialize model spec skill resolution

* test: satisfy model spec load config typing

* fix: apply model spec skills to added conversations

* fix: support alwaysApply frontmatter alias

* fix: address model spec skills review
This commit is contained in:
Danny Avila 2026-06-05 10:22:02 -04:00 committed by GitHub
parent 3fb48021f7
commit 6357ea10c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1398 additions and 139 deletions

View file

@ -327,6 +327,7 @@ describe('GET /api/config', () => {
{
name: 'guarded-spec',
label: 'Guarded Spec',
skills: ['private-skill'],
preset: {
endpoint: 'openAI',
model: 'gpt-4o',
@ -352,6 +353,7 @@ describe('GET /api/config', () => {
model: 'gpt-4o',
greeting: 'Hello',
});
expect(response.body.modelSpecs.list[0]).not.toHaveProperty('skills');
});
it('should include full interface config', async () => {

View file

@ -3,10 +3,14 @@ const {
ADDED_AGENT_ID,
initializeAgent,
validateAgentModel,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
loadAddedAgent: loadAddedAgentFn,
} = require('@librechat/api');
const { isEphemeralAgentId } = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { getMCPServerTools } = require('~/server/services/Config');
const { canAuthorSkillFiles } = require('./skillDeps');
const db = require('~/models');
const loadAddedAgent = (params) =>
@ -40,6 +44,13 @@ const loadAddedAgent = (params) =>
* @param {Map} params.agentConfigs - Map of agent configs to add to
* @param {string} params.primaryAgentId - The primary agent ID
* @param {Object|undefined} params.userMCPAuthMap - User MCP auth map to merge into
* @param {Array} [params.accessibleSkillIds] - Full VIEW-accessible skill IDs for the user
* @param {Array} [params.editableSkillIds] - Full EDIT-accessible skill IDs for the user
* @param {boolean} [params.skillsCapabilityEnabled] - Whether endpoint Skills are enabled
* @param {boolean} [params.ephemeralSkillsToggle] - Per-request ephemeral Skills badge state
* @param {boolean} [params.skillCreateAllowed] - Whether the user can create Skills
* @param {Record<string, boolean>} [params.skillStates] - Per-user Skill active overrides
* @param {boolean} [params.defaultActiveOnShare] - Default active state for shared Skills
* @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag;
* forwarded verbatim to the added agent's `initializeAgent`. @see
* InitializeAgentParams.codeEnvAvailable for full semantics.
@ -60,6 +71,13 @@ const processAddedConvo = async ({
primaryAgentId,
primaryAgent,
userMCPAuthMap,
accessibleSkillIds = [],
editableSkillIds = [],
skillsCapabilityEnabled = false,
ephemeralSkillsToggle = false,
skillCreateAllowed = false,
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
}) => {
const addedConvo = endpointOption.addedConvo;
@ -94,6 +112,47 @@ const processAddedConvo = async ({
return { userMCPAuthMap };
}
const selectedModelSpec =
addedConvo.spec && Array.isArray(req.config?.modelSpecs?.list)
? req.config.modelSpecs.list.find((modelSpec) => modelSpec.name === addedConvo.spec)
: null;
if (
addedAgent &&
isEphemeralAgentId(addedAgent.id) &&
selectedModelSpec &&
Object.hasOwn(selectedModelSpec, 'skills')
) {
if (selectedModelSpec.skills === true) {
addedAgent.skills_enabled = true;
delete addedAgent.skills;
} else if (selectedModelSpec.skills === false) {
addedAgent.skills_enabled = false;
addedAgent.skills = [];
} else if (Array.isArray(selectedModelSpec.skills)) {
const resolvedSkillIds = await resolveModelSpecSkillIds({
names: selectedModelSpec.skills,
accessibleSkillIds,
getSkillByName: db.getSkillByName,
});
addedAgent.skills_enabled = true;
addedAgent.skills = resolvedSkillIds.map((id) => id.toString());
}
}
const scopedSkillIds = resolveAgentScopedSkillIds({
agent: addedAgent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
agent: addedAgent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const addedConfig = await initializeAgent(
{
req,
@ -105,7 +164,17 @@ const processAddedConvo = async ({
agent: addedAgent,
endpointOption,
allowedProviders,
accessibleSkillIds: scopedSkillIds,
skillAuthoringAvailable: canAuthorSkillFiles({
agent: addedAgent,
scopedEditableSkillIds,
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
codeEnvAvailable,
skillStates,
defaultActiveOnShare,
},
{
getFiles: db.getFiles,
@ -118,6 +187,9 @@ const processAddedConvo = async ({
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
},
);

View file

@ -1,6 +1,9 @@
const mockInitializeAgent = jest.fn();
const mockValidateAgentModel = jest.fn();
const mockLoadAddedAgent = jest.fn();
const mockResolveAgentScopedSkillIds = jest.fn();
const mockResolveModelSpecSkillIds = jest.fn();
const mockCanAuthorSkillFiles = jest.fn();
const mockGetAgent = jest.fn();
const mockGetMCPServerTools = jest.fn();
@ -18,6 +21,8 @@ jest.mock('@librechat/api', () => ({
initializeAgent: (...args) => mockInitializeAgent(...args),
validateAgentModel: (...args) => mockValidateAgentModel(...args),
loadAddedAgent: (params) => mockLoadAddedAgent(params),
resolveAgentScopedSkillIds: (...args) => mockResolveAgentScopedSkillIds(...args),
resolveModelSpecSkillIds: (...args) => mockResolveModelSpecSkillIds(...args),
}));
jest.mock('~/server/services/Files/permissions', () => ({
@ -28,11 +33,20 @@ jest.mock('~/server/services/Config', () => ({
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
}));
jest.mock('./skillDeps', () => ({
canAuthorSkillFiles: (...args) => mockCanAuthorSkillFiles(...args),
}));
jest.mock('~/models', () => ({
getAgent: (...args) => mockGetAgent(...args),
getSkillByName: jest.fn(),
listSkillsByAccess: jest.fn(),
listAlwaysApplySkills: jest.fn(),
}));
const { processAddedConvo } = require('./addedConvo');
const db = require('~/models');
const { Constants } = require('librechat-data-provider');
const makeReq = () => ({ user: { id: 'u1', role: 'USER' } });
@ -44,7 +58,7 @@ const makeReq = () => ({ user: { id: 'u1', role: 'USER' } });
* `CodeExecutionToolDefinition` landed in their `toolDefinitions` via the
* registry regardless of any explicit flag.
*/
describe('processAddedConvo — codeEnvAvailable passthrough', () => {
describe('processAddedConvo', () => {
beforeEach(() => {
jest.clearAllMocks();
mockValidateAgentModel.mockResolvedValue({ isValid: true });
@ -53,6 +67,11 @@ describe('processAddedConvo — codeEnvAvailable passthrough', () => {
userMCPAuthMap: undefined,
});
mockLoadAddedAgent.mockResolvedValue({ id: 'added-agent', provider: 'openai' });
mockResolveAgentScopedSkillIds.mockImplementation(
({ accessibleSkillIds }) => accessibleSkillIds,
);
mockResolveModelSpecSkillIds.mockResolvedValue([]);
mockCanAuthorSkillFiles.mockReturnValue(false);
});
const baseParams = (overrides = {}) => ({
@ -105,4 +124,108 @@ describe('processAddedConvo — codeEnvAvailable passthrough', () => {
expect.anything(),
);
});
it('resolves and forwards model-spec skill scope for added ephemeral agents', async () => {
const accessibleSkillId = { toString: () => 'accessible-skill' };
const editableSkillId = { toString: () => 'editable-skill' };
const resolvedSkillId = { toString: () => 'resolved-skill' };
const scopedSkillId = { toString: () => 'scoped-skill' };
const scopedEditableSkillId = { toString: () => 'scoped-editable-skill' };
const skillStates = { 'scoped-skill': true };
mockLoadAddedAgent.mockResolvedValue({
id: Constants.EPHEMERAL_AGENT_ID,
provider: 'openai',
skills_enabled: true,
skills: [],
});
mockResolveModelSpecSkillIds.mockResolvedValue([resolvedSkillId]);
mockResolveAgentScopedSkillIds
.mockReturnValueOnce([scopedSkillId])
.mockReturnValueOnce([scopedEditableSkillId]);
mockCanAuthorSkillFiles.mockReturnValue(true);
await processAddedConvo(
baseParams({
req: {
user: { id: 'u1', role: 'USER' },
config: {
modelSpecs: {
list: [
{
name: 'added-spec',
skills: ['finance-analyst'],
},
],
},
},
},
endpointOption: {
spec: 'primary-spec',
addedConvo: {
endpoint: 'openai',
model: 'gpt-4o',
spec: 'added-spec',
},
},
accessibleSkillIds: [accessibleSkillId],
editableSkillIds: [editableSkillId],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
skillCreateAllowed: true,
skillStates,
defaultActiveOnShare: true,
}),
);
expect(mockResolveModelSpecSkillIds).toHaveBeenCalledWith({
names: ['finance-analyst'],
accessibleSkillIds: [accessibleSkillId],
getSkillByName: db.getSkillByName,
});
expect(mockResolveAgentScopedSkillIds).toHaveBeenNthCalledWith(1, {
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
skills_enabled: true,
skills: ['resolved-skill'],
}),
accessibleSkillIds: [accessibleSkillId],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(mockResolveAgentScopedSkillIds).toHaveBeenNthCalledWith(2, {
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
skills_enabled: true,
skills: ['resolved-skill'],
}),
accessibleSkillIds: [editableSkillId],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(mockCanAuthorSkillFiles).toHaveBeenCalledWith({
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
skills_enabled: true,
skills: ['resolved-skill'],
}),
scopedEditableSkillIds: [scopedEditableSkillId],
skillCreateAllowed: true,
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
accessibleSkillIds: [scopedSkillId],
skillAuthoringAvailable: true,
skillStates,
defaultActiveOnShare: true,
}),
expect.objectContaining({
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
}),
);
});
});

View file

@ -10,6 +10,7 @@ const {
getCustomEndpointConfig,
discoverConnectedAgents,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
buildAgentContextAttachmentsByAgentId,
} = require('@librechat/api');
const {
@ -133,7 +134,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
/** Query accessible skill IDs once per run (shared across all agents).
* Skills activate under strict opt-in semantics see
* `resolveAgentScopedSkillIds` for the per-agent activation predicate:
* - Ephemeral agent per-conversation skills badge toggle (full catalog).
* - Ephemeral agent model-spec `skills` config first, otherwise the
* per-conversation skills badge toggle (full catalog).
* - Persisted agent `agent.skills_enabled === true`. Optional
* `agent.skills` allowlist narrows the catalog; empty/undefined
* allowlist with the toggle on = full accessible catalog. */
@ -288,6 +290,34 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
*/
const manualSkills = extractManualSkills(req.body);
const selectedModelSpec =
endpointOption.spec && Array.isArray(appConfig?.modelSpecs?.list)
? appConfig.modelSpecs.list.find((modelSpec) => modelSpec.name === endpointOption.spec)
: null;
if (
primaryAgent &&
isEphemeralAgentId(primaryAgent.id) &&
selectedModelSpec &&
Object.hasOwn(selectedModelSpec, 'skills')
) {
if (selectedModelSpec.skills === true) {
primaryAgent.skills_enabled = true;
delete primaryAgent.skills;
} else if (selectedModelSpec.skills === false) {
primaryAgent.skills_enabled = false;
primaryAgent.skills = [];
} else if (Array.isArray(selectedModelSpec.skills)) {
const resolvedSkillIds = await resolveModelSpecSkillIds({
names: selectedModelSpec.skills,
accessibleSkillIds,
getSkillByName: db.getSkillByName,
});
primaryAgent.skills_enabled = true;
primaryAgent.skills = resolvedSkillIds.map((id) => id.toString());
}
}
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
agent: primaryAgent,
accessibleSkillIds,
@ -456,6 +486,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
parentMessageId,
allowedProviders,
primaryAgentId: primaryConfig.id,
accessibleSkillIds,
editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
skillCreateAllowed,
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
});

View file

@ -6,6 +6,7 @@ const {
PrincipalModel,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_GRAPH_NODES,
Constants,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
@ -277,6 +278,42 @@ describe('initializeClient — processAgent ACL gate', () => {
expect(initializeParams.accessibleSkillIds.map(String)).toContain(skill._id.toString());
expect(initializeParams.skillAuthoringAvailable).toBe(false);
});
it('enables skill authoring when model specs enable skills for an ephemeral agent', async () => {
const endpointOption = makeEndpointOption();
endpointOption.spec = 'spec-skills';
endpointOption.agent = Promise.resolve({
id: Constants.EPHEMERAL_AGENT_ID,
name: 'Ephemeral Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
req.config.modelSpecs = {
list: [{ name: 'spec-skills', skills: true }],
};
const canCreateSkillSpy = jest
.spyOn(getSkillToolDeps(), 'canCreateSkill')
.mockResolvedValue(true);
try {
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption,
});
} finally {
canCreateSkillSpy.mockRestore();
}
const initializeParams = mockInitializeAgent.mock.calls[0][0];
expect(initializeParams.agent.skills_enabled).toBe(true);
expect(initializeParams.skillAuthoringAvailable).toBe(true);
});
});
describe('initializeClient — subagent loading', () => {

View file

@ -128,6 +128,12 @@ function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralS
return false;
}
if (isEphemeralAgentId(agent.id)) {
if (agent.skills_enabled === false) {
return false;
}
if (agent.skills_enabled === true) {
return true;
}
return ephemeralSkillsToggle === true;
}
return agent.skills_enabled === true;