From 9bfb2b9b01d3ded0801421fb41a8a1b70c9c60c7 Mon Sep 17 00:00:00 2001 From: Ashwin Bharambe Date: Thu, 1 May 2025 13:43:01 -0700 Subject: [PATCH] fix: remove code interpeter implementation --- .../tool_runtime/code_interpreter/__init__.py | 19 -- .../code_interpreter/code_env_prefix.py | 131 --------- .../code_interpreter/code_execution.py | 256 ------------------ .../code_interpreter/code_interpreter.py | 80 ------ .../tool_runtime/code_interpreter/config.py | 15 - .../matplotlib_custom_backend.py | 93 ------- .../tool_runtime/code_interpreter/utils.py | 21 -- .../providers/registry/tool_runtime.py | 7 - 8 files changed, 622 deletions(-) delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/__init__.py delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/code_env_prefix.py delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/code_execution.py delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/code_interpreter.py delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/config.py delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/matplotlib_custom_backend.py delete mode 100644 llama_stack/providers/inline/tool_runtime/code_interpreter/utils.py diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/__init__.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/__init__.py deleted file mode 100644 index d91005c6c..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - -from typing import Any - -from .config import CodeInterpreterToolConfig - -__all__ = ["CodeInterpreterToolConfig", "CodeInterpreterToolRuntimeImpl"] - - -async def get_provider_impl(config: CodeInterpreterToolConfig, _deps: dict[str, Any]): - from .code_interpreter import CodeInterpreterToolRuntimeImpl - - impl = CodeInterpreterToolRuntimeImpl(config) - await impl.initialize() - return impl diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/code_env_prefix.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/code_env_prefix.py deleted file mode 100644 index 9c5f642ea..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/code_env_prefix.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - -import errno - -# Disabling potentially dangerous functions -import os as _os -from functools import partial - -os_funcs_to_disable = [ - "kill", - "system", - "putenv", - "remove", - "removedirs", - "rmdir", - "fchdir", - "setuid", - "fork", - "forkpty", - "killpg", - "rename", - "renames", - "truncate", - "replace", - # "unlink", # Commenting as this was blocking matpltlib from rendering plots correctly - "fchmod", - "fchown", - "chmod", - "chown", - "chroot", - "fchdir", - "lchflags", - "lchmod", - "lchown", - "chdir", -] - - -def call_not_allowed(*args, **kwargs): - raise OSError(errno.EPERM, "Call are not permitted in this environment") - - -for func_name in os_funcs_to_disable: - if hasattr(_os, func_name): - setattr(_os, func_name, partial(call_not_allowed, _func_name=f"os.{func_name}")) - -import shutil as _shutil - -for func_name in ["rmtree", "move", "chown"]: - if hasattr(_shutil, func_name): - setattr( - _shutil, - func_name, - partial(call_not_allowed, _func_name=f"shutil.{func_name}"), - ) - -import subprocess as _subprocess - - -def popen_not_allowed(*args, **kwargs): - raise _subprocess.CalledProcessError( - -1, - args[0] if args else "unknown", - stderr="subprocess.Popen is not allowed in this environment", - ) - - -_subprocess.Popen = popen_not_allowed # type: ignore - - -import atexit as _atexit -import builtins as _builtins -import io as _io -import json as _json -import sys as _sys - -# NB! The following "unused" imports crucial, make sure not not to remove -# them with linters - they're used in code_execution.py -from contextlib import ( # noqa - contextmanager as _contextmanager, -) -from multiprocessing.connection import Connection as _Connection - -# Mangle imports to avoid polluting model execution namespace. - -_IO_SINK = _io.StringIO() -_NETWORK_TIMEOUT = 5 -_NETWORK_CONNECTIONS = None - - -def _open_connections(): - global _NETWORK_CONNECTIONS - if _NETWORK_CONNECTIONS is not None: - # Ensure connections only opened once. - return _NETWORK_CONNECTIONS - req_w_fd, resp_r_fd = _sys.argv[1], _sys.argv[2] - req_con = _Connection(int(req_w_fd), readable=False) - resp_con = _Connection(int(resp_r_fd), writable=False) - _NETWORK_CONNECTIONS = (req_con, resp_con) - return _NETWORK_CONNECTIONS - - -_builtins._open_connections = _open_connections # type: ignore - - -@_atexit.register -def _close_connections(): - global _NETWORK_CONNECTIONS - if _NETWORK_CONNECTIONS is None: - return - for con in _NETWORK_CONNECTIONS: - con.close() - del _NETWORK_CONNECTIONS - - -def _network_call(request): - # NOTE: We communicate with the parent process in json, encoded - # in raw bytes. We do this because native send/recv methods use - # pickle which involves execution of arbitrary code. - _open_connections() - req_con, resp_con = _NETWORK_CONNECTIONS - - req_con.send_bytes(_json.dumps(request).encode("utf-8")) - if resp_con.poll(timeout=_NETWORK_TIMEOUT) is None: - raise Exception(f"Network request timed out: {_json.dumps(request)}") - else: - return _json.loads(resp_con.recv_bytes().decode("utf-8")) diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/code_execution.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/code_execution.py deleted file mode 100644 index 6c9765b55..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/code_execution.py +++ /dev/null @@ -1,256 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - -import base64 -import json -import multiprocessing -import os -import re -import subprocess -import sys -import tempfile -import textwrap -import time -from dataclasses import dataclass -from datetime import datetime -from io import BytesIO -from pathlib import Path - -from PIL import Image - -from .utils import get_code_env_prefix - -TOOLS_ATTACHMENT_KEY = "__tools_attachment__" -TOOLS_ATTACHMENT_KEY_REGEX = re.compile(r"__tools_attachment__=(\{.*?\})") - -DIRNAME = Path(__file__).parent - -CODE_EXEC_TIMEOUT = 20 -CODE_ENV_PREFIX = get_code_env_prefix() - -STDOUTERR_SINK_WRAPPER_TEMPLATE = """\ -with _redirect_stdout(_IO_SINK), _redirect_stderr(_IO_SINK): -{code}\ -""" - -TRYEXCEPT_WRAPPER_TEMPLATE = """\ -try: -{code} -except: - pass\ -""" - - -def generate_bwrap_command(bind_dirs: list[str]) -> str: - """ - Generate the bwrap command string for binding all - directories in the current directory read-only. - """ - bwrap_args = "" - bwrap_args += "--ro-bind / / " - # Add the --dev flag to mount device files - bwrap_args += "--dev /dev " - for d in bind_dirs: - bwrap_args += f"--bind {d} {d} " - - # Add the --unshare-all flag to isolate the sandbox from the rest of the system - bwrap_args += "--unshare-all " - # Add the --die-with-parent flag to ensure the child process dies when bwrap's parent dies - bwrap_args += "--die-with-parent " - return bwrap_args - - -@dataclass -class CodeExecutionContext: - matplotlib_dump_dir: str - - -@dataclass -class CodeExecutionRequest: - scripts: list[str] - only_last_cell_stdouterr: bool = True - only_last_cell_fail: bool = True - seed: int = 0 - strip_fpaths_in_stderr: bool = True - use_bwrap: bool = True - - -class CodeExecutor: - def __init__(self, context: CodeExecutionContext): - self.context = context - - def execute(self, req: CodeExecutionRequest) -> dict: - scripts = req.scripts - for i in range(len(scripts) - 1): - if req.only_last_cell_stdouterr: - scripts[i] = STDOUTERR_SINK_WRAPPER_TEMPLATE.format(code=textwrap.indent(scripts[i], " " * 4)) - if req.only_last_cell_fail: - scripts[i] = TRYEXCEPT_WRAPPER_TEMPLATE.format(code=textwrap.indent(scripts[i], " " * 4)) - - # Seeds prefix: - seed = req.seed - seeds_prefix = f"""\ -def _set_seeds(): - import random - random.seed({seed}) - import numpy as np - np.random.seed({seed}) -_set_seeds()\ -""" - - script = "\n\n".join([seeds_prefix] + [CODE_ENV_PREFIX] + scripts) - with tempfile.TemporaryDirectory() as dpath: - code_fpath = os.path.join(dpath, "code.py") - with open(code_fpath, "w") as f: - f.write(script) - - try: - python_path = os.environ.get("PYTHONPATH", "") - env = dict( - os.environ, - PYTHONHASHSEED=str(seed), - MPLCONFIGDIR=dpath, - MPLBACKEND="module://matplotlib_custom_backend", - PYTHONPATH=f"{DIRNAME}:{python_path}", - ) - - if req.use_bwrap: - bwrap_prefix = "bwrap " + generate_bwrap_command(bind_dirs=[dpath]) - cmd = [*bwrap_prefix.split(), sys.executable, "-c", script] - else: - cmd = [sys.executable, "-c", script] - - stdout, stderr, returncode = do_subprocess( - cmd=cmd, - env=env, - ctx=self.context, - ) - - stderr = stderr.strip() - if req.strip_fpaths_in_stderr: - pattern = r'File "([^"]+)", line (\d+)' - stderr = re.sub(pattern, r"line \2", stderr) - - return { - "process_status": "completed", - "returncode": returncode, - "stdout": stdout.strip(), - "stderr": stderr, - } - - except subprocess.TimeoutExpired: - return { - "process_status": "timeout", - "stdout": "Timed out", - "stderr": "Timed out", - } - - except Exception as e: - return { - "process_status": "error", - "error_type": type(e).__name__, - "stderr": str(e), - "stdout": str(e), - } - - -def process_matplotlib_response(response, matplotlib_dump_dir: str): - image_data = response["image_data"] - # Convert the base64 string to a bytes object - images_raw = [base64.b64decode(d["image_base64"]) for d in image_data] - # Create a list of PIL images from the bytes objects - images = [Image.open(BytesIO(img)) for img in images_raw] - # Create a list of image paths - image_paths = [] - for i, img in enumerate(images): - # create new directory for each day to better organize data: - dump_dname = datetime.today().strftime("%Y-%m-%d") # noqa: DTZ002 - we don't care about timezones here since we are displaying the date - dump_dpath = Path(matplotlib_dump_dir, dump_dname) - dump_dpath.mkdir(parents=True, exist_ok=True) - # save image into a file - dump_fname = f"matplotlib_{str(time.time()).replace('.', '_')}_{i}.png" - dump_fpath = dump_dpath / dump_fname - img.save(dump_fpath, "PNG") - image_paths.append(str(dump_fpath)) - - # this is kind of convoluted, we send back this response to the subprocess which - # prints it out - info = { - "filepath": str(image_paths[-1]), - "mimetype": "image/png", - } - return f"{TOOLS_ATTACHMENT_KEY}={json.dumps(info)}" - - -def execute_subprocess_request(request, ctx: CodeExecutionContext): - "Route requests from the subprocess (via network Pipes) to the internet/tools." - if request["type"] == "matplotlib": - return process_matplotlib_response(request, ctx.matplotlib_dump_dir) - else: - raise Exception(f"Unrecognised network request type: {request['type']}") - - -def do_subprocess(*, cmd: list, env: dict, ctx: CodeExecutionContext): - # Create Pipes to be used for any external tool/network requests. - req_r, req_w = multiprocessing.Pipe(duplex=False) - resp_r, resp_w = multiprocessing.Pipe(duplex=False) - - cmd += [str(req_w.fileno()), str(resp_r.fileno())] - proc = subprocess.Popen( - cmd, - pass_fds=(req_w.fileno(), resp_r.fileno()), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - close_fds=True, - env=env, - ) - - # Close unnecessary fds. - req_w.close() - resp_r.close() - - pipe_close = False - done_read = False - start = time.monotonic() - while proc.poll() is None and not pipe_close: - if req_r.poll(0.1): - # NB: Python pipe semantics for poll and recv mean that - # poll() returns True is a pipe is closed. - # CF old school PEP from '09 - # https://bugs.python.org/issue5573 - try: - request = json.loads(req_r.recv_bytes().decode("utf-8")) - response = execute_subprocess_request(request, ctx) - - resp_w.send_bytes(json.dumps(response).encode("utf-8")) - except EOFError: - # The request pipe is closed - set a marker to exit - # after the next attempt at reading stdout/stderr. - pipe_close = True - - try: - # If lots has been printed, pipe might be full but - # proc cannot exit until all the stdout/stderr - # been written/read. - stdout, stderr = proc.communicate(timeout=0.3) - done_read = True - except subprocess.TimeoutExpired: - # The program has not terminated. Ignore it, there - # may be more network/tool requests. - continue - if time.monotonic() - start > CODE_EXEC_TIMEOUT: - proc.terminate() - raise subprocess.TimeoutExpired(cmd, CODE_EXEC_TIMEOUT) - - if not done_read: - # Solve race condition where process terminates before - # we hit the while loop. - stdout, stderr = proc.communicate(timeout=0.3) - - resp_w.close() - req_r.close() - return stdout, stderr, proc.returncode diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/code_interpreter.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/code_interpreter.py deleted file mode 100644 index 041104040..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/code_interpreter.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - - -import asyncio -import logging -import os -import tempfile -from typing import Any - -from llama_stack.apis.common.content_types import URL -from llama_stack.apis.tools import ( - ListToolDefsResponse, - Tool, - ToolDef, - ToolInvocationResult, - ToolParameter, - ToolRuntime, -) -from llama_stack.providers.datatypes import ToolsProtocolPrivate - -from .code_execution import CodeExecutionContext, CodeExecutionRequest, CodeExecutor -from .config import CodeInterpreterToolConfig - -log = logging.getLogger(__name__) - - -class CodeInterpreterToolRuntimeImpl(ToolsProtocolPrivate, ToolRuntime): - def __init__(self, config: CodeInterpreterToolConfig): - self.config = config - ctx = CodeExecutionContext( - matplotlib_dump_dir=tempfile.mkdtemp(), - ) - self.code_executor = CodeExecutor(ctx) - - async def initialize(self): - pass - - async def register_tool(self, tool: Tool) -> None: - pass - - async def unregister_tool(self, tool_id: str) -> None: - return - - async def list_runtime_tools( - self, tool_group_id: str | None = None, mcp_endpoint: URL | None = None - ) -> ListToolDefsResponse: - return ListToolDefsResponse( - data=[ - ToolDef( - name="code_interpreter", - description="Execute code", - parameters=[ - ToolParameter( - name="code", - description="The code to execute", - parameter_type="string", - ), - ], - ) - ] - ) - - async def invoke_tool(self, tool_name: str, kwargs: dict[str, Any]) -> ToolInvocationResult: - script = kwargs["code"] - # Use environment variable to control bwrap usage - force_disable_bwrap = os.environ.get("DISABLE_CODE_SANDBOX", "").lower() in ("1", "true", "yes") - req = CodeExecutionRequest(scripts=[script], use_bwrap=not force_disable_bwrap) - res = await asyncio.to_thread(self.code_executor.execute, req) - pieces = [res["process_status"]] - for out_type in ["stdout", "stderr"]: - res_out = res[out_type] - if res_out != "": - pieces.extend([f"[{out_type}]", res_out, f"[/{out_type}]"]) - if out_type == "stderr": - log.error(f"ipython tool error: ↓\n{res_out}") - return ToolInvocationResult(content="\n".join(pieces)) diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/config.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/config.py deleted file mode 100644 index caf51d573..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/config.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - -from typing import Any - -from pydantic import BaseModel - - -class CodeInterpreterToolConfig(BaseModel): - @classmethod - def sample_run_config(cls, __distro_dir__: str, **kwargs: Any) -> dict[str, Any]: - return {} diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/matplotlib_custom_backend.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/matplotlib_custom_backend.py deleted file mode 100644 index 6454358a5..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/matplotlib_custom_backend.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - -""" -A custom Matplotlib backend that overrides the show method to return image bytes. -""" - -import base64 -import io -import json as _json -import logging - -import matplotlib -from matplotlib.backend_bases import FigureManagerBase - -# Import necessary components from Matplotlib -from matplotlib.backends.backend_agg import FigureCanvasAgg - -log = logging.getLogger(__name__) - - -class CustomFigureCanvas(FigureCanvasAgg): - def show(self): - # Save the figure to a BytesIO object - buf = io.BytesIO() - self.print_png(buf) - image_bytes = buf.getvalue() - buf.close() - return image_bytes - - -class CustomFigureManager(FigureManagerBase): - def __init__(self, canvas, num): - super().__init__(canvas, num) - - -# Mimic module initialization that integrates with the Matplotlib backend system -def _create_figure_manager(num, *args, **kwargs): - """ - Create a custom figure manager instance. - """ - FigureClass = kwargs.pop("FigureClass", None) # noqa: N806 - if FigureClass is None: - from matplotlib.figure import Figure - - FigureClass = Figure # noqa: N806 - fig = FigureClass(*args, **kwargs) - canvas = CustomFigureCanvas(fig) - manager = CustomFigureManager(canvas, num) - return manager - - -def show(): - """ - Handle all figures and potentially return their images as bytes. - - This function iterates over all figures registered with the custom backend, - renders them as images in bytes format, and could return a list of bytes objects, - one for each figure, or handle them as needed. - """ - image_data = [] - for manager in matplotlib._pylab_helpers.Gcf.get_all_fig_managers(): - # Get the figure from the manager - fig = manager.canvas.figure - buf = io.BytesIO() # Create a buffer for the figure - fig.savefig(buf, format="png") # Save the figure to the buffer in PNG format - buf.seek(0) # Go to the beginning of the buffer - image_bytes = buf.getvalue() # Retrieve bytes value - image_base64 = base64.b64encode(image_bytes).decode("utf-8") - image_data.append({"image_base64": image_base64}) - buf.close() - - # The _open_connections method is dynamically made available to - # the interpreter by bundling code from "code_env_prefix.py" -- by literally prefixing it -- and - # then "eval"ing it within a sandboxed interpreter. - req_con, resp_con = _open_connections() # noqa: F821 - - _json_dump = _json.dumps( - { - "type": "matplotlib", - "image_data": image_data, - } - ) - req_con.send_bytes(_json_dump.encode("utf-8")) - resp = _json.loads(resp_con.recv_bytes().decode("utf-8")) - log.info(resp) - - -FigureCanvas = CustomFigureCanvas -FigureManager = CustomFigureManager diff --git a/llama_stack/providers/inline/tool_runtime/code_interpreter/utils.py b/llama_stack/providers/inline/tool_runtime/code_interpreter/utils.py deleted file mode 100644 index fabddbf0b..000000000 --- a/llama_stack/providers/inline/tool_runtime/code_interpreter/utils.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the terms described in the LICENSE file in -# the root directory of this source tree. - -import os - -DIR = os.path.dirname(os.path.realpath(__file__)) -CODE_ENV_PREFIX_FILE = os.path.join(DIR, "code_env_prefix.py") -CODE_ENV_PREFIX = None - - -def get_code_env_prefix() -> str: - global CODE_ENV_PREFIX - - if CODE_ENV_PREFIX is None: - with open(CODE_ENV_PREFIX_FILE) as f: - CODE_ENV_PREFIX = f.read() - - return CODE_ENV_PREFIX diff --git a/llama_stack/providers/registry/tool_runtime.py b/llama_stack/providers/registry/tool_runtime.py index 3140626f9..b9194810e 100644 --- a/llama_stack/providers/registry/tool_runtime.py +++ b/llama_stack/providers/registry/tool_runtime.py @@ -35,13 +35,6 @@ def available_providers() -> list[ProviderSpec]: config_class="llama_stack.providers.inline.tool_runtime.rag.config.RagToolRuntimeConfig", api_dependencies=[Api.vector_io, Api.inference], ), - InlineProviderSpec( - api=Api.tool_runtime, - provider_type="inline::code-interpreter", - pip_packages=[], - module="llama_stack.providers.inline.tool_runtime.code_interpreter", - config_class="llama_stack.providers.inline.tool_runtime.code_interpreter.config.CodeInterpreterToolConfig", - ), remote_provider_spec( api=Api.tool_runtime, adapter=AdapterSpec(