diff --git a/api/package.json b/api/package.json index 61a65429b7..268be9ef3d 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.68", + "@librechat/agents": "^3.1.66-dev.0", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index f55b798f14..a444b18863 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -114,6 +114,19 @@ jest.mock('~/server/services/PermissionService', () => ({ checkPermission: jest.fn().mockResolvedValue(true), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({}), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn().mockResolvedValue(null), + checkIfActive: jest.fn().mockReturnValue(false), +})); + const mockUpdateBalance = jest.fn().mockResolvedValue({}); const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 1a6fa6053f..cc2086766f 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -144,6 +144,19 @@ jest.mock('~/cache', () => ({ logViolation: jest.fn(), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({}), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn().mockResolvedValue(null), + checkIfActive: jest.fn().mockReturnValue(false), +})); + const mockUpdateBalance = jest.fn().mockResolvedValue({}); const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 40fdf74212..40483ebdc3 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -7,6 +7,7 @@ const { GraphEvents, GraphNodeKeys, ToolEndHandler, + CODE_EXECUTION_TOOLS, } = require('@librechat/agents'); const { sendEvent, @@ -443,9 +444,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) return; } - const isCodeTool = - output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING; - if (!isCodeTool) { + if (!CODE_EXECUTION_TOOLS.has(output.name)) { return; } @@ -651,9 +650,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) return; } - const isCodeTool = - output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING; - if (!isCodeTool) { + if (!CODE_EXECUTION_TOOLS.has(output.name)) { return; } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 71fd843b0f..7828048991 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -742,12 +742,18 @@ class AgentClient extends BaseClient { const toolSet = buildToolSet(this.options.agent); const tokenCounter = createTokenCounter(this.getEncoding()); + + /** Pre-resolve invoked skill bodies + re-prime files before formatting messages */ + const skillPrimeResult = this.options.primeInvokedSkills + ? await this.options.primeInvokedSkills(payload) + : undefined; + let { messages: initialMessages, indexTokenCountMap, summary: initialSummary, boundaryTokenAdjustment, - } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet); + } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet, skillPrimeResult?.skills); if (boundaryTokenAdjustment) { logger.debug( `[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`, @@ -829,6 +835,7 @@ class AgentClient extends BaseClient { messages, indexTokenCountMap, initialSummary, + initialSessions: skillPrimeResult?.initialSessions, calibrationRatio, runId: this.responseMessageId, signal: abortController.signal, diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index e7cd7e1c29..9826adfad2 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -6,6 +6,7 @@ const { ResourceType, PermissionBits, hasPermissions, + AgentCapabilities, } = require('librechat-data-provider'); const { writeSSE, @@ -40,6 +41,10 @@ const { findAccessibleResources, getEffectivePermissions, } = require('~/server/services/PermissionService'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, +} = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -235,8 +240,22 @@ const OpenAIChatCompletionController = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, + listSkillsByAccess: db.listSkillsByAccess, }; + const enabledCapabilities = new Set(agentsEConfig?.capabilities); + const ephemeralAgent = req.body?.ephemeralAgent; + const skillsEnabled = + enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + const primaryConfig = await initializeAgent( { req, @@ -249,6 +268,8 @@ const OpenAIChatCompletionController = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, dbMethods, ); @@ -375,7 +396,7 @@ const OpenAIChatCompletionController = async (req, res) => { const toolExecuteOptions = { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -386,8 +407,10 @@ const OpenAIChatCompletionController = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds); }, toolEndCallback, + ...getSkillToolDeps(), }; const summarizationConfig = appConfig?.summarization; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3cb24b2083..3993be345f 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -7,6 +7,7 @@ const { ResourceType, PermissionBits, hasPermissions, + AgentCapabilities, } = require('librechat-data-provider'); const { createRun, @@ -49,6 +50,10 @@ const { findAccessibleResources, getEffectivePermissions, } = require('~/server/services/PermissionService'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, +} = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -362,8 +367,24 @@ const createResponse = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, + listSkillsByAccess: db.listSkillsByAccess, }; + 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 + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + const primaryConfig = await initializeAgent( { req, @@ -376,6 +397,8 @@ const createResponse = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, dbMethods, ); @@ -533,7 +556,7 @@ const createResponse = async (req, res) => { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -544,8 +567,10 @@ const createResponse = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds); }, toolEndCallback, + ...getSkillToolDeps(), }; // Combine handlers @@ -701,7 +726,7 @@ const createResponse = async (req, res) => { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -712,8 +737,10 @@ const createResponse = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds); }, toolEndCallback, + ...getSkillToolDeps(), }; const handlers = { diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 549e9047b5..1cabe402fc 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,7 +1,8 @@ const { logger } = require('@librechat/data-schemas'); -const { createContentAggregator } = require('@librechat/agents'); +const { EnvVar, createContentAggregator } = require('@librechat/agents'); const { initializeAgent, + primeInvokedSkills, validateAgentModel, GenerationJobManager, getCustomEndpointConfig, @@ -11,6 +12,7 @@ const { EModelEndpoint, isAgentsEndpoint, getResponseSender, + AgentCapabilities, isEphemeralAgentId, } = require('librechat-data-provider'); const { @@ -18,9 +20,11 @@ const { getDefaultHandlers, } = require('~/server/controllers/agents/callbacks'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); +const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); +const { getSkillToolDeps, enrichWithSkillConfigurable } = require('./skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); -const { checkPermission } = require('~/server/services/PermissionService'); +const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService'); const AgentClient = require('~/server/controllers/agents/client'); const { processAddedConvo } = require('./addedConvo'); const { logViolation } = require('~/cache'); @@ -102,6 +106,37 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const { contentParts, aggregateContent } = createContentAggregator(); 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). */ + const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills; + const skillsCapabilityEnabled = + enabledCapabilities.has(AgentCapabilities.skills) && ephemeralSkillsToggle === true; + + const accessibleSkillIds = skillsCapabilityEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + + // Resolve code API key once for the entire run (shared by primeInvokedSkills + // and enrichWithSkillConfigurable) to avoid redundant auth lookups. + let codeApiKey; + if (skillsCapabilityEnabled && enabledCapabilities.has(AgentCapabilities.execute_code)) { + try { + const authValues = await loadAuthValues({ + userId: req.user.id, + authFields: [EnvVar.CODE_API_KEY], + }); + codeApiKey = authValues[EnvVar.CODE_API_KEY]; + } catch { + // non-fatal — primeInvokedSkills and enrichWithSkillConfigurable will work without it + } + } + /** * Agent context store - populated after initialization, accessed by callback via closure. * Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey } @@ -135,9 +170,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { }); logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`); - return result; + return enrichWithSkillConfigurable(result, req, ctx.accessibleSkillIds, codeApiKey); }, toolEndCallback, + ...getSkillToolDeps(), }; const summarizationOptions = @@ -200,6 +236,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, { getFiles: db.getFiles, @@ -212,6 +250,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, }, ); @@ -224,6 +263,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: primaryConfig.userMCPAuthMap, tool_resources: primaryConfig.tool_resources, actionsEnabled: primaryConfig.actionsEnabled, + accessibleSkillIds: primaryConfig.accessibleSkillIds, }); const { @@ -259,6 +299,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, }, // The callback fires during BFS, before the helper prunes agents // whose edges end up filtered. Don't populate `agentConfigs` here — @@ -273,6 +314,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, }); }, // Pass through the `@librechat/api` exports so that tests which @@ -325,6 +367,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, }); } @@ -356,6 +399,18 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { modelLabel: endpointOption.model_parameters.modelLabel, }); + const handlePrimeInvokedSkills = skillsCapabilityEnabled + ? (payload) => + primeInvokedSkills({ + req, + payload, + accessibleSkillIds, + codeApiKey, + loadAuthValues, + ...getSkillToolDeps(), + }) + : undefined; + const client = new AgentClient({ req, res, @@ -366,6 +421,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { collectedUsage, aggregateContent, artifactPromises, + primeInvokedSkills: handlePrimeInvokedSkills, agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js new file mode 100644 index 0000000000..32a7995a49 --- /dev/null +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -0,0 +1,43 @@ +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { batchUploadCodeEnvFiles } = require('~/server/services/Files/Code/crud'); +const { getSessionInfo, checkIfActive } = require('~/server/services/Files/Code/process'); +const { loadAuthValues } = require('~/server/services/Tools/credentials'); +const { enrichWithSkillConfigurable } = require('@librechat/api'); +const db = require('~/models'); + +/** Skill-related properties for ToolExecuteOptions (stable references, allocated once). */ +const skillToolDeps = { + getSkillByName: db.getSkillByName, + listSkillFiles: db.listSkillFiles, + getStrategyFunctions, + batchUploadCodeEnvFiles, + getSessionInfo, + checkIfActive, + updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds, + getSkillFileByPath: db.getSkillFileByPath, + updateSkillFileContent: db.updateSkillFileContent, +}; + +function getSkillToolDeps() { + return skillToolDeps; +} + +/** + * Wraps the TS enrichWithSkillConfigurable with the CJS loadAuthValues dependency. + * @param {object} result - The result from loadToolsForExecution + * @param {object} req - The Express request object + * @param {Array} accessibleSkillIds - Pre-computed accessible skill IDs + * @param {string} [preResolvedCodeApiKey] - Pre-resolved code API key (skips redundant lookup) + * @returns {Promise} Augmented result with skill configurable + */ +function enrichConfigurable(result, req, accessibleSkillIds, preResolvedCodeApiKey) { + return enrichWithSkillConfigurable( + result, + req, + accessibleSkillIds, + loadAuthValues, + preResolvedCodeApiKey, + ); +} + +module.exports = { getSkillToolDeps, enrichWithSkillConfigurable: enrichConfigurable }; diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 945aec787b..5bf028702a 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -1,4 +1,5 @@ const FormData = require('form-data'); +const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); const { logAxiosError, @@ -107,4 +108,82 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' } } -module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile }; +/** + * Uploads multiple files to the code execution environment in a single request. + * Uses the /upload/batch endpoint which shares one session_id across all files. + * + * @param {object} params + * @param {import('express').Request & { user: { id: string } }} params.req - The request object. + * @param {Array<{ stream: NodeJS.ReadableStream; filename: string }>} params.files - Files to upload. + * @param {string} params.apiKey - The API key for authentication. + * @param {string} [params.entity_id] - Optional entity ID. + * @returns {Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }> }>} + * @throws {Error} If the batch upload fails entirely. + */ +async function batchUploadCodeEnvFiles({ req, files, apiKey, entity_id = '' }) { + try { + const form = new FormData(); + if (entity_id.length > 0) { + form.append('entity_id', entity_id); + } + for (const file of files) { + form.append('file', file.stream, file.filename); + } + + const baseURL = getCodeBaseURL(); + /** @type {import('axios').AxiosRequestConfig} */ + const options = { + headers: { + ...form.getHeaders(), + 'Content-Type': 'multipart/form-data', + 'User-Agent': 'LibreChat/1.0', + 'User-Id': req.user.id, + 'X-API-Key': apiKey, + }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 120000, + maxContentLength: MAX_FILE_SIZE, + maxBodyLength: MAX_FILE_SIZE, + }; + + const response = await axios.post(`${baseURL}/upload/batch`, form, options); + + /** @type {{ message: string; session_id: string; files: Array<{ status: string; fileId?: string; filename: string; error?: string }>; succeeded: number; failed: number }} */ + const result = response.data; + if ( + !result || + typeof result !== 'object' || + !result.session_id || + !Array.isArray(result.files) + ) { + throw new Error(`Unexpected batch upload response: ${JSON.stringify(result).slice(0, 200)}`); + } + if (result.message === 'error') { + throw new Error('All files in batch upload failed'); + } + + if (result.failed > 0) { + const failedNames = result.files + .filter((f) => f.status === 'error') + .map((f) => `${f.filename}: ${f.error || 'unknown'}`) + .join(', '); + logger.warn(`[batchUploadCodeEnvFiles] ${result.failed} file(s) failed: ${failedNames}`); + } + + const successFiles = result.files + .filter((f) => f.status === 'success' && f.fileId) + .map((f) => ({ fileId: f.fileId, filename: f.filename })); + + return { session_id: result.session_id, files: successFiles }; + } catch (error) { + throw new Error( + logAxiosError({ + message: `Error in batch upload to code environment: ${error instanceof Error ? error.message : String(error)}`, + error, + }), + ); + } +} + +module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile, batchUploadCodeEnvFiles }; diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 7cdebeb202..d7076f75f2 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -292,8 +292,7 @@ async function getSessionInfo(fileIdentifier, apiKey) { try { const baseURL = getCodeBaseURL(); const [path, queryString] = fileIdentifier.split('?'); - const session_id = path.split('/')[0]; - + const [session_id, fileId] = path.split('/'); let queryParams = {}; if (queryString) { queryParams = Object.fromEntries(new URLSearchParams(queryString).entries()); @@ -301,11 +300,8 @@ async function getSessionInfo(fileIdentifier, apiKey) { const response = await axios({ method: 'get', - url: `${baseURL}/files/${session_id}`, - params: { - detail: 'summary', - ...queryParams, - }, + url: `${baseURL}/sessions/${session_id}/objects/${fileId}`, + params: queryParams, headers: { 'User-Agent': 'LibreChat/1.0', 'X-API-Key': apiKey, @@ -315,7 +311,7 @@ async function getSessionInfo(fileIdentifier, apiKey) { timeout: 5000, }); - return response.data.find((file) => file.name.startsWith(path))?.lastModified; + return response.data?.lastModified; } catch (error) { logAxiosError({ message: `Error fetching session info: ${error.message}`, @@ -460,6 +456,7 @@ const primeFiles = async (options, apiKey) => { module.exports = { primeFiles, + checkIfActive, getSessionInfo, processCodeOutput, }; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 7478564292..79b562caec 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -7,6 +7,7 @@ const { GraphEvents, createToolSearch, Constants: AgentConstants, + createBashExecutionTool, createProgrammaticToolCallingTool, } = require('@librechat/agents'); const { @@ -1269,9 +1270,46 @@ async function loadToolsForExecution({ } } + const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL); + if (isBashTool) { + try { + const authValues = await loadAuthValues({ + userId: req.user.id, + authFields: [EnvVar.CODE_API_KEY], + }); + const codeApiKey = authValues[EnvVar.CODE_API_KEY]; + + if (codeApiKey) { + const bashTool = createBashExecutionTool({ apiKey: codeApiKey }); + allLoadedTools.push(bashTool); + } else { + logger.debug('[loadToolsForExecution] bash_tool requested but CODE_API_KEY not available'); + allLoadedTools.push( + toolFn( + async () => [ + 'Code execution is not available. Use the read_file tool instead.', + undefined, + ], + { + name: AgentConstants.BASH_TOOL, + description: 'Bash execution (unavailable - no code API key configured)', + schema: { type: 'object', properties: { command: { type: 'string' } } }, + responseFormat: AgentConstants.CONTENT_AND_ARTIFACT, + }, + ), + ); + } + } catch (error) { + logger.error('[loadToolsForExecution] Error creating bash tool:', error); + } + } + const specialToolNames = new Set([ AgentConstants.TOOL_SEARCH, AgentConstants.PROGRAMMATIC_TOOL_CALLING, + AgentConstants.BASH_TOOL, + AgentConstants.SKILL_TOOL, + AgentConstants.READ_FILE, ]); let ptcOrchestratedToolNames = []; diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 1bedcec66f..05f6528145 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -17,6 +17,7 @@ interface BadgeRowContextType { conversationId?: string | null; storageContextKey?: string; agentsConfig?: TAgentsEndpoint | null; + skills: ReturnType; webSearch: ReturnType; artifacts: ReturnType; fileSearch: ReturnType; @@ -100,13 +101,15 @@ export default function BadgeRowProvider({ const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${storageSuffix}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${storageSuffix}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${storageSuffix}`; + const skillsToggleKey = `${LocalStorageKeys.LAST_SKILLS_TOGGLE_}${storageSuffix}`; const codeToggleValue = getTimestampedValue(codeToggleKey); const webSearchToggleValue = getTimestampedValue(webSearchToggleKey); const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey); const artifactsToggleValue = getTimestampedValue(artifactsToggleKey); + const skillsToggleValue = getTimestampedValue(skillsToggleKey); - const initialValues: Record = {}; + const initialValues: Record = {}; if (codeToggleValue !== null) { try { @@ -140,6 +143,14 @@ export default function BadgeRowProvider({ } } + if (skillsToggleValue !== null) { + try { + initialValues[AgentCapabilities.skills] = JSON.parse(skillsToggleValue); + } catch (e) { + console.error('Failed to parse skills toggle value:', e); + } + } + const hasOverrides = Object.keys(initialValues).length > 0; /** Read persisted MCP values from localStorage */ @@ -238,9 +249,19 @@ export default function BadgeRowProvider({ isAuthenticated: true, }); + /** Skills hook - using a custom key since it's not a Tool but a capability */ + const skills = useToolToggle({ + conversationId, + storageContextKey, + toolKey: AgentCapabilities.skills, + localStorageKey: LocalStorageKeys.LAST_SKILLS_TOGGLE_, + isAuthenticated: true, + }); + const mcpServerManager = useMCPServerManager({ conversationId, storageContextKey }); const value: BadgeRowContextType = { + skills, webSearch, artifacts, fileSearch, diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 6fea6b0d58..7b7140c9fb 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -21,6 +21,7 @@ import FileSearch from './FileSearch'; import Artifacts from './Artifacts'; import MCPSelect from './MCPSelect'; import WebSearch from './WebSearch'; +import Skills from './Skills'; import store from '~/store'; interface BadgeRowProps { @@ -373,6 +374,7 @@ function BadgeRow({ + diff --git a/client/src/components/Chat/Input/Skills.tsx b/client/src/components/Chat/Input/Skills.tsx new file mode 100644 index 0000000000..2a5706eb5f --- /dev/null +++ b/client/src/components/Chat/Input/Skills.tsx @@ -0,0 +1,36 @@ +import React, { memo } from 'react'; +import { ScrollText } from 'lucide-react'; +import { CheckboxButton } from '@librechat/client'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; + +function Skills() { + const localize = useLocalize(); + const context = useBadgeRowContext(); + const { toggleState: skillsActive, debouncedChange, isPinned } = context?.skills ?? {}; + + const canUseSkills = useHasAccess({ + permissionType: PermissionTypes.SKILLS, + permission: Permissions.USE, + }); + + if (!canUseSkills) { + return null; + } + + return ( + (skillsActive || isPinned) && ( +