"""Executor for Python code runner nodes.""" import os import re import subprocess import textwrap from dataclasses import dataclass from pathlib import Path from typing import List from entity.configs import Node from entity.configs.node.python_runner import PythonRunnerConfig from entity.messages import Message, MessageRole from runtime.node.executor.base import NodeExecutor _CODE_BLOCK_RE = re.compile(r"```(?P[a-zA-Z0-9_+-]*)?\s*\n(?P.*?)```", re.DOTALL) @dataclass class _ExecutionResult: success: bool stdout: str stderr: str exit_code: int | None error: str | None = None class PythonNodeExecutor(NodeExecutor): """Execute inline Python code passed to the node.""" WORKSPACE_KEY = "python_workspace_root" COUNTER_KEY = "python_node_run_counters" def execute(self, node: Node, inputs: List[Message]) -> List[Message]: if node.node_type != "python": raise ValueError(f"Node {node.id} is not a python node") workspace = self._ensure_workspace_root() last_message = inputs[-1] if inputs else None code_payload = self._extract_code(last_message) if not code_payload: return [self._build_failure_message( node, workspace, error_text="No executable code segment found", )] script_path = self._write_script_file(node, workspace, code_payload) config = node.as_config(PythonRunnerConfig) if not config: raise ValueError(f"Node {node.id} missing PythonRunnerConfig") result = self._run_process(config, script_path, workspace, node) metadata = { "workspace": str(workspace), "script_path": str(script_path), } if result.success: if result.stderr: self.log_manager.debug( f"Python node {node.id} stderr", node_id=node.id, details={"stderr": result.stderr} ) return [self._build_message( role=MessageRole.ASSISTANT, content=result.stdout, source=node.id, metadata=metadata, )] error_text = result.error or "Script execution failed" return [self._build_failure_message( node, workspace, error_text=error_text, exit_code=result.exit_code, stderr=result.stderr, script_path=script_path, )] def _ensure_workspace_root(self) -> Path: root = self.context.global_state.setdefault(self.WORKSPACE_KEY, None) if root is None: graph_dir = self.context.global_state.get("graph_directory") if not graph_dir: raise RuntimeError("graph_directory missing from execution context") root = (Path(graph_dir) / "code_workspace").resolve() root.mkdir(parents=True, exist_ok=True) self.context.global_state[self.WORKSPACE_KEY] = str(root) else: root = Path(root).resolve() root.mkdir(parents=True, exist_ok=True) return root def _extract_code(self, message: Message | None) -> str: if not message: return "" raw = message.text_content() if not raw or not raw.strip(): return "" match = _CODE_BLOCK_RE.search(raw) code = match.group("code") if match else raw return textwrap.dedent(code).strip() def _write_script_file(self, node: Node, workspace: Path, code: str) -> Path: counters = self.context.global_state.setdefault(self.COUNTER_KEY, {}) safe_node_id = re.sub(r"[^0-9A-Za-z_\-]", "_", node.id) run_count = counters.get(node.id, 0) + 1 counters[node.id] = run_count suffix = f"_run-{run_count}" if run_count > 1 else "" filename = f"{safe_node_id}{suffix}.py" path = (workspace / filename).resolve() path.write_text(code + ("\n" if not code.endswith("\n") else ""), encoding="utf-8") return path def _run_process( self, config: PythonRunnerConfig, script_path: Path, workspace: Path, node: Node, ) -> _ExecutionResult: cmd = [config.interpreter] if config.args: cmd.extend(config.args) cmd.append(str(script_path)) env = os.environ.copy() env.update(config.env or {}) env.update( { "MAC_CODE_WORKSPACE": str(workspace), "MAC_CODE_SCRIPT": str(script_path), "MAC_NODE_ID": node.id, } ) try: completed = subprocess.run( cmd, cwd=str(workspace), capture_output=True, check=False, timeout=config.timeout_seconds, ) except subprocess.TimeoutExpired as exc: return _ExecutionResult( success=False, stdout="", stderr=exc.stdout.decode(config.encoding, errors="replace") if exc.stdout else "", exit_code=None, error=f"Script did not finish within {config.timeout_seconds}s", ) except FileNotFoundError: return _ExecutionResult( success=False, stdout="", stderr="", exit_code=None, error=f"Interpreter {config.interpreter} not found", ) stdout = completed.stdout.decode(config.encoding, errors="replace") stderr = completed.stderr.decode(config.encoding, errors="replace") return _ExecutionResult( success=completed.returncode == 0, stdout=stdout, stderr=stderr, exit_code=completed.returncode, ) def _build_failure_message( self, node: Node, workspace: Path, *, error_text: str, exit_code: int | None = None, stderr: str | None = None, script_path: Path | None = None, ) -> Message: metadata = { "workspace": str(workspace), } if script_path: metadata["script_path"] = str(script_path) if exit_code is not None: metadata["exit_code"] = exit_code if stderr: metadata["stderr"] = stderr content_lines = ["==CODE EXECUTION FAILED==", error_text] if exit_code is not None: content_lines.append(f"exit_code={exit_code}") if stderr: content_lines.append(f"stderr:\n{stderr}") return self._build_message( role=MessageRole.ASSISTANT, content="\n".join(content_lines), source=node.id, metadata=metadata, ) # workspace hook handled via ExecutionContext.workspace_hook