| """ |
| Python REPL and Bash execution tools for CodeAct. |
| |
| Simple tools that execute code/commands. Paths are configured globally. |
| """ |
|
|
| import sys |
| import io |
| import traceback |
| import subprocess |
| import os |
| import base64 |
| import glob |
| from contextlib import redirect_stdout, redirect_stderr |
| from typing import Annotated, Dict, Any, List, Set |
| from langchain_core.tools import tool |
| from pydantic import Field |
|
|
|
|
| |
| |
| |
|
|
| _config = { |
| "save_path": "./experiments", |
| "data_path": "./data", |
| } |
|
|
| |
| _new_image_files: List[str] = [] |
|
|
| def get_new_image_files() -> List[str]: |
| """Get and clear list of new image files created during execution.""" |
| global _new_image_files |
| files = _new_image_files.copy() |
| _new_image_files = [] |
| return files |
|
|
| |
| _injected_tools = {} |
|
|
| def configure_coding_tools(save_path: str = "./experiments", data_path: str = "./data"): |
| """Configure paths for coding tools. Call this before using the tools.""" |
| _config["save_path"] = save_path |
| _config["data_path"] = data_path |
| |
| global _repl_instance |
| _repl_instance = None |
|
|
| def inject_tools_into_repl(tools: dict): |
| """ |
| Inject tools into the REPL namespace so they can be called directly. |
| |
| Args: |
| tools: Dict mapping tool names to callable functions |
| """ |
| global _injected_tools, _repl_instance |
| _injected_tools = tools |
| |
| _repl_instance = None |
|
|
|
|
| |
| |
| |
|
|
| class _StatefulPythonREPL: |
| """Stateful Python REPL that maintains variables across executions.""" |
|
|
| def __init__(self, save_path: str = None, data_path: str = None): |
| self.save_path = save_path or _config["save_path"] |
| self.data_path = data_path or _config["data_path"] |
| |
| |
| self.namespace = {"__builtins__": __builtins__} |
| self._libraries_imported = False |
|
|
| def _preimport_libraries(self): |
| """Lazily import common libraries on first execution.""" |
| if self._libraries_imported: |
| return |
|
|
| common_imports = f""" |
| import numpy as np |
| import pandas as pd |
| import scanpy as sc |
| import squidpy as sq |
| import matplotlib.pyplot as plt |
| import seaborn as sns |
| from pathlib import Path |
| import os |
| import json |
| |
| import matplotlib |
| matplotlib.use('Agg') |
| |
| save_path = r'{self.save_path}' |
| data_path = r'{self.data_path}' |
| """ |
| try: |
| exec(common_imports, self.namespace) |
| except Exception as e: |
| print(f"Warning: Could not pre-import libraries: {e}") |
|
|
| |
| for tool_name, tool_func in _injected_tools.items(): |
| self.namespace[tool_name] = tool_func |
| self._libraries_imported = True |
|
|
| def _scan_image_files(self, directory: str) -> Set[str]: |
| """Scan directory recursively for image files, return set of (path, mtime) tuples.""" |
| image_extensions = ('*.png', '*.jpg', '*.jpeg', '*.svg', '*.pdf') |
| files = set() |
| for ext in image_extensions: |
| for f in glob.glob(os.path.join(directory, '**', ext), recursive=True): |
| try: |
| mtime = os.path.getmtime(f) |
| files.add((f, mtime)) |
| except OSError: |
| pass |
| return files |
|
|
| def _find_new_images(self, before: Set[str], after: Set[str]) -> List[str]: |
| """Find images that are new or modified.""" |
| before_paths = {f for f, _ in before} |
| new_images = [] |
| for path, mtime in after: |
| if path not in before_paths: |
| |
| new_images.append(path) |
| else: |
| |
| old_mtime = next((m for p, m in before if p == path), None) |
| if old_mtime and mtime > old_mtime: |
| new_images.append(path) |
| return sorted(new_images) |
|
|
| def _get_monitor_paths(self) -> List[str]: |
| """Get list of directories to monitor for new images.""" |
| paths = set() |
| |
| paths.add(self.save_path) |
|
|
| |
| path_var_names = ['save_path', 'output_dir', 'output_path', 'out_dir', 'fig_dir', 'figure_dir', 'results_dir'] |
|
|
| |
| for var_name in path_var_names: |
| if var_name in self.namespace: |
| val = self.namespace[var_name] |
| |
| paths.add(str(val)) |
|
|
| |
| try: |
| import scanpy as sc |
| if hasattr(sc.settings, 'figdir') and sc.settings.figdir: |
| paths.add(str(sc.settings.figdir)) |
| except ImportError: |
| pass |
|
|
| |
| return [p for p in paths if os.path.isdir(p)] |
|
|
| def execute(self, code: str) -> Dict[str, Any]: |
| """Execute Python code and return results.""" |
| global _new_image_files |
| self._preimport_libraries() |
|
|
| |
| code = code.strip() |
| if code.startswith("```"): |
| |
| lines = code.split("\n") |
| if lines[0].startswith("```"): |
| lines = lines[1:] |
| |
| if lines and lines[-1].strip() == "```": |
| lines = lines[:-1] |
| code = "\n".join(lines) |
|
|
| |
| images_before = set() |
| for path in self._get_monitor_paths(): |
| images_before.update(self._scan_image_files(path)) |
|
|
| stdout_capture = io.StringIO() |
| stderr_capture = io.StringIO() |
|
|
| try: |
| with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): |
| try: |
| result = eval(code, self.namespace) |
| result_str = repr(result) if result is not None else "" |
| except SyntaxError: |
| exec(code, self.namespace) |
| result_str = "" |
|
|
| output = stdout_capture.getvalue() |
| stderr = stderr_capture.getvalue() |
|
|
| |
| images_after = set() |
| monitor_paths_after = self._get_monitor_paths() |
| for path in monitor_paths_after: |
| images_after.update(self._scan_image_files(path)) |
| new_images = self._find_new_images(images_before, images_after) |
| _new_image_files.extend(new_images) |
|
|
| return { |
| "success": True, |
| "output": output + stderr if stderr else output, |
| "result": result_str, |
| "error": None |
| } |
|
|
| except Exception as e: |
| |
| images_after = set() |
| for path in self._get_monitor_paths(): |
| images_after.update(self._scan_image_files(path)) |
| new_images = self._find_new_images(images_before, images_after) |
| _new_image_files.extend(new_images) |
|
|
| |
| tb = traceback.extract_tb(e.__traceback__) |
| |
| user_lines = [frame for frame in tb if frame.filename == "<string>"] |
|
|
| error_parts = [ |
| "=" * 60, |
| f"ERROR: {type(e).__name__}", |
| f"MESSAGE: {str(e)}", |
| "=" * 60, |
| ] |
|
|
| if user_lines: |
| last_frame = user_lines[-1] |
| error_parts.append(f"LINE {last_frame.lineno}: {last_frame.line}") |
|
|
| error_parts.append("") |
| error_parts.append("Fix this error in your next code block.") |
|
|
| return { |
| "success": False, |
| "output": stdout_capture.getvalue(), |
| "result": None, |
| "error": "\n".join(error_parts) |
| } |
|
|
|
|
| |
| _repl_instance = None |
|
|
| def _get_repl(): |
| global _repl_instance |
| if _repl_instance is None: |
| _repl_instance = _StatefulPythonREPL() |
| return _repl_instance |
|
|
|
|
| |
| |
| |
|
|
| @tool |
| def execute_python( |
| code: Annotated[str, Field(description="Python code to execute")], |
| ) -> str: |
| """ |
| Execute Python code in a stateful environment. |
| |
| Pre-imported: numpy (np), pandas (pd), scanpy (sc), squidpy (sq), matplotlib.pyplot (plt), seaborn (sns) |
| Available variables: save_path, data_path |
| Variables persist across calls. |
| """ |
| repl = _get_repl() |
| result = repl.execute(code) |
|
|
| if result["success"]: |
| parts = [] |
| if result["output"]: |
| parts.append(f"Output:\n{result['output']}") |
| if result["result"]: |
| parts.append(f"Result: {result['result']}") |
| if not parts: |
| parts.append("Code executed successfully (no output).") |
| return "\n\n".join(parts) |
| else: |
| return f"Error executing code:\n{result['error']}" |
|
|
|
|
| @tool |
| def execute_bash( |
| command: Annotated[str, Field(description="Bash command to execute")], |
| ) -> str: |
| """ |
| Execute a Bash shell command. |
| |
| Environment variables: $SAVE_PATH, $DATA_PATH |
| Default timeout: 60 seconds. |
| """ |
| env = os.environ.copy() |
| env['SAVE_PATH'] = _config["save_path"] |
| env['DATA_PATH'] = _config["data_path"] |
|
|
| try: |
| result = subprocess.run( |
| command, |
| shell=True, |
| capture_output=True, |
| text=True, |
| timeout=60, |
| env=env, |
| ) |
|
|
| if result.returncode == 0: |
| parts = [] |
| if result.stdout: |
| parts.append(f"Output:\n{result.stdout}") |
| if result.stderr: |
| parts.append(f"Warnings:\n{result.stderr}") |
| if not parts: |
| parts.append("Command executed successfully (no output).") |
| return "\n\n".join(parts) |
| else: |
| return f"Command failed (code {result.returncode}):\n{result.stderr or result.stdout}" |
|
|
| except subprocess.TimeoutExpired: |
| return "Command timed out after 60 seconds" |
| except Exception as e: |
| return f"Error: {type(e).__name__}: {str(e)}" |
|
|
|
|
| |
| |
| |
|
|
| |
| StatefulPythonREPL = _StatefulPythonREPL |
|
|
| def create_python_repl_tool(save_path: str, data_path: str): |
| """Deprecated: Use configure_coding_tools() then import execute_python directly.""" |
| configure_coding_tools(save_path, data_path) |
| return execute_python, _get_repl() |
|
|
| def create_bash_tool(save_path: str, data_path: str): |
| """Deprecated: Use configure_coding_tools() then import execute_bash directly.""" |
| configure_coding_tools(save_path, data_path) |
| return execute_bash |
|
|