diff --git a/.github/workflows/run_evals.yml b/.github/workflows/run_evals.yml index 9544b7f3..71123acf 100644 --- a/.github/workflows/run_evals.yml +++ b/.github/workflows/run_evals.yml @@ -32,6 +32,14 @@ on: required: false default: 200 type: number + sandbox: + description: 'Code sandbox to use' + required: false + default: 'terrarium' + type: choice + options: + - terrarium + - e2b jobs: eval: @@ -100,6 +108,8 @@ jobs: SERPER_DEV_API_KEY: ${{ matrix.dataset != 'math500' && secrets.SERPER_DEV_API_KEY }} OLOSTEP_API_KEY: ${{ matrix.dataset != 'math500' && secrets.OLOSTEP_API_KEY }} HF_TOKEN: ${{ secrets.HF_TOKEN }} + E2B_API_KEY: ${{ inputs.sandbox == 'e2b' && secrets.E2B_API_KEY }} + E2B_TEMPLATE: ${{ vars.E2B_TEMPLATE }} KHOJ_ADMIN_EMAIL: khoj KHOJ_ADMIN_PASSWORD: khoj POSTGRES_HOST: localhost @@ -148,6 +158,7 @@ jobs: echo "**$(head -n 1 *_evaluation_summary_*.txt)**" >> $GITHUB_STEP_SUMMARY echo "- Khoj Version: ${{ steps.hatch.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "- Chat Model: Gemini 2.0 Flash" >> $GITHUB_STEP_SUMMARY + echo "- Code Sandbox: ${{ inputs.sandbox}}" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY tail -n +2 *_evaluation_summary_*.txt >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/docker-compose.yml b/docker-compose.yml index ea0b603b..3ff21b11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,8 +58,10 @@ services: - KHOJ_DEBUG=False - KHOJ_ADMIN_EMAIL=username@example.com - KHOJ_ADMIN_PASSWORD=password - # Default URL of Terrarium, the Python sandbox used by Khoj to run code. Its container is specified above + # Default URL of Terrarium, the default Python sandbox used by Khoj to run code. Its container is specified above - KHOJ_TERRARIUM_URL=http://sandbox:8080 + # Uncomment line below to have Khoj run code in remote E2B code sandbox instead of the self-hosted Terrarium sandbox above. Get your E2B API key from https://e2b.dev/. + # - E2B_API_KEY=your_e2b_api_key # Default URL of SearxNG, the default web search engine used by Khoj. Its container is specified above - KHOJ_SEARXNG_URL=http://search:8080 # Uncomment line below to use with Ollama running on your local machine at localhost:11434. diff --git a/documentation/docs/features/code_execution.md b/documentation/docs/features/code_execution.md index 8403d466..05c994d7 100644 --- a/documentation/docs/features/code_execution.md +++ b/documentation/docs/features/code_execution.md @@ -3,22 +3,23 @@ # Code Execution -Khoj can generate and run very simple Python code snippets as well. This is useful if you want to generate a plot, run a simple calculation, or do some basic data manipulation. LLMs by default aren't skilled at complex quantitative tasks. Code generation & execution can come in handy for such tasks. +Khoj can generate and run simple Python code as well. This is useful if you want to have Khoj do some data analysis, generate plots and reports. LLMs by default aren't skilled at complex quantitative tasks. Code generation & execution can come in handy for such tasks. -Just use `/code` in your chat command. +Khoj automatically infers when to use the code tool. You can also tell it explicitly to use the code tool or use the `/code` [slash command](https://docs.khoj.dev/features/chat/#commands) in your chat. -### Setup (Self-Hosting) -Run [Cohere's Terrarium](https://github.com/cohere-ai/cohere-terrarium) on your machine to enable code generation and execution. +## Setup (Self-Hosting) +### Terrarium Sandbox +Use [Cohere's Terrarium](https://github.com/cohere-ai/cohere-terrarium) to host the code sandbox locally on your machine for free. -Check the [instructions](https://github.com/cohere-ai/cohere-terrarium?tab=readme-ov-file#development) for running from source. - -For running with Docker, you can use our [docker-compose.yml](https://github.com/khoj-ai/khoj/blob/master/docker-compose.yml), or start it manually like this: +To run with Docker, use our [docker-compose.yml](https://github.com/khoj-ai/khoj/blob/master/docker-compose.yml) to automatically setup the Terrarium code sandbox, or start it manually like this: ```bash docker pull ghcr.io/khoj-ai/terrarium:latest docker run -d -p 8080:8080 ghcr.io/khoj-ai/terrarium:latest ``` +To run from source, check [these instructions](https://github.com/khoj-ai/cohere-terrarium?tab=readme-ov-file#development). + #### Verify Verify that it's running, by evaluating a simple Python expression: @@ -28,3 +29,12 @@ curl -X POST -H "Content-Type: application/json" \ --data-raw '{"code": "1 + 1"}' \ --no-buffer ``` + +### E2B Sandbox +[E2B](https://e2b.dev/) allows Khoj to run code on a remote but versatile sandbox with support for more python libraries. This is [not free](https://e2b.dev/pricing). + +To have Khoj use E2B as the code sandbox: +1. Generate an API key on [their dashboard](https://e2b.dev/dashboard). +2. Set the `E2B_API_KEY` environment variable to it on the machine running your Khoj server. + - When using our [docker-compose.yml](https://github.com/khoj-ai/khoj/blob/master/docker-compose.yml), uncomment and set the `E2B_API_KEY` env var in the `docker-compose.yml` file. +3. Now restart your Khoj server to switch to using the E2B code sandbox. diff --git a/pyproject.toml b/pyproject.toml index 1ed9426e..5093fee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ "authlib == 1.2.1", "llama-cpp-python == 0.2.88", "itsdangerous == 2.1.2", - "httpx == 0.25.0", + "httpx == 0.27.2", "pgvector == 0.2.4", "psycopg2-binary == 2.9.9", "lxml == 4.9.3", @@ -92,6 +92,7 @@ dependencies = [ "pyjson5 == 1.6.7", "resend == 1.0.1", "email-validator == 2.2.0", + "e2b-code-interpreter ~= 1.0.0", ] dynamic = ["version"] diff --git a/src/khoj/processor/conversation/prompts.py b/src/khoj/processor/conversation/prompts.py index 61060572..63cf028f 100644 --- a/src/khoj/processor/conversation/prompts.py +++ b/src/khoj/processor/conversation/prompts.py @@ -974,9 +974,8 @@ Khoj: python_code_generation_prompt = PromptTemplate.from_template( """ You are Khoj, an advanced python programmer. You are tasked with constructing a python program to best answer the user query. -- The python program will run in a pyodide python sandbox with no network access. +- The python program will run in a sandbox with no network access. - You can write programs to run complex calculations, analyze data, create charts, generate documents to meticulously answer the query. -- The sandbox has access to the standard library, matplotlib, panda, numpy, scipy, bs4 and sympy packages. The requests, torch, catboost, tensorflow and tkinter packages are not available. - List known file paths to required user documents in "input_files" and known links to required documents from the web in the "input_links" field. - The python program should be self-contained. It can only read data generated by the program itself and from provided input_files, input_links by their basename (i.e filename excluding file path). - Do not try display images or plots in the code directly. The code should save the image or plot to a file instead. @@ -1030,6 +1029,13 @@ Code Execution Results: """.strip() ) +e2b_sandbox_context = """ +- The sandbox has access to only the standard library, matplotlib, pandas, numpy, scipy, bs4, sympy, einops, biopython, shapely, plotly and rdkit packages. The requests, torch, catboost, tensorflow and tkinter packages are not available. +""".strip() + +terrarium_sandbox_context = """ +The sandbox has access to the standard library, matplotlib, pandas, numpy, scipy, bs4 and sympy packages. The requests, torch, catboost, tensorflow, rdkit and tkinter packages are not available. +""".strip() # Automations # -- diff --git a/src/khoj/processor/tools/run_code.py b/src/khoj/processor/tools/run_code.py index 62ffde74..af1f0ffd 100644 --- a/src/khoj/processor/tools/run_code.py +++ b/src/khoj/processor/tools/run_code.py @@ -27,7 +27,12 @@ from khoj.processor.conversation.utils import ( load_complex_json, ) from khoj.routers.helpers import send_message_to_model_wrapper -from khoj.utils.helpers import is_none_or_empty, timer, truncate_code_context +from khoj.utils.helpers import ( + is_e2b_code_sandbox_enabled, + is_none_or_empty, + timer, + truncate_code_context, +) from khoj.utils.rawconfig import LocationData logger = logging.getLogger(__name__) @@ -131,6 +136,12 @@ async def generate_python_code( prompts.personality_context.format(personality=agent.personality) if agent and agent.personality else "" ) + # add sandbox specific context like available packages + sandbox_context = ( + prompts.e2b_sandbox_context if is_e2b_code_sandbox_enabled() else prompts.terrarium_sandbox_context + ) + personality_context = f"{sandbox_context}\n{personality_context}" + code_generation_prompt = prompts.python_code_generation_prompt.format( current_date=utc_date, query=q, @@ -182,15 +193,104 @@ async def execute_sandboxed_python(code: str, input_data: list[dict], sandbox_ur Reference data i/o format based on Terrarium example client code at: https://github.com/cohere-ai/cohere-terrarium/blob/main/example-clients/python/terrarium_client.py """ - headers = {"Content-Type": "application/json"} cleaned_code = clean_code_python(code) - data = {"code": cleaned_code, "files": input_data} + if is_e2b_code_sandbox_enabled(): + try: + return await execute_e2b(cleaned_code, input_data) + except ImportError: + pass + return await execute_terrarium(cleaned_code, input_data, sandbox_url) + +async def execute_e2b(code: str, input_files: list[dict]) -> dict[str, Any]: + """Execute code and handle file I/O in e2b sandbox""" + from e2b_code_interpreter import AsyncSandbox + + sandbox = await AsyncSandbox.create( + api_key=os.getenv("E2B_API_KEY"), + template=os.getenv("E2B_TEMPLATE", "pmt2o0ghpang8gbiys57"), + timeout=120, + request_timeout=30, + ) + + try: + # Upload input files in parallel + upload_tasks = [ + sandbox.files.write(path=file["filename"], data=base64.b64decode(file["b64_data"]), request_timeout=30) + for file in input_files + ] + await asyncio.gather(*upload_tasks) + + # Note stored files before execution + E2bFile = NamedTuple("E2bFile", [("name", str), ("path", str)]) + original_files = {E2bFile(f.name, f.path) for f in await sandbox.files.list("~")} + + # Execute code from main.py file + execution = await sandbox.run_code(code=code, timeout=60) + + # Collect output files + output_files = [] + + # Identify new files created during execution + new_files = set(E2bFile(f.name, f.path) for f in await sandbox.files.list("~")) - original_files + # Read newly created files in parallel + download_tasks = [sandbox.files.read(f.path, request_timeout=30) for f in new_files] + downloaded_files = await asyncio.gather(*download_tasks) + for f, content in zip(new_files, downloaded_files): + if isinstance(content, bytes): + # Binary files like PNG - encode as base64 + b64_data = base64.b64encode(content).decode("utf-8") + elif Path(f.name).suffix in [".png", ".jpeg", ".jpg", ".svg"]: + # Ignore image files as they are extracted from execution results below for inline display + continue + else: + # Text files - encode utf-8 string as base64 + b64_data = base64.b64encode(content.encode("utf-8")).decode("utf-8") + output_files.append({"filename": f.name, "b64_data": b64_data}) + + # Collect output files from execution results + for idx, result in enumerate(execution.results): + for result_type in ["png", "jpeg", "svg", "text", "markdown", "json"]: + if b64_data := getattr(result, result_type, None): + output_files.append({"filename": f"{idx}.{result_type}", "b64_data": b64_data}) + break + + # collect logs + success = not execution.error and not execution.logs.stderr + stdout = "\n".join(execution.logs.stdout) + errors = "\n".join(execution.logs.stderr) + if execution.error: + errors = f"{execution.error}\n{errors}" + + return { + "code": code, + "success": success, + "std_out": stdout, + "std_err": errors, + "output_files": output_files, + } + except Exception as e: + return { + "code": code, + "success": False, + "std_err": f"Sandbox failed to execute code: {str(e)}", + "output_files": [], + } + + +async def execute_terrarium( + code: str, + input_data: list[dict], + sandbox_url: str, +) -> dict[str, Any]: + """Execute code using Terrarium sandbox""" + headers = {"Content-Type": "application/json"} + data = {"code": code, "files": input_data} async with aiohttp.ClientSession() as session: async with session.post(sandbox_url, json=data, headers=headers, timeout=30) as response: if response.status == 200: result: dict[str, Any] = await response.json() - result["code"] = cleaned_code + result["code"] = code # Store decoded output files result["output_files"] = result.get("output_files", []) for output_file in result["output_files"]: @@ -202,7 +302,7 @@ async def execute_sandboxed_python(code: str, input_data: list[dict], sandbox_ur return result else: return { - "code": cleaned_code, + "code": code, "success": False, "std_err": f"Failed to execute code with {response.status}", "output_files": [], diff --git a/src/khoj/utils/helpers.py b/src/khoj/utils/helpers.py index b48436c6..4723403e 100644 --- a/src/khoj/utils/helpers.py +++ b/src/khoj/utils/helpers.py @@ -321,6 +321,12 @@ def get_device() -> torch.device: return torch.device("cpu") +def is_e2b_code_sandbox_enabled(): + """Check if E2B code sandbox is enabled. + Set E2B_API_KEY environment variable to use it.""" + return not is_none_or_empty(os.getenv("E2B_API_KEY")) + + class ConversationCommand(str, Enum): Default = "default" General = "general" @@ -362,20 +368,23 @@ command_descriptions_for_agent = { ConversationCommand.Code: "Agent can run Python code to parse information, run complex calculations, create documents and charts.", } +e2b_tool_description = "To run Python code in a E2B sandbox with no network access. Helpful to parse complex information, run calculations, create text documents and create charts with quantitative data. Only matplotlib, pandas, numpy, scipy, bs4, sympy, einops, biopython, shapely and rdkit external packages are available." +terrarium_tool_description = "To run Python code in a Terrarium, Pyodide sandbox with no network access. Helpful to parse complex information, run complex calculations, create plaintext documents and create charts with quantitative data. Only matplotlib, panda, numpy, scipy, bs4 and sympy external packages are available." + tool_descriptions_for_llm = { ConversationCommand.Default: "To use a mix of your internal knowledge and the user's personal knowledge, or if you don't entirely understand the query.", ConversationCommand.General: "To use when you can answer the question without any outside information or personal knowledge", ConversationCommand.Notes: "To search the user's personal knowledge base. Especially helpful if the question expects context from the user's notes or documents.", ConversationCommand.Online: "To search for the latest, up-to-date information from the internet. Note: **Questions about Khoj should always use this data source**", ConversationCommand.Webpage: "To use if the user has directly provided the webpage urls or you are certain of the webpage urls to read.", - ConversationCommand.Code: "To run Python code in a Pyodide sandbox with no network access. Helpful when need to parse complex information, run complex calculations, create plaintext documents, and create charts with quantitative data. Only matplotlib, panda, numpy, scipy, bs4 and sympy external packages are available.", + ConversationCommand.Code: e2b_tool_description if is_e2b_code_sandbox_enabled() else terrarium_tool_description, } function_calling_description_for_llm = { ConversationCommand.Notes: "To search the user's personal knowledge base. Especially helpful if the question expects context from the user's notes or documents.", ConversationCommand.Online: "To search the internet for information. Useful to get a quick, broad overview from the internet. Provide all relevant context to ensure new searches, not in previous iterations, are performed.", ConversationCommand.Webpage: "To extract information from webpages. Useful for more detailed research from the internet. Usually used when you know the webpage links to refer to. Share the webpage links and information to extract in your query.", - ConversationCommand.Code: "To run Python code in a Pyodide sandbox with no network access. Helpful when need to parse complex information, run complex calculations, create plaintext documents, and create charts with quantitative data. Only matplotlib, panda, numpy, scipy, bs4 and sympy external packages are available.", + ConversationCommand.Code: e2b_tool_description if is_e2b_code_sandbox_enabled() else terrarium_tool_description, } mode_descriptions_for_llm = {