diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index a444b18863..fd876f03ea 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -40,6 +40,7 @@ jest.mock('@librechat/api', () => ({ }), createChunk: jest.fn().mockReturnValue({}), buildToolSet: jest.fn().mockReturnValue(new Set()), + scopeSkillIds: jest.fn().mockImplementation((ids) => ids), sendFinalChunk: jest.fn(), createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }), validateRequest: jest diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index cc2086766f..720bd1e4f3 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -41,6 +41,7 @@ jest.mock('@librechat/api', () => ({ processStream: jest.fn().mockResolvedValue(undefined), }), buildToolSet: jest.fn().mockReturnValue(new Set()), + scopeSkillIds: jest.fn().mockImplementation((ids) => ids), createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }), initializeAgent: jest.fn().mockResolvedValue({ id: 'agent-123', diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 9826adfad2..13b1fa0d72 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -13,6 +13,7 @@ const { createRun, createChunk, buildToolSet, + scopeSkillIds, sendFinalChunk, createSafeUser, validateRequest, @@ -244,10 +245,9 @@ const OpenAIChatCompletionController = async (req, res) => { }; const enabledCapabilities = new Set(agentsEConfig?.capabilities); - const ephemeralAgent = req.body?.ephemeralAgent; - const skillsEnabled = - enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true; - const accessibleSkillIds = skillsEnabled + const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ userId: req.user.id, role: req.user.role, @@ -268,7 +268,10 @@ const OpenAIChatCompletionController = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, - accessibleSkillIds, + accessibleSkillIds: scopeSkillIds( + accessibleSkillIds, + ephemeralSkillsToggle ? undefined : agent.skills, + ), codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, dbMethods, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3993be345f..772b564fcc 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -12,6 +12,7 @@ const { const { createRun, buildToolSet, + scopeSkillIds, createSafeUser, initializeAgent, getBalanceConfig, @@ -373,10 +374,9 @@ const createResponse = async (req, res) => { const enabledCapabilities = new Set( appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities, ); - const ephemeralAgent = req.body?.ephemeralAgent; - const skillsEnabled = - enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true; - const accessibleSkillIds = skillsEnabled + const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ userId: req.user.id, role: req.user.role, @@ -397,7 +397,10 @@ const createResponse = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, - accessibleSkillIds, + accessibleSkillIds: scopeSkillIds( + accessibleSkillIds, + ephemeralSkillsToggle ? undefined : agent.skills, + ), codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, dbMethods, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 1cabe402fc..08a4eb03ac 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,6 +1,7 @@ const { logger } = require('@librechat/data-schemas'); const { EnvVar, createContentAggregator } = require('@librechat/agents'); const { + scopeSkillIds, initializeAgent, primeInvokedSkills, validateAgentModel, @@ -107,11 +108,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). - * Requires both admin capability AND per-conversation toggle (if ephemeral). */ + * 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). */ const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities); - const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills; - const skillsCapabilityEnabled = - enabledCapabilities.has(AgentCapabilities.skills) && ephemeralSkillsToggle === true; + const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ @@ -236,7 +238,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpointOption, allowedProviders, isInitialAgent: true, - accessibleSkillIds, + accessibleSkillIds: scopeSkillIds( + accessibleSkillIds, + ephemeralSkillsToggle ? undefined : primaryAgent.skills, + ), codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, { @@ -283,6 +288,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { requestFiles, conversationId, parentMessageId, + computeAccessibleSkillIds: (agent) => + scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills), }, { getAgent: db.getAgent, @@ -399,6 +406,15 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { modelLabel: endpointOption.model_parameters.modelLabel, }); + /** primeInvokedSkills reconstructs bodies of skills invoked in prior turns so + * formatAgentMessages can rebuild HumanMessages and re-prime code-env files. + * Unlike catalog injection and runtime invocation (both scoped per-agent), + * history priming must use the user's full ACL-accessible set: historical + * skill calls can reference skills no longer in any active agent's scope + * (agent.skills edited, ephemeral toggle flipped), and scoping those out + * would drop prior skill context and break file references in follow-up + * turns. The ACL check remains the security gate; handleSkillToolCall is + * where per-agent scoping prevents NEW invocations. */ const handlePrimeInvokedSkills = skillsCapabilityEnabled ? (payload) => primeInvokedSkills({ diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 2ac6bc7b48..8495da8027 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -74,17 +74,25 @@ export default function AgentConfig() { const skills = useWatch({ control, name: 'skills' }); const agent_id = useWatch({ control, name: 'id' }); + const { + codeEnabled, + toolsEnabled, + contextEnabled, + actionsEnabled, + skillsEnabled, + artifactsEnabled, + webSearchEnabled, + fileSearchEnabled, + } = useAgentCapabilities(agentsConfig?.capabilities); + const hasSkillsAccess = useHasAccess({ permissionType: PermissionTypes.SKILLS, permission: Permissions.USE, }); - const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: false }); + const showSkills = hasSkillsAccess && skillsEnabled; + const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills }); const skillsMap = useMemo(() => { const map = new Map(); - // Backend list response: `{ skills: TSkillSummary[]; ... }` (renamed - // from `.data` in the CRUD PR). This integration is gated behind - // `false &&` below so this map is currently unreachable — kept here - // so the section compiles for when agent-skills wiring lands. for (const skill of skillsData?.skills ?? []) { map.set(skill._id, skill.name); } @@ -103,16 +111,6 @@ export default function AgentConfig() { return newFileMap; }, [fileMap, agentFiles]); - const { - codeEnabled, - toolsEnabled, - contextEnabled, - actionsEnabled, - artifactsEnabled, - webSearchEnabled, - fileSearchEnabled, - } = useAgentCapabilities(agentsConfig?.capabilities); - const context_files = useMemo(() => { if (typeof agent === 'string') { return []; @@ -340,8 +338,7 @@ export default function AgentConfig() { /> )} - {/* WIP: Skills — remove `false &&` to re-enable */} - {false && hasSkillsAccess && ( + {showSkills && (