🎚️ feat: Per-User Skill Active/Inactive Toggle with Ownership-Aware Defaults (#12692)

* feat: per-user skill active/inactive toggle with ownership-aware defaults

- Add `skillStates` map (Record<string, boolean>) to user schema for
  per-user active/inactive overrides on skills
- Add `defaultActiveOnShare` to interface.skills config (default: false)
  so admins can control whether shared skills auto-activate
- Add GET/POST /api/user/settings/skills/active endpoints with validation
- Add React Query hooks with optimistic mutations for skill states
- Add useSkillActiveState hook with ownership-aware resolution:
  owned skills default active, shared skills default inactive
- Add toggle switch UI to SkillListItem and SkillDetail components
- Filter inactive skills in injectSkillCatalog before agent injection
- Add localization keys for active/inactive labels

* fix: use Record instead of Map for IUser.skillStates

Mongoose .lean() flattens Map to a plain object, causing type
incompatibility with IUser in methods that return lean documents.

* fix: address review findings for skill active states

- Fail-closed when userId is absent: filter rejects all shared skills
  instead of passing them through unfiltered (Codex P1)
- Validate Mongoose Map key characters (reject . and $) in controller
  to return 400 instead of a 500 from schema validation (Codex P2)
- Block toggle while initial skill states query is loading to prevent
  overwriting server-side overrides with an empty snapshot (Codex P2)
- Extract shared SkillToggle component, eliminating duplicate toggle
  markup in SkillListItem and SkillDetail (Finding #3)
- Move skill state query/mutation hooks from Favorites.ts to
  Skills/queries.ts per feature-directory convention (Finding #4)
- Fix hardcoded English aria-label in SkillListItem by passing the
  localized string from the parent SkillList (Finding #5)
- Fix inline arrow in SkillList render loop: pass stable callback
  reference so SkillListItem memo() is not invalidated (Finding #1)
- Extract toRecord() helper in controller to DRY the Map-to-Object
  conversion (Finding #6)
- Remove Promise.resolve wrapping synchronous config read (Finding #8)
- Remove unused TUpdateSkillStatesRequest type (Finding #12)

* fix: forward tabIndex on SkillToggle to preserve list keyboard nav

The original inline toggle had tabIndex={-1} so the row itself
remained the sole tab target. The extraction into SkillToggle
dropped this prop, making every list toggle a tab stop. Add an
optional tabIndex prop and pass -1 from SkillListItem.

* fix: plumb skillStates to all agent entry points, isolate toggle keydown

- Add skillStates/defaultActiveOnShare loading to openai.js and
  responses.js controllers so shared-skill activation is respected
  across all agent entry points, not just initialize.js (Codex P1)
- Stop keydown propagation on SkillToggle so Enter/Space does not
  bubble to the parent row's navigation handler (Codex P2)

* fix: paginate catalog fetch and serialize toggle writes

- Paginate listSkillsByAccess (up to 10 pages of 100) until the active
  catalog quota is filled, so inactive shared skills in recent positions
  do not starve active owned skills past the first page (Codex P1)
- Extend listSkillsByAccess interface with cursor/has_more/after for
  catalog pagination
- Serialize skill-state writes via a ref queue: one in-flight request
  at a time, with the latest desired state sent when the previous one
  settles. Prevents last-response-wins races where an older request
  overwrites newer toggles (Codex P2)

* fix: share write queue across hook instances, block toggle on fetch error

- Move the write queue from a per-instance useRef to a module-scoped
  object so every mount of useSkillActiveState (SkillList, SkillDetail,
  etc.) serializes against the same in-flight slot. Prior per-instance
  queues allowed two components to race full-map POSTs (Codex P1)
- Extend the toggle guard beyond isLoading: also block when isError is
  true or data is undefined. Prevents a failed GET from seeding a
  toggle with an empty baseline that would wipe server-side overrides
  on the next successful POST (Codex P1)

* fix: stale closure, orphan cleanup, and cap-error UX

- Read toggle baseline from React Query cache via queryClient.getQueryData
  instead of the captured skillStates closure. The closure can be stale
  between onMutate's setQueryData and the next render, so rapid successive
  toggles would build on old state and drop earlier changes (Codex P1)
- Surface the MAX_SKILL_STATES_EXCEEDED error code with a specific toast
  key (com_ui_skill_states_limit) so users understand the 200-cap rather
  than seeing a generic error
- Prune orphaned entries (skillIds whose Skill doc no longer exists) on
  both GET and POST in SkillStatesController. Self-heals over time
  without needing cascade-delete hooks or a migration job. Uses one
  indexed Skill._id query per request

* test: pin skill active-state precedence with unit tests

Extract the active-state resolution logic from a closure inside
injectSkillCatalog into an exported resolveSkillActive helper, then
cover every branch of the precedence matrix:

- Fails closed when userId is absent (even with defaultActiveOnShare=true)
- Explicit override wins over ownership and config (both true and false)
- Owned skills default to active when no override is set
- Shared skills default to defaultActiveOnShare value
- Undefined skillStates behaves identically to an empty object
- defaultActiveOnShare defaults to false when omitted
- Owned skills ignore defaultActiveOnShare entirely

Closes Finding #2 from the pre-rebase comprehensive review. Mirrors
the existing scopeSkillIds test style; injectSkillCatalog now calls
resolveSkillActive instead of inlining the closure.

* refactor: limit skill active toggle to detail header, drop label

- Remove the per-row toggle from SkillListItem and the active-state
  plumbing (hook call, isSkillEnabled/onToggleEnabled/toggleAriaLabel
  props) from SkillList. The detail view is now the single place to
  change a skill's active state
- Drop dim/muted styling for inactive skills in the sidebar: without
  a control there, the visual indication has nowhere to land
- Resize SkillToggle to match neighbor buttons: outer h-9 container,
  h-6 w-11 track with size-5 knob, no label span. The 'Active' /
  'Inactive' text that accompanied the detail-view toggle is removed
- Remove the now-unused label prop and tabIndex prop (the tabIndex
  existed only for the list-row context) from SkillToggle. Drop the
  onKeyDown stopPropagation for the same reason
- Remove now-orphaned com_ui_skill_active / com_ui_skill_inactive
  translation keys

* style: shrink SkillToggle track to h-5 w-9 with size-4 knob

Container stays at h-9 to match neighbor button heights. The toggle
track itself drops from h-6 w-11 to h-5 w-9, with a size-4 knob
travelling 1.125rem on activation. Visually lighter inside the row.

* fix: remove redundant skillStates entries that match the resolved default

When a toggle lands on the ownership/config default, delete the key
from the map instead of persisting `{id: defaultValue}`. Without this,
a user toggling a skill off and back on would leave `{id: true}` for
an owned skill (whose default is already true), silently consuming a
slot against the 200-entry cap. Repeated round-trip toggles could
exhaust the quota with zero meaningful overrides (Codex P2).

Preserves the exceptions-list invariant that the runtime-resolution
design depends on.

* fix: prune before enforcing skill-state cap; reject non-ObjectId keys

Reorder the update controller so pruneOrphans runs before the 200-cap
check. Without this, a user near the cap with some orphaned entries
(skills deleted since their last GET) could send a payload that would
pass after pruning but gets rejected by the raw-size check first.

Add a sanity cap on raw payload size (2 * MAX_SKILL_STATES) so abusive
inputs do not reach the DB query, and enforce the real cap on the
pruned result instead.

Harden pruneOrphans: the earlier early-return path could pass
non-ObjectId keys through unchanged. Now only valid ObjectIds are
returned, and the Skill-model-unavailable fallback filters by format.

Also add isValidObjectIdString validation at the input boundary so
malformed (but otherwise non-Mongo-unsafe) keys never reach persistence
(Codex P2 x2).

* fix: enforce active filter at execute time, prune revoked shares, scope queue per user

P1: injectSkillCatalog now returns activeSkillIds (the filtered set
that appears in the catalog). initializeAgent uses that set as the
stored accessibleSkillIds on the initialized agent, so getSkillByName
at runtime cannot resolve a deactivated skill — even if the LLM
hallucinates a name or the user invokes by direct-invocation shorthand.
Previously the executor authorized against the full ACL set, bypassing
the active-state guarantee (Codex P1).

P2: pruneOrphans now checks user access via findAccessibleResources
in addition to skill existence. When a share is revoked, the user's
skillStates entry for that skill had no cleanup path and silently
consumed the 200-cap. Self-heals on both GET and POST. One extra ACL
query per settings read/write; scoped to a single user so no N-user
amplification (Codex P2).

P2: the write queue moves from a single module-scoped object to a Map
keyed by userId. Logout/login in the same tab can no longer flush the
previous user's pending snapshot under the new session's auth. Each
userId gets its own pending/inFlight slot; the in-flight request
retains its original auth via the cookie already attached when sent,
so the race window closes (Codex P2).

* refactor: extract skillStates helpers to packages/api; add tests; polish

Address the remaining valid findings from the comprehensive review:

- Extract toRecord, loadSkillStates, validateSkillStatesPayload, and
  pruneOrphanSkillStates into packages/api/src/skills/skillStates.ts
  as TypeScript. The controller in /api shrinks to a ~90-line thin
  wrapper that builds live dependency adapters for Mongoose + the
  permission service (Review #2 DRY, #3 workspace boundary)

- Replace the triplicated 12-line skillStates loading block in
  initialize.js, openai.js, and responses.js with a single call to
  loadSkillStates from @librechat/api. One helper, three sites

- Swap console.error for the project logger in the controller
  (Review #7)

- Remove the redundant INVALID_KEY_PATTERN regex: a valid ObjectId
  cannot contain . or $, so isValidObjectIdString already covers it
  (Review #11)

- Parameterize the 200-cap error toast with {{0}} interpolation
  driven by the error response's `limit` field, so future changes to
  MAX_SKILL_STATES update the UI message automatically (Review #12)

- Add 24 unit tests for the new skillStates helpers (toRecord,
  resolveDefaultActiveOnShare, loadSkillStates, validateSkillStates-
  Payload, pruneOrphanSkillStates) covering success paths, malformed
  input, cap boundaries, and parallel-query behavior (Review #4)

- Add 10 tests for injectSkillCatalog pagination covering empty
  accessible set, missing listSkillsByAccess, single-page filter,
  owned-vs-shared defaults, explicit-override precedence, multi-page
  collection, MAX_CATALOG_PAGES safety cap, early termination on
  has_more=false, additional_instructions injection, and fail-closed
  without userId (Review #5)

Total test count: 60 (was 26 on this surface).

* fix: rename skillStates ValidationError to avoid barrel-export collision

packages/api/src/types/error.ts already exports a ValidationError
(MongooseError extension). Re-exporting a different shape from
skills/skillStates.ts through the skills barrel caused TS2308 in CI
because the root index re-exports both. Rename to
SkillStatesValidationError to keep the exports disjoint.

* refactor: tighten tests and absorb caller guard into loadSkillStates

Address the followup review findings:

- Add optional `accessibleSkillIds` param to loadSkillStates so the
  helper short-circuits to defaults when no skills are accessible.
  All three controllers drop the residual 7-line conditional wrapper
  in favor of a single destructured call (Review #2)

- Remove the unreachable `typeof key !== 'string'` check from
  validateSkillStatesPayload: Object.entries always yields string
  keys per the JS spec (Review #3)

- Replace the two `as unknown as` agent casts in the injectSkillCatalog
  tests with a `makeAgent()` factory typed directly as the function's
  parameter shape (Review #4)

- Tighten the MAX_CATALOG_PAGES assertion from `toBeLessThanOrEqual(11)`
  to `toHaveBeenCalledTimes(10)` — the loop deterministically makes
  exactly 10 page fetches before hitting the cap (Review #1)

- Rewrite the parallel-execution test for pruneOrphanSkillStates using
  deferred promises instead of microtask-order assertions. The test
  now inspects `toHaveBeenCalledTimes(1)` on both mocks after a single
  Promise.resolve() yield, pinning Promise.all usage without relying
  on push-order into a shared array (Review #5)

- Evict stale writeQueue entries on user change via a module-scoped
  `lastSeenUserId` sentinel. When a different user's toggle is the
  first one after a logout/login, the previous user's queue entry is
  deleted. Keeps the Map bounded without adding hook-instance effect
  cleanup (Review #6)

* fix(test): mock loadSkillStates in openai and responses controller specs

The prior refactor replaced the inline 12-line skillStates loading
block with a call to loadSkillStates from @librechat/api. Both
controller spec files mock @librechat/api as a flat object, so any
new named import from that package is undefined in the test env.
Calling `await loadSkillStates(...)` threw before recordCollectedUsage
ran, surfacing as "undefined is not iterable" on the test's array
destructure of `mockRecordCollectedUsage.mock.calls[0]`.

Add the missing mock to both spec files alongside the existing
scopeSkillIds stub.

* fix: abandon stale skillStates write queues on user switch

Close the cross-session leak window where an in-flight flush loop
still holds a reference to a previous user's queue: it could fire its
next mutateAsync under the new session's auth cookies and persist
the stale snapshot to the new user's document (Codex P1).

Add an `abandoned` flag on `WriteQueue`. Three mechanisms cooperate:

- `getWriteQueue` marks every non-active queue abandoned when the
  user differs from the last-seen identity (pre-existing eviction
  site, now more aggressive).
- A `useEffect` on `userId` calls the same abandonment pass on every
  render with a new active identity, covering the window between
  logout/login and the new user's first toggle (when `getWriteQueue`
  would otherwise not fire).
- The flush loop checks `!queue.abandoned` in its while condition so
  the second and later iterations exit without firing another
  `mutateAsync` after the session changes.

The first iteration's in-flight request (already dispatched under the
original user's cookies) still runs to completion or failure on its
own — only the subsequent iterations, which are the dangerous ones,
are blocked.
This commit is contained in:
Danny Avila 2026-04-16 21:20:50 -04:00
parent 3e064c2f2b
commit 9225a279eb
29 changed files with 1331 additions and 25 deletions

View file

@ -0,0 +1,93 @@
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
MAX_SKILL_STATES,
toSkillStatesRecord,
validateSkillStatesPayload,
pruneOrphanSkillStates,
} = require('@librechat/api');
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { updateUser, getUserById } = require('~/models');
/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */
function buildPruneDeps(user) {
return {
findExistingSkillIds: async (validIds) => {
const Skill = mongoose.models.Skill;
if (!Skill) {
return validIds;
}
const existing = await Skill.find({ _id: { $in: validIds } })
.select('_id')
.lean();
return existing.map((doc) => doc._id.toString());
},
findAccessibleSkillIds: () =>
findAccessibleResources({
userId: user.id,
role: user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
};
}
const getSkillStatesController = async (req, res) => {
try {
const userId = req.user.id;
const user = await getUserById(userId, 'skillStates');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const states = toSkillStatesRecord(user.skillStates);
const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user));
return res.status(200).json(pruned);
} catch (error) {
logger.error('[SkillStatesController] Error fetching skill states:', error);
return res.status(500).json({ message: 'Internal server error' });
}
};
const updateSkillStatesController = async (req, res) => {
try {
const { skillStates } = req.body;
const validationError = validateSkillStatesPayload(skillStates);
if (validationError) {
const { message, code, limit } = validationError;
const payload = { message };
if (code) payload.code = code;
if (limit != null) payload.limit = limit;
return res.status(400).json(payload);
}
const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user));
if (Object.keys(pruned).length > MAX_SKILL_STATES) {
return res.status(400).json({
code: 'MAX_SKILL_STATES_EXCEEDED',
message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`,
limit: MAX_SKILL_STATES,
});
}
const user = await updateUser(req.user.id, { skillStates: pruned });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
return res.status(200).json(toSkillStatesRecord(user.skillStates));
} catch (error) {
logger.error('[SkillStatesController] Error updating skill states:', error);
return res.status(500).json({ message: 'Internal server error' });
}
};
module.exports = {
getSkillStatesController,
updateSkillStatesController,
};

View file

@ -41,6 +41,7 @@ jest.mock('@librechat/api', () => ({
createChunk: jest.fn().mockReturnValue({}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
sendFinalChunk: jest.fn(),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
validateRequest: jest

View file

@ -42,6 +42,7 @@ jest.mock('@librechat/api', () => ({
}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
initializeAgent: jest.fn().mockResolvedValue({
id: 'agent-123',

View file

@ -14,6 +14,7 @@ const {
createChunk,
buildToolSet,
scopeSkillIds,
loadSkillStates,
sendFinalChunk,
createSafeUser,
validateRequest,
@ -256,6 +257,13 @@ const OpenAIChatCompletionController = async (req, res) => {
})
: [];
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
userId: req.user.id,
appConfig,
getUserById: db.getUserById,
accessibleSkillIds,
});
const primaryConfig = await initializeAgent(
{
req,
@ -273,6 +281,8 @@ const OpenAIChatCompletionController = async (req, res) => {
ephemeralSkillsToggle ? undefined : agent.skills,
),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,
},
dbMethods,
);

View file

@ -13,6 +13,7 @@ const {
createRun,
buildToolSet,
scopeSkillIds,
loadSkillStates,
createSafeUser,
initializeAgent,
getBalanceConfig,
@ -385,6 +386,13 @@ const createResponse = async (req, res) => {
})
: [];
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
userId: req.user.id,
appConfig,
getUserById: db.getUserById,
accessibleSkillIds,
});
const primaryConfig = await initializeAgent(
{
req,
@ -402,6 +410,8 @@ const createResponse = async (req, res) => {
ephemeralSkillsToggle ? undefined : agent.skills,
),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,
},
dbMethods,
);

View file

@ -3,11 +3,17 @@ const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
const {
getSkillStatesController,
updateSkillStatesController,
} = require('~/server/controllers/SkillStatesController');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
router.get('/favorites', requireJwtAuth, getFavoritesController);
router.post('/favorites', requireJwtAuth, updateFavoritesController);
router.get('/skills/active', requireJwtAuth, getSkillStatesController);
router.post('/skills/active', requireJwtAuth, updateSkillStatesController);
module.exports = router;

View file

@ -2,6 +2,7 @@ const { logger } = require('@librechat/data-schemas');
const { EnvVar, createContentAggregator } = require('@librechat/agents');
const {
scopeSkillIds,
loadSkillStates,
initializeAgent,
primeInvokedSkills,
validateAgentModel,
@ -124,6 +125,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
})
: [];
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
userId: req.user.id,
appConfig,
getUserById: db.getUserById,
accessibleSkillIds,
});
// Resolve code API key once for the entire run (shared by primeInvokedSkills
// and enrichWithSkillConfigurable) to avoid redundant auth lookups.
let codeApiKey;
@ -243,6 +251,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
ephemeralSkillsToggle ? undefined : primaryAgent.skills,
),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,
},
{
getFiles: db.getFiles,
@ -290,6 +300,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
parentMessageId,
computeAccessibleSkillIds: (agent) =>
scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills),
skillStates,
defaultActiveOnShare,
},
{
getAgent: db.getAgent,

View file

@ -0,0 +1,40 @@
import { memo } from 'react';
import { cn } from '~/utils';
interface SkillToggleProps {
enabled: boolean;
onChange: () => void;
ariaLabel: string;
}
function SkillToggle({ enabled, onChange, ariaLabel }: SkillToggleProps) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={ariaLabel}
onClick={(e) => {
e.stopPropagation();
onChange();
}}
className="inline-flex h-9 items-center justify-center rounded-md px-1 transition-colors hover:bg-surface-hover"
>
<span
className={cn(
'relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200',
enabled ? 'bg-green-500' : 'bg-border-medium',
)}
>
<span
className={cn(
'pointer-events-none mt-0.5 inline-block size-4 rounded-full bg-white shadow-sm transition-transform duration-200',
enabled ? 'translate-x-[1.125rem]' : 'translate-x-0.5',
)}
/>
</span>
</button>
);
}
export default memo(SkillToggle);

View file

@ -1,3 +1,4 @@
export { default as AdminSettings } from './AdminSettings';
export { default as SkillToggle } from './SkillToggle';
export { default as ShareSkill } from './ShareSkill';
export { default as CreateSkillMenu } from './CreateSkillMenu';

View file

@ -3,11 +3,11 @@ import { format } from 'date-fns';
import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react';
import { TooltipAnchor } from '@librechat/client';
import type { TSkill } from 'librechat-data-provider';
import { useLocalize, useAuthContext, useSkillPermissions } from '~/hooks';
import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks';
import { ShareSkill, SkillToggle } from '../buttons';
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
import { parseFrontmatter } from '../utils';
import DeleteSkill from '../dialogs/DeleteSkill';
import { ShareSkill } from '../buttons';
import { cn } from '~/utils';
interface SkillDetailProps {
@ -68,7 +68,9 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp
const localize = useLocalize();
const { user } = useAuthContext();
const permissions = useSkillPermissions(skill);
const { isActive, toggle } = useSkillActiveState();
const [viewMode, setViewMode] = useState<'rendered' | 'source'>('rendered');
const skillEnabled = isActive(skill);
const isPublic = skill.isPublic === true;
const isShared = skill.author !== user?.id && Boolean(skill.authorName);
@ -130,6 +132,11 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp
{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
<SkillToggle
enabled={skillEnabled}
onChange={() => toggle(skill)}
ariaLabel={localize('com_ui_skill_toggle_active')}
/>
<ShareSkill skill={skill} />
{permissions.canEdit && onEdit && (
<button

View file

@ -3,8 +3,8 @@ import { ChevronRight } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { Skeleton } from '@librechat/client';
import type { TSkill } from 'librechat-data-provider';
import SkillListItem from './SkillListItem';
import { useLocalize } from '~/hooks';
import SkillListItem from './SkillListItem';
import { cn } from '~/utils';
interface SkillListProps {
@ -13,9 +13,7 @@ interface SkillListProps {
activeSkillId?: string;
}
/**
* Claude.aistyle skill list with a collapsible "Personal skills" section.
*/
/** Collapsible skill list. Active/inactive toggling lives in the detail view. */
export default function SkillList({ skills, isLoading, activeSkillId }: SkillListProps) {
const localize = useLocalize();
const [searchParams] = useSearchParams();

View file

@ -1,4 +1,4 @@
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type {
QueryObserverResult,
@ -9,6 +9,7 @@ import type {
TSkill,
TSkillListRequest,
TSkillListResponse,
TSkillStatesResponse,
TListSkillFilesResponse,
TSkillFileContentResponse,
} from 'librechat-data-provider';
@ -149,3 +150,39 @@ export const useGetSkillFileContentQuery = (
},
);
};
/** Per-user skill active/inactive overrides. */
export const useGetSkillStatesQuery = (
config?: Omit<UseQueryOptions<TSkillStatesResponse, Error>, 'queryKey' | 'queryFn'>,
) => {
return useQuery<TSkillStatesResponse, Error>(
[QueryKeys.skillStates],
() => dataService.getSkillStates(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};
export const useUpdateSkillStatesMutation = () => {
const queryClient = useQueryClient();
return useMutation(
(skillStates: TSkillStatesResponse) => dataService.updateSkillStates(skillStates),
{
onMutate: async (next) => {
await queryClient.cancelQueries([QueryKeys.skillStates]);
const previous = queryClient.getQueryData<TSkillStatesResponse>([QueryKeys.skillStates]);
queryClient.setQueryData([QueryKeys.skillStates], next);
return { previous };
},
onError: (_err, _next, context) => {
if (context?.previous !== undefined) {
queryClient.setQueryData([QueryKeys.skillStates], context.previous);
}
},
},
);
};

View file

@ -1,2 +1,3 @@
export { default as useSkillActiveState } from './useSkillActiveState';
export { default as useSkillPermissions } from './useSkillPermissions';
export type { SkillPermissions } from './useSkillPermissions';

View file

@ -0,0 +1,214 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import type { TSkillStatesResponse } from 'librechat-data-provider';
import {
useGetSkillStatesQuery,
useUpdateSkillStatesMutation,
useGetStartupConfig,
} from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import { logger } from '~/utils';
const EMPTY_STATES: TSkillStatesResponse = {};
interface WriteQueue {
pending: TSkillStatesResponse | null;
inFlight: boolean;
/**
* Set when the active user changes so any in-flight flush loop still
* holding a reference to this queue exits before sending another request
* under the new user's auth context. Once abandoned, the queue is single-
* use-dead: callers must create a new one via `getWriteQueue`.
*/
abandoned: boolean;
}
/**
* Module-scoped, per-user write queues so every hook instance (SkillList,
* SkillDetail, etc.) shares a single in-flight slot for the active user.
* Per-instance refs let two components race full-map POSTs and drop toggles
* via last-writer-wins. Keying by `userId` prevents pending writes from
* leaking across account transitions in the same browser tab (logout/login
* flushing a previous user's snapshot into the new session). `lastSeenUserId`
* tracks the most recent active identity so we can evict and abandon a prior
* user's queue entry when the active identity changes.
*/
const writeQueues = new Map<string, WriteQueue>();
let lastSeenUserId: string | null = null;
/** Marks every queue whose userId does not match `activeUserId` as abandoned. */
function abandonOtherQueues(activeUserId: string): void {
for (const [id, queue] of writeQueues) {
if (id !== activeUserId) {
queue.abandoned = true;
queue.pending = null;
writeQueues.delete(id);
}
}
}
function getWriteQueue(userId: string): WriteQueue {
if (lastSeenUserId !== null && lastSeenUserId !== userId) {
abandonOtherQueues(userId);
}
lastSeenUserId = userId;
let queue = writeQueues.get(userId);
if (!queue) {
queue = { pending: null, inFlight: false, abandoned: false };
writeQueues.set(userId, queue);
}
return queue;
}
/**
* Resolves the default active state for a skill the user has never toggled.
*
* - Owned skills (author === currentUser) default to **active**.
* - Shared skills default to the `defaultActiveOnShare` config value (default `false`).
*/
function resolveDefault(author: string, userId: string, defaultActiveOnShare: boolean): boolean {
return author === userId ? true : defaultActiveOnShare;
}
/**
* Hook for managing per-user skill active/inactive state.
*
* The `skillStates` map stores explicit overrides (`{ [skillId]: boolean }`).
* Skills absent from the map use the ownership-based default: owned -> active,
* shared -> `defaultActiveOnShare` from the interface config. Toggles that
* land on the resolved default remove the key from the map rather than
* persisting a redundant entry, keeping `skillStates` strictly an exceptions
* list (otherwise rapid round-trip toggles would exhaust the 200-entry cap).
*
* React Query is the single source of truth. Toggling drives an optimistic
* mutation that updates the cache, identical to the favorites pattern.
* Toggling is blocked until the initial fetch succeeds, preventing an empty
* baseline (from isLoading or a failed GET) from wiping server-side overrides.
* Writes are serialized via a module-scoped queue so rapid toggles from any
* hook instance cannot race: only one request is ever in flight, and the
* latest desired state is sent when the previous one settles. Each toggle
* reads the latest optimistic state directly from the React Query cache so
* rapid successive toggles cannot drop earlier changes via stale closure.
* An `abandoned` flag on the queue guards against cross-session writes: on
* logout/login, the previous user's queue is marked abandoned so an in-flight
* flush loop exits instead of posting the stale snapshot under the new auth.
*/
export default function useSkillActiveState() {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const queryClient = useQueryClient();
const configQuery = useGetStartupConfig();
const getQuery = useGetSkillStatesQuery();
const updateMutation = useUpdateSkillStatesMutation();
const userId = user?.id ?? '';
/**
* Proactively abandon other users' queues whenever the active identity
* changes. `getWriteQueue` already handles this on the next toggle, but an
* in-flight flush loop from the previous user can fire its next iteration
* before any toggle runs that iteration would carry the stale payload
* under the new user's cookies. Running this effect on every userId change
* closes that window for any hook instance that renders post-login.
*/
useEffect(() => {
if (!userId) {
return;
}
abandonOtherQueues(userId);
lastSeenUserId = userId;
}, [userId]);
const defaultActiveOnShare = useMemo(() => {
const skills = configQuery.data?.interface?.skills;
if (typeof skills === 'object' && skills !== null && 'defaultActiveOnShare' in skills) {
return skills.defaultActiveOnShare === true;
}
return false;
}, [configQuery.data]);
const skillStates = useMemo<TSkillStatesResponse>(
() => (getQuery.data && typeof getQuery.data === 'object' ? getQuery.data : EMPTY_STATES),
[getQuery.data],
);
const canToggle = !getQuery.isLoading && !getQuery.isError && getQuery.data !== undefined;
const flush = useCallback(async () => {
if (!userId) {
return;
}
const queue = getWriteQueue(userId);
while (queue.pending !== null && !queue.abandoned) {
const next = queue.pending;
queue.pending = null;
queue.inFlight = true;
try {
await updateMutation.mutateAsync(next);
} catch (error) {
logger.error('Error updating skill states:', error);
const data = (error as { response?: { data?: { code?: string; limit?: number } } })
?.response?.data;
const message =
data?.code === 'MAX_SKILL_STATES_EXCEEDED'
? localize('com_ui_skill_states_limit', { 0: String(data.limit ?? '') })
: localize('com_ui_error');
showToast({ message, status: 'error' });
queue.pending = null;
break;
}
}
queue.inFlight = false;
}, [userId, updateMutation, showToast, localize]);
const isActive = useCallback(
(skill: { _id: string; author: string }): boolean => {
const override = skillStates[skill._id];
if (override !== undefined) {
return override;
}
return resolveDefault(skill.author, userId, defaultActiveOnShare);
},
[skillStates, userId, defaultActiveOnShare],
);
const toggle = useCallback(
(skill: { _id: string; author: string }) => {
if (!canToggle || !userId) {
return;
}
const queue = getWriteQueue(userId);
const cached =
queryClient.getQueryData<TSkillStatesResponse>([QueryKeys.skillStates]) ?? EMPTY_STATES;
const baseline = queue.pending ?? cached;
const defaultValue = resolveDefault(skill.author, userId, defaultActiveOnShare);
const override = baseline[skill._id];
const currentActive = override !== undefined ? override : defaultValue;
const nextValue = !currentActive;
const next = { ...baseline };
if (nextValue === defaultValue) {
delete next[skill._id];
} else {
next[skill._id] = nextValue;
}
queue.pending = next;
if (!queue.inFlight) {
flush();
}
},
[queryClient, userId, defaultActiveOnShare, canToggle, flush],
);
return {
skillStates,
defaultActiveOnShare,
isActive,
toggle,
isLoading: getQuery.isLoading,
isError: getQuery.isError,
isUpdating: updateMutation.isLoading,
};
}

View file

@ -1463,6 +1463,8 @@
"com_ui_size": "Size",
"com_ui_size_sort": "Sort by Size",
"com_ui_skill": "Skill",
"com_ui_skill_states_limit": "You've reached the limit of {{0}} active/inactive skill overrides. Remove some overrides to toggle new skills.",
"com_ui_skill_toggle_active": "Toggle skill active state",
"com_ui_skill_content": "Skill Content",
"com_ui_skill_content_placeholder": "Enter your skill instructions in markdown...",
"com_ui_skill_create_error": "Failed to create skill",

View file

@ -1,4 +1,8 @@
/** Mock Constants.SKILL_TOOL since the installed SDK version may not include it yet */
/**
* Mock the pieces of `@librechat/agents` the installed SDK version may not
* export yet. Includes both the `Constants.SKILL_TOOL` stub and the skill
* catalog/tool-definition helpers needed to exercise `injectSkillCatalog`.
*/
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
Constants: {
@ -6,12 +10,33 @@ jest.mock('@librechat/agents', () => ({
.Constants,
SKILL_TOOL: 'skill',
},
formatSkillCatalog: (skills: Array<{ name: string; description: string }>) =>
skills.map((s) => `- ${s.name}: ${s.description}`).join('\n'),
SkillToolDefinition: { name: 'skill', description: 'skill tool', parameters: {} },
ReadFileToolDefinition: {
name: 'read_file',
description: 'read file',
parameters: {},
responseFormat: 'content',
},
BashExecutionToolDefinition: {
name: 'bash_tool',
description: 'bash',
schema: {},
},
}));
import { Types } from 'mongoose';
import { scopeSkillIds } from '../skills';
import { scopeSkillIds, resolveSkillActive, injectSkillCatalog } from '../skills';
import { extractInvokedSkillsFromPayload } from '../run';
type PageSkill = {
_id: Types.ObjectId;
name: string;
description: string;
author: Types.ObjectId;
};
describe('extractInvokedSkillsFromPayload', () => {
it('extracts skill names from assistant messages with skill tool_calls', () => {
const payload = [
@ -248,3 +273,289 @@ describe('scopeSkillIds', () => {
expect(scopeSkillIds([], [new Types.ObjectId().toString()])).toEqual([]);
});
});
describe('resolveSkillActive', () => {
const makeSkill = (author: Types.ObjectId) => ({ _id: new Types.ObjectId(), author });
it('fails closed when userId is undefined and no override is set', () => {
const skill = makeSkill(new Types.ObjectId());
expect(
resolveSkillActive({
skill,
skillStates: {},
userId: undefined,
defaultActiveOnShare: true,
}),
).toBe(false);
});
it('fails closed when userId is undefined even if defaultActiveOnShare is true', () => {
const skill = makeSkill(new Types.ObjectId());
expect(
resolveSkillActive({
skill,
userId: undefined,
defaultActiveOnShare: true,
}),
).toBe(false);
});
it('respects explicit override = true regardless of ownership or config', () => {
const userId = new Types.ObjectId().toString();
const sharedSkill = makeSkill(new Types.ObjectId());
expect(
resolveSkillActive({
skill: sharedSkill,
skillStates: { [sharedSkill._id.toString()]: true },
userId,
defaultActiveOnShare: false,
}),
).toBe(true);
});
it('respects explicit override = false even for owned skills', () => {
const userObjectId = new Types.ObjectId();
const userId = userObjectId.toString();
const ownedSkill = makeSkill(userObjectId);
expect(
resolveSkillActive({
skill: ownedSkill,
skillStates: { [ownedSkill._id.toString()]: false },
userId,
defaultActiveOnShare: true,
}),
).toBe(false);
});
it('owned skills default to active when no override is present', () => {
const userObjectId = new Types.ObjectId();
const userId = userObjectId.toString();
const ownedSkill = makeSkill(userObjectId);
expect(
resolveSkillActive({
skill: ownedSkill,
skillStates: {},
userId,
defaultActiveOnShare: false,
}),
).toBe(true);
});
it('shared skills default to inactive when defaultActiveOnShare is false', () => {
const userId = new Types.ObjectId().toString();
const sharedSkill = makeSkill(new Types.ObjectId());
expect(
resolveSkillActive({
skill: sharedSkill,
skillStates: {},
userId,
defaultActiveOnShare: false,
}),
).toBe(false);
});
it('shared skills default to active when defaultActiveOnShare is true', () => {
const userId = new Types.ObjectId().toString();
const sharedSkill = makeSkill(new Types.ObjectId());
expect(
resolveSkillActive({
skill: sharedSkill,
skillStates: {},
userId,
defaultActiveOnShare: true,
}),
).toBe(true);
});
it('treats skillStates = undefined identically to an empty object', () => {
const userObjectId = new Types.ObjectId();
const userId = userObjectId.toString();
const ownedSkill = makeSkill(userObjectId);
const sharedSkill = makeSkill(new Types.ObjectId());
expect(resolveSkillActive({ skill: ownedSkill, userId, defaultActiveOnShare: false })).toBe(
resolveSkillActive({
skill: ownedSkill,
skillStates: {},
userId,
defaultActiveOnShare: false,
}),
);
expect(resolveSkillActive({ skill: sharedSkill, userId, defaultActiveOnShare: true })).toBe(
resolveSkillActive({
skill: sharedSkill,
skillStates: {},
userId,
defaultActiveOnShare: true,
}),
);
});
it('defaults defaultActiveOnShare to false when the param is omitted', () => {
const userId = new Types.ObjectId().toString();
const sharedSkill = makeSkill(new Types.ObjectId());
expect(resolveSkillActive({ skill: sharedSkill, skillStates: {}, userId })).toBe(false);
});
it('ignores defaultActiveOnShare for owned skills', () => {
const userObjectId = new Types.ObjectId();
const userId = userObjectId.toString();
const ownedSkill = makeSkill(userObjectId);
expect(
resolveSkillActive({
skill: ownedSkill,
skillStates: {},
userId,
defaultActiveOnShare: false,
}),
).toBe(true);
});
});
describe('injectSkillCatalog', () => {
const userId = new Types.ObjectId().toString();
const userObjectId = new Types.ObjectId(userId);
/** Minimal `Agent` shape `injectSkillCatalog` actually reads/writes. */
type MockAgent = Parameters<typeof injectSkillCatalog>[0]['agent'];
function makeAgent(): MockAgent {
return { additional_instructions: undefined } as MockAgent;
}
function makeSkill(name: string, author: Types.ObjectId = userObjectId): PageSkill {
return {
_id: new Types.ObjectId(),
name,
description: `desc-${name}`,
author,
};
}
function buildPager(pages: PageSkill[][]) {
return jest.fn().mockImplementation(async ({ cursor }: { cursor?: string | null }) => {
const pageIndex = cursor ? Number(cursor) : 0;
const skills = pages[pageIndex] ?? [];
const has_more = pageIndex < pages.length - 1;
return {
skills,
has_more,
after: has_more ? String(pageIndex + 1) : null,
};
});
}
function baseParams(overrides: Partial<Parameters<typeof injectSkillCatalog>[0]> = {}) {
return {
agent: makeAgent(),
toolDefinitions: undefined,
toolRegistry: undefined,
accessibleSkillIds: [new Types.ObjectId()],
contextWindowTokens: 200_000,
listSkillsByAccess: jest.fn(),
userId,
skillStates: {},
defaultActiveOnShare: false,
...overrides,
};
}
it('returns empty when no skills are accessible', async () => {
const result = await injectSkillCatalog(
baseParams({ accessibleSkillIds: [], listSkillsByAccess: jest.fn() }),
);
expect(result.skillCount).toBe(0);
expect(result.activeSkillIds).toEqual([]);
});
it('returns empty when listSkillsByAccess is not provided', async () => {
const result = await injectSkillCatalog(baseParams({ listSkillsByAccess: undefined }));
expect(result.skillCount).toBe(0);
expect(result.activeSkillIds).toEqual([]);
});
it('filters out inactive skills on a single page (shared, default inactive)', async () => {
const owned = makeSkill('my-skill', userObjectId);
const sharedInactive = makeSkill('other-skill', new Types.ObjectId());
const listSkillsByAccess = buildPager([[owned, sharedInactive]]);
const result = await injectSkillCatalog(baseParams({ listSkillsByAccess }));
expect(result.skillCount).toBe(1);
expect(result.activeSkillIds.map((id) => id.toString())).toEqual([owned._id.toString()]);
});
it('includes shared skills when defaultActiveOnShare is true', async () => {
const shared = makeSkill('other-skill', new Types.ObjectId());
const listSkillsByAccess = buildPager([[shared]]);
const result = await injectSkillCatalog(
baseParams({ listSkillsByAccess, defaultActiveOnShare: true }),
);
expect(result.skillCount).toBe(1);
expect(result.activeSkillIds.map((id) => id.toString())).toEqual([shared._id.toString()]);
});
it('honors explicit overrides (deactivated owned skill absent from catalog)', async () => {
const owned = makeSkill('off', userObjectId);
const listSkillsByAccess = buildPager([[owned]]);
const result = await injectSkillCatalog(
baseParams({
listSkillsByAccess,
skillStates: { [owned._id.toString()]: false },
}),
);
expect(result.skillCount).toBe(0);
expect(result.activeSkillIds).toEqual([]);
});
it('paginates across pages and collects active skills from later pages', async () => {
const sharedInactive = Array.from({ length: 3 }, (_, i) =>
makeSkill(`shared-${i}`, new Types.ObjectId()),
);
const owned = makeSkill('my-skill', userObjectId);
const listSkillsByAccess = buildPager([sharedInactive, [owned]]);
const result = await injectSkillCatalog(baseParams({ listSkillsByAccess }));
expect(result.skillCount).toBe(1);
expect(result.activeSkillIds.map((id) => id.toString())).toEqual([owned._id.toString()]);
expect(listSkillsByAccess).toHaveBeenCalledTimes(2);
});
it('stops paginating at MAX_CATALOG_PAGES even if no active skills found', async () => {
const inactivePage = Array.from({ length: 10 }, (_, i) =>
makeSkill(`shared-${i}`, new Types.ObjectId()),
);
// Build 12 pages, all inactive — scanner should stop at page cap.
const pages = Array.from({ length: 12 }, () => inactivePage);
const listSkillsByAccess = buildPager(pages);
const result = await injectSkillCatalog(baseParams({ listSkillsByAccess }));
expect(result.skillCount).toBe(0);
expect(result.activeSkillIds).toEqual([]);
// MAX_CATALOG_PAGES = 10 — loop terminates after exactly 10 page fetches.
expect(listSkillsByAccess).toHaveBeenCalledTimes(10);
});
it('terminates early when has_more is false even below the catalog limit', async () => {
const owned = makeSkill('solo', userObjectId);
const listSkillsByAccess = buildPager([[owned]]);
await injectSkillCatalog(baseParams({ listSkillsByAccess }));
expect(listSkillsByAccess).toHaveBeenCalledTimes(1);
});
it('appends the catalog text to agent.additional_instructions', async () => {
const owned = makeSkill('my-skill', userObjectId);
const listSkillsByAccess = buildPager([[owned]]);
const agent = makeAgent();
await injectSkillCatalog(baseParams({ listSkillsByAccess, agent }));
expect(agent.additional_instructions).toContain('my-skill');
expect(agent.additional_instructions).toContain('desc-my-skill');
});
it('fails closed when userId is absent (shared skills drop, owned would need override)', async () => {
const owned = makeSkill('my-skill', userObjectId);
const shared = makeSkill('shared-skill', new Types.ObjectId());
const listSkillsByAccess = buildPager([[owned, shared]]);
const result = await injectSkillCatalog(
baseParams({ listSkillsByAccess, userId: undefined, defaultActiveOnShare: true }),
);
expect(result.skillCount).toBe(0);
expect(result.activeSkillIds).toEqual([]);
});
});

View file

@ -69,6 +69,10 @@ export interface DiscoverConnectedAgentsParams {
* allowlist (or the full accessible set when scoping is disabled).
*/
computeAccessibleSkillIds?: (agent: Agent) => InitializeAgentParams['accessibleSkillIds'];
/** Per-user skill active/inactive state, forwarded to each sub-agent. */
skillStates?: InitializeAgentParams['skillStates'];
/** Default active-on-share flag, forwarded to each sub-agent. */
defaultActiveOnShare?: InitializeAgentParams['defaultActiveOnShare'];
}
export interface DiscoverConnectedAgentsDeps {
@ -134,6 +138,8 @@ export async function discoverConnectedAgents(
parentMessageId,
resourceType = ResourceType.AGENT,
computeAccessibleSkillIds,
skillStates,
defaultActiveOnShare,
} = params;
const {
@ -232,6 +238,8 @@ export async function discoverConnectedAgents(
endpointOption: subAgentEndpointOption,
allowedProviders,
accessibleSkillIds: computeAccessibleSkillIds?.(agent),
skillStates,
defaultActiveOnShare,
},
db,
);

View file

@ -123,6 +123,10 @@ export interface InitializeAgentParams {
accessibleSkillIds?: import('mongoose').Types.ObjectId[];
/** Whether the code execution environment is available (execute_code capability enabled) */
codeEnvAvailable?: boolean;
/** Per-user skill active/inactive overrides for filtering the skill catalog. */
skillStates?: Record<string, boolean>;
/** Admin-configured default for shared skills (`true` = shared skills auto-activate). */
defaultActiveOnShare?: boolean;
}
/**
@ -158,7 +162,17 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
listSkillsByAccess?: (params: {
accessibleIds: import('mongoose').Types.ObjectId[];
limit: number;
}) => Promise<{ skills: Array<{ name: string; description: string }> }>;
cursor?: string | null;
}) => Promise<{
skills: Array<{
_id: import('mongoose').Types.ObjectId;
name: string;
description: string;
author: import('mongoose').Types.ObjectId;
}>;
has_more?: boolean;
after?: string | null;
}>;
}
/**
@ -433,6 +447,13 @@ export async function initializeAgent(
}
let skillCount = 0;
/**
* IDs authorized for runtime skill execution starts as the ACL-scoped set
* and gets replaced with the active-filtered subset after catalog injection.
* Ensures `getSkillByName` cannot resolve a deactivated skill even if the
* LLM (or a direct-invocation path) names one.
*/
let executableSkillIds = params.accessibleSkillIds;
const { accessibleSkillIds } = params;
if (accessibleSkillIds && accessibleSkillIds.length > 0) {
const skillResult = await injectSkillCatalog({
@ -443,9 +464,13 @@ export async function initializeAgent(
contextWindowTokens: Number(agentMaxContextTokens) || 200_000,
listSkillsByAccess: db?.listSkillsByAccess,
codeEnvAvailable: params.codeEnvAvailable,
userId: req.user?.id,
skillStates: params.skillStates,
defaultActiveOnShare: params.defaultActiveOnShare,
});
toolDefinitions = skillResult.toolDefinitions;
skillCount = skillResult.skillCount;
executableSkillIds = skillResult.activeSkillIds;
}
const agentMaxContextNum = Number(agentMaxContextTokens) || DEFAULT_MAX_CONTEXT_TOKENS;
@ -480,7 +505,7 @@ export async function initializeAgent(
actionsEnabled,
baseContextTokens,
skillCount,
accessibleSkillIds: params.accessibleSkillIds,
accessibleSkillIds: executableSkillIds,
attachments: finalAttachments,
toolContextMap: toolContextMap ?? {},
useLegacyContent: !!options.useLegacyContent,

View file

@ -11,6 +11,10 @@ import type { Agent } from 'librechat-data-provider';
import type { InitializeAgentDbMethods } from './initialize';
const SKILL_CATALOG_LIMIT = 100;
/** Max pages scanned per run when filtering out inactive skills. */
const MAX_CATALOG_PAGES = 10;
/** Page size used when paginating to fill the active-skill quota. */
const CATALOG_PAGE_SIZE = 100;
/**
* Scopes user-accessible skill IDs to only those configured on the agent.
@ -37,6 +41,39 @@ export function scopeSkillIds(
return accessibleSkillIds.filter((oid) => agentSet.has(oid.toString()));
}
export interface ResolveSkillActiveParams {
/** Skill being evaluated. Only `_id` and `author` matter for resolution. */
skill: { _id: Types.ObjectId | string; author: Types.ObjectId | string };
/** Per-user overrides: `{ [skillId]: boolean }`. Missing entries use the default. */
skillStates?: Record<string, boolean>;
/** Current user ID. When absent, the function fails closed for all non-overridden skills. */
userId?: string;
/** Admin-configured default for shared skills. `true` = shared skills auto-activate. */
defaultActiveOnShare?: boolean;
}
/**
* Resolves whether a skill should be injected into the agent catalog for the
* current user. Precedence (pinned by unit tests):
*
* 1. Explicit override in `skillStates` wins above all.
* 2. Absent `userId` fail closed. The caller lost user context, so we do
* not fall back to ownership-based defaults that could leak shared skills.
* 3. Owned skills (author === userId) default to **active**.
* 4. Shared skills default to `defaultActiveOnShare` (admin-configured, default `false`).
*/
export function resolveSkillActive(params: ResolveSkillActiveParams): boolean {
const { skill, skillStates, userId, defaultActiveOnShare = false } = params;
const override = skillStates?.[skill._id.toString()];
if (override !== undefined) {
return override;
}
if (!userId) {
return false;
}
return skill.author.toString() === userId ? true : defaultActiveOnShare;
}
export interface InjectSkillCatalogParams {
agent: Agent;
toolDefinitions: LCTool[] | undefined;
@ -46,11 +83,24 @@ export interface InjectSkillCatalogParams {
listSkillsByAccess: InitializeAgentDbMethods['listSkillsByAccess'];
/** When true, registers bash_tool alongside skill + read_file. */
codeEnvAvailable?: boolean;
/** Current user ID — used to determine skill ownership for active-state resolution. */
userId?: string;
/** Per-user skill overrides: `{ [skillId]: boolean }`. Missing entries use the default. */
skillStates?: Record<string, boolean>;
/** Admin-configured default for shared skills. `true` = shared skills auto-activate. */
defaultActiveOnShare?: boolean;
}
export interface InjectSkillCatalogResult {
toolDefinitions: LCTool[] | undefined;
skillCount: number;
/**
* IDs of skills that passed the active-state filter and appear in the
* injected catalog. Runtime tool execution must authorize against this set,
* not the full `accessibleSkillIds`, so deactivated skills cannot be
* invoked by name even if the LLM hallucinates them.
*/
activeSkillIds: Types.ObjectId[];
}
/**
@ -75,30 +125,62 @@ export async function injectSkillCatalog(
contextWindowTokens,
listSkillsByAccess,
codeEnvAvailable,
userId,
skillStates,
defaultActiveOnShare = false,
} = params;
if (!listSkillsByAccess || accessibleSkillIds.length === 0) {
return { toolDefinitions: inputDefs, skillCount: 0 };
return { toolDefinitions: inputDefs, skillCount: 0, activeSkillIds: [] };
}
const { skills } = await listSkillsByAccess({
accessibleIds: accessibleSkillIds,
limit: SKILL_CATALOG_LIMIT,
});
type SkillSummary = Awaited<ReturnType<NonNullable<typeof listSkillsByAccess>>>['skills'][number];
if (skills.length === SKILL_CATALOG_LIMIT) {
const isActive = (s: SkillSummary): boolean =>
resolveSkillActive({ skill: s, skillStates, userId, defaultActiveOnShare });
const activeSkills: SkillSummary[] = [];
let cursor: string | null = null;
let pages = 0;
let reachedEnd = false;
while (activeSkills.length < SKILL_CATALOG_LIMIT && pages < MAX_CATALOG_PAGES) {
const page = await listSkillsByAccess({
accessibleIds: accessibleSkillIds,
limit: CATALOG_PAGE_SIZE,
cursor,
});
for (const skill of page.skills) {
if (activeSkills.length >= SKILL_CATALOG_LIMIT) {
break;
}
if (isActive(skill)) {
activeSkills.push(skill);
}
}
if (!page.has_more || !page.after) {
reachedEnd = true;
break;
}
cursor = page.after;
pages += 1;
}
if (activeSkills.length === 0) {
return { toolDefinitions: inputDefs, skillCount: 0, activeSkillIds: [] };
}
if (!reachedEnd && activeSkills.length < SKILL_CATALOG_LIMIT) {
logger.warn(
`[injectSkillCatalog] Skill catalog reached limit of ${SKILL_CATALOG_LIMIT}. Some skills may be excluded.`,
`[injectSkillCatalog] Scanned ${MAX_CATALOG_PAGES} pages without filling the ${SKILL_CATALOG_LIMIT}-skill catalog. Some active skills may be excluded.`,
);
}
if (skills.length === 0) {
return { toolDefinitions: inputDefs, skillCount: 0 };
}
// Warn on duplicate names — model may invoke the wrong skill
const nameCount = new Map<string, number>();
for (const s of skills) {
for (const s of activeSkills) {
nameCount.set(s.name, (nameCount.get(s.name) ?? 0) + 1);
}
for (const [dupName, count] of nameCount) {
@ -110,7 +192,7 @@ export async function injectSkillCatalog(
}
const catalog = formatSkillCatalog(
skills.map((s) => ({ name: s.name, description: s.description })),
activeSkills.map((s) => ({ name: s.name, description: s.description })),
{ contextWindowTokens: contextWindowTokens || 200_000 },
);
@ -152,5 +234,9 @@ export async function injectSkillCatalog(
}
}
return { toolDefinitions, skillCount: skills.length };
return {
toolDefinitions,
skillCount: activeSkills.length,
activeSkillIds: activeSkills.map((s) => s._id),
};
}

View file

@ -0,0 +1,244 @@
import { Types } from 'mongoose';
import {
MAX_KEY_LENGTH,
MAX_RAW_PAYLOAD,
MAX_SKILL_STATES,
loadSkillStates,
pruneOrphanSkillStates,
resolveDefaultActiveOnShare,
toSkillStatesRecord,
validateSkillStatesPayload,
} from '../skillStates';
describe('toSkillStatesRecord', () => {
it('converts a Mongoose Map to a plain record', () => {
const map = new Map<string, boolean>([
['a', true],
['b', false],
]);
expect(toSkillStatesRecord(map)).toEqual({ a: true, b: false });
});
it('returns a plain object unchanged', () => {
const input = { x: true, y: false };
expect(toSkillStatesRecord(input)).toBe(input);
});
it('returns {} for null, undefined, or primitives', () => {
expect(toSkillStatesRecord(null)).toEqual({});
expect(toSkillStatesRecord(undefined)).toEqual({});
expect(toSkillStatesRecord('not-an-object' as unknown as Record<string, boolean>)).toEqual({});
});
});
describe('resolveDefaultActiveOnShare', () => {
it('returns true when config object has defaultActiveOnShare: true', () => {
expect(resolveDefaultActiveOnShare({ defaultActiveOnShare: true })).toBe(true);
});
it('returns false when defaultActiveOnShare is false, missing, or non-boolean', () => {
expect(resolveDefaultActiveOnShare({ defaultActiveOnShare: false })).toBe(false);
expect(resolveDefaultActiveOnShare({})).toBe(false);
expect(resolveDefaultActiveOnShare({ defaultActiveOnShare: 'true' })).toBe(false);
});
it('returns false for non-object config (boolean shorthand or missing)', () => {
expect(resolveDefaultActiveOnShare(true)).toBe(false);
expect(resolveDefaultActiveOnShare(null)).toBe(false);
expect(resolveDefaultActiveOnShare(undefined)).toBe(false);
});
});
describe('loadSkillStates', () => {
it('returns the user\u2019s stored states and the admin-configured default', async () => {
const getUserById = jest.fn().mockResolvedValue({
skillStates: new Map([['skill-a', true]]),
});
const result = await loadSkillStates({
userId: 'user-1',
appConfig: { interfaceConfig: { skills: { defaultActiveOnShare: true } } },
getUserById,
});
expect(result.skillStates).toEqual({ 'skill-a': true });
expect(result.defaultActiveOnShare).toBe(true);
expect(getUserById).toHaveBeenCalledWith('user-1', 'skillStates');
});
it('returns an empty record when the user has no stored states', async () => {
const getUserById = jest.fn().mockResolvedValue({ skillStates: undefined });
const result = await loadSkillStates({
userId: 'user-1',
appConfig: null,
getUserById,
});
expect(result.skillStates).toEqual({});
expect(result.defaultActiveOnShare).toBe(false);
});
it('handles a missing user doc gracefully', async () => {
const getUserById = jest.fn().mockResolvedValue(null);
const result = await loadSkillStates({
userId: 'user-1',
appConfig: {},
getUserById,
});
expect(result.skillStates).toEqual({});
expect(result.defaultActiveOnShare).toBe(false);
});
});
describe('validateSkillStatesPayload', () => {
it('accepts a valid ObjectId-keyed boolean map', () => {
const payload = {
[new Types.ObjectId().toString()]: true,
[new Types.ObjectId().toString()]: false,
};
expect(validateSkillStatesPayload(payload)).toBeNull();
});
it('accepts an empty object', () => {
expect(validateSkillStatesPayload({})).toBeNull();
});
it('rejects non-object payloads', () => {
expect(validateSkillStatesPayload(null)?.message).toMatch(/plain object/);
expect(validateSkillStatesPayload(undefined)?.message).toMatch(/plain object/);
expect(validateSkillStatesPayload('string')?.message).toMatch(/plain object/);
expect(validateSkillStatesPayload([])?.message).toMatch(/plain object/);
});
it('rejects payloads that exceed the raw sanity bound', () => {
const payload: Record<string, boolean> = {};
for (let i = 0; i < MAX_RAW_PAYLOAD + 1; i += 1) {
payload[new Types.ObjectId().toString()] = true;
}
const error = validateSkillStatesPayload(payload);
expect(error?.code).toBe('SKILL_STATES_PAYLOAD_TOO_LARGE');
expect(error?.limit).toBe(MAX_RAW_PAYLOAD);
});
it('does not reject a payload at the strict 200-cap (enforcement happens post-prune)', () => {
const payload: Record<string, boolean> = {};
for (let i = 0; i < MAX_SKILL_STATES + 1; i += 1) {
payload[new Types.ObjectId().toString()] = true;
}
expect(validateSkillStatesPayload(payload)).toBeNull();
});
it('rejects empty string keys', () => {
expect(validateSkillStatesPayload({ '': true })?.message).toMatch(/non-empty string/);
});
it('rejects overlong keys', () => {
const long = 'a'.repeat(MAX_KEY_LENGTH + 1);
expect(validateSkillStatesPayload({ [long]: true })?.message).toMatch(/non-empty string/);
});
it('rejects keys that are not valid ObjectIds', () => {
expect(validateSkillStatesPayload({ 'not-an-objectid': true })?.message).toMatch(
/valid ObjectId/,
);
});
it('rejects non-boolean values', () => {
const id = new Types.ObjectId().toString();
expect(validateSkillStatesPayload({ [id]: 'true' })?.message).toMatch(/boolean/);
expect(validateSkillStatesPayload({ [id]: 1 })?.message).toMatch(/boolean/);
expect(validateSkillStatesPayload({ [id]: null })?.message).toMatch(/boolean/);
});
});
describe('pruneOrphanSkillStates', () => {
const makeId = () => new Types.ObjectId().toString();
it('drops entries for skills that do not exist', async () => {
const kept = makeId();
const orphan = makeId();
const pruned = await pruneOrphanSkillStates(
{ [kept]: true, [orphan]: false },
{
findExistingSkillIds: async () => [kept],
findAccessibleSkillIds: async () => [kept, orphan],
},
);
expect(pruned).toEqual({ [kept]: true });
});
it('drops entries whose user access was revoked (skill exists but not accessible)', async () => {
const kept = makeId();
const revoked = makeId();
const pruned = await pruneOrphanSkillStates(
{ [kept]: true, [revoked]: false },
{
findExistingSkillIds: async () => [kept, revoked],
findAccessibleSkillIds: async () => [kept],
},
);
expect(pruned).toEqual({ [kept]: true });
});
it('drops malformed (non-ObjectId) keys before querying', async () => {
const kept = makeId();
const findExistingSkillIds = jest.fn().mockResolvedValue([kept]);
const findAccessibleSkillIds = jest.fn().mockResolvedValue([kept]);
const pruned = await pruneOrphanSkillStates(
{ [kept]: true, 'not-an-objectid': false, '': true },
{ findExistingSkillIds, findAccessibleSkillIds },
);
expect(pruned).toEqual({ [kept]: true });
expect(findExistingSkillIds).toHaveBeenCalledWith([kept]);
});
it('returns {} and skips DB calls when every key is malformed', async () => {
const findExistingSkillIds = jest.fn();
const findAccessibleSkillIds = jest.fn();
const pruned = await pruneOrphanSkillStates(
{ 'bad-1': true, 'bad-2': false },
{ findExistingSkillIds, findAccessibleSkillIds },
);
expect(pruned).toEqual({});
expect(findExistingSkillIds).not.toHaveBeenCalled();
expect(findAccessibleSkillIds).not.toHaveBeenCalled();
});
it('issues existence and access queries in parallel (both called before either resolves)', async () => {
const id = makeId();
let resolveExisting: (value: string[]) => void = () => undefined;
let resolveAccessible: (value: string[]) => void = () => undefined;
const existingPromise = new Promise<string[]>((resolve) => {
resolveExisting = resolve;
});
const accessiblePromise = new Promise<string[]>((resolve) => {
resolveAccessible = resolve;
});
const findExistingSkillIds = jest.fn(() => existingPromise);
const findAccessibleSkillIds = jest.fn(() => accessiblePromise);
const pending = pruneOrphanSkillStates(
{ [id]: true },
{ findExistingSkillIds, findAccessibleSkillIds },
);
// Yield one microtask so any sequential-await implementation would have
// invoked only the first dep. `Promise.all` invokes both synchronously.
await Promise.resolve();
expect(findExistingSkillIds).toHaveBeenCalledTimes(1);
expect(findAccessibleSkillIds).toHaveBeenCalledTimes(1);
resolveExisting([id]);
resolveAccessible([id]);
await pending;
});
it('accepts Types.ObjectId instances in the accessible list', async () => {
const id = new Types.ObjectId();
const pruned = await pruneOrphanSkillStates(
{ [id.toString()]: true },
{
findExistingSkillIds: async () => [id.toString()],
findAccessibleSkillIds: async () => [id],
},
);
expect(pruned).toEqual({ [id.toString()]: true });
});
});

View file

@ -1,3 +1,4 @@
export * from './binary';
export * from './handlers';
export * from './import';
export * from './skillStates';

View file

@ -0,0 +1,164 @@
import { isValidObjectIdString } from '@librechat/data-schemas';
import type { Types } from 'mongoose';
/** Hard cap on explicit override entries stored on a user document. */
export const MAX_SKILL_STATES = 200;
/** Max length of a skill-ID map key (matches ObjectId hex length with slack). */
export const MAX_KEY_LENGTH = 64;
/**
* Generous upper bound on raw payload size to reject abusive inputs before
* we spend cycles validating or querying the DB for orphan cleanup.
*/
export const MAX_RAW_PAYLOAD = MAX_SKILL_STATES * 2;
/** Map of skillId → explicit active state override. */
export type SkillStatesRecord = Record<string, boolean>;
/**
* Converts a Mongoose Map (non-lean) or plain object (lean) into a
* `SkillStatesRecord`. Returns `{}` for any other shape.
*/
export function toSkillStatesRecord(
raw: Map<string, boolean> | Record<string, boolean> | null | undefined,
): SkillStatesRecord {
if (raw instanceof Map) {
return Object.fromEntries(raw);
}
if (raw && typeof raw === 'object') {
return raw as SkillStatesRecord;
}
return {};
}
/**
* Reads `defaultActiveOnShare` out of the `interface.skills` config shape.
* The shape is a Zod union (boolean | object) so we handle both gracefully.
*/
export function resolveDefaultActiveOnShare(skillsConfig: unknown): boolean {
if (skillsConfig && typeof skillsConfig === 'object') {
return (skillsConfig as { defaultActiveOnShare?: unknown }).defaultActiveOnShare === true;
}
return false;
}
/** Return shape from `loadSkillStates`. */
export interface LoadedSkillStates {
skillStates: SkillStatesRecord;
defaultActiveOnShare: boolean;
}
export interface LoadSkillStatesParams {
userId: string;
appConfig?: { interfaceConfig?: { skills?: unknown } } | null;
getUserById: (
id: string,
select: string,
) => Promise<{ skillStates?: Map<string, boolean> | Record<string, boolean> } | null | undefined>;
/**
* When provided and empty, the helper short-circuits to defaults without
* issuing the user query. Lets every agent entry point skip the DB round
* trip when no skills are accessible, without re-implementing the guard.
*/
accessibleSkillIds?: ArrayLike<unknown>;
}
/**
* Loads a user's `skillStates` overrides and the admin-configured
* `defaultActiveOnShare` in one call. Used by every agent entry point so the
* same loading block is not duplicated across controllers.
*/
export async function loadSkillStates(params: LoadSkillStatesParams): Promise<LoadedSkillStates> {
const { userId, appConfig, getUserById, accessibleSkillIds } = params;
if (accessibleSkillIds !== undefined && accessibleSkillIds.length === 0) {
return { skillStates: {}, defaultActiveOnShare: false };
}
const user = await getUserById(userId, 'skillStates');
return {
skillStates: toSkillStatesRecord(user?.skillStates),
defaultActiveOnShare: resolveDefaultActiveOnShare(appConfig?.interfaceConfig?.skills),
};
}
export interface SkillStatesValidationError {
code?: string;
message: string;
limit?: number;
}
/**
* Validates a raw skill-states update payload. Returns `null` on success or
* a structured `SkillStatesValidationError` describing the first rejection
* reason. Caller maps the error to an HTTP 400 response.
*
* Rejects: non-object payloads, oversize payloads (sanity bound for abuse),
* non-string/empty/oversize keys, keys that are not valid ObjectIds, and
* non-boolean values. The strict 200-entry cap is enforced *after* orphan
* pruning, not here, so stale-client payloads near the cap do not get a
* false-positive rejection.
*/
export function validateSkillStatesPayload(payload: unknown): SkillStatesValidationError | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return { message: 'skillStates must be a plain object' };
}
const entries = Object.entries(payload as Record<string, unknown>);
if (entries.length > MAX_RAW_PAYLOAD) {
return {
code: 'SKILL_STATES_PAYLOAD_TOO_LARGE',
message: `Payload exceeds ${MAX_RAW_PAYLOAD} entries`,
limit: MAX_RAW_PAYLOAD,
};
}
for (const [key, value] of entries) {
if (key.length === 0 || key.length > MAX_KEY_LENGTH) {
return {
message: `Each skill ID must be a non-empty string (max ${MAX_KEY_LENGTH} chars)`,
};
}
if (!isValidObjectIdString(key)) {
return { message: 'Each skill ID must be a valid ObjectId' };
}
if (typeof value !== 'boolean') {
return { message: 'Each skill state value must be a boolean' };
}
}
return null;
}
export interface PruneOrphansDeps {
/** Returns the subset of the given IDs that exist as Skill documents. */
findExistingSkillIds: (validIds: string[]) => Promise<string[]>;
/** Returns IDs the current user has VIEW access to via ACL. */
findAccessibleSkillIds: () => Promise<Array<Types.ObjectId | string>>;
}
/**
* Returns a copy of `skillStates` containing only entries that: are valid
* ObjectIds, point to a Skill that currently exists, AND the user still has
* VIEW access to. Self-heals three classes of orphan without cascade logic:
* malformed keys, deleted skills, and revoked shares.
*
* Deps are injected so the pure logic can be tested without Mongoose or ACL
* wiring. Callers in `/api` adapt their live model + permission service.
*/
export async function pruneOrphanSkillStates(
skillStates: SkillStatesRecord,
deps: PruneOrphansDeps,
): Promise<SkillStatesRecord> {
const validIds = Object.keys(skillStates).filter((id) => isValidObjectIdString(id));
if (validIds.length === 0) {
return {};
}
const [existing, accessible] = await Promise.all([
deps.findExistingSkillIds(validIds),
deps.findAccessibleSkillIds(),
]);
const existingSet = new Set(existing);
const accessibleSet = new Set(accessible.map((id) => id.toString()));
const pruned: SkillStatesRecord = {};
for (const id of validIds) {
if (existingSet.has(id) && accessibleSet.has(id)) {
pruned[id] = skillStates[id];
}
}
return pruned;
}

View file

@ -400,6 +400,9 @@ export const skillTree = ({ skillId, path = '' }: { skillId: string; path?: stri
return url;
};
/* Skill active states (per-user overrides) */
export const skillStates = () => `${BASE_URL}/api/user/settings/skills/active`;
/* Roles */
export const roles = () => `${BASE_URL}/api/roles`;
export const adminRoles = () => `${BASE_URL}/api/admin/roles`;

View file

@ -755,6 +755,7 @@ export const interfaceSchema = z
create: z.boolean().optional(),
share: z.boolean().optional(),
public: z.boolean().optional(),
defaultActiveOnShare: z.boolean().optional(),
}),
])
.optional(),
@ -808,6 +809,7 @@ export const interfaceSchema = z
create: true,
share: false,
public: false,
defaultActiveOnShare: false,
},
});

View file

@ -47,6 +47,17 @@ export function updateSkillFavorites(skillFavorites: string[]): Promise<string[]
return Promise.resolve(skillFavorites);
}
/** Per-user skill active/inactive overrides. */
export function getSkillStates(): Promise<sk.TSkillStatesResponse> {
return request.get(endpoints.skillStates());
}
export function updateSkillStates(
skillStates: sk.TSkillStatesResponse,
): Promise<sk.TSkillStatesResponse> {
return request.post(endpoints.skillStates(), { skillStates });
}
export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesResponse> {
return request.get(endpoints.shareMessages(shareId));
}

View file

@ -75,6 +75,8 @@ export enum QueryKeys {
skillNodeContent = 'skillNodeContent',
/* Skill favorites (star a skill in the sidebar) */
skillFavorites = 'skillFavorites',
/* Per-user skill active/inactive overrides */
skillStates = 'skillStates',
/* General user favorites */
favorites = 'favorites',
}

View file

@ -259,3 +259,11 @@ export type TDeleteSkillFileVariables = {
skillId: string;
relativePath: string;
};
/**
* Per-user skill active/inactive overrides (GET response and POST body payload).
* Key = skill ObjectId string, value = explicit active state.
* Skills absent from the map use the ownership-based default:
* owned = active, shared = `defaultActiveOnShare` from config.
*/
export type TSkillStatesResponse = Record<string, boolean>;

View file

@ -145,6 +145,11 @@ const userSchema = new Schema<IUser>(
],
default: [],
},
skillStates: {
type: Map,
of: Boolean,
default: () => new Map(),
},
/** Field for external source identification (for consistency with TPrincipal schema) */
idOnTheSource: {
type: String,

View file

@ -43,6 +43,8 @@ export interface IUser extends Document {
memories?: boolean;
};
favorites?: TUserFavorite[];
/** Per-skill active/inactive overrides. Key = skillId, value = active state. */
skillStates?: Record<string, boolean>;
createdAt?: Date;
updatedAt?: Date;
/** Field for external source identification (for consistency with TPrincipal schema) */
@ -85,6 +87,7 @@ export interface UpdateUserRequest {
personalization?: {
memories?: boolean;
};
skillStates?: Record<string, boolean>;
}
export interface UserDeleteResult {