🛡️ fix: Strict Opt-In Skills Activation per Agent (#12823)

* 🛡️ fix: Strict opt-in skills activation per agent

Skills were activating on every agent run that had the capability +
RBAC enabled, regardless of whether the user (ephemeral) or author
(persisted) had opted in. `scopeSkillIds(undefined)` fell through to
"full accessible catalog" whenever `agent.skills` was unset, which is
the default state for any agent created before skills existed and for
every ephemeral agent.

Activation now requires an explicit signal:
- Ephemeral agent → per-conversation skills badge toggle.
- Persisted agent → new `skills_enabled` master switch on the agent
  doc, surfaced as a toggle in the Agent Builder skills section.
  Enabled + empty/undefined allowlist = full accessible catalog;
  enabled + non-empty allowlist = narrow to those ids; disabled (or
  undefined) = no skills available, even if an allowlist is set.

Centralised the predicate in `resolveAgentScopedSkillIds` so the
primary-agent path, handoff/discovery, the subagent loop, and both
OpenAI controllers all share one source of truth. Frontend `$`
popover scope mirrors the same logic so the UI never offers skills
the backend would refuse to activate.

* test: mock resolveAgentScopedSkillIds in agent controller specs

* refactor: address review findings on skills opt-in PR

- AgentConfig: associate skills label with toggle via htmlFor for
  click/keyboard affordance; simplify Switch handler to Boolean(value).
- skills: mark scopeSkillIds as @internal so runtime callers continue
  to route through resolveAgentScopedSkillIds and inherit the activation
  predicate (ephemeral toggle, persisted skills_enabled).

* fix(agents): include skills_enabled in agent list projection

Without this field, agents loaded via the list endpoint hydrate into the
client agentsMap with skills_enabled === undefined, causing the `$`
skill popover to hide every skill on a fresh page load even when the
agent was saved with skills_enabled: true.

* fix(skills): fail closed for persisted agents during agentsMap hydration

Returning undefined while the agents map loads let the popover render the
full catalog for a persisted agent before we could read its
skills_enabled flag, so the user could pick a skill the backend would
then refuse for the turn. Match the strict opt-in contract by returning
[] until the map is authoritative.

* refactor(skills): extract skillsHintKey for readability

Replaces the nested ternary in the skills section JSX with a
pre-computed constant so the activation -> hint key mapping reads
top-down.

* refactor(skills): unflatten skillsHintKey to remove nested ternary
This commit is contained in:
Danny Avila 2026-04-25 00:34:12 -07:00
parent 7f3d41024a
commit 596f806f60
20 changed files with 404 additions and 61 deletions

View file

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

View file

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

View file

@ -13,26 +13,26 @@ const {
createRun,
createChunk,
buildToolSet,
scopeSkillIds,
loadSkillStates,
sendFinalChunk,
createSafeUser,
validateRequest,
initializeAgent,
getBalanceConfig,
injectSkillPrimes,
extractManualSkills,
createErrorResponse,
recordCollectedUsage,
getTransactionsConfig,
resolveRecursionLimit,
discoverConnectedAgents,
getRemoteAgentPermissions,
createToolExecuteHandler,
buildNonStreamingResponse,
createOpenAIStreamTracker,
resolveAgentScopedSkillIds,
createOpenAIContentAggregator,
injectSkillPrimes,
isChatCompletionValidationFailure,
discoverConnectedAgents,
getRemoteAgentPermissions,
} = require('@librechat/api');
const {
buildSummarizationHandlers,
@ -283,10 +283,12 @@ const OpenAIChatCompletionController = async (req, res) => {
endpointOption,
allowedProviders,
isInitialAgent: true,
accessibleSkillIds: scopeSkillIds(
accessibleSkillIds: resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
ephemeralSkillsToggle ? undefined : agent.skills,
),
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,

View file

@ -12,8 +12,8 @@ const {
const {
createRun,
buildToolSet,
scopeSkillIds,
loadSkillStates,
resolveAgentScopedSkillIds,
createSafeUser,
initializeAgent,
getBalanceConfig,
@ -413,10 +413,12 @@ const createResponse = async (req, res) => {
endpointOption,
allowedProviders,
isInitialAgent: true,
accessibleSkillIds: scopeSkillIds(
accessibleSkillIds: resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
ephemeralSkillsToggle ? undefined : agent.skills,
),
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
skillStates,
defaultActiveOnShare,

View file

@ -1,7 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { createContentAggregator } = require('@librechat/agents');
const {
scopeSkillIds,
loadSkillStates,
initializeAgent,
primeInvokedSkills,
@ -10,6 +9,7 @@ const {
GenerationJobManager,
getCustomEndpointConfig,
discoverConnectedAgents,
resolveAgentScopedSkillIds,
} = require('@librechat/api');
const {
ResourceType,
@ -115,9 +115,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId });
/** Query accessible skill IDs once per run (shared across all agents).
* Skills activate when the admin capability is enabled AND either:
* - the per-conversation toggle is on (ephemeral), OR
* - the agent has stored skills (scoped by scopeSkillIds later). */
* Skills activate under strict opt-in semantics see
* `resolveAgentScopedSkillIds` for the per-agent activation predicate:
* - Ephemeral agent per-conversation skills badge toggle (full catalog).
* - Persisted agent `agent.skills_enabled === true`. Optional
* `agent.skills` allowlist narrows the catalog; empty/undefined
* allowlist with the toggle on = full accessible catalog. */
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
@ -259,6 +262,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
*/
const manualSkills = extractManualSkills(req.body);
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
agent: primaryAgent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const primaryConfig = await initializeAgent(
{
req,
@ -271,10 +281,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
endpointOption,
allowedProviders,
isInitialAgent: true,
accessibleSkillIds: scopeSkillIds(
accessibleSkillIds,
ephemeralSkillsToggle ? undefined : primaryAgent.skills,
),
accessibleSkillIds: primaryScopedSkillIds,
codeEnvAvailable,
skillStates,
defaultActiveOnShare,
@ -340,7 +347,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
conversationId,
parentMessageId,
computeAccessibleSkillIds: (agent) =>
scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills),
resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
@ -544,10 +556,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
parentMessageId,
endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents },
allowedProviders,
accessibleSkillIds: scopeSkillIds(
accessibleSkillIds: resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
ephemeralSkillsToggle ? undefined : agent.skills,
),
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
skillStates,
defaultActiveOnShare,
},

View file

@ -40,6 +40,7 @@ export type AgentForm = {
/** Per-tool configuration options (deferred loading, allowed callers, etc.) */
tool_options?: AgentToolOptions;
skills?: string[];
skills_enabled?: boolean;
provider?: AgentProvider | OptionWithIcon;
/** @deprecated Use edges instead */
agent_ids?: string[];

View file

@ -96,31 +96,33 @@ function SkillsCommandContent({
const agentsMap = useAgentsMapContext();
const { isActive } = useSkillActiveState();
/* Resolve the per-agent skill scope. Mirrors backend `scopeSkillIds` for
the happy path: no `skills` field no scope, `[]` opt-out, non-empty
intersection. Ephemeral agent ids (null/undefined/placeholder strings
that don't begin with `agent_`) are unscoped they correspond to
conversations without a persisted agent and are intentionally absent
from the agents map. While the map is still hydrating we pass through
(undefined full catalog): the backend enforces scope at turn time, so
there's no security benefit to flashing an empty popover, and the map
typically lands well before the first open. Once the map is authoritative
but the agent isn't in it (deleted, or VIEW revoked mid-session), we fail
closed scope is unresolvable and the full catalog would be misleading.
`agentId` is threaded in as a prop so this component stays memoizable
and skips re-renders on unrelated conversation-shape changes. */
/* Resolve the per-agent skill scope. Mirrors backend
`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
`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).
`agentId` is threaded in as a prop so this component stays
memoizable. */
const agentSkillIds = useMemo<string[] | null | undefined>(() => {
if (!agentId || isEphemeralAgent(agentId)) {
return undefined;
}
if (!agentsMap) {
return undefined;
return [];
}
const agent = agentsMap[agentId];
if (!agent) {
return [];
}
return agent.skills;
if (agent.skills_enabled !== true) {
return [];
}
return Array.isArray(agent.skills) && agent.skills.length > 0 ? agent.skills : undefined;
}, [agentId, agentsMap]);
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =

View file

@ -225,7 +225,7 @@ describe('SkillsCommand', () => {
expect(mockSetShowSkillsPopover).toHaveBeenCalledWith(false);
});
it('narrows the list to the agent-configured scope when agent.skills is set', async () => {
it('narrows the list to the agent-configured scope when agent.skills is set and skills_enabled is true', async () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,
isLoading: false,
@ -235,7 +235,7 @@ describe('SkillsCommand', () => {
isFetchingNextPage: false,
});
mockUseAgentsMapContext.mockReturnValue({
agent_1: { id: 'agent_1', skills: ['2'] },
agent_1: { id: 'agent_1', skills: ['2'], skills_enabled: true },
});
const textAreaRef = makeTextarea('$');
@ -253,7 +253,7 @@ describe('SkillsCommand', () => {
expect(await screen.findByRole('button', { name: /Style Guide/i })).toBeInTheDocument();
});
it('shows nothing when the agent has an empty skills array (explicit opt-out)', () => {
it('shows nothing when the agent has skills_enabled:false, regardless of allowlist', () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,
isLoading: false,
@ -263,7 +263,7 @@ describe('SkillsCommand', () => {
isFetchingNextPage: false,
});
mockUseAgentsMapContext.mockReturnValue({
agent_1: { id: 'agent_1', skills: [] },
agent_1: { id: 'agent_1', skills: ['1', '2'], skills_enabled: false },
});
const textAreaRef = makeTextarea('$');
@ -280,7 +280,7 @@ describe('SkillsCommand', () => {
expect(screen.queryByRole('button', { name: /Style Guide/i })).toBeNull();
});
it('shows the full ACL catalog when the agent has no skills field configured', async () => {
it('hides all skills for a persisted agent with skills_enabled undefined (default off)', async () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,
isLoading: false,
@ -303,8 +303,38 @@ describe('SkillsCommand', () => {
/>,
);
/* Mirrors backend `resolveAgentScopedSkillIds`: persisted agents are
off by default; the builder's `skills_enabled` master toggle is
the only signal that activates skills for the agent. */
expect(screen.queryByRole('button', { name: /Brand Guidelines/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Style Guide/i })).toBeNull();
});
it('shows the full catalog for a persisted agent with skills_enabled:true and empty allowlist', async () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,
isLoading: false,
isError: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
});
mockUseAgentsMapContext.mockReturnValue({
agent_1: { id: 'agent_1', skills_enabled: true },
});
const textAreaRef = makeTextarea('$');
render(
<SkillsCommand
index={0}
textAreaRef={textAreaRef}
conversationId={CONVO_ID}
agentId="agent_1"
/>,
);
expect(await screen.findByRole('button', { name: /Brand Guidelines/i })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /Style Guide/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Style Guide/i })).toBeInTheDocument();
});
it('treats an ephemeral agent id as unscoped and shows the full ACL catalog', async () => {
@ -334,7 +364,7 @@ describe('SkillsCommand', () => {
expect(await screen.findByRole('button', { name: /Style Guide/i })).toBeInTheDocument();
});
it('shows the full ACL catalog while the agents map is hydrating (backend still gates the turn)', async () => {
it('fails closed for persisted agents while the agents map is hydrating', async () => {
mockUseSkillsInfiniteQuery.mockReturnValue({
data: twoSkillsResponse,
isLoading: false,
@ -355,11 +385,12 @@ describe('SkillsCommand', () => {
/>,
);
/* Hydration race: map not yet loaded. Pass through to full catalog
the backend scopes at turn time and blanking the popover during
sub-second hydration is worse UX for no security benefit. */
expect(await screen.findByRole('button', { name: /Brand Guidelines/i })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /Style Guide/i })).toBeInTheDocument();
/* Hydration race: agents map not yet loaded. Without `skills_enabled`
visibility we cannot prove the persisted agent opted in, so fail
closed; otherwise the user can pick a skill the backend will then
refuse for the turn. */
expect(screen.queryByRole('button', { name: /Brand Guidelines/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Style Guide/i })).toBeNull();
});
it('fails closed when the agent id is set but missing from the agents map', () => {

View file

@ -1,6 +1,6 @@
import React, { useState, useMemo, useCallback } from 'react';
import { X } from 'lucide-react';
import { useToastContext } from '@librechat/client';
import { Switch, useToastContext } from '@librechat/client';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import {
EModelEndpoint,
@ -72,8 +72,20 @@ export default function AgentConfig() {
const agent = useWatch({ control, name: 'agent' });
const tools = useWatch({ control, name: 'tools' });
const skills = useWatch({ control, name: 'skills' });
const skillsActive = useWatch({ control, name: 'skills_enabled' });
const agent_id = useWatch({ control, name: 'id' });
let skillsHintKey:
| 'com_ui_skills_disabled_hint'
| 'com_ui_skills_enabled_allowlist_hint'
| 'com_ui_skills_enabled_all_hint' = 'com_ui_skills_disabled_hint';
if (skillsActive === true) {
skillsHintKey =
(skills ?? []).length > 0
? 'com_ui_skills_enabled_allowlist_hint'
: 'com_ui_skills_enabled_all_hint';
}
const {
codeEnabled,
toolsEnabled,
@ -340,10 +352,32 @@ export default function AgentConfig() {
{showSkills && (
<div className="mb-4">
<label className="text-token-text-primary mb-2 block text-sm font-medium">
{localize('com_ui_skills')}
</label>
<div>
<div className="mb-2 flex items-center justify-between">
<label
htmlFor="skills_enabled"
className="text-token-text-primary block text-sm font-medium"
>
{localize('com_ui_skills')}
</label>
<Controller
name="skills_enabled"
control={control}
render={({ field }) => (
<Switch
id="skills_enabled"
checked={field.value === true}
onCheckedChange={(value: boolean) => field.onChange(Boolean(value))}
data-testid="skills_enabled"
aria-label={localize('com_ui_skills_enable_toggle')}
/>
)}
/>
</div>
<p className="mb-2 text-xs text-text-secondary">{localize(skillsHintKey)}</p>
<div
className={skillsActive === true ? undefined : 'pointer-events-none opacity-50'}
aria-disabled={skillsActive !== true}
>
<div className="mb-1">
{(skills ?? []).map((skillId) => {
const skillName = skillsMap.get(skillId);
@ -368,6 +402,7 @@ export default function AgentConfig() {
}}
className="ml-2 flex-shrink-0 text-text-secondary transition-colors hover:text-text-primary"
aria-label={localize('com_ui_remove_skill_var', { 0: skillName })}
disabled={skillsActive !== true}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
@ -381,6 +416,7 @@ export default function AgentConfig() {
onClick={() => setShowSkillDialog(true)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
disabled={skillsActive !== true}
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_skills')}

View file

@ -77,6 +77,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
support_contact,
tool_options,
skills,
skills_enabled,
avatar_action: avatarActionState,
} = data;
@ -105,6 +106,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
support_contact,
tool_options,
skills,
skills_enabled,
...(shouldResetAvatar ? { avatar: null } : {}),
},
provider,

View file

@ -115,6 +115,11 @@ function AgentSelect({
return;
}
if (name === 'skills_enabled' && typeof value === 'boolean') {
formValues[name] = value;
return;
}
if (name === 'edges' && Array.isArray(value)) {
formValues[name] = value;
return;

View file

@ -1546,7 +1546,11 @@
"com_ui_skills_always_apply_invoked": "Auto-applied skills",
"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_disabled_hint": "Skills are off for this agent. Enable to allow the model to use your skills catalog.",
"com_ui_skills_empty": "No skills yet",
"com_ui_skills_enable_toggle": "Enable skills for this agent",
"com_ui_skills_enabled_all_hint": "Enabled. The full accessible catalog is available; narrow it below if you want to limit which skills this agent can use.",
"com_ui_skills_enabled_allowlist_hint": "Enabled. Limited to the skills selected below.",
"com_ui_skills_load_error": "Failed to load skills",
"com_ui_skills_manual_invoked": "Manually invoked skills",
"com_ui_skills_queued": "Skills queued for next submission",

View file

@ -31,6 +31,7 @@ import { HumanMessage, AIMessage } from '@langchain/core/messages';
import {
scopeSkillIds,
resolveSkillActive,
resolveAgentScopedSkillIds,
injectSkillCatalog,
buildSkillPrimeMessage,
resolveManualSkills,
@ -293,6 +294,176 @@ describe('scopeSkillIds', () => {
});
});
describe('resolveAgentScopedSkillIds', () => {
const makeId = () => new Types.ObjectId();
const persistedAgent = (
skills?: string[],
skills_enabled?: boolean,
): { id: string; skills?: string[]; skills_enabled?: boolean } => ({
id: 'agent_persisted_1',
skills,
skills_enabled,
});
const ephemeralAgent = (
skills?: string[],
): { id: string; skills?: string[]; skills_enabled?: boolean } => ({
id: 'ephemeral_convo_xyz',
skills,
});
it('returns [] when the skills capability is disabled, even with every other signal on', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: persistedAgent([a.toString()], true),
accessibleSkillIds: [a],
skillsCapabilityEnabled: false,
ephemeralSkillsToggle: true,
}),
).toEqual([]);
});
it('returns [] when accessibleSkillIds is empty', () => {
expect(
resolveAgentScopedSkillIds({
agent: ephemeralAgent(),
accessibleSkillIds: [],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: true,
}),
).toEqual([]);
});
describe('ephemeral agent', () => {
it('returns [] when the skills badge toggle is off', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: ephemeralAgent(),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
}),
).toEqual([]);
});
it('returns the full accessible catalog when the skills badge toggle is on', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: ephemeralAgent(),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: true,
});
expect(scoped).toHaveLength(2);
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), b.toString()].sort());
});
it('ignores any `skills` field on an ephemeral agent (toggle is the only signal)', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: ephemeralAgent([a.toString()]),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
}),
).toEqual([]);
});
});
describe('persisted agent', () => {
it('returns [] when `skills_enabled` is undefined (default / never toggled)', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: persistedAgent([a.toString()], undefined),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
}),
).toEqual([]);
});
it('returns [] when `skills_enabled` is false, even if an allowlist is set', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: persistedAgent([a.toString()], false),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
}),
).toEqual([]);
});
it('returns full accessible catalog when `skills_enabled` is true and allowlist is undefined', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: persistedAgent(undefined, true),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(scoped).toHaveLength(2);
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), b.toString()].sort());
});
it('returns full accessible catalog when `skills_enabled` is true and allowlist is empty', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: persistedAgent([], true),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(scoped).toHaveLength(2);
});
it('returns intersection when `skills_enabled` is true and allowlist overlaps accessible set', () => {
const a = makeId();
const b = makeId();
const c = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: persistedAgent([a.toString(), c.toString()], true),
accessibleSkillIds: [a, b, c],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: false,
});
expect(scoped).toHaveLength(2);
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), c.toString()].sort());
});
it('is unaffected by the ephemeral toggle — the persisted config is authoritative', () => {
const a = makeId();
const b = makeId();
const scoped = resolveAgentScopedSkillIds({
agent: persistedAgent([a.toString()], true),
accessibleSkillIds: [a, b],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: true,
});
expect(scoped).toHaveLength(1);
expect(scoped[0].toString()).toBe(a.toString());
});
it('still returns [] when `skills_enabled` is missing even if the ephemeral toggle is on', () => {
const a = makeId();
expect(
resolveAgentScopedSkillIds({
agent: persistedAgent(undefined, undefined),
accessibleSkillIds: [a],
skillsCapabilityEnabled: true,
ephemeralSkillsToggle: true,
}),
).toEqual([]);
});
});
});
describe('resolveSkillActive', () => {
const makeSkill = (author: Types.ObjectId) => ({ _id: new Types.ObjectId(), author });

View file

@ -1,5 +1,6 @@
import { logger } from '@librechat/data-schemas';
import { HumanMessage } from '@langchain/core/messages';
import { isEphemeralAgentId } from 'librechat-data-provider';
import { formatSkillCatalog, SkillToolDefinition } from '@librechat/agents';
import type { LCToolRegistry, LCTool, InjectedMessage } from '@librechat/agents';
import type { BaseMessage } from '@langchain/core/messages';
@ -101,6 +102,11 @@ export function isSkillPrimeMessage(msg: unknown): boolean {
* the full catalog fallback.
* - non-empty array of skill `_id` hex strings intersection of accessible IDs
* and agent-configured IDs.
*
* @internal Building block for {@link resolveAgentScopedSkillIds}; runtime
* call sites should prefer the resolver so the activation predicate
* (`skillsCapabilityEnabled`, ephemeral toggle, persisted `skills_enabled`)
* is enforced uniformly.
*/
export function scopeSkillIds(
accessibleSkillIds: Types.ObjectId[],
@ -116,6 +122,52 @@ export function scopeSkillIds(
return accessibleSkillIds.filter((oid) => agentSet.has(oid.toString()));
}
export interface ResolveAgentScopedSkillIdsParams {
/** Agent being initialized. Reads `id`, `skills`, and `skills_enabled`. */
agent: Pick<Agent, 'id' | 'skills' | 'skills_enabled'>;
/** Full set of skill IDs the user can VIEW (pre-scoped by ACL). */
accessibleSkillIds: Types.ObjectId[];
/** Admin capability: `AgentCapabilities.skills` on the agents endpoint. */
skillsCapabilityEnabled: boolean;
/** Per-conversation skills badge toggle (`req.body.ephemeralAgent.skills`). */
ephemeralSkillsToggle: boolean;
}
/**
* Strict opt-in resolver for per-agent skill scope. Activation requires an
* explicit signal from the user or the agent author:
* - Ephemeral agent the skills badge toggle for this conversation.
* Toggle ON = full accessible catalog; OFF = no skills.
* - 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.
*
* When not activated, returns `[]` so `injectSkillCatalog`,
* `resolveManualSkills`, and `resolveAlwaysApplySkills` all no-op.
*
* Without this gate, an `agent.skills` of `undefined` on a persisted agent
* would fall through to the "full catalog" branch of `scopeSkillIds`,
* exposing the skill tool on runs where the author never opted in.
*/
export function resolveAgentScopedSkillIds(
params: ResolveAgentScopedSkillIdsParams,
): Types.ObjectId[] {
const { agent, accessibleSkillIds, skillsCapabilityEnabled, ephemeralSkillsToggle } = params;
if (!skillsCapabilityEnabled || accessibleSkillIds.length === 0) {
return [];
}
if (isEphemeralAgentId(agent.id)) {
return ephemeralSkillsToggle ? scopeSkillIds(accessibleSkillIds, undefined) : [];
}
if (agent.skills_enabled !== true) {
return [];
}
if (!Array.isArray(agent.skills) || agent.skills.length === 0) {
return scopeSkillIds(accessibleSkillIds, undefined);
}
return scopeSkillIds(accessibleSkillIds, agent.skills);
}
export interface ResolveSkillActiveParams {
/** Skill being evaluated. Only `_id` and `author` matter for resolution. */
skill: { _id: Types.ObjectId | string; author: Types.ObjectId | string };

View file

@ -99,6 +99,7 @@ export const agentBaseSchema = z.object({
model_parameters: z.record(z.unknown()).optional(),
tools: z.array(z.string()).optional(),
skills: z.array(z.string()).optional(),
skills_enabled: z.boolean().optional(),
/** @deprecated Use edges instead */
agent_ids: z.array(z.string()).optional(),
edges: z.array(graphEdgeSchema).optional(),

View file

@ -293,11 +293,12 @@ export const defaultAgentFormValues = {
name: '',
email: '',
},
/** `undefined` = not configured, full catalog applies. `[]` = explicitly none.
* Keeping this `undefined` ensures a brand-new agent (where the user never
* interacted with the skills UI) does not accidentally persist "explicit none"
* on first save removeNullishValues strips the field server-side. */
/** Optional allowlist. Only applies when `skills_enabled === true`.
* Empty/undefined + enabled = full catalog; non-empty + enabled = narrow to ids. */
skills: undefined as string[] | undefined,
/** 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,
/** `undefined` = feature disabled by default (no subagent tool injected). */
subagents: undefined as
| { enabled?: boolean; allowSelf?: boolean; agent_ids?: string[] }

View file

@ -288,8 +288,11 @@ export type Agent = {
support_contact?: SupportContact;
/** Per-tool configuration options (deferred loading, allowed callers, etc.) */
tool_options?: AgentToolOptions;
/** Skill ObjectIds the agent can invoke — phase 2 wiring in AgentConfig. */
/** Optional allowlist of skill ObjectIds. Only applies when `skills_enabled`. */
skills?: string[];
/** 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;
/** Subagent spawning configuration — isolated-context child agents. */
subagents?: AgentSubagentsConfig;
};
@ -318,6 +321,7 @@ export type AgentCreateParams = {
| 'support_contact'
| 'tool_options'
| 'skills'
| 'skills_enabled'
| 'subagents'
>;
@ -344,6 +348,7 @@ export type AgentUpdateParams = {
| 'support_contact'
| 'tool_options'
| 'skills'
| 'skills_enabled'
| 'subagents'
>;

View file

@ -712,8 +712,11 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag
support_contact: 1,
is_promoted: 1,
/* Needed so the client can scope the `$` skill popover to each agent's
configured catalog without refetching the full agent document. */
configured catalog without refetching the full agent document. The
master toggle is required alongside the allowlist so the popover can
distinguish "enabled with full catalog" from "disabled". */
skills: 1,
skills_enabled: 1,
}).sort({ updatedAt: -1, _id: 1 });
if (isPaginated && normalizedLimit) {

View file

@ -48,6 +48,10 @@ const agentSchema = new Schema<IAgent>(
type: [String],
default: undefined,
},
skills_enabled: {
type: Boolean,
default: undefined,
},
tool_kwargs: {
type: [{ type: Schema.Types.Mixed }],
},

View file

@ -28,6 +28,7 @@ export interface IAgent extends Omit<Document, 'model'> {
recursion_limit?: number;
tools?: string[];
skills?: string[];
skills_enabled?: boolean;
tool_kwargs?: Array<unknown>;
actions?: string[];
author: Types.ObjectId;