File size: 5,048 Bytes
6a88274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a495d22
 
 
 
6a88274
 
a495d22
 
 
 
 
 
 
 
 
 
 
6a88274
 
 
c3b49d6
6a88274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
6a88274
 
 
c3b49d6
6a88274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a495d22
 
 
 
6a88274
 
 
 
 
 
 
 
 
 
 
a495d22
 
 
 
 
6a88274
c3b49d6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
"""
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-<pid>)
  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'].")