mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🛡️ 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:
parent
7f3d41024a
commit
596f806f60
20 changed files with 404 additions and 61 deletions
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 } =
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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[] }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue