From a7374e660130008188d10d7eff3505ab721c3ccc Mon Sep 17 00:00:00 2001
From: Jonathan Addington
-
-
-
-
- LibreChat
-
-
- - - -
- - ---- - -## โจ Contributions - -Contributions, suggestions, bug reports and fixes are welcome! - -For new features, components, or extensions, please open an issue and discuss before sending a PR. - ---- - -## ๐ This project exists in its current state thanks to all the people who contribute - - - - +***WE STILL LOVE LIBRECHAT!*** In fact, we're proud to be monthly sponsors. Our choice to use E2B.dev is not about detracting from LibreChat's service; we simply need additional functionality to fulfill our unique needs. \ No newline at end of file diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index f6fca55112a..9df414b6aac 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -946,4 +946,4 @@ class GoogleClient extends BaseClient { } } -module.exports = GoogleClient; +module.exports = GoogleClient; \ No newline at end of file diff --git a/api/app/clients/index.js b/api/app/clients/index.js index a5e8eee5045..5d34ec5ff38 100644 --- a/api/app/clients/index.js +++ b/api/app/clients/index.js @@ -6,6 +6,7 @@ const TextStream = require('./TextStream'); const AnthropicClient = require('./AnthropicClient'); const toolUtils = require('./tools/util'); + module.exports = { ChatGPTClient, OpenAIClient, diff --git a/api/app/clients/tools/__tests__/openWeather.integration.test.js b/api/app/clients/tools/__tests__/openWeather.integration.test.js new file mode 100644 index 00000000000..e8a0df7b88c --- /dev/null +++ b/api/app/clients/tools/__tests__/openWeather.integration.test.js @@ -0,0 +1,43 @@ +// __tests__/openWeather.integration.test.js +const OpenWeather = require('../structured/OpenWeather'); +const fetch = require('node-fetch'); + +// If you havenโt mocked fetch globally for other tests, you may remove the mocking. +// If fetch is mocked globally, you will need to unmock it here. +// For example: +// jest.unmock('node-fetch'); + +describe('OpenWeather Tool (Integration Test)', () => { + let tool; + + beforeAll(() => { + tool = new OpenWeather(); + }); + + test('current_forecast with a real API key, if available', async () => { + // Check if API key is available + if (!process.env.OPENWEATHER_API_KEY) { + console.warn("Skipping real API test, no OPENWEATHER_API_KEY found."); + return; // Test passes but does nothing + } + + // Provide a real city and action + const result = await tool.call({ + action: 'current_forecast', + city: 'London', + units: 'Celsius' + }); + + // Try to parse the JSON result + let parsed; + try { + parsed = JSON.parse(result); + } catch (e) { + throw new Error(`Could not parse JSON from response: ${result}`); + } + + // Check that the response contains expected fields + expect(parsed).toHaveProperty('current'); + expect(typeof parsed.current.temp).toBe('number'); + }); +}); diff --git a/api/app/clients/tools/__tests__/openweather.test.js b/api/app/clients/tools/__tests__/openweather.test.js new file mode 100644 index 00000000000..2f9c512c0c7 --- /dev/null +++ b/api/app/clients/tools/__tests__/openweather.test.js @@ -0,0 +1,116 @@ +// __tests__/openWeather.test.js +const OpenWeather = require('../structured/OpenWeather'); +const fetch = require('node-fetch'); + +// Mock environment variable +process.env.OPENWEATHER_API_KEY = 'test-api-key'; + +// Mock the fetch function globally +jest.mock('node-fetch', () => jest.fn()); + +describe('OpenWeather Tool', () => { + let tool; + + beforeAll(() => { + tool = new OpenWeather(); + }); + + beforeEach(() => { + fetch.mockReset(); + }); + + test('action=help returns help instructions', async () => { + const result = await tool.call({ + action: 'help' + }); + + expect(typeof result).toBe('string'); + const parsed = JSON.parse(result); + expect(parsed.title).toBe('OpenWeather One Call API 3.0 Help'); + }); + + test('current_forecast with a city and successful geocoding + forecast', async () => { + // Mock geocoding response + fetch.mockImplementationOnce((url) => { + if (url.includes('geo/1.0/direct')) { + return Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }] + }); + } + return Promise.reject('Unexpected fetch call for geocoding'); + }); + + // Mock forecast response + fetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: async () => ({ + current: { temp: 293.15, feels_like: 295.15 }, + daily: [{ temp: { day: 293.15, night: 283.15 } }] + }) + })); + + const result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee', + units: 'Kelvin' + }); + + const parsed = JSON.parse(result); + expect(parsed.current.temp).toBe(293); + expect(parsed.current.feels_like).toBe(295); + expect(parsed.daily[0].temp.day).toBe(293); + expect(parsed.daily[0].temp.night).toBe(283); + }); + + test('timestamp action without a date returns an error message', async () => { + const result = await tool.call({ + action: 'timestamp', + lat: 35.9606, + lon: -83.9207 + }); + expect(result).toMatch(/Error: For timestamp action, a 'date' in YYYY-MM-DD format is required./); + }); + + test('unknown action returns an error due to schema validation', async () => { + await expect(tool.call({ + action: 'unknown_action' + })).rejects.toThrow(/Received tool input did not match expected schema/); + }); + + + test('geocoding failure returns a descriptive error', async () => { + fetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: async () => [] + })); + + const result = await tool.call({ + action: 'current_forecast', + city: 'NowhereCity' + }); + expect(result).toMatch(/Error: Could not find coordinates for city: NowhereCity/); + }); + + test('API request failure returns an error', async () => { + // Mock geocoding success + fetch.mockImplementationOnce(() => Promise.resolve({ + ok: true, + json: async () => [{ lat: 35.9606, lon: -83.9207 }] + })); + + // Mock weather request failure (e.g., 404) + fetch.mockImplementationOnce(() => Promise.resolve({ + ok: false, + status: 404, + json: async () => ({ message: 'Not found' }) + })); + + const result = await tool.call({ + action: 'current_forecast', + city: 'Knoxville, Tennessee' + }); + // Adjusted regex to match without quotes + expect(result).toMatch(/Error: OpenWeather API request failed with status 404: Not found/); + }); +}); diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index a8532d4581f..7fe270651bc 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -8,6 +8,10 @@ const StructuredSD = require('./structured/StableDiffusion'); const GoogleSearchAPI = require('./structured/GoogleSearch'); const TraversaalSearch = require('./structured/TraversaalSearch'); const TavilySearchResults = require('./structured/TavilySearchResults'); +const FluxAPI = require('./structured/FluxAPI'); +const WebNavigator = require('./structured/WebNavigator'); +const E2BCode = require('./structured/E2BCode'); +const OpenWeather = require('./structured/OpenWeather'); module.exports = { availableTools, @@ -19,4 +23,8 @@ module.exports = { TraversaalSearch, StructuredWolfram, TavilySearchResults, + FluxAPI, + WebNavigator, + E2BCode, + OpenWeather, }; diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index d2748cdea11..fe216694e7c 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -138,5 +138,41 @@ "description": "You need to provideq your API Key for Azure AI Search." } ] + }, + { + "name": "WebNavigator", + "pluginKey": "WebNavigator", + "description": "WebNavigator", + "icon": "/assets/webnavigator.png", + "isAuthRequired": "false", + "authConfig": [] + }, + { + "name": "Flux", + "pluginKey": "flux", + "description": "Generate images using text with the Flux API.", + "icon": "/assets/flux.png", + "isAuthRequired": "true", + "authConfig": [ + { + "authField": "FLUX_API_KEY", + "label": "Your Flux API Key", + "description": "Provide your Flux API key from your user profile." + } + ] + }, + { + "name": "E2BCode", + "pluginKey": "E2BCode", + "description": "Run code in an E2B sandbox", + "icon": "/assets/e2b_symbol.png", + "isAuthRequired": "true", + "authConfig": [ + { + "authField": "E2B_API_KEY", + "label": "E2B.dev API key", + "description": "API Key from the e2b.dev dashboard" + } + ] } ] diff --git a/api/app/clients/tools/structured/E2BCode.js b/api/app/clients/tools/structured/E2BCode.js new file mode 100644 index 00000000000..a532daa7402 --- /dev/null +++ b/api/app/clients/tools/structured/E2BCode.js @@ -0,0 +1,1237 @@ +const { z } = require('zod'); +const { Tool } = require('@langchain/core/tools'); +const { getEnvironmentVariable } = require('@langchain/core/utils/env'); +const { Sandbox } = require('@e2b/code-interpreter'); +const { logger } = require('~/config'); + +const MAX_OUTPUT_LENGTH = 10000; // 10k characters +const MAX_TAIL_LINES = 20; +const MAX_TAIL_BYTES = 2000; // ~2KB + +// Store active sandboxes with their session IDs +const sandboxes = (global.sandboxes = global.sandboxes || new Map()); + +class E2BCode extends Tool { + constructor(fields = {}) { + super(); + const envVar = 'E2B_API_KEY'; + const override = fields.override ?? false; + this.apiKey = fields.apiKey ?? this.getApiKey(envVar, override); + const keySuffix = this.apiKey ? this.apiKey.slice(-5) : 'none'; + logger.debug('[E2BCode] Initialized with API key ' + `*****${keySuffix}`); + this.name = 'E2BCode'; + this.description = ` + Use E2B to execute code, run shell commands, manage files, install packages, and manage sandbox environments in an isolated sandbox environment. + + YOU CANNOT RUN MORE THAN 25 COMMANDS SEQUENTIALLY WITHOUT OUTPUT TO THE USER! + + Sessions: You must provide a unique \`sessionId\` string to maintain session state between calls. Use the same \`sessionId\` for related actions. + + Use the help action before executing anything else to understand the available actions and parameters. Before you run a command for the first + time, use the help action for that command to understand the parameters required for that action. + + To copy files from one sandbox to another is to gzip them, then use the get_download_url action to get a link, + and then use wget on the new sandbox to download. + `; + + this.schema = z.object({ + sessionId: z + .string() + .optional() + .describe( + 'A unique identifier for the session. Use the same `sessionId` to maintain state across multiple calls.' + ), + sandboxId: z + .string() + .optional() + .describe( + 'The sandbox ID to use for the kill_sandbox action. If not provided, the sandbox associated with the `sessionId` will be used.' + ), + action: z + .enum([ + 'help', + 'create', + 'list_sandboxes', + 'kill', + 'set_timeout', + 'shell', + 'kill_command', + 'write_file', + 'read_file', + 'install', + 'get_file_downloadurl', + 'get_host', + 'command_run', + 'start_server', + 'command_list', + 'command_kill', + 'processinfo', + 'system_install', + ]) + .describe('The action to perform.'), + template: z + .string() + .optional() + .describe( + 'Sandbox template name or ID to create the sandbox from (used with `create` action).' + ), + language: z + .enum(['python', 'javascript', 'typescript', 'shell']) + .optional() + .describe('The programming language environment for installs. Defaults to `python`.'), + cmd: z + .string() + .optional() + .describe( + 'Command to execute (used with `shell`, `command_run` and `start_server` actions).' + ), + background: z + .boolean() + .optional() + .describe( + 'Whether to run the command in the background (for `command_run`, `shell` actions). Defaults to `false`.' + ), + cwd: z + .string() + .optional() + .describe( + 'Working directory for the command (used with `command_run` and `start_server` actions).' + ), + timeoutMs: z + .number() + .int() + .min(1000) + .default(60 * 1000) + .optional() + .describe( + 'Timeout in milliseconds for the command (used with `command_run` and `start_server` actions).' + ), + user: z + .string() + .optional() + .describe( + 'User to run the command as (used with `command_run` and `start_server` actions).' + ), + commandId: z + .string() + .optional() + .describe( + 'The ID of the background command to kill (required for `kill_command` action).' + ), + filePath: z + .string() + .optional() + .describe( + 'Path for read/write operations (used with `write_file`, `read_file`, and `get_file_downloadurl` actions).' + ), + fileContent: z + .string() + .optional() + .describe('Content to write to file (required for `write_file` action).'), + port: z + .number() + .int() + .optional() + .describe( + 'Port number to use for the host (used with `get_host` and `start_server` actions).' + ), + logFile: z + .string() + .optional() + .describe( + 'Path to the log file where stdout and stderr will be redirected (required for `start_server` action).' + ), + timeout: z + .number() + .int() + .optional() + .default(60) + .describe( + 'Timeout in minutes for the sandbox environment. Defaults to 60 minutes.' + ), + envs: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to set when creating the sandbox (used with `create` action) and for other actions that run commands.' + ), + command_name: z + .string() + .optional() + .describe( + 'The name of the command to get detailed help about (used with the `help` action).' + ), + pid: z + .number() + .int() + .optional() + .describe( + 'Process ID of the command to kill (required for `command_kill` action) or get info (required for `processinfo` action).' + ), + packages: z + .array(z.string()) + .optional() + .describe('List of packages to install (used with `install` and `system_install` actions).') + }); + } + + getApiKey(envVar, override) { + const key = getEnvironmentVariable(envVar); + if (!key && !override) { + logger.error(`[E2BCode] Missing ${envVar} environment variable`); + throw new Error(`Missing ${envVar} environment variable.`); + } + return key; + } + + // Method to retrieve hidden environment variables starting with E2B_CODE_EV_ + getHiddenEnvVars() { + const hiddenEnvVars = {}; + Object.keys(process.env).forEach((key) => { + if (key.startsWith('E2B_CODE_EV_')) { + hiddenEnvVars[key.substring('E2B_CODE_EV_'.length)] = process.env[key]; + } + }); + return hiddenEnvVars; + } + + // If the output is too large, return an error message and the last 20 lines (up to 2KB). + safeReturn(obj) { + let str = JSON.stringify(obj); + if (str.length > MAX_OUTPUT_LENGTH) { + // Extract last 20 lines of the JSON output (based on newline). + const lines = str.split('\n'); + const tailLines = lines.slice(-MAX_TAIL_LINES).join('\n'); + + // Truncate to 2KB if needed + let truncated = tailLines; + if (Buffer.byteLength(truncated, 'utf8') > MAX_TAIL_BYTES) { + truncated = truncated.slice(0, 2000); + } + + // Return error message with truncated output + return JSON.stringify({ + error: 'Output too long. We are truncating the output. If you need more, please rerun the command and redirect the output to a log file that you can tail if needed.', + truncated_tail: truncated + }); + } + return str; + } + + getDetailedHelp(commandName) { + const helpTexts = { + 'help': ` + Returns information about every possible action that can be performed using the E2BCode tool. + `, + 'create': ` + **create** + + - **Description:** Create a new E2B sandbox environment from a template. + + - **Required Parameters:** + - \`sessionId\`: A unique identifier for the session. Use the same \`sessionId\` to maintain state across multiple calls. + + - **Optional Parameters:** + - \`template\`: The sandbox template name or ID to create the environment from. + - \`timeout\`: Timeout in minutes for the sandbox environment. Defaults to 60 minutes. + - \`envs\`: A key-value object of environment variables to set when creating the sandbox. + `, + 'list_sandboxes': ` + **list_sandboxes** + + - **Description:** List all active E2B sandboxes for the current session. + + - **Parameters:** None (include \`sessionId\` for consistency). + `, + 'kill': ` + **kill** + + - **Description:** Terminate the E2B sandbox environment associated with the provided \`sessionId\` or \`sandboxId\`. + + - **Required Parameters:** + - Either \`sessionId\` or \`sandboxId\` must be provided. If both are provided \`sandboxId\` will take precedence. + `, + 'set_timeout': ` + **set_timeout** + + - **Description:** Update the timeout for the sandbox environment to keep it alive for the specified duration. + + - **Required Parameters:** + - \`sessionId\` + - \`timeout\`: Timeout in minutes for the sandbox environment. + `, + 'shell': ` + **shell** + + - **Description:** Run a shell command inside the sandbox environment. + + - **Required Parameters:** + - \`sessionId\` + - \`cmd\`: The shell command to execute. + + - **Optional Parameters:** + - \`background\`: Whether to run the shell command in the background. Boolean value; defaults to \`false\`. + - \`envs\`: Environment variables to set for this execution. + `, + 'kill_command': ` + **kill_command** + + - **Description:** Terminate a background shell command that was previously started. + + - **Required Parameters:** + - \`sessionId\` + - \`commandId\`: The ID of the background command to kill. + `, + 'write_file': ` + **write_file** + + - **Description:** Write content to a file in the sandbox environment. + + - **Required Parameters:** + - \`sessionId\` + - \`filePath\`: The path to the file where content will be written. + - \`fileContent\`: The content to write to the file. + `, + 'read_file': ` + **read_file** + + - **Description:** Read the content of a file from the sandbox environment. + + - **Required Parameters:** + - \`sessionId\` + - \`filePath\`: The path to the file to read. + `, + 'install': ` + **install** + + - **Description:** Install python or node packages within the sandbox environment. + Use \`system_install\` for system packages. + + - **Required Parameters:** + - \`sessionId\` + - \`packages\`: An array of package names to install. + + - **Optional Parameters:** + - \`language\`: The environment to use (\`python\` uses pip, \`javascript\` or \`typescript\` use npm). Defaults to \`python\`. + - \`envs\`: Environment variables to set for this installation. + `, + 'get_file_downloadurl': ` + **get_file_downloadurl** + + - **Description:** Obtain a download URL for a file in the sandbox environment. + + - **Required Parameters:** + - \`sessionId\` + - \`filePath\`: The path to the file for which to generate a download URL. + `, + 'get_host': ` + **get_host** + + - **Description:** Retrieve the host and port information for accessing services running inside the sandbox. + + - **Required Parameters:** + - \`sessionId\` + - \`port\`: The port number that the service is running on inside the sandbox. + `, + 'command_run': ` + **command_run** + + - **Description:** Start a new command and wait until it finishes executing, or run it in the background. + Use this for running most commands that do not require a PTY session. + + - **Required Parameters:** + - \`sessionId\` + - \`cmd\`: The command to execute. + + - **Optional Parameters:** + - \`background\`: Whether to run the command in the background. Defaults to \`false\`. + - \`cwd\`: Working directory for the command. + - \`timeoutMs\`: Timeout in milliseconds for the command. + - \`user\`: User to run the command as. + - \`envs\`: Environment variables to set for this command. + `, + 'start_server': ` + **start_server** + + - **Description:** Start a server process (e.g., nginx, flask) in the sandbox environment by executing a command in the background, + redirecting stdout and stderr to a specified log file, and returning the host and port information for accessing the server. + + - **Required Parameters:** + - \`sessionId\` + - \`cmd\`: The command to execute to start the server. + - \`port\`: The port number on which the server is expected to listen inside the sandbox. + - \`logFile\`: The path to the log file where stdout and stderr will be redirected. + + - **Optional Parameters:** + - \`cwd\`: Working directory for the command. + - \`timeoutMs\`: Timeout in milliseconds for the command. + - \`user\`: User to run the command as. + - \`envs\`: Environment variables to set for this execution. + + - **Returns:** + - \`sessionId\`: The session ID. + - \`commandId\`: The ID of the background command started. + - \`host\`: The host address to access the server. + - \`logFile\`: The location of the log file. + - \`message\`: Confirmation message of server start and log file location. + `, + 'command_list': ` + **command_list** + + - **Description:** List all running commands and PTY sessions within the sandbox environment. + + - **Required Parameters:** + - \`sessionId\` + `, + 'command_kill': ` + **command_kill** + + - **Description:** Kill a running command specified by its process ID. + + - **Required Parameters:** + - \`sessionId\` + - \`pid\`: Process ID of the command to kill. + `, + 'processinfo': ` + **processinfo** + + - **Description:** Get detailed information about a running command specified by its process ID. + + - **Required Parameters:** + - \`sessionId\` + - \`pid\`: Process ID of the command to get information about. + `, + 'system_install': ` + **system_install** + + - **Description:** Install system packages within the sandbox environment using \`sudo apt-get install\`. + + - **Required Parameters:** + - \`sessionId\` + - \`packages\`: An array of system package names to install. + + - **Optional Parameters:** + - \`envs\`: Environment variables to set for this installation. + `, + }; + + return helpTexts[commandName]; + } + + async _call(input) { + const { + sessionId, + sandboxId, + packages, + language = 'python', + action, + cmd, + background = false, + cwd, + timeoutMs = 30 * 1000, + user, + commandId, + filePath, + fileContent, + port, + timeout = 60 * 60, + envs, + command_name, + logFile, + pid, + template, + } = input; + + // Make sure we have sessionId or sandboxId for all actions except help and list_sandboxes, and create + if ( + action !== 'help' && + action !== 'list_sandboxes' && + action !== 'create' && + (!sessionId || (!sandboxId && action !== 'create')) + ) { + logger.error('[E2BCode] `sessionId` is required for most actions', { + action, + }); + } + + let adjustedTimeoutMs = timeoutMs; + if (adjustedTimeoutMs < 1000) { + adjustedTimeoutMs = 1000; + } + + let adjustedTimeout = timeout; + if (adjustedTimeout < 1) { + adjustedTimeout = 1; + } + + logger.debug('[E2BCode] Processing request', { + action, + language, + sessionId, + }); + + try { + switch (action) { + case 'help': + if (command_name) { + // Return detailed help about the specified command + const detailedHelp = this.getDetailedHelp(command_name.trim()); + if (detailedHelp) { + return JSON.stringify({ message: detailedHelp }); + } else { + return JSON.stringify({ + message: `No detailed help available for command '${command_name}'.`, + }); + } + } else { + // Return overview of available commands + const commandList = [ + 'help', + 'create', + 'list_sandboxes', + 'kill', + 'set_timeout', + 'shell', + 'kill_command', + 'write_file', + 'read_file', + 'install', + 'system_install', + 'get_file_downloadurl', + 'get_host', + 'command_run', + 'start_server', + 'command_list', + 'command_kill', + 'processinfo', + ]; + const overview = `Available actions: ${commandList.join(', ')}. Use 'help' with a command name to get detailed help about a specific command. You are HIGHLY encouraged to run help for system_install, command_run, shell and start_server to understand the differences between them and how to use them.`; + return JSON.stringify({ message: overview }); + } + + case 'create': { + if (sandboxes.has(sessionId)) { + logger.error('[E2BCode] Sandbox already exists', { sessionId }); + throw new Error(`Sandbox with sessionId ${sessionId} already exists.`); + } + logger.debug('[E2BCode] Creating new sandbox', { + sessionId, + timeout: adjustedTimeout, + }); + const sandboxCreateOptions = { + apiKey: this.apiKey, + timeoutMs: adjustedTimeout * 60 * 1000, + }; + + // Get hidden environment variables + const hiddenEnvVarsCreate = this.getHiddenEnvVars(); + // Merge hidden env vars with any provided envs, without exposing hidden vars to the LLM + if (Object.keys(hiddenEnvVarsCreate).length > 0 || envs) { + sandboxCreateOptions.env = { + ...hiddenEnvVarsCreate, + ...envs, + }; + } + + let sandboxCreate; + let skippedTemplate = false; + //Try to use a template if provided: + if (template) { + try { + sandboxCreate = await Sandbox.create(template, sandboxCreateOptions); + } catch (error) { + //Try without a template: + try { + sandboxCreate = await Sandbox.create(sandboxCreateOptions); + skippedTemplate = true; + } catch (error) { + logger.error('[E2BCode] Error creating sandbox', { sessionId, error: error.message }); + throw new Error(`Error creating sandbox: ${error.message}`); + } + } + } else { + //Try without a template: + try { + sandboxCreate = await Sandbox.create(sandboxCreateOptions); + } catch (error) { + logger.error('[E2BCode] Error creating sandbox', { sessionId, error: error.message }); + throw new Error(`Error creating sandbox: ${error.message}`); + } + } + + sandboxes.set(sessionId, { + sandbox: sandboxCreate, + lastAccessed: Date.now(), + commands: new Map(), + }); + + // Get current user and current directory inside the sandbox + const whoamiResult = await sandboxCreate.commands.run('whoami'); + const currentUser = whoamiResult.stdout.trim(); + + const pwdResult = await sandboxCreate.commands.run('pwd'); + const currentDirectory = pwdResult.stdout.trim(); + + // Get sandbox ID + const createdSandboxId = sandboxCreate.sandboxId; + + let message; + if (!skippedTemplate) { + message = `Sandbox created with sandboxId ${createdSandboxId} from template '${template}' with timeout ${adjustedTimeout} minutes.`; + } else { + message = `Sandbox created with sandboxId ${createdSandboxId} with timeout ${adjustedTimeout} minutes. There was an error attempting to use the template so none was used.`; + } + + //Add the current user and current directory to the response + message += ` You are user ${currentUser} and current directory is ${currentDirectory}.`; + + return JSON.stringify({ + sessionId, + sandboxId: createdSandboxId, + currentUser, + currentDirectory, + success: true, + message: message, + }); + } + + case 'list_sandboxes': + logger.debug('[E2BCode] Listing all active sandboxes'); + try { + const sandboxesList = await Sandbox.list({ apiKey: this.apiKey }); + if (sandboxesList.length === 0) { + logger.debug('[E2BCode] No active sandboxes found'); + return JSON.stringify({ + message: 'No active sandboxes found', + }); + } + // Map sandbox info to include sandboxId and any other relevant details + const sandboxDetails = sandboxesList.map((sandbox) => { + const [id] = sandbox.sandboxId.split('-'); + return { + sandboxId: id, + createdAt: sandbox.createdAt, + status: sandbox.status, + }; + }); + return JSON.stringify({ + message: 'Active sandboxes found', + sandboxes: sandboxDetails, + }); + } catch (error) { + logger.error('[E2BCode] Error listing sandboxes', { error: error.message }); + return JSON.stringify({ + error: 'Error listing sandboxes: ' + error.message, + }); + } + + case 'kill': + let killSandboxId = sandboxId; + if (!killSandboxId) { + // Try to get it from sessionId mapping + if (sandboxes.has(sessionId)) { + const sandboxInfo = sandboxes.get(sessionId); + killSandboxId = sandboxInfo.sandbox.sandboxId; + } + } + if (!killSandboxId) { + logger.error('[E2BCode] No sandboxId or sessionId provided to kill', { sessionId }); + throw new Error(`No sandboxId or sessionId provided. Cannot kill sandbox.`); + } + const [validSandboxId] = killSandboxId.split('-'); + logger.debug('[E2BCode] Killing sandbox', { sessionId, validSandboxId }); + let sandboxToKill; + try { + sandboxToKill = await Sandbox.connect(validSandboxId, { apiKey: this.apiKey }); + } catch (error) { + logger.error('[E2BCode] Error connecting to sandbox to kill', { sessionId, validSandboxId, error: error.message }); + if (sandboxes.has(sessionId)) { + sandboxes.delete(sessionId); + } + return JSON.stringify({ + sessionId, + success: false, + message: `No sandbox found with sandboxId ${validSandboxId} and sessionId ${sessionId}.`, + }); + } + + try { + await sandboxToKill.kill(); + } catch (error) { + logger.error('[E2BCode] Error killing sandbox', { sessionId, validSandboxId, error: error.message }); + if (sandboxes.has(sessionId)) { + sandboxes.delete(sessionId); + } + return JSON.stringify({ + sessionId, + success: false, + message: `Failed to kill sandbox with sandboxId ${validSandboxId} and sessionId ${sessionId}.`, + }); + } + + if (sandboxes.has(sessionId)) { + sandboxes.delete(sessionId); + } + + return JSON.stringify({ + sessionId, + success: true, + message: `Sandbox with sessionId ${sessionId} and sandboxId ${validSandboxId} has been killed.`, + }); + + case 'set_timeout': + if (!sandboxes.has(sessionId)) { + logger.error('[E2BCode] No sandbox found to set timeout', { + sessionId, + }); + throw new Error(`No sandbox found with sessionId ${sessionId}.`); + } + if (!timeout) { + logger.error( + '[E2BCode] `timeout` is required for set_timeout action', + { sessionId } + ); + throw new Error('`timeout` is required for `set_timeout` action.'); + } + logger.debug('[E2BCode] Setting sandbox timeout', { + sessionId, + timeout: adjustedTimeout, + }); + const { sandbox: sandboxSetTimeout } = sandboxes.get(sessionId); + await sandboxSetTimeout.setTimeout(adjustedTimeout * 60 * 1000); + return JSON.stringify({ + sessionId, + success: true, + message: `Sandbox timeout updated to ${adjustedTimeout} minutes.`, + }); + + default: + // For other actions, proceed to get the sandbox + const sandboxInfo = await this.getSandboxInfo(sessionId); + const sandbox = sandboxInfo.sandbox; + // Get hidden environment variables + const hiddenEnvVars = this.getHiddenEnvVars(); + + switch (action) { + case 'shell': + if (!cmd) { + logger.error('[E2BCode] Command (cmd) missing for shell action', { + sessionId, + }); + throw new Error('Command (cmd) is required for `shell` action.'); + } + logger.debug('[E2BCode] Executing shell command', { + sessionId, + cmd, + background, + }); + const shellOptions = {}; + if (Object.keys(hiddenEnvVars).length > 0 || envs) { + shellOptions.envs = { + ...hiddenEnvVars, + ...envs, + }; + } + if (background) { + shellOptions.background = true; + const backgroundCommand = await sandbox.commands.run( + cmd, + shellOptions + ); + const cmdId = backgroundCommand.id; + sandboxInfo.commands.set(cmdId, backgroundCommand); + logger.debug('[E2BCode] Background command started', { + sessionId, + commandId: cmdId, + }); + return JSON.stringify({ + sessionId, + commandId: cmdId, + success: true, + message: `Background command started with ID ${cmdId}`, + }); + } else { + const shellResult = await sandbox.commands.run( + cmd, + shellOptions + ); + logger.debug('[E2BCode] Shell command completed', { + sessionId, + exitCode: shellResult.exitCode, + }); + return JSON.stringify({ + sessionId, + output: shellResult.stdout, + error: shellResult.stderr, + exitCode: shellResult.exitCode, + }); + } + + case 'kill_command': + if (!commandId) { + logger.error( + '[E2BCode] `commandId` missing for kill_command action', + { sessionId } + ); + throw new Error( + '`commandId` is required for `kill_command` action.' + ); + } + logger.debug('[E2BCode] Killing background command', { + sessionId, + commandId, + }); + const commandToKill = sandboxInfo.commands.get(commandId); + if (!commandToKill) { + logger.error('[E2BCode] No command found to kill', { + sessionId, + commandId, + }); + throw new Error( + `No background command found with ID ${commandId}.` + ); + } + await commandToKill.kill(); + sandboxInfo.commands.delete(commandId); + return JSON.stringify({ + sessionId, + success: true, + message: `Background command with ID ${commandId} has been killed.`, + }); + + case 'write_file': + if (!filePath || !fileContent) { + logger.error( + '[E2BCode] Missing parameters for write_file action', + { + sessionId, + hasFilePath: !!filePath, + hasContent: !!fileContent, + } + ); + throw new Error( + '`filePath` and `fileContent` are required for `write_file` action.' + ); + } + logger.debug('[E2BCode] Writing file', { sessionId, filePath }); + await sandbox.files.write(filePath, fileContent); + logger.debug('[E2BCode] File written successfully', { + sessionId, + filePath, + }); + return JSON.stringify({ + sessionId, + success: true, + message: `File written to ${filePath}`, + }); + + case 'read_file': + if (!filePath) { + logger.error( + '[E2BCode] `filePath` missing for read_file action', + { sessionId } + ); + throw new Error('`filePath` is required for `read_file` action.'); + } + logger.debug('[E2BCode] Reading file', { sessionId, filePath }); + const content = await sandbox.files.read(filePath); + logger.debug('[E2BCode] File read successfully', { + sessionId, + filePath, + }); + return JSON.stringify({ + sessionId, + content: content.toString(), + success: true, + }); + + case 'install': + if (!packages || packages.length === 0) { + logger.error( + '[E2BCode] Packages missing for install action', + { + sessionId, + language, + } + ); + throw new Error('`packages` array is required for `install` action.'); + } + logger.debug('[E2BCode] Installing packages', { + sessionId, + language, + packages, + }); + const installOptions = {}; + if (Object.keys(hiddenEnvVars).length > 0 || envs) { + installOptions.envs = { + ...hiddenEnvVars, + ...envs, + }; + } + if (language === 'python') { + const pipResult = await sandbox.commands.run( + `pip install ${packages.join(' ')}`, + installOptions + ); + logger.debug( + '[E2BCode] Python package installation completed', + { + sessionId, + success: pipResult.exitCode === 0, + } + ); + return JSON.stringify({ + sessionId, + success: pipResult.exitCode === 0, + output: pipResult.stdout, + error: pipResult.stderr, + }); + } else if (language === 'javascript' || language === 'typescript') { + const npmResult = await sandbox.commands.run( + `npm install ${packages.join(' ')}`, + installOptions + ); + logger.debug( + '[E2BCode] Node package installation completed', + { + sessionId, + success: npmResult.exitCode === 0, + } + ); + return JSON.stringify({ + sessionId, + success: npmResult.exitCode === 0, + output: npmResult.stdout, + error: npmResult.stderr, + }); + } else { + logger.error( + '[E2BCode] Unsupported language for package installation', + { sessionId, language } + ); + throw new Error( + `Unsupported language for package installation: ${language}` + ); + } + + case 'get_file_downloadurl': + if (!filePath) { + logger.error( + '[E2BCode] `filePath` is required for get_file_downloadurl action', + { + sessionId, + } + ); + throw new Error( + '`filePath` is required for `get_file_downloadurl` action.' + ); + } + logger.debug('[E2BCode] Generating download URL for file', { + sessionId, + filePath, + }); + const downloadUrl = await sandbox.downloadUrl(filePath); + logger.debug('[E2BCode] Download URL generated', { + sessionId, + filePath, + downloadUrl, + }); + return JSON.stringify({ + sessionId, + success: true, + downloadUrl, + message: `Download URL generated for ${filePath}`, + }); + + case 'get_host': + if (!port) { + logger.error('[E2BCode] `port` is required for get_host action', { + sessionId, + }); + throw new Error('`port` is required for `get_host` action.'); + } + logger.debug('[E2BCode] Getting host+port', { sessionId, port }); + const host = await sandbox.getHost(port); + logger.debug('[E2BCode] Host+port retrieved', { sessionId, host }); + return JSON.stringify({ + sessionId, + host, + port, + message: `Host+port retrieved for port ${port}`, + }); + + case 'system_install': + if (!packages || packages.length === 0) { + logger.error('[E2BCode] Packages missing for system_install action', { sessionId }); + throw new Error('`packages` array is required for `system_install` action.'); + } + logger.debug('[E2BCode] Installing system packages', { + sessionId, + packages, + }); + const aptGetInstallCommand = `sudo apt-get update && sudo apt-get install -y ${packages.join(' ')}`; + const systemInstallOptions = {}; + if (Object.keys(hiddenEnvVars).length > 0 || envs) { + systemInstallOptions.envs = { + ...hiddenEnvVars, + ...envs, + }; + } + const aptGetResult = await sandbox.commands.run(aptGetInstallCommand, systemInstallOptions); + logger.debug('[E2BCode] System package installation completed', { + sessionId, + success: aptGetResult.exitCode === 0, + }); + return JSON.stringify({ + sessionId, + success: aptGetResult.exitCode === 0, + output: aptGetResult.stdout, + error: aptGetResult.stderr, + }); + + case 'command_run': + if (!cmd) { + logger.error('[E2BCode] `cmd` is missing for command_run action', { + sessionId, + }); + throw new Error('`cmd` is required for `command_run` action.'); + } + logger.debug('[E2BCode] Running command', { + sessionId, + cmd, + background, + }); + const commandOptions = {}; + if (background !== undefined) { + commandOptions.background = background; + } + if (cwd) { + commandOptions.cwd = cwd; + } + if (adjustedTimeoutMs) { + commandOptions.timeoutMs = adjustedTimeoutMs; + } + if (user) { + commandOptions.user = user; + } + if (Object.keys(hiddenEnvVars).length > 0 || envs) { + commandOptions.envs = { + ...hiddenEnvVars, + ...envs, + }; + } + if (background) { + const commandHandle = await sandbox.commands.run(cmd, commandOptions); + const cmdId = commandHandle.id; + sandboxInfo.commands.set(cmdId, commandHandle); + logger.debug('[E2BCode] Background command started', { + sessionId, + commandId: cmdId, + }); + return JSON.stringify({ + sessionId, + commandId: cmdId, + success: true, + message: `Background command started with ID ${cmdId}`, + }); + } else { + const commandResult = await sandbox.commands.run(cmd, commandOptions); + logger.debug('[E2BCode] Command execution completed', { + sessionId, + exitCode: commandResult.exitCode, + }); + return JSON.stringify({ + sessionId, + stdout: commandResult.stdout, + stderr: commandResult.stderr, + exitCode: commandResult.exitCode, + success: commandResult.exitCode === 0, + }); + } + + case 'start_server': + if (!cmd) { + logger.error('[E2BCode] `cmd` is missing for start_server action', { + sessionId, + }); + throw new Error('`cmd` is required for `start_server` action.'); + } + if (!port) { + logger.error('[E2BCode] `port` is missing for start_server action', { + sessionId, + }); + throw new Error('`port` is required for `start_server` action.'); + } + if (!logFile) { + logger.error('[E2BCode] `logFile` is missing for start_server action', { + sessionId, + }); + throw new Error('`logFile` is required for `start_server` action.'); + } + logger.debug('[E2BCode] Starting server', { + sessionId, + cmd, + port, + logFile, + }); + const serverCommand = `${cmd} > ${logFile} 2>&1`; + const serverOptions = {}; + serverOptions.background = true; + if (cwd) { + serverOptions.cwd = cwd; + } + if (adjustedTimeoutMs) { + serverOptions.timeoutMs = adjustedTimeoutMs; + } + if (user) { + serverOptions.user = user; + } + if (Object.keys(hiddenEnvVars).length > 0 || envs) { + serverOptions.envs = { + ...hiddenEnvVars, + ...envs, + }; + } + const serverHandle = await sandbox.commands.run( + serverCommand, + serverOptions + ); + const serverCommandId = serverHandle.id; + sandboxInfo.commands.set(serverCommandId, serverHandle); + logger.debug('[E2BCode] Server started', { + sessionId, + commandId: serverCommandId, + }); + const serverHost = await sandbox.getHost(port); + logger.debug('[E2BCode] Host+port retrieved', { sessionId, serverHost }); + return JSON.stringify({ + sessionId, + commandId: serverCommandId, + success: true, + serverHost, + logFile, + message: `Server started with ID ${serverCommandId}, accessible at ${serverHost}:${port}. Logs are redirected to ${logFile}`, + }); + + case 'command_list': + // Retrieve the list of running commands and PTY sessions + const processList = await sandbox.commands.list(); + logger.debug('[E2BCode] Retrieved list of commands', { + sessionId, + processCount: processList.length, + }); + return JSON.stringify({ + sessionId, + success: true, + processes: processList, + }); + + case 'command_kill': + if (pid === undefined) { + logger.error( + '[E2BCode] `pid` is missing for `command_kill` action', + { sessionId } + ); + throw new Error('`pid` is required for `command_kill` action.'); + } + logger.debug('[E2BCode] Killing process', { + sessionId, + pid, + }); + const killResult = await sandbox.commands.kill(pid); + if (killResult) { + logger.debug('[E2BCode] Process killed successfully', { + sessionId, + pid, + }); + return JSON.stringify({ + sessionId, + success: true, + message: `Process with PID ${pid} has been killed.`, + }); + } else { + logger.error('[E2BCode] Failed to kill process', { + sessionId, + pid, + }); + return JSON.stringify({ + sessionId, + success: false, + message: `Failed to kill process with PID ${pid}.`, + }); + } + + case 'processinfo': + if (pid === undefined) { + logger.error( + '[E2BCode] `pid` is missing for `processinfo` action', + { sessionId } + ); + throw new Error('`pid` is required for `processinfo` action.'); + } + logger.debug('[E2BCode] Getting process info', { + sessionId, + pid, + }); + const processinfo_processList = await sandbox.commands.list(); + const processInfo = processinfo_processList.find((p) => p.pid === pid); + if (processInfo) { + logger.debug('[E2BCode] Process info retrieved', { + sessionId, + pid, + }); + return JSON.stringify({ + sessionId, + success: true, + process: processInfo, + }); + } else { + logger.error('[E2BCode] Process not found', { + sessionId, + pid, + }); + return JSON.stringify({ + sessionId, + success: false, + message: `No process found with PID ${pid}.`, + }); + } + + default: + logger.error('[E2BCode] Unknown action requested', { + sessionId, + action, + }); + throw new Error(`Unknown action: ${action}`); + } + } + } catch (error) { + logger.error('[E2BCode] Error during execution', { + sessionId, + action, + error: error.message, + }); + return JSON.stringify({ + sessionId, + error: error.message, + success: false, + }); + } + } + + // Method to get an existing sandbox and its info based on sessionId + async getSandboxInfo(sessionId) { + if (sandboxes.has(sessionId)) { + logger.debug('[E2BCode] Reusing existing sandbox', { sessionId }); + const sandboxInfo = sandboxes.get(sessionId); + sandboxInfo.lastAccessed = Date.now(); + return sandboxInfo; + } + logger.error('[E2BCode] No sandbox found for session', { sessionId }); + throw new Error( + `No sandbox found for sessionId ${sessionId}. Please create one using the 'create' action.` + ); + } +} + +module.exports = E2BCode; \ No newline at end of file diff --git a/api/app/clients/tools/structured/E2BCode.md b/api/app/clients/tools/structured/E2BCode.md new file mode 100644 index 00000000000..b890cf718b6 --- /dev/null +++ b/api/app/clients/tools/structured/E2BCode.md @@ -0,0 +1,433 @@ +# E2BCode Plugin for LibreChat + +## Overview + +The **E2BCode Plugin** integrates the capabilities of the [E2B Code Interpreter](https://e2b.dev/) into [LibreChat](https://librechat.ai/), allowing users to execute code, run shell commands, manage files, and more within an isolated sandbox environment directly from the chat interface. This plugin leverages the E2B SDK and is built as a LangChain plugin. + +## Features + +- **Isolated Sandboxing**: Run code and commands in a secure, isolated sandbox. +- **Code Execution**: Supports execution of code in Python, JavaScript, TypeScript, and Shell. +- **File Management**: Read from and write to files within the sandbox. +- **Package Installation**: Install packages using `pip` or `npm`. +- **Environment Variables**: Set environment variables for sandbox environments and executions. +- **Background Processes**: Run and manage background shell commands. +- **Timeout Management**: Configure and adjust sandbox timeouts. + +--- + +## Prerequisites + +- **LibreChat** installed and running. +- **Node.js** (version 14.x or higher). +- **E2B API Key**: Sign up at [E2B](https://e2b.dev/) to obtain an API key. + +--- + +## Installation and Configuration + +### 1. Clone the Repository + +Clone the LibreChat fork at https://github.com/jmaddington/libreChat/ to access the E2BCode plugin. + +Not all branches have the E2BCode plugin. `jm-production` is a branch that has the plugin. Probably. + + +### 2. Set Environment Variables + +Create a `.env` file in the plugin directory or set environment variables in your system. The plugin requires the following environment variables: + +- `E2B_API_KEY`: Your E2B API key. +- Any environment variables that start with `E2B_CODE_EV_` will be passed to the sandbox without being revealed to the LLM. For example, to pass a variable `SECRET_KEY`, you would set `E2B_CODE_EV_SECRET_KEY`. + +Example `.env` file: + +```dotenv +E2B_API_KEY=your_e2b_api_key +E2B_CODE_EV_SECRET_KEY=your_secret_value +``` + +--- + +## Usage Instructions + +### Overview + +The E2BCode plugin allows you to interact with an isolated sandbox environment directly from LibreChat. You can perform various actions such as creating sandboxes, executing code, running shell commands, managing files, installing packages, and more. + +### General Workflow + +1. **Create a Sandbox**: Before executing any code or commands, you need to create a new sandbox environment. +2. **Perform Actions**: Use the various actions provided by the plugin to interact with the sandbox. +3. **Manage Sandbox**: Adjust the sandbox timeout or kill the sandbox when done. + +### Available Actions + +Below are the actions you can perform, along with the required parameters: + +1. **create** + + - **Purpose**: Create a new sandbox environment. + - **Parameters**: + - `sessionId` (required): A unique identifier to maintain session state. + - `timeout` (optional): Timeout in minutes for the sandbox environment (default is 60 minutes). + - `envs` (optional): Environment variables to set when creating the sandbox. + - **Example**: + + ```json + { + "action": "create", + "sessionId": "unique_session_id", + "timeout": 120, + "envs": { "MY_VAR": "my_value" } + } + ``` + +2. **list_sandboxes** + + - **Purpose**: List all active sandboxes. + - **Parameters**: Only `action` parameter. + - **Example**: + + ```json + { + "action": "list_sandboxes" + } + ``` + +3. **set_timeout** + + - **Purpose**: Change the timeout of an existing sandbox. + - **Parameters**: + - `sessionId` (required) + - `timeout` (required): New timeout in minutes. + - **Example**: + + ```json + { + "action": "set_timeout", + "sessionId": "unique_session_id", + "timeout": 180 + } + ``` + +4. **kill** + + - **Purpose**: Terminate an existing sandbox. + - **Parameters**: + - `sessionId` (required) + - **Example**: + + ```json + { + "action": "kill", + "sessionId": "unique_session_id" + } + ``` + +5. **execute** + + - **Purpose**: Execute code within the sandbox. + - **Parameters**: + - `sessionId` (required) + - `code` (required): The code to execute. + - `language` (optional): Programming language (`"python"`, `"javascript"`, `"typescript"`, `"shell"`). Defaults to `"python"`. + - `envs` (optional): Environment variables for the execution. + - **Example**: + + ```json + { + "action": "execute", + "sessionId": "unique_session_id", + "code": "import os; print(os.environ.get('MY_VAR'))", + "language": "python", + "envs": { "MY_VAR": "value" } + } + ``` + +6. **shell** + + - **Purpose**: Run a shell command within the sandbox. + - **Parameters**: + - `sessionId` (required) + - `command` (required): The shell command to execute. + - `background` (optional): Whether to run the command in the background. Defaults to `false`. + - `envs` (optional) + - **Example (foreground)**: + + ```json + { + "action": "shell", + "sessionId": "unique_session_id", + "command": "ls -la" + } + ``` + + - **Example (background)**: + + ```json + { + "action": "shell", + "sessionId": "unique_session_id", + "command": "python app.py > output.log", + "background": true + } + ``` + +7. **kill_command** + + - **Purpose**: Terminate a background command. + - **Parameters**: + - `sessionId` (required) + - `commandId` (required): The ID of the background command to kill. + - **Example**: + + ```json + { + "action": "kill_command", + "sessionId": "unique_session_id", + "commandId": "command_id_from_background_command" + } + ``` + +8. **write_file** + + - **Purpose**: Write content to a file in the sandbox. + - **Parameters**: + - `sessionId` (required) + - `filePath` (required): Path to the file. + - `fileContent` (required): Content to write. + - **Example**: + + ```json + { + "action": "write_file", + "sessionId": "unique_session_id", + "filePath": "/home/user/test.txt", + "fileContent": "Hello, world!" + } + ``` + +9. **read_file** + + - **Purpose**: Read content from a file in the sandbox. + - **Parameters**: + - `sessionId` (required) + - `filePath` (required): Path to the file. + - **Example**: + + ```json + { + "action": "read_file", + "sessionId": "unique_session_id", + "filePath": "/home/user/test.txt" + } + ``` + +10. **install** + + - **Purpose**: Install a package within the sandbox. + - **Parameters**: + - `sessionId` (required) + - `code` (required): Name of the package to install. + - `language` (optional): Programming language (`"python"`, `"javascript"`, `"typescript"`). Defaults to `"python"`. + - `envs` (optional) + - **Example**: + + ```json + { + "action": "install", + "sessionId": "unique_session_id", + "code": "requests", + "language": "python" + } + ``` + +11. **get_file_downloadurl** + + - **Purpose**: Generate a download URL for a file in the sandbox. + - **Parameters**: + - `sessionId` (required) + - `filePath` (required): Path to the file. + - **Example**: + + ```json + { + "action": "get_file_downloadurl", + "sessionId": "unique_session_id", + "filePath": "/home/user/output.txt" + } + ``` + +12. **get_host** + + - **Purpose**: Get the host and port for accessing a service running in the sandbox. + - **Parameters**: + - `sessionId` (required) + - `port` (required): Port number used by the service. + - **Example**: + + ```json + { + "action": "get_host", + "sessionId": "unique_session_id", + "port": 8080 + } + ``` + +### Steps to Use the Plugin in LibreChat + +1. **Start a New Conversation** + + Open LibreChat and start a new conversation with the assistant. + +2. **Initialize Session** + + Begin by creating a sandbox: + + ```json + { + "action": "create", + "sessionId": "my_unique_session_id", + "timeout": 60 + } + ``` + +3. **Perform Actions** + + Use any of the available actions to interact with the sandbox. Ensure you include the same `sessionId` used when creating the sandbox. + + **Example - Execute Code:** + + ```json + { + "action": "execute", + "sessionId": "my_unique_session_id", + "code": "print('Hello from the sandbox!')" + } + ``` + +4. **View Responses** + + The plugin will return the output or result of the action, which will be displayed in the chat interface. + +5. **Terminate Sandbox** + + When finished, you can kill the sandbox to free up resources: + + ```json + { + "action": "kill", + "sessionId": "my_unique_session_id" + } + ``` + +### Notes on Environment Variables + +- **Passing Hidden Environment Variables**: + + Any environment variable set on the host system that starts with `E2B_CODE_EV_` will be passed to the sandbox without being revealed to the LLM. The prefix `E2B_CODE_EV_` is stripped when passed to the sandbox. + + - **Example**: If you set `E2B_CODE_EV_SECRET_KEY=supersecret`, the sandbox will have an environment variable `SECRET_KEY` set to `supersecret`. + +- **Setting Environment Variables in Actions**: + + You can also pass `envs` in actions to set environment variables for specific executions. + +### Error Handling + +- **Error Responses**: + + If an error occurs while executing an action, you will receive a response containing an `error` message and `success` set to `false`. + + - **Example**: + + ```json + { + "sessionId": "my_unique_session_id", + "error": "Code is required for `execute` action.", + "success": false + } + ``` + +- **Success Indicator**: + + Check the `success` field in the response to determine if the action was successful. + +### Background Commands and Hosting Services + +- **Running Services**: + + To run services like Flask or Node.js servers, use the `shell` action with `background` set to `true` and redirect output to a log file. + +- **Example**: + + ```json + { + "action": "shell", + "sessionId": "my_unique_session_id", + "command": "python app.py > output.log", + "background": true + } + ``` + +- **Accessing the Service**: + + Use the `get_host` action to retrieve the host and port to access the running service. + + ```json + { + "action": "get_host", + "sessionId": "my_unique_session_id", + "port": 5000 + } + ``` + +--- + +## Best Practices + +- **Unique Session IDs**: Always use unique and consistent `sessionId` values for your sessions. +- **Security**: Do not expose sensitive information directly in requests. Use the hidden environment variables feature to securely pass secrets. +- **Resource Management**: Remember to kill sandboxes when they are no longer needed to free up resources. + +--- + +## Troubleshooting + +- **Sandbox Not Found**: + + If you receive an error stating that the sandbox was not found, ensure that you have created a sandbox and are using the correct `sessionId`. + +- **Timeouts**: + + If actions are not completing, check if the sandbox has expired due to the timeout. Increase the timeout if necessary using the `set_timeout` action. + +- **Permissions**: + + Ensure that LibreChat has the necessary permissions and configurations to run plugins and access environment variables. + +--- + +## Support + +For issues related to the E2BCode plugin, please open an issue on the plugin's repository: [GitHub Repository](https://github.com/jmaddington/libreChat/issues) + +For issues related to LibreChat, visit [LibreChat Support](https://librechat.ai) or consult their Discord. + +--- + +## Contributing + +Contributions are welcome! Fork the repository and submit a pull request with your changes. + +--- + +## Acknowledgments + +- **E2B**: For providing the E2B Code Interpreter SDK. +- **LibreChat**: For the chat platform that makes this integration possible. + +--- + +Feel free to reach out if you have any questions or need assistance with setup and usage. diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js new file mode 100644 index 00000000000..13b76f07557 --- /dev/null +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -0,0 +1,238 @@ +// FluxAPI.js + +const axios = require('axios'); +const { v4: uuidv4 } = require('uuid'); +const { Tool } = require('@langchain/core/tools'); +const { z } = require('zod'); +const { logger } = require('~/config'); +const { FileContext } = require('librechat-data-provider'); +const { processFileURL } = require('~/server/services/Files/process'); + +class FluxAPI extends Tool { + constructor(fields) { + super(); + + this.override = fields.override ?? false; + this.returnMetadata = fields.returnMetadata ?? false; + + this.userId = fields.userId; + this.fileStrategy = fields.fileStrategy; + + if (fields.processFileURL) { + this.processFileURL = fields.processFileURL.bind(this); + } + + this.name = 'flux'; + this.apiKey = fields.FLUX_API_KEY || this.getApiKey(); + this.description = + "Use Flux to generate images from text descriptions. This tool is exclusively for visual content."; + this.description_for_model = `// Use Flux to generate images from text descriptions. + // Guidelines: + // - Provide a detailed and vivid prompt for the image you want to generate, but don't change it if the user asks you not to. + // - Include parameters for image width and height if necessary (default width: 1024, height: 768). + // - Visually describe the moods, details, structures, styles, and proportions of the image. + // - Craft your input by "showing" and not "telling" the imagery. + // - Generate images only once per human query unless explicitly requested by the user. + // - If the user requests multiple images, set the 'number_of_images' parameter to the desired number (up to 24). + // - Output in PNG format by default. + // - Default to the endpoint /v1/flux-pro-1.1 unless the user says otherwise. + // - Upsample if the user says so. + // - **Include the generated image(s) in your text response to the user by embedding the Markdown links.** + // - **Include the prompt you created for flux in your response so the user can see what you generated.** + + /* Available endpoints: + - /v1/flux-pro-1.1 + - /v1/flux-pro + - /v1/flux-dev + - /v1/flux-pro-1.1-ultra + */ + `; + + // Define the schema for structured input + this.schema = z.object({ + prompt: z.string().describe('Text prompt for image generation.'), + width: z + .number() + .optional() + .describe( + 'Width of the generated image in pixels. Must be a multiple of 32. Default is 1024.' + ), + height: z + .number() + .optional() + .describe( + 'Height of the generated image in pixels. Must be a multiple of 32. Default is 768.' + ), + prompt_upsampling: z + .boolean() + .optional() + .describe('Whether to perform upsampling on the prompt.'), + steps: z + .number() + .int() + .optional() + .describe('Number of steps to run the model for, a number from 1 to 50. Default is 40.'), + seed: z.number().optional().describe('Optional seed for reproducibility.'), + safety_tolerance: z + .number() + .optional() + .describe( + 'Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.' + ), + output_format: z + .string() + .optional() + .describe('Output format for the generated image. Can be "jpeg" or "png".'), + endpoint: z + .string() + .optional() + .describe('Endpoint to use for image generation. Default is /v1/flux-pro.'), + number_of_images: z + .number() + .int() + .min(1) + .max(24) + .optional() + .describe('Number of images to generate, up to a maximum of 24. Default is 1.'), + }); + } + + getApiKey() { + const apiKey = process.env.FLUX_API_KEY || ''; + if (!apiKey && !this.override) { + throw new Error('Missing FLUX_API_KEY environment variable.'); + } + return apiKey; + } + + wrapInMarkdown(imageUrl) { + return `![generated image](${imageUrl})`; + } + + async _call(data) { + const baseUrl = 'https://api.bfl.ml'; + const { + prompt, + width = 1024, + height = 768, + steps = 40, + prompt_upsampling = false, + seed = null, + safety_tolerance = 2, + output_format = 'png', + endpoint = '/v1/flux-pro', + number_of_images = 1, + } = data; + + const generateUrl = `${baseUrl}${endpoint}`; + const resultUrl = `${baseUrl}/v1/get_result`; + + const payload = { + prompt, + width, + height, + steps, + prompt_upsampling, + seed, + safety_tolerance, + output_format, + }; + + const headers = { + 'x-key': this.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + const totalImages = Math.min(Math.max(number_of_images, 1), 24); + + const imageResults = []; + + for (let i = 0; i < totalImages; i++) { + let taskResponse; + try { + taskResponse = await axios.post(generateUrl, payload, { headers }); + } catch (error) { + logger.error( + '[FluxAPI] Error while submitting task:', + error.response ? error.response.data : error.message + ); + imageResults.push('Error submitting task to Flux API.'); + continue; + } + + const taskId = taskResponse.data.id; + + // Polling for the result + let status = 'Pending'; + let resultData = null; + while (status !== 'Ready' && status !== 'Error') { + try { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const resultResponse = await axios.get(resultUrl, { + headers, + params: { id: taskId }, + }); + status = resultResponse.data.status; + + if (status === 'Ready') { + resultData = resultResponse.data.result; + break; + } else if (status === 'Error') { + logger.error('[FluxAPI] Error in task:', resultResponse.data); + imageResults.push('Error occurred during image generation.'); + break; + } + } catch (error) { + logger.error( + '[FluxAPI] Error while getting result:', + error.response ? error.response.data : error.message + ); + imageResults.push('Error getting result from Flux API.'); + break; + } + } + + if (!resultData || !resultData.sample) { + logger.error('[FluxAPI] No image data received from API. Response:', resultData); + imageResults.push('No image data received from Flux API.'); + continue; + } + + const imageUrl = resultData.sample; + const imageName = `img-${uuidv4()}.png`; + + try { + const result = await this.processFileURL({ + fileStrategy: this.fileStrategy, + userId: this.userId, + URL: imageUrl, + fileName: imageName, + basePath: 'images', + context: FileContext.image_generation, + }); + + if (this.returnMetadata) { + imageResults.push(result); + } else { + const markdownImage = this.wrapInMarkdown(result.filepath); + imageResults.push(markdownImage); + } + } catch (error) { + logger.error('Error while saving the image:', error); + imageResults.push(`Failed to save the image locally. ${error.message}`); + } + } // End of loop + + if (this.returnMetadata) { + this.result = imageResults; + } else { + // Join the markdown image links with double newlines for better spacing + this.result = imageResults.join('\n\n'); + } + + return this.result; + } +} + +module.exports = FluxAPI; \ No newline at end of file diff --git a/api/app/clients/tools/structured/OpenWeather.js b/api/app/clients/tools/structured/OpenWeather.js new file mode 100644 index 00000000000..4223b1d344a --- /dev/null +++ b/api/app/clients/tools/structured/OpenWeather.js @@ -0,0 +1,293 @@ +const { StructuredTool } = require('langchain/tools'); +const { z } = require('zod'); +const { getEnvironmentVariable } = require('@langchain/core/utils/env'); +const fetch = require('node-fetch'); + +// Utility to retrieve API key +function getApiKey(envVar, override, providedKey) { + if (providedKey) return providedKey; + const key = getEnvironmentVariable(envVar); + if (!key && !override) { + throw new Error(`Missing ${envVar} environment variable.`); + } + return key; +} + +/** + * Map user-friendly units to OpenWeather units. + * Defaults to Celsius if not specified. + */ +function mapUnitsToOpenWeather(unit) { + if (!unit) return 'metric'; // Default to Celsius + switch (unit) { + case 'Celsius': + return 'metric'; + case 'Kelvin': + return 'standard'; + case 'Fahrenheit': + return 'imperial'; + default: + return 'metric'; // fallback + } +} + +/** + * Recursively round temperature fields in the API response. + */ +function roundTemperatures(obj) { + const tempKeys = new Set([ + 'temp', 'feels_like', 'dew_point', + 'day', 'min', 'max', 'night', 'eve', 'morn', + 'afternoon', 'morning', 'evening' + ]); + + if (Array.isArray(obj)) { + return obj.map((item) => roundTemperatures(item)); + } else if (obj && typeof obj === 'object') { + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (value && typeof value === 'object') { + obj[key] = roundTemperatures(value); + } else if (typeof value === 'number' && tempKeys.has(key)) { + obj[key] = Math.round(value); + } + } + } + return obj; +} + +class OpenWeather extends StructuredTool { + name = 'OpenWeather'; + description = 'Provides weather data from OpenWeather One Call API 3.0. ' + + 'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' + + 'If lat/lon not provided, specify "city" for geocoding. ' + + 'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' + + 'For timestamp action, use "date" in YYYY-MM-DD format.'; + + schema = z.object({ + action: z.enum(["help", "current_forecast", "timestamp", "daily_aggregation", "overview"]), + city: z.string().optional(), + lat: z.number().optional(), + lon: z.number().optional(), + exclude: z.string().optional(), + units: z.enum(["Celsius", "Kelvin", "Fahrenheit"]).optional(), + lang: z.string().optional(), + date: z.string().optional(), // For timestamp and daily_aggregation + tz: z.string().optional() + }); + + constructor(options = {}) { + super(); + const { apiKey, override = false } = options; + this.apiKey = getApiKey('OPENWEATHER_API_KEY', override, apiKey); + } + + async geocodeCity(city) { + const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(city)}&limit=1&appid=${this.apiKey}`; + const res = await fetch(geocodeUrl); + const data = await res.json(); + if (!res.ok || !Array.isArray(data) || data.length === 0) { + throw new Error(`Could not find coordinates for city: ${city}`); + } + return { lat: data[0].lat, lon: data[0].lon }; + } + + convertDateToUnix(dateStr) { + const parts = dateStr.split('-'); + if (parts.length !== 3) { + throw new Error("Invalid date format. Expected YYYY-MM-DD."); + } + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10); + const day = parseInt(parts[2], 10); + if (isNaN(year) || isNaN(month) || isNaN(day)) { + throw new Error("Invalid date format. Expected YYYY-MM-DD with valid numbers."); + } + + const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); + if (isNaN(dateObj.getTime())) { + throw new Error("Invalid date provided. Cannot parse into a valid date."); + } + + return Math.floor(dateObj.getTime() / 1000); + } + + async _call(args) { + try { + const { action, city, lat, lon, exclude, units, lang, date, tz } = args; + const owmUnits = mapUnitsToOpenWeather(units); + + if (action === 'help') { + return JSON.stringify({ + title: "OpenWeather One Call API 3.0 Help", + description: "Guidance on using the OpenWeather One Call API 3.0.", + endpoints: { + current_and_forecast: { + endpoint: "data/3.0/onecall", + data_provided: [ + "Current weather", + "Minute forecast (1h)", + "Hourly forecast (48h)", + "Daily forecast (8 days)", + "Government weather alerts" + ], + required_params: [ + ["lat", "lon"], + ["city"] + ], + optional_params: ["exclude", "units (Celsius/Kelvin/Fahrenheit)", "lang"], + usage_example: { + city: "Knoxville, Tennessee", + units: "Fahrenheit", + lang: "en" + } + }, + weather_for_timestamp: { + endpoint: "data/3.0/onecall/timemachine", + data_provided: [ + "Historical weather (since 1979-01-01)", + "Future forecast up to 4 days ahead" + ], + required_params: [ + ["lat", "lon", "date (YYYY-MM-DD)"], + ["city", "date (YYYY-MM-DD)"] + ], + optional_params: ["units (Celsius/Kelvin/Fahrenheit)", "lang"], + usage_example: { + city: "Knoxville, Tennessee", + date: "2020-03-04", + units: "Fahrenheit", + lang: "en" + } + }, + daily_aggregation: { + endpoint: "data/3.0/onecall/day_summary", + data_provided: [ + "Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)" + ], + required_params: [ + ["lat", "lon", "date (YYYY-MM-DD)"], + ["city", "date (YYYY-MM-DD)"] + ], + optional_params: ["units (Celsius/Kelvin/Fahrenheit)", "lang", "tz"], + usage_example: { + city: "Knoxville, Tennessee", + date: "2020-03-04", + units: "Celsius", + lang: "en" + } + }, + weather_overview: { + endpoint: "data/3.0/onecall/overview", + data_provided: ["Human-readable weather summary (today/tomorrow)"], + required_params: [ + ["lat", "lon"], + ["city"] + ], + optional_params: ["date (YYYY-MM-DD)", "units (Celsius/Kelvin/Fahrenheit)"], + usage_example: { + city: "Knoxville, Tennessee", + date: "2024-05-13", + units: "Celsius" + } + } + }, + notes: [ + "If lat/lon not provided, you can specify a city name and it will be geocoded.", + "For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.", + "By default, temperatures are returned in Celsius.", + "You can specify units as Celsius, Kelvin, or Fahrenheit.", + "All temperatures are rounded to the nearest degree." + ], + errors: [ + "400: Bad Request (missing/invalid params)", + "401: Unauthorized (check API key)", + "404: Not Found (no data or city)", + "429: Too many requests", + "5xx: Internal error" + ] + }, null, 2); + } + + let finalLat = lat; + let finalLon = lon; + + // If lat/lon not provided but city is given, geocode it + if ((finalLat == null || finalLon == null) && city) { + const coords = await this.geocodeCity(city); + finalLat = coords.lat; + finalLon = coords.lon; + } + + if (["current_forecast", "timestamp", "daily_aggregation", "overview"].includes(action)) { + if (typeof finalLat !== 'number' || typeof finalLon !== 'number') { + return "Error: lat and lon are required and must be numbers for this action (or specify 'city')."; + } + } + + const baseUrl = "https://api.openweathermap.org/data/3.0"; + let endpoint = ""; + const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits }); + + let dt; + if (action === "timestamp") { + if (!date) { + return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required."; + } + dt = this.convertDateToUnix(date); + } + + if (action === "daily_aggregation" && !date) { + return "Error: date (YYYY-MM-DD) is required for daily_aggregation action."; + } + + switch (action) { + case "current_forecast": + endpoint = "/onecall"; + params.append("lat", String(finalLat)); + params.append("lon", String(finalLon)); + if (exclude) params.append('exclude', exclude); + if (lang) params.append('lang', lang); + break; + case "timestamp": + endpoint = "/onecall/timemachine"; + params.append("lat", String(finalLat)); + params.append("lon", String(finalLon)); + params.append("dt", String(dt)); + if (lang) params.append('lang', lang); + break; + case "daily_aggregation": + endpoint = "/onecall/day_summary"; + params.append("lat", String(finalLat)); + params.append("lon", String(finalLon)); + params.append("date", date); + if (lang) params.append('lang', lang); + if (tz) params.append('tz', tz); + break; + case "overview": + endpoint = "/onecall/overview"; + params.append("lat", String(finalLat)); + params.append("lon", String(finalLon)); + if (date) params.append('date', date); + break; + default: + return `Error: Unknown action: ${action}`; + } + + const url = `${baseUrl}${endpoint}?${params.toString()}`; + const response = await fetch(url); + const json = await response.json(); + if (!response.ok) { + return `Error: OpenWeather API request failed with status ${response.status}: ${json.message || JSON.stringify(json)}`; + } + + const roundedJson = roundTemperatures(json); + return JSON.stringify(roundedJson); + + } catch (err) { + return `Error: ${err.message}`; + } + } +} + +module.exports = OpenWeather; diff --git a/api/app/clients/tools/structured/WebNavigator.js b/api/app/clients/tools/structured/WebNavigator.js new file mode 100644 index 00000000000..6581fde83ee --- /dev/null +++ b/api/app/clients/tools/structured/WebNavigator.js @@ -0,0 +1,281 @@ +const { z } = require('zod'); +const { Tool } = require('@langchain/core/tools'); +const fetch = require('node-fetch'); +const { URL, URLSearchParams } = require('url'); +const cheerio = require('cheerio'); + +class WebNavigator extends Tool { + constructor(fields = {}) { + super(); + + this.name = 'WebNavigator'; + this.description = + 'Simulates a curl action. Useful for making HTTP requests with various methods and parameters. Accepts an array of commands similar to curl.'; + this.description_for_model = `Simulates a curl action by making HTTP requests with various methods and parameters. Accepts input similar to curl commands. + +Guidelines: +- **Default Behavior:** By default, the response will: + - Exclude \`