""" Executor seam — the stable interface generated code runs through. The agent is a CodeAct loop: the model emits code that gets executed in a persistent, Jupyter-kernel-style namespace holding session state across steps. Today that runs in-process via `PythonExecutor` (`python_executor.py`). ADR-0007 ("Per-Session Sandboxed Code Execution") calls for swapping in a network-isolated, per-session *sandboxed* executor behind exactly this interface — WITHOUT rewriting the agent loop, which already depends only on the small surface below. This module extracts that seam (ADR-0007 Phase 0): an `Executor` Protocol capturing the current `PythonExecutor` surface, plus a config-selected factory `get_executor()` mirroring `get_log_sink()` in `src/logging_sink.py`. There is NO behavior change — the default (`EXECUTOR=in_process`) still returns an in-process `PythonExecutor`. Selection is by the `EXECUTOR` env var (`in_process` | `sandbox`), default `in_process`: EXECUTOR=in_process PythonExecutor() — runs code in this process (DEFAULT) EXECUTOR=sandbox SandboxedExecutor() — per-session sandbox kernel (ADR-0007 Phase 1). Runs code in an isolated kernel started by a pluggable launcher (env `SANDBOX_LAUNCHER`, default `container` for prod; set `subprocess` for dev/test). An unknown value raises ValueError (fail loud on misconfiguration, before any run). Sandbox env vars (all optional; sensible defaults) — mirrors the LOG_SINK style: SANDBOX_LAUNCHER subprocess | container (default: container) SANDBOX_MCP_URL MCP HTTP server URL the in-kernel tool stubs dispatch to (default: unset — tool stubs raise loudly if invoked) SANDBOX_SESSION_ID logical session id (default: sess-) SANDBOX_IMAGE sandbox container image (default: decouplerpy-sandbox:latest) SANDBOX_KERNEL_PORT default kernel port (default: 8790; per-session a free port is auto-picked by the launcher) SANDBOX_HEALTH_TIMEOUT seconds to wait for /health after launch (default: 30) """ import os from typing import Any, Protocol, runtime_checkable __all__ = ["Executor", "get_executor"] @runtime_checkable class Executor(Protocol): """Structural interface for a persistent code-execution environment. Any executor the agent uses must provide this surface. `PythonExecutor` (in-process, the default) conforms to it today; a future `SandboxedExecutor` (ADR-0007 Phase 1) will implement the same contract while proxying each call to a Python kernel inside an isolated per-session container. Because the only thing that crosses the boundary is captured stdout (a string), the interface is trivially serializable over a socket/HTTP. """ def reset_environment(self) -> None: """Reset the execution environment to a clean state.""" ... def send_functions(self, functions: dict[str, Any]) -> None: """Inject tool functions into the execution namespace.""" ... def send_variables(self, variables: dict[str, Any]) -> None: """Inject variables into the execution namespace.""" ... def execute(self, code: str) -> str: """Execute code in the persistent namespace; return captured stdout.""" ... def __call__(self, code: str) -> str: """Alias for `execute` — run `code`, return captured stdout.""" ... # --------------------------------------------------------------------------- # # Factory # --------------------------------------------------------------------------- # def get_executor(kind: str = None) -> Executor: """Return the configured Executor. `kind` overrides the `EXECUTOR` env var; default is `in_process`. An unknown value raises ValueError (fail loud on misconfiguration, before any run). - `in_process` (DEFAULT) → `PythonExecutor()`, unchanged current behavior. - `sandbox` → the ADR-0007 Phase 1 per-session `SandboxedExecutor`: a kernel (subprocess in dev/test, container in prod) running behind this same Executor interface. Launcher chosen by `SANDBOX_LAUNCHER` (default `container`). See docs/adr/ADR-0007-sandboxed-code-execution.md. """ kind = (kind or os.environ.get("EXECUTOR", "in_process")).strip().lower() if kind == "in_process": # Imported here (not at module top) so the interface + factory stay # import-light and free of any circular import with python_executor. from .python_executor import PythonExecutor return PythonExecutor() if kind == "sandbox": # Imported lazily so the factory stays import-light: pulling in the # sandbox package (launchers, urllib) only happens when it's selected. from .sandbox.executor import SandboxedExecutor return SandboxedExecutor() raise ValueError(f"Unknown EXECUTOR={kind!r}. Valid values: ['in_process', 'sandbox'].")