🪶 feat: Enable Standalone Skill Authoring (#15480)

* feat: enable standalone skill authoring

* test: align skill e2e flow with enable toggle

* fix: preserve legacy skill state on version restore
This commit is contained in:
Danny Avila 2026-09-01 18:39:05 -04:00 • committed by GitHub
parent dfb264811c
commit aa7539909b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 683 additions and 123 deletions

View file

@ -429,6 +429,8 @@ class AgentClient extends BaseClient {
subagents: agent.subagents,
memory_scope: agent.memory_scope,
skills_enabled: agent.skills_enabled,
skill_authoring_enabled: agent.skill_authoring_enabled,
skills_scope: agent.skills_scope,
skills: agent.skills,
backgroundToolNames: agent.backgroundToolNames,
intentToolNames: agent.intentToolNames,

View file

@ -41,6 +41,7 @@ const {
const {
Time,
Tools,
SkillsScope,
CacheKeys,
Constants,
FileSources,
@ -241,18 +242,38 @@ const blockFilteredAgentContent = async (req, res, agentData) => {
const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
const skillScopeEnabled = agent.skills_enabled === true;
const configuredScope = agent.skills_scope;
delete agent.skills_enabled;
delete agent.skills_scope;
if (!skillScopeEnabled) {
delete agent.skills;
return agent;
}
const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
if (configuredSkills.length === 0) {
// Empty allowlist means the viewer's full accessible catalog.
if (configuredScope === SkillsScope.none) {
delete agent.skills;
agent.skills_enabled = true;
agent.skills_scope = SkillsScope.none;
return agent;
}
if (configuredScope === SkillsScope.all) {
delete agent.skills;
agent.skills_enabled = true;
agent.skills_scope = SkillsScope.all;
return agent;
}
const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
if (configuredSkills.length === 0) {
// Legacy empty allowlists mean the viewer's full accessible catalog;
// explicit selected scope remains an intentionally empty catalog.
delete agent.skills;
agent.skills_enabled = true;
if (configuredScope === SkillsScope.selected) {
agent.skills_scope = SkillsScope.selected;
}
return agent;
}
@ -262,11 +283,18 @@ const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
if (visibleSkills.length === 0) {
delete agent.skills;
if (configuredScope === SkillsScope.selected) {
agent.skills_enabled = true;
agent.skills_scope = SkillsScope.selected;
}
return agent;
}
agent.skills = visibleSkills;
agent.skills_enabled = true;
if (configuredScope === SkillsScope.selected) {
agent.skills_scope = SkillsScope.selected;
}
return agent;
};

View file

@ -4,6 +4,7 @@ const { v4: uuidv4 } = require('uuid');
const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas');
const {
Tools,
SkillsScope,
FileSources,
PermissionBits,
PrincipalModel,
@ -3210,6 +3211,34 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].skills_enabled).toBe(true);
});
test('should preserve an enabled empty catalog for VIEW list callers', async () => {
await Agent.findByIdAndUpdate(agentA1._id, {
skills_enabled: false,
skill_authoring_enabled: true,
skills_scope: SkillsScope.none,
skills: [],
});
mockReq.user.id = userB.toString();
mockReq.query.requiredPermission = String(PermissionBits.VIEW);
findAccessibleResources.mockImplementation(({ resourceType }) => {
if (resourceType === ResourceType.AGENT) {
return Promise.resolve([agentA1._id]);
}
return Promise.resolve([]);
});
findPubliclyAccessibleResources.mockResolvedValue([]);
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data).toHaveLength(1);
expect(response.data[0].skills).toBeUndefined();
expect(response.data[0].skills_enabled).toBeUndefined();
expect(response.data[0].skill_authoring_enabled).toBe(true);
expect(response.data[0].skills_scope).toBeUndefined();
});
test('should return raw skill configuration for EDIT list callers', async () => {
const visibleSkillId = new mongoose.Types.ObjectId();
const hiddenSkillId = new mongoose.Types.ObjectId();

View file

@ -551,6 +551,41 @@ describe('initializeClient — processAgent ACL gate', () => {
expect(initializeParams.skillAuthoringAvailable).toBe(true);
});
it('enables standalone authoring without exposing the persisted agent skill catalog', async () => {
const endpointOption = makeEndpointOption();
endpointOption.agent = Promise.resolve({
id: PRIMARY_ID,
name: 'Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
skills: [],
skills_enabled: false,
skill_authoring_enabled: true,
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
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.accessibleSkillIds).toEqual([]);
expect(initializeParams.skillAuthoringAvailable).toBe(true);
});
it('loads model validation and skill permissions without serial waits', async () => {
const models = deferred();
const createPermission = deferred();

View file

@ -158,7 +158,11 @@ function canEditSkill({ req, skillId }) {
});
}
function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralSkillsToggle }) {
function isAgentSkillAuthoringEnabledForRun({
agent,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}) {
if (!skillsCapabilityEnabled) {
return false;
}
@ -171,7 +175,7 @@ function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralS
}
return ephemeralSkillsToggle === true;
}
return agent.skills_enabled === true;
return agent.skills_enabled === true || agent.skill_authoring_enabled === true;
}
function canAuthorSkillFiles({
@ -182,7 +186,11 @@ function canAuthorSkillFiles({
ephemeralSkillsToggle,
}) {
return (
isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralSkillsToggle }) &&
isAgentSkillAuthoringEnabledForRun({
agent,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}) &&
(scopedEditableSkillIds.length > 0 || skillCreateAllowed === true)
);
}
@ -397,7 +405,7 @@ function getSkillToolDeps() {
module.exports = {
getSkillToolDeps,
canAuthorSkillFiles,
isAgentSkillsEnabledForRun,
isAgentSkillAuthoringEnabledForRun,
getSkillDbMethods,
withDeploymentSkillIds,
getSkillStrategyFunctions,

View file

@ -6,6 +6,7 @@ import type {
SupportContact,
AgentProvider,
MemoryScope,
SkillsScope,
StatefulCodeEnvironment,
GraphEdge,
Agent,
@ -43,6 +44,8 @@ export type AgentForm = {
tool_options?: AgentToolOptions;
skills?: string[];
skills_enabled?: boolean;
skill_authoring_enabled?: boolean;
skills_scope?: SkillsScope;
/** Memory partition: 'agent' isolates memories per (user, agent); default shared pool */
memory_scope?: MemoryScope;
/** Sharing scope for stateful Code API workspaces. */

View file

@ -1,13 +1,16 @@
import { memo, useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { ScrollText } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useAtomValue, useSetAtom } from 'jotai';
import { AutoSizer, List } from 'react-virtualized';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Input, Spinner, useCombobox } from '@librechat/client';
import { SkillsScope, resolveAgentSkillsScope } from 'librechat-data-provider';
import type { TSkillSummary } from 'librechat-data-provider';
import type { MentionOption } from '~/common';
import useInitPopoverInput from '~/hooks/Input/useInitPopoverInput';
import { useLocalize, useSkillActiveState } from '~/hooks';
import { useSkillsInfiniteQuery } from '~/data-provider';
import { showSkillsPopoverFamily } from './skillsState';
import { useAgentsMapContext } from '~/Providers';
import { ephemeralAgentByConvoId } from '~/store';
import { isEphemeralAgent } from '~/common';
@ -86,7 +89,7 @@ function SkillsCommandContent({
agentId?: string | null;
}) {
const localize = useLocalize();
const setShowSkillsPopover = useSetRecoilState(store.showSkillsPopoverFamily(index));
const setShowSkillsPopover = useSetAtom(showSkillsPopoverFamily(index));
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const setPendingManualSkills = useSetRecoilState(
store.pendingManualSkillsByConvoId(conversationId),
@ -99,9 +102,8 @@ function SkillsCommandContent({
`resolveAgentScopedSkillIds`: ephemeral agents always see the full
catalog (picking any skill flips `ephemeralAgent.skills = true` and
activates for the turn); persisted agents gate on the builder's
`skills_enabled` master toggle — off or unset means opt-out, on with
an empty allowlist means full catalog, on with a non-empty allowlist
means narrow to those ids. Persisted agents fail closed during the
`skills_enabled` master toggle and explicit catalog scope. Legacy agents
without a scope retain empty allowlist = full catalog. Persisted agents fail closed during the
`agentsMap` hydration window so the user cannot pick a skill the
backend will then refuse, and again when the map is authoritative
but the agent isn't in it (deleted, or VIEW revoked mid-session).
@ -121,7 +123,14 @@ function SkillsCommandContent({
if (agent.skills_enabled !== true) {
return [];
}
return Array.isArray(agent.skills) && agent.skills.length > 0 ? agent.skills : undefined;
const scope = resolveAgentSkillsScope(agent.skills, agent.skills_enabled, agent.skills_scope);
if (scope === SkillsScope.none) {
return [];
}
if (scope === SkillsScope.all) {
return undefined;
}
return agent.skills ?? [];
}, [agentId, agentsMap]);
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
@ -375,7 +384,7 @@ const SkillsCommand = memo(function SkillsCommand({
conversationId: string;
agentId?: string | null;
}) {
const show = useRecoilValue(store.showSkillsPopoverFamily(index));
const show = useAtomValue(showSkillsPopoverFamily(index));
if (!show) {
return null;
}

View file

@ -13,6 +13,7 @@
import React from 'react';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SkillsScope } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { TSkillSummary } from 'librechat-data-provider';
@ -23,21 +24,27 @@ const mockSetEphemeralAgent = jest.fn();
const mockSetPendingManualSkills = jest.fn();
const mockShowSkillsPopover = { current: true };
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn((atom: unknown) =>
atom === 'show-skills-popover' ? mockShowSkillsPopover.current : undefined,
),
useSetAtom: jest.fn((atom: unknown) =>
atom === 'show-skills-popover' ? mockSetShowSkillsPopover : jest.fn(),
),
}));
jest.mock('../skillsState', () => ({
showSkillsPopoverFamily: () => 'show-skills-popover',
}));
jest.mock('recoil', () => {
const actual = jest.requireActual('recoil');
return {
...actual,
useRecoilValue: jest.fn((atom: unknown) => {
if (atom === 'show-skills-popover') {
return mockShowSkillsPopover.current;
}
return undefined;
}),
useRecoilValue: jest.fn(() => undefined),
useRecoilState: jest.fn(() => [null, jest.fn()]),
useSetRecoilState: jest.fn((atom: unknown) => {
if (atom === 'show-skills-popover') {
return mockSetShowSkillsPopover;
}
if (atom === 'ephemeral-agent') {
return mockSetEphemeralAgent;
}
@ -52,7 +59,6 @@ jest.mock('recoil', () => {
jest.mock('~/store', () => ({
__esModule: true,
default: {
showSkillsPopoverFamily: () => 'show-skills-popover',
pendingManualSkillsByConvoId: () => 'pending-manual-skills',
},
ephemeralAgentByConvoId: () => 'ephemeral-agent',
@ -379,6 +385,39 @@ describe('SkillsCommand', () => {
expect(screen.getByRole('button', { name: /Style Guide/i })).toBeInTheDocument();
});
it('shows no catalog entries for an enabled authoring-only agent', () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,
isLoading: false,
isError: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
});
mockUseAgentsMapContext.mockReturnValue({
agent_1: {
id: 'agent_1',
skills: [],
skills_enabled: false,
skill_authoring_enabled: true,
skills_scope: SkillsScope.none,
},
});
const textAreaRef = makeTextarea('$');
render(
<SkillsCommand
index={0}
textAreaRef={textAreaRef}
conversationId={CONVO_ID}
agentId="agent_1"
/>,
);
expect(screen.queryByRole('button', { name: /Brand Guidelines/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Style Guide/i })).toBeNull();
});
it('treats an ephemeral agent id as unscoped and shows the full ACL catalog', async () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,

View file

@ -0,0 +1,4 @@
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
export const showSkillsPopoverFamily = atomFamily((_index: string | number | null) => atom(false));

View file

@ -91,6 +91,8 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
tool_options,
skills,
skills_enabled,
skill_authoring_enabled,
skills_scope,
memory_scope,
avatar_action: avatarActionState,
} = data;
@ -132,6 +134,8 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
tool_options: normalizedToolOptions,
skills,
skills_enabled,
skill_authoring_enabled,
skills_scope,
/** A hidden stale 'agent' scope must not survive disabling memory —
* runtime partitioning keys off memory_scope alone. */
memory_scope: data.memory === true ? memory_scope : MemoryScope.user,

View file

@ -19,7 +19,7 @@ import type { AgentItem } from './items/types';
import type { AgentForm } from '~/common';
import { useLocalize, useHasAccess, useAuthContext, useToolFavorites } from '~/hooks';
import { CreateSkillDialog } from '~/components/Skills/dialogs';
import { skillsEnabledTransition } from './items/mutations';
import { skillsSelectionTransition } from './items/mutations';
import { useSkillsInfiniteQuery } from '~/data-provider';
import MarketplaceCatalog from './MarketplaceCatalog';
import { CategoryIcon } from '~/components/Prompts';
@ -143,9 +143,20 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
const applySkillsSelection = useCallback(
(next: string[]) => {
setValue('skills', next, { shouldDirty: true });
const flag = skillsEnabledTransition(next, getValues('skills_enabled'));
if (flag !== undefined) {
setValue('skills_enabled', flag, { shouldDirty: true });
const transition = skillsSelectionTransition(
next,
getValues('skills_enabled'),
getValues('skill_authoring_enabled'),
getValues('skills_scope'),
);
if (transition.enabled !== undefined) {
setValue('skills_enabled', transition.enabled, { shouldDirty: true });
}
if (transition.authoringEnabled !== undefined) {
setValue('skill_authoring_enabled', transition.authoringEnabled, { shouldDirty: true });
}
if (transition.scope !== undefined) {
setValue('skills_scope', transition.scope, { shouldDirty: true });
}
},
[getValues, setValue],

View file

@ -1,12 +1,6 @@
import { useState, useMemo, useCallback, useRef } from 'react';
import { Plus } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import {
PermissionTypes,
Permissions,
AgentCapabilities,
removeCodeExecutionCaller,
} from 'librechat-data-provider';
import {
Label,
Switch,
@ -17,6 +11,14 @@ import {
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import {
PermissionTypes,
Permissions,
SkillsScope,
AgentCapabilities,
resolveAgentSkillsScope,
removeCodeExecutionCaller,
} from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { CapabilityFileCounts } from './items/capabilities';
@ -28,7 +30,7 @@ import {
useResolvedSkills,
useUninstallToolCredentials,
} from './hooks';
import { computeToggleAction, skillsEnabledTransition } from './items/mutations';
import { computeToggleAction, skillsSelectionTransition } from './items/mutations';
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
import { requiresFileManagerRemoval } from './items/capabilities';
import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP';
@ -90,7 +92,16 @@ export default function ToolsSection({ agentId }: Props) {
const skillsValue = useWatch({ control, name: 'skills' });
const skillsEnabledValue = useWatch({ control, name: 'skills_enabled' });
const useAllSkills = skillsEnabledValue === true && (skillsValue ?? []).length === 0;
const skillAuthoringEnabledValue = useWatch({ control, name: 'skill_authoring_enabled' });
const skillsScopeValue = useWatch({ control, name: 'skills_scope' });
const explicitSkillsEnabled = skillsEnabledValue === true;
const skillsCapabilityEnabled = explicitSkillsEnabled || skillAuthoringEnabledValue === true;
const effectiveSkillsScope = resolveAgentSkillsScope(
skillsValue,
skillsEnabledValue,
skillsScopeValue,
);
const useAllSkills = explicitSkillsEnabled && effectiveSkillsScope === SkillsScope.all;
/** Selection stashed when "use all skills" turns on, so turning it back off
* restores the previous picks instead of destroying them. Cleared when the
* agent changes — the section isn't remounted on switch (only the form
@ -103,17 +114,65 @@ export default function ToolsSection({ agentId }: Props) {
stashedSkillsRef.current = [];
}
const handleSkillsEnabledChange = useCallback(
(checked: boolean) => {
if (!checked) {
setValue('skills_enabled', false, { shouldDirty: true });
setValue('skill_authoring_enabled', false, { shouldDirty: true });
return;
}
const current = (getValues('skills') ?? []) as string[];
if (current.length === 0 && getValues('skills_scope') === SkillsScope.all) {
setValue('skills_enabled', true, { shouldDirty: true });
setValue('skill_authoring_enabled', false, { shouldDirty: true });
return;
}
const transition = skillsSelectionTransition(
current,
getValues('skills_enabled'),
getValues('skill_authoring_enabled'),
getValues('skills_scope'),
);
if (transition.enabled !== undefined) {
setValue('skills_enabled', transition.enabled, { shouldDirty: true });
}
if (transition.authoringEnabled !== undefined) {
setValue('skill_authoring_enabled', transition.authoringEnabled, { shouldDirty: true });
}
if (transition.scope !== undefined) {
setValue('skills_scope', transition.scope, { shouldDirty: true });
}
},
[getValues, setValue],
);
const handleUseAllSkillsChange = useCallback(
(checked: boolean) => {
if (checked) {
stashedSkillsRef.current = (getValues('skills') ?? []) as string[];
setValue('skills', [], { shouldDirty: true });
setValue('skills_enabled', true, { shouldDirty: true });
setValue('skill_authoring_enabled', false, { shouldDirty: true });
setValue('skills_scope', SkillsScope.all, { shouldDirty: true });
return;
}
const restored = stashedSkillsRef.current;
setValue('skills', restored, { shouldDirty: true });
setValue('skills_enabled', restored.length > 0, { shouldDirty: true });
const transition = skillsSelectionTransition(
restored,
getValues('skills_enabled'),
getValues('skill_authoring_enabled'),
getValues('skills_scope'),
);
if (transition.enabled !== undefined) {
setValue('skills_enabled', transition.enabled, { shouldDirty: true });
}
if (transition.authoringEnabled !== undefined) {
setValue('skill_authoring_enabled', transition.authoringEnabled, { shouldDirty: true });
}
if (transition.scope !== undefined) {
setValue('skills_scope', transition.scope, { shouldDirty: true });
}
},
[getValues, setValue],
);
@ -168,9 +227,22 @@ export default function ToolsSection({ agentId }: Props) {
const current = (getValues('skills') ?? []) as string[];
const next = current.filter((s) => s !== patch.id);
setValue('skills', next, { shouldDirty: true });
const flag = skillsEnabledTransition(next, getValues('skills_enabled'));
if (flag !== undefined) {
setValue('skills_enabled', flag, { shouldDirty: true });
const transition = skillsSelectionTransition(
next,
getValues('skills_enabled'),
getValues('skill_authoring_enabled'),
getValues('skills_scope'),
);
if (transition.enabled !== undefined) {
setValue('skills_enabled', transition.enabled, { shouldDirty: true });
}
if (transition.authoringEnabled !== undefined) {
setValue('skill_authoring_enabled', transition.authoringEnabled, {
shouldDirty: true,
});
}
if (transition.scope !== undefined) {
setValue('skills_scope', transition.scope, { shouldDirty: true });
}
break;
}
@ -288,9 +360,35 @@ export default function ToolsSection({ agentId }: Props) {
onInfo={setDialogItem}
onRemove={handleQuickRemove}
badgeText={useAllSkills ? localize('com_ui_all_proper') : undefined}
showAdd={!useAllSkills}
showBody={!useAllSkills}
showAdd={skillsCapabilityEnabled && !useAllSkills}
showBody={skillsCapabilityEnabled && !useAllSkills}
>
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
<div className="flex min-w-0 items-center gap-1.5">
<span
id="skills-enabled-label"
className="truncate text-[13px] font-medium text-text-primary"
>
{localize('com_ui_skills_enable')}
</span>
<HoverCard openDelay={50}>
<InfoTrigger />
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<p className="text-sm text-text-secondary">
{localize('com_ui_skills_enable_hint')}
</p>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</div>
<Switch
id="skills-enabled"
checked={skillsCapabilityEnabled}
onCheckedChange={handleSkillsEnabledChange}
aria-labelledby="skills-enabled-label"
/>
</div>
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
<div className="flex min-w-0 items-center gap-1.5">
<span
@ -313,6 +411,7 @@ export default function ToolsSection({ agentId }: Props) {
<Switch
id="use-all-skills"
checked={useAllSkills}
disabled={!skillsCapabilityEnabled}
onCheckedChange={handleUseAllSkillsChange}
aria-labelledby="use-all-skills-label"
/>

View file

@ -1,5 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { SkillsScope } from 'librechat-data-provider';
import { render, screen, fireEvent } from '@testing-library/react';
import type { AgentItem } from '../items/types';
import ToolsSection from '../ToolsSection';
@ -89,10 +90,14 @@ jest.mock('@librechat/client', () => ({
Switch: ({
id,
checked,
disabled,
'aria-labelledby': ariaLabelledBy,
onCheckedChange,
}: {
id?: string;
checked?: boolean;
disabled?: boolean;
'aria-labelledby'?: string;
onCheckedChange?: (value: boolean) => void;
}) => (
<button
@ -100,6 +105,8 @@ jest.mock('@librechat/client', () => ({
id={id}
role="switch"
aria-checked={checked}
aria-labelledby={ariaLabelledBy}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
/>
),
@ -140,7 +147,7 @@ describe('ToolsSection', () => {
test('renders a separate Skills section', () => {
render(<ToolsSection agentId="a" />);
expect(screen.getByText('com_ui_skills')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'com_ui_add_skills' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'com_ui_add_skills' })).not.toBeInTheDocument();
});
test('renders Add button that opens the marketplace dialog', () => {
@ -232,25 +239,74 @@ describe('ToolsSection', () => {
});
describe('use all skills toggle', () => {
test('renders off inside the Skills section by default', () => {
const enableSwitch = () => screen.getByRole('switch', { name: 'com_ui_skills_enable' });
const useAllSwitch = () => screen.getByRole('switch', { name: 'com_ui_skills_use_all' });
test('renders both controls off inside the Skills section by default', () => {
render(<ToolsSection agentId="a" />);
expect(screen.getByText('com_ui_skills_enable')).toBeInTheDocument();
expect(screen.getByText('com_ui_skills_enable_hint')).toBeInTheDocument();
expect(screen.getByText('com_ui_skills_use_all')).toBeInTheDocument();
expect(screen.getByText('com_ui_skills_use_all_hint')).toBeInTheDocument();
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
expect(enableSwitch()).toHaveAttribute('aria-checked', 'false');
expect(useAllSwitch()).toHaveAttribute('aria-checked', 'false');
expect(useAllSwitch()).toBeDisabled();
});
test('enables skills with an empty authoring-only catalog', () => {
render(<ToolsSection agentId="a" />);
fireEvent.click(enableSwitch());
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', false, { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skill_authoring_enabled', true, {
shouldDirty: true,
});
expect(mockSetValue).toHaveBeenCalledWith('skills_scope', SkillsScope.none, {
shouldDirty: true,
});
});
test('restores the full catalog when the master switch is re-enabled', () => {
mockFormValues = {
skills: [],
skills_enabled: false,
skill_authoring_enabled: false,
skills_scope: SkillsScope.all,
};
render(<ToolsSection agentId="a" />);
fireEvent.click(enableSwitch());
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', true, { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skill_authoring_enabled', false, {
shouldDirty: true,
});
expect(mockSetValue).not.toHaveBeenCalledWith(
'skills_scope',
expect.anything(),
expect.anything(),
);
});
test('turning it on clears the selection and enables the master flag', () => {
mockFormValues = { skills: ['s1'], skills_enabled: true };
mockFormValues = {
skills: ['s1'],
skills_enabled: true,
skills_scope: SkillsScope.selected,
};
render(<ToolsSection agentId="a" />);
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(useAllSwitch());
expect(mockSetValue).toHaveBeenCalledWith('skills', [], { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', true, { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skill_authoring_enabled', false, {
shouldDirty: true,
});
expect(mockSetValue).toHaveBeenCalledWith('skills_scope', SkillsScope.all, {
shouldDirty: true,
});
});
test('while on, hides Add and the skill list and shows the All badge', () => {
mockFormValues = { skills: [], skills_enabled: true };
mockFormValues = { skills: [], skills_enabled: true, skills_scope: SkillsScope.all };
render(<ToolsSection agentId="a" />);
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
expect(useAllSwitch()).toHaveAttribute('aria-checked', 'true');
expect(screen.queryByRole('button', { name: 'com_ui_add_skills' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /com_ui_skills_empty/ })).not.toBeInTheDocument();
expect(screen.getByText('com_ui_all_proper')).toBeInTheDocument();
@ -263,34 +319,59 @@ describe('use all skills toggle', () => {
});
test('turning it off restores the previously selected skills', () => {
mockFormValues = { skills: ['s1'], skills_enabled: true };
mockFormValues = {
skills: ['s1'],
skills_enabled: true,
skills_scope: SkillsScope.selected,
};
const { rerender } = render(<ToolsSection agentId="a" />);
fireEvent.click(screen.getByRole('switch'));
mockFormValues = { skills: [], skills_enabled: true };
fireEvent.click(useAllSwitch());
mockFormValues = { skills: [], skills_enabled: true, skills_scope: SkillsScope.all };
rerender(<ToolsSection agentId="a" />);
mockSetValue.mockClear();
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(useAllSwitch());
expect(mockSetValue).toHaveBeenCalledWith('skills', ['s1'], { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', true, { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skill_authoring_enabled', false, {
shouldDirty: true,
});
expect(mockSetValue).toHaveBeenCalledWith('skills_scope', SkillsScope.selected, {
shouldDirty: true,
});
});
test('does not restore a stash from a different agent', () => {
mockFormValues = { skills: ['s1'], skills_enabled: true };
mockFormValues = {
skills: ['s1'],
skills_enabled: true,
skills_scope: SkillsScope.selected,
};
const { rerender } = render(<ToolsSection agentId="a" />);
fireEvent.click(screen.getByRole('switch'));
mockFormValues = { skills: [], skills_enabled: true };
fireEvent.click(useAllSwitch());
mockFormValues = { skills: [], skills_enabled: true, skills_scope: SkillsScope.all };
rerender(<ToolsSection agentId="b" />);
mockSetValue.mockClear();
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(useAllSwitch());
expect(mockSetValue).toHaveBeenCalledWith('skills', [], { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', false, { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skill_authoring_enabled', true, {
shouldDirty: true,
});
expect(mockSetValue).toHaveBeenCalledWith('skills_scope', SkillsScope.none, {
shouldDirty: true,
});
});
test('turning it off with nothing stashed disables the master flag', () => {
mockFormValues = { skills: [], skills_enabled: true };
test('turning use-all off with nothing stashed keeps authoring enabled', () => {
mockFormValues = { skills: [], skills_enabled: true, skills_scope: SkillsScope.all };
render(<ToolsSection agentId="a" />);
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(useAllSwitch());
expect(mockSetValue).toHaveBeenCalledWith('skills', [], { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skills_enabled', false, { shouldDirty: true });
expect(mockSetValue).toHaveBeenCalledWith('skill_authoring_enabled', true, {
shouldDirty: true,
});
expect(mockSetValue).toHaveBeenCalledWith('skills_scope', SkillsScope.none, {
shouldDirty: true,
});
});
});

View file

@ -1,7 +1,7 @@
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import { SkillsScope, AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import type { AgentItem } from '../types';
import { makePlugin, makeSkill, makeMcpServer, makeAction } from 'test/itemFactories';
import { computeToggleAction, skillsEnabledTransition } from '../mutations';
import { computeToggleAction, skillsSelectionTransition } from '../mutations';
const builtinCode: AgentItem = {
kind: 'builtin',
@ -131,25 +131,34 @@ describe('computeToggleAction', () => {
});
});
describe('skillsEnabledTransition', () => {
test('a non-empty selection turns the master flag on when it is off', () => {
expect(skillsEnabledTransition(['s1'], undefined)).toBe(true);
expect(skillsEnabledTransition(['s1'], false)).toBe(true);
expect(skillsEnabledTransition(['s1', 's2'], false)).toBe(true);
describe('skillsSelectionTransition', () => {
test('a non-empty selection enables skills and selects the allowlist scope', () => {
expect(skillsSelectionTransition(['s1'], undefined, undefined, undefined)).toEqual({
enabled: true,
authoringEnabled: false,
scope: SkillsScope.selected,
});
expect(skillsSelectionTransition(['s1', 's2'], false, true, SkillsScope.none)).toEqual({
enabled: true,
authoringEnabled: false,
scope: SkillsScope.selected,
});
});
test('a non-empty selection leaves an already-on flag alone', () => {
expect(skillsEnabledTransition(['s1'], true)).toBeUndefined();
expect(skillsEnabledTransition(['s1', 's2'], true)).toBeUndefined();
test('an existing matching selection state needs no writes', () => {
expect(skillsSelectionTransition(['s1'], true, false, SkillsScope.selected)).toEqual({});
});
test('clearing the selection turns the master flag off', () => {
expect(skillsEnabledTransition([], true)).toBe(false);
});
test('an empty selection leaves an off flag alone', () => {
expect(skillsEnabledTransition([], false)).toBeUndefined();
expect(skillsEnabledTransition([], undefined)).toBeUndefined();
test('clearing the selection keeps the capability enabled in authoring-only mode', () => {
expect(skillsSelectionTransition([], true, false, SkillsScope.selected)).toEqual({
enabled: false,
authoringEnabled: true,
scope: SkillsScope.none,
});
expect(skillsSelectionTransition([], false, false, undefined)).toEqual({
authoringEnabled: true,
scope: SkillsScope.none,
});
});
});

View file

@ -1,4 +1,4 @@
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import { SkillsScope, AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import type { AgentItem } from './types';
export type TogglePatch =
@ -52,22 +52,24 @@ export function computeToggleAction(item: AgentItem, state: { selected: boolean
}
/**
* `skills_enabled` is the master opt-in for the skill allowlist, and an empty
* allowlist with the flag on means the FULL accessible catalog ("use all
* skills"). Selection edits sync the flag to the selection: any non-empty
* selection needs the flag on to take effect (this also heals agents saved
* with the since-removed Advanced kill switch off while skills were still
* selected), and clearing the selection turns it off so the agent doesn't
* silently escalate to the full catalog. Returns `undefined` when the flag
* already matches. The "use all skills" state (flag on, empty selection) is
* only ever set by its explicit toggle, never by selection edits.
* Selection edits enable the capability and move its catalog between an
* explicit allowlist and an authoring-only empty scope. They never produce
* the full-catalog state; only the "use all skills" control can do that.
*/
export function skillsEnabledTransition(
export function skillsSelectionTransition(
next: string[],
enabled: boolean | undefined,
): boolean | undefined {
if (next.length > 0) {
return enabled === true ? undefined : true;
}
return enabled === true ? false : undefined;
authoringEnabled: boolean | undefined,
scope: SkillsScope | undefined,
): { enabled?: boolean; authoringEnabled?: boolean; scope?: SkillsScope } {
const nextEnabled = next.length > 0;
const nextAuthoringEnabled = !nextEnabled;
const nextScope = next.length > 0 ? SkillsScope.selected : SkillsScope.none;
return {
...(enabled === nextEnabled ? {} : { enabled: nextEnabled }),
...(authoringEnabled === nextAuthoringEnabled
? {}
: { authoringEnabled: nextAuthoringEnabled }),
...(scope === nextScope ? {} : { scope: nextScope }),
};
}

View file

@ -159,6 +159,18 @@ describe('composeAgentUpdatePayload', () => {
expect(payload.code_environment_id).toBeUndefined();
});
it('persists standalone skill authoring separately from catalog access', () => {
const form = createForm();
form.skills = [];
form.skills_enabled = false;
form.skill_authoring_enabled = true;
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.skills_enabled).toBe(false);
expect(payload.skill_authoring_enabled).toBe(true);
});
});
describe('persistAvatarChanges', () => {

View file

@ -1,10 +1,13 @@
import { useStore } from 'jotai';
import { useRecoilCallback } from 'recoil';
import { showSkillsPopoverFamily } from '~/components/Chat/Input/skillsState';
import { clearLocalStorage } from '~/utils/localStorage';
import store from '~/store';
export default function useClearStates() {
const clearConversations = store.useClearConvoState();
const clearSubmissions = store.useClearSubmissionState();
const jotaiStore = useStore();
const clearStates = useRecoilCallback(
({ reset, snapshot }) =>
@ -30,7 +33,7 @@ export default function useClearStates() {
reset(store.showMentionPopoverFamily(key));
reset(store.showPlusPopoverFamily(key));
reset(store.showPromptsPopoverFamily(key));
reset(store.showSkillsPopoverFamily(key));
jotaiStore.set(showSkillsPopoverFamily(key), false);
reset(store.pendingManualSkillsByConvoId(key.toString()));
reset(store.pendingQuotesByConvoId(key.toString()));
/**
@ -55,7 +58,7 @@ export default function useClearStates() {
clearLocalStorage(skipFirst);
},
[],
[clearConversations, clearSubmissions, jotaiStore],
);
return clearStates;

View file

@ -9,6 +9,17 @@ const mockSkillsEnabled = { current: true };
const mockEndpoint = { current: 'openAI' as string | null };
const mockCommandToggles = { at: true, plus: true, slash: true, dollar: true };
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useSetAtom: jest.fn((atom: string) =>
atom === 'showSkillsPopoverFamily-0' ? mockSetShowSkillsPopover : jest.fn(),
),
}));
jest.mock('~/components/Chat/Input/skillsState', () => ({
showSkillsPopoverFamily: (idx: number) => `showSkillsPopoverFamily-${idx}`,
}));
jest.mock('recoil', () => ({
...jest.requireActual('recoil'),
useRecoilValue: jest.fn((atom) => {
@ -39,9 +50,6 @@ jest.mock('recoil', () => ({
if (atom === 'showPromptsPopoverFamily-0') {
return mockSetShowPromptsPopover;
}
if (atom === 'showSkillsPopoverFamily-0') {
return mockSetShowSkillsPopover;
}
return jest.fn();
}),
}));
@ -50,7 +58,6 @@ jest.mock('~/store', () => ({
showPromptsPopoverFamily: (idx: number) => `showPromptsPopoverFamily-${idx}`,
showMentionPopoverFamily: (idx: number) => `showMentionPopoverFamily-${idx}`,
showPlusPopoverFamily: (idx: number) => `showPlusPopoverFamily-${idx}`,
showSkillsPopoverFamily: (idx: number) => `showSkillsPopoverFamily-${idx}`,
effectiveEndpointByIndex: (idx: number) => `effectiveEndpointByIndex-${idx}`,
atCommand: 'atCommand',
plusCommand: 'plusCommand',

View file

@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useSetAtom } from 'jotai';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions, isAssistantsEndpoint } from 'librechat-data-provider';
import { showSkillsPopoverFamily } from '~/components/Chat/Input/skillsState';
import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage';
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
import useGetAgentsConfig from '~/hooks/Agents/useGetAgentsConfig';
@ -75,7 +77,7 @@ const useHandleKeyUp = ({
const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index));
const setShowPlusPopover = useSetRecoilState(store.showPlusPopoverFamily(index));
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const setShowSkillsPopover = useSetRecoilState(store.showSkillsPopoverFamily(index));
const setShowSkillsPopover = useSetAtom(showSkillsPopoverFamily(index));
const atCommandEnabled = useRecoilValue(store.atCommand);
const plusCommandEnabled = useRecoilValue(store.plusCommand);

View file

@ -2138,6 +2138,8 @@
"com_ui_skills_always_apply_pin_title": "Always-applied skill (auto-primed on every turn)",
"com_ui_skills_command_placeholder": "Select a Skill by name",
"com_ui_skills_dialog_description": "Browse and add skills to your agent.",
"com_ui_skills_enable": "Enable skills",
"com_ui_skills_enable_hint": "Allow the agent to use attached skills and, when permitted, create skills at runtime.",
"com_ui_skills_empty": "No skills yet",
"com_ui_skills_empty_hint": "Add a skill to give your agent reusable instructions.",
"com_ui_skills_filter": "Filter skills",

View file

@ -299,11 +299,6 @@ const showPromptsPopoverFamily = atomFamily<boolean, string | number | null>({
default: false,
});
const showSkillsPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showSkillsPopoverByIndex',
default: false,
});
/**
* Per-conversation queue of skill names the user invoked manually via the
* `$` popover for the next submission. Structured channel that the submit
@ -825,7 +820,6 @@ export default {
activePromptByIndex,
useClearSubmissionState,
showPromptsPopoverFamily,
showSkillsPopoverFamily,
pendingComposerTextByConvoId,
pendingManualSkillsByConvoId,
pendingQuotesByConvoId,

View file

@ -138,6 +138,11 @@ async function settleCleanup(tasks: Promise<unknown>[]): Promise<void> {
}
async function openSkillsDialog(page: Page, form: Locator): Promise<Locator> {
const enableSkillsSwitch = form.getByRole('switch', { name: 'Enable skills' });
if ((await enableSkillsSwitch.getAttribute('aria-checked')) !== 'true') {
await enableSkillsSwitch.click();
await expect(enableSkillsSwitch).toHaveAttribute('aria-checked', 'true');
}
await form.getByRole('button', { name: 'Add Skills', exact: true }).click();
const dialog = page
.getByRole('dialog')
@ -503,8 +508,14 @@ test.describe('Agent Builder skills', () => {
await selectMockModel(page, true);
form = page.getByRole('form', { name: 'Agent configuration form' });
const enableSkillsSwitch = form.getByRole('switch', { name: 'Enable skills' });
const useAllSwitch = form.getByRole('switch', { name: 'Use all skills' });
await expect(enableSkillsSwitch).toHaveAttribute('aria-checked', 'false');
await expect(useAllSwitch).toHaveAttribute('aria-checked', 'false');
await expect(useAllSwitch).toBeDisabled();
await enableSkillsSwitch.click();
await expect(enableSkillsSwitch).toHaveAttribute('aria-checked', 'true');
await expect(useAllSwitch).toBeEnabled();
await useAllSwitch.click();
await expect(useAllSwitch).toHaveAttribute('aria-checked', 'true');
await expect(form.getByRole('button', { name: 'Add Skills', exact: true })).toHaveCount(0);

View file

@ -34,6 +34,7 @@ jest.mock('@librechat/agents', () => ({
import { Types } from 'mongoose';
import { logger } from '@librechat/data-schemas';
import { SkillsScope } from 'librechat-data-provider';
import { HumanMessage, AIMessage } from '@librechat/agents/langchain/messages';
import {
scopeSkillIds,
@ -309,10 +310,17 @@ describe('resolveAgentScopedSkillIds', () => {
const persistedAgent = (
skills?: string[],
skills_enabled?: boolean,
): { id: string; skills?: string[]; skills_enabled?: boolean } => ({
skills_scope?: SkillsScope,
): {
id: string;
skills?: string[];
skills_enabled?: boolean;
skills_scope?: SkillsScope;
} => ({
id: 'agent_persisted_1',
skills,
skills_enabled,
skills_scope,
});
const ephemeralAgent = (
skills?: string[],
@ -485,6 +493,42 @@ describe('resolveAgentScopedSkillIds', () => {
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), c.toString()].sort());
});
it('returns no catalog for an enabled agent with explicit none scope', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: persistedAgent([], true, SkillsScope.none),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
}),
).toEqual([]);
});
it('returns the full catalog for explicit all scope even with stale selected ids', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: persistedAgent([a.toString()], true, SkillsScope.all),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(scoped).toEqual([a, b]);
});
it('fails closed when explicit selected scope has no ids', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: persistedAgent([], true, SkillsScope.selected),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
}),
).toEqual([]);
});
it('is unaffected by the ephemeral toggle — the persisted config is authoritative', () => {
const a = makeId();
const b = makeId();

View file

@ -1,3 +1,4 @@
import { SkillsScope } from 'librechat-data-provider';
import { getLazySubagentConfigId } from './lazySubagents';
const agent = {
@ -59,4 +60,20 @@ describe('getLazySubagentConfigId', () => {
getLazySubagentConfigId(agent),
);
});
it('changes when the persisted skill catalog scope changes', () => {
expect(
getLazySubagentConfigId({
...agent,
skills_enabled: true,
skills_scope: SkillsScope.none,
}),
).not.toBe(
getLazySubagentConfigId({
...agent,
skills_enabled: true,
skills_scope: SkillsScope.all,
}),
);
});
});

View file

@ -18,6 +18,8 @@ type VersionedAgent = Pick<
| 'tool_resources'
| 'skills'
| 'skills_enabled'
| 'skill_authoring_enabled'
| 'skills_scope'
| 'stateful_code_sessions'
| 'stateful_code_environment'
| 'code_environment_id'
@ -88,6 +90,8 @@ export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedA
tool_resources,
skills,
skills_enabled,
skill_authoring_enabled,
skills_scope,
stateful_code_sessions,
stateful_code_environment,
code_environment_id,
@ -118,6 +122,8 @@ export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedA
tool_resources,
skills,
skills_enabled,
skill_authoring_enabled,
skills_scope,
stateful_code_sessions,
stateful_code_environment,
code_environment_id,

View file

@ -1,6 +1,6 @@
import { logger } from '@librechat/data-schemas';
import { isEphemeralAgentId } from 'librechat-data-provider';
import { HumanMessage } from '@librechat/agents/langchain/messages';
import { SkillsScope, isEphemeralAgentId, resolveAgentSkillsScope } from 'librechat-data-provider';
import { formatSkillCatalog, SkillToolDefinition, ReadFileToolDefinition } from '@librechat/agents';
import type { LCToolRegistry, LCTool, InjectedMessage } from '@librechat/agents';
import type { BaseMessage } from '@librechat/agents/langchain/messages';
@ -311,8 +311,8 @@ export async function resolveModelSpecSkillIds({
}
export interface ResolveAgentScopedSkillIdsParams {
/** Agent being initialized. Reads `id`, `skills`, and `skills_enabled`. */
agent: Pick<Agent, 'id' | 'skills' | 'skills_enabled'>;
/** Agent being initialized. Reads its persisted skill capability and catalog scope. */
agent: Pick<Agent, 'id' | 'skills' | 'skills_enabled' | 'skills_scope'>;
/** Full set of skill IDs the user can VIEW (pre-scoped by ACL). */
accessibleSkillIds: Types.ObjectId[];
/** Admin capability: `AgentCapabilities.skills` on the agents endpoint. */
@ -328,9 +328,9 @@ export interface ResolveAgentScopedSkillIdsParams {
* `true` = full accessible catalog, string list = scoped allowlist,
* empty list / `false` = no skills. Otherwise the skills badge toggle
* controls the full accessible catalog.
* - Persisted agent → the builder's `skills_enabled` master switch.
* Enabled + empty allowlist = full catalog; enabled + non-empty
* allowlist = narrow to those ids; disabled (or undefined) = no skills.
* - Persisted agent → the builder's `skills_enabled` master switch and
* optional explicit `skills_scope`. Legacy agents without a scope retain
* enabled + empty = full catalog behavior.
*
* When not activated, returns `[]` so `injectSkillCatalog`,
* `resolveManualSkills`, and `resolveAlwaysApplySkills` all no-op.
@ -363,9 +363,16 @@ export function resolveAgentScopedSkillIds(
if (agent.skills_enabled !== true) {
return [];
}
if (!Array.isArray(agent.skills) || agent.skills.length === 0) {
const scope = resolveAgentSkillsScope(agent.skills, agent.skills_enabled, agent.skills_scope);
if (scope === SkillsScope.none) {
return [];
}
if (scope === SkillsScope.all) {
return scopeSkillIds(accessibleSkillIds, undefined);
}
if (!Array.isArray(agent.skills) || agent.skills.length === 0) {
return [];
}
return scopeSkillIds(accessibleSkillIds, agent.skills);
}

View file

@ -1,6 +1,7 @@
import { z } from 'zod';
import {
MemoryScope,
SkillsScope,
getMaxSubagents,
resolveModelCatalogKey,
ViolationTypes,
@ -431,6 +432,8 @@ export const agentBaseSchema: z.ZodObject<
tools: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
skills_enabled: z.ZodOptional<z.ZodBoolean>;
skill_authoring_enabled: z.ZodOptional<z.ZodBoolean>;
skills_scope: z.ZodOptional<z.ZodNativeEnum<typeof SkillsScope>>;
memory_scope: z.ZodOptional<z.ZodNativeEnum<typeof MemoryScope>>;
/** @deprecated Use edges instead */
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
@ -553,6 +556,8 @@ export const agentBaseSchema: z.ZodObject<
tools: z.array(z.string()).optional(),
skills: z.array(z.string()).optional(),
skills_enabled: z.boolean().optional(),
skill_authoring_enabled: z.boolean().optional(),
skills_scope: z.nativeEnum(SkillsScope).optional(),
memory_scope: z.nativeEnum(MemoryScope).optional(),
/** @deprecated Use edges instead */
agent_ids: z.array(z.string()).optional(),
@ -601,6 +606,8 @@ export const agentCreateSchema: z.ZodObject<
model_parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
skills_enabled: z.ZodOptional<z.ZodBoolean>;
skill_authoring_enabled: z.ZodOptional<z.ZodBoolean>;
skills_scope: z.ZodOptional<z.ZodNativeEnum<typeof SkillsScope>>;
memory_scope: z.ZodOptional<z.ZodNativeEnum<typeof MemoryScope>>;
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
edges: z.ZodOptional<
@ -733,6 +740,8 @@ export const agentUpdateSchema: z.ZodObject<
tools: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
skills: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
skills_enabled: z.ZodOptional<z.ZodBoolean>;
skill_authoring_enabled: z.ZodOptional<z.ZodBoolean>;
skills_scope: z.ZodOptional<z.ZodNativeEnum<typeof SkillsScope>>;
memory_scope: z.ZodOptional<z.ZodNativeEnum<typeof MemoryScope>>;
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
edges: z.ZodOptional<

View file

@ -361,6 +361,10 @@ export const defaultAgentFormValues = {
/** Master toggle for skill use on this agent. `true` activates skills
* (full catalog unless `skills` narrows it). Anything else = inactive. */
skills_enabled: undefined as boolean | undefined,
/** Enables runtime skill creation without exposing an existing skill catalog. */
skill_authoring_enabled: undefined as boolean | undefined,
/** Explicit catalog scope. Missing preserves the legacy enabled + empty = all behavior. */
skills_scope: undefined as SkillsScope | undefined,
/** `undefined` = feature disabled by default (no subagent tool injected). */
subagents: undefined as
| {
@ -931,6 +935,28 @@ export enum MemoryScope {
agent = 'agent',
}
/** Catalog exposure for a persisted agent with skills enabled. */
export enum SkillsScope {
all = 'all',
selected = 'selected',
none = 'none',
}
/** Resolves explicit and legacy persisted-agent skill catalog states. */
export function resolveAgentSkillsScope(
skills: readonly string[] | undefined,
enabled: boolean | undefined,
scope: SkillsScope | undefined,
): SkillsScope {
if (enabled !== true) {
return SkillsScope.none;
}
if (scope !== undefined) {
return scope;
}
return (skills ?? []).length > 0 ? SkillsScope.selected : SkillsScope.all;
}
export type MemoryArtifact = {
key: string;
value?: string;

View file

@ -1,5 +1,5 @@
import type { OpenAPIV3 } from 'openapi-types';
import type { AssistantsEndpoint, AgentProvider, MemoryScope } from 'src/schemas';
import type { AssistantsEndpoint, AgentProvider, MemoryScope, SkillsScope } from 'src/schemas';
import type { StatefulCodeEnvironment } from '../stateful-code';
import type { Agents, GraphEdge } from './agents';
import type { ContentTypes } from './runs';
@ -368,6 +368,10 @@ export type Agent = {
/** Master toggle for skill use on this agent. `true` = active (full catalog unless
* `skills` narrows it). `false`/undefined = inactive (no skills available). */
skills_enabled?: boolean;
/** Enables runtime skill creation without exposing an existing skill catalog. */
skill_authoring_enabled?: boolean;
/** Explicit catalog exposure while skills are enabled. Missing preserves legacy semantics. */
skills_scope?: SkillsScope;
/** Subagent spawning configuration — isolated-context child agents. */
subagents?: AgentSubagentsConfig;
/** Memory partition: `agent` isolates memories per (user, agent); default shared pool */
@ -402,6 +406,8 @@ export type AgentCreateParams = {
| 'tool_options'
| 'skills'
| 'skills_enabled'
| 'skill_authoring_enabled'
| 'skills_scope'
| 'subagents'
| 'memory_scope'
>;
@ -433,6 +439,8 @@ export type AgentUpdateParams = {
| 'tool_options'
| 'skills'
| 'skills_enabled'
| 'skill_authoring_enabled'
| 'skills_scope'
| 'subagents'
| 'memory_scope'
>;

View file

@ -8,6 +8,7 @@ import {
PrincipalModel,
PermissionBits,
EToolResources,
SkillsScope,
Constants,
actionDelimiter,
} from 'librechat-data-provider';
@ -2564,6 +2565,36 @@ describe('Agent Methods', () => {
expect(revertedAgent.code_environment_id).toBeUndefined();
});
test('should clear skill fields absent from a restored legacy version', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Legacy Skill Scope Agent',
provider: 'test',
model: 'test-model',
author: authorId,
skills: [],
skills_enabled: true,
});
await updateAgent(
{ id: agentId },
{
skills_enabled: false,
skill_authoring_enabled: true,
skills_scope: SkillsScope.none,
},
);
const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
expect(revertedAgent.skills_enabled).toBe(true);
expect(revertedAgent.skills).toEqual([]);
expect(revertedAgent.skills_scope).toBeUndefined();
expect(revertedAgent.skill_authoring_enabled).toBeUndefined();
});
test('should prune deleted skill ids when reverting to an older version', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();

View file

@ -1163,6 +1163,8 @@ export function createAgentMethods(
if (includeSkillConfig) {
projection.skills = 1;
projection.skills_enabled = 1;
projection.skill_authoring_enabled = 1;
projection.skills_scope = 1;
}
let query = Agent.find(baseQuery, projection).sort({ updatedAt: -1, _id: 1 });
@ -1244,13 +1246,16 @@ export function createAgentMethods(
}
}
const restoresDeploymentDefault = !Object.prototype.hasOwnProperty.call(
revertToVersion,
'code_environment_id',
);
const revertUpdate = restoresDeploymentDefault
? { $set: revertToVersion, $unset: { code_environment_id: 1 } }
: { $set: revertToVersion };
const unsetOnRestore: Record<string, 1> = {};
for (const field of ['code_environment_id', 'skills_scope', 'skill_authoring_enabled']) {
if (!Object.prototype.hasOwnProperty.call(revertToVersion, field)) {
unsetOnRestore[field] = 1;
}
}
const revertUpdate =
Object.keys(unsetOnRestore).length > 0
? { $set: revertToVersion, $unset: unsetOnRestore }
: { $set: revertToVersion };
const revertedAgent = await Agent.findOneAndUpdate(searchParameter, revertUpdate, {
new: true,
}).lean<IAgent>();

View file

@ -1,4 +1,5 @@
import { Schema } from 'mongoose';
import { SkillsScope } from 'librechat-data-provider';
import type { IAgent } from '~/types';
const agentSchema: Schema<IAgent> = new Schema<IAgent>(
@ -52,6 +53,15 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
type: Boolean,
default: undefined,
},
skill_authoring_enabled: {
type: Boolean,
default: undefined,
},
skills_scope: {
type: String,
enum: Object.values(SkillsScope),
default: undefined,
},
tool_kwargs: {
type: [{ type: Schema.Types.Mixed }],
},

View file

@ -2,6 +2,7 @@ import { Document, Types } from 'mongoose';
import type {
GraphEdge,
MemoryScope,
SkillsScope,
AgentToolOptions,
AgentToolResources,
AgentSubagentsConfig,
@ -30,6 +31,8 @@ export interface IAgent extends Omit<Document, 'model'> {
tools?: string[];
skills?: string[];
skills_enabled?: boolean;
skill_authoring_enabled?: boolean;
skills_scope?: SkillsScope;
tool_kwargs?: Array<unknown>;
actions?: string[];
author: Types.ObjectId;