mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧭 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:
parent
3fb48021f7
commit
6357ea10c1
28 changed files with 1398 additions and 139 deletions
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue