mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
🪶 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:
parent
dfb264811c
commit
aa7539909b
33 changed files with 683 additions and 123 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
4
client/src/components/Chat/Input/skillsState.ts
Normal file
4
client/src/components/Chat/Input/skillsState.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { atom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
|
||||
export const showSkillsPopoverFamily = atomFamily((_index: string | number | null) => atom(false));
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue