levain-runtime 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ """Calls to the platform API other than the heartbeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .config import Credentials
11
+
12
+ log = logging.getLogger("levain_runtime.platform")
13
+
14
+ DEFAULT_API_URL = "https://api.levainlabs.com"
15
+ # Tests swap in an ``httpx.MockTransport``.
16
+ TRANSPORT: httpx.BaseTransport | None = None
17
+
18
+
19
+ def _client(**kwargs: Any) -> httpx.Client:
20
+ return httpx.Client(timeout=30, transport=TRANSPORT, **kwargs)
21
+
22
+
23
+ def fetch_connection(creds: Credentials) -> dict[str, Any]:
24
+ """The platform's current gateway, port and forwards for this runtime."""
25
+ with _client() as client:
26
+ resp = client.get(
27
+ f"{creds.api_url}/api/v1/runtimes/{creds.runtime_id}/connection",
28
+ headers={"Authorization": f"Bearer {creds.secret}"},
29
+ )
30
+ resp.raise_for_status()
31
+ data = resp.json()
32
+ if not isinstance(data, dict):
33
+ raise ValueError("connection response is not an object")
34
+ return data
35
+
36
+
37
+ def refresh_connection(creds: Credentials) -> bool:
38
+ """Apply the platform's connection settings in place; False if unreachable.
39
+
40
+ A runner that cannot reach the API keeps its stored settings so a
41
+ platform outage never blocks a tunnel that would otherwise come up.
42
+ """
43
+ try:
44
+ data = fetch_connection(creds)
45
+ except (httpx.HTTPError, ValueError) as exc:
46
+ log.warning("could not refresh the connection settings: %s", exc)
47
+ return False
48
+ if creds.apply_connection(data):
49
+ log.info("connection settings updated from the platform")
50
+ return True
51
+
52
+
53
+ def register_runtime(
54
+ api_url: str, api_key: str, body: dict[str, Any]
55
+ ) -> dict[str, Any]:
56
+ """``POST /api/v1/runtimes``; returns the response body.
57
+
58
+ Raises ``httpx.HTTPStatusError`` with the API's message on refusal.
59
+ """
60
+ with _client() as client:
61
+ resp = client.post(
62
+ f"{api_url.rstrip('/')}/api/v1/runtimes",
63
+ json=body,
64
+ headers={"Authorization": f"Bearer {api_key}"},
65
+ )
66
+ if resp.status_code >= 400:
67
+ raise httpx.HTTPStatusError(
68
+ _error_message(resp), request=resp.request, response=resp
69
+ )
70
+ data = resp.json()
71
+ if not isinstance(data, dict) or "credentials" not in data:
72
+ raise ValueError("unexpected response from the platform")
73
+ return data
74
+
75
+
76
+ def _error_message(resp: httpx.Response) -> str:
77
+ try:
78
+ detail = resp.json().get("detail")
79
+ except ValueError:
80
+ detail = None
81
+ if isinstance(detail, dict):
82
+ detail = detail.get("message") or detail.get("reason")
83
+ return str(detail or f"HTTP {resp.status_code}")
@@ -0,0 +1,172 @@
1
+ """Subprocess streaming shared by the direct and docker backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import signal
9
+ import sys
10
+ from collections.abc import AsyncIterator
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ _CHUNK = 65536
15
+ _DROPPED_ENV = ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT")
16
+
17
+
18
+ def host_env() -> dict[str, str]:
19
+ """The host environment minus the Claude CLI's nested-session markers.
20
+
21
+ The platform runs each node as ``python /sandbox/node_runner.py``,
22
+ so ``python`` must resolve to an interpreter that has the SDK
23
+ installed. That is this process's own interpreter (``uv tool
24
+ install`` puts only the ``levain-runtime`` entrypoint on PATH),
25
+ so its bin directory goes first.
26
+ """
27
+ env = {k: v for k, v in os.environ.items() if k not in _DROPPED_ENV}
28
+ env["PYTHONUNBUFFERED"] = "1"
29
+ own_bin = str(Path(sys.executable).parent)
30
+ path = env.get("PATH", "")
31
+ if own_bin not in path.split(os.pathsep):
32
+ env["PATH"] = own_bin + (os.pathsep + path if path else "")
33
+ return env
34
+
35
+
36
+ def _kill_group(proc: asyncio.subprocess.Process) -> None:
37
+ if proc.returncode is not None:
38
+ return
39
+ with contextlib.suppress(ProcessLookupError, PermissionError):
40
+ os.killpg(proc.pid, signal.SIGKILL)
41
+
42
+
43
+ async def _pump(
44
+ stream: asyncio.StreamReader | None,
45
+ key: str,
46
+ queue: asyncio.Queue[dict[str, Any] | None],
47
+ ) -> None:
48
+ buf = b""
49
+ try:
50
+ while stream is not None:
51
+ chunk = await stream.read(_CHUNK)
52
+ if not chunk:
53
+ break
54
+ buf += chunk
55
+ while b"\n" in buf:
56
+ line, buf = buf.split(b"\n", 1)
57
+ await queue.put({key: line.decode("utf-8", "replace")})
58
+ if buf:
59
+ await queue.put({key: buf.decode("utf-8", "replace")})
60
+ finally:
61
+ await queue.put(None)
62
+
63
+
64
+ async def _feed_stdin(proc: asyncio.subprocess.Process, data: bytes) -> None:
65
+ assert proc.stdin is not None
66
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
67
+ proc.stdin.write(data)
68
+ await proc.stdin.drain()
69
+ with contextlib.suppress(Exception):
70
+ proc.stdin.close()
71
+
72
+
73
+ class _Watchdog:
74
+ def __init__(
75
+ self, proc: asyncio.subprocess.Process, timeout: float
76
+ ) -> None:
77
+ self.fired = False
78
+ self._proc = proc
79
+ self._timeout = timeout
80
+
81
+ async def run(self) -> None:
82
+ await asyncio.sleep(self._timeout)
83
+ self.fired = True
84
+ _kill_group(self._proc)
85
+
86
+
87
+ async def stream_process(
88
+ argv: list[str],
89
+ *,
90
+ env: dict[str, str],
91
+ stdin: bytes | None = None,
92
+ timeout: float = 0,
93
+ cwd: str | None = None,
94
+ ) -> AsyncIterator[dict[str, Any]]:
95
+ """Run ``argv`` and yield ``{"out"|"err": line}`` then ``{"exit", "timed_out"}``.
96
+
97
+ Lines are split by hand from fixed-size reads so a very long line
98
+ (an SDK message dump) never trips a reader limit. The process gets
99
+ its own session so a timeout can kill the whole group.
100
+ """
101
+ proc = await asyncio.create_subprocess_exec(
102
+ *argv,
103
+ env=env,
104
+ cwd=cwd,
105
+ stdin=asyncio.subprocess.PIPE
106
+ if stdin is not None
107
+ else asyncio.subprocess.DEVNULL,
108
+ stdout=asyncio.subprocess.PIPE,
109
+ stderr=asyncio.subprocess.PIPE,
110
+ start_new_session=True,
111
+ )
112
+ queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
113
+ tasks = [
114
+ asyncio.create_task(_pump(proc.stdout, "out", queue)),
115
+ asyncio.create_task(_pump(proc.stderr, "err", queue)),
116
+ ]
117
+ if stdin is not None:
118
+ tasks.append(asyncio.create_task(_feed_stdin(proc, stdin)))
119
+ watchdog = _Watchdog(proc, timeout) if timeout and timeout > 0 else None
120
+ if watchdog is not None:
121
+ tasks.append(asyncio.create_task(watchdog.run()))
122
+ try:
123
+ closed = 0
124
+ while closed < 2:
125
+ item = await queue.get()
126
+ if item is None:
127
+ closed += 1
128
+ continue
129
+ yield item
130
+ rc = await proc.wait()
131
+ timed_out = watchdog is not None and watchdog.fired
132
+ yield {"exit": -1 if timed_out else rc, "timed_out": timed_out}
133
+ finally:
134
+ # A consumer that stops reading (client gone) must not leave
135
+ # the node running.
136
+ _kill_group(proc)
137
+ for t in tasks:
138
+ t.cancel()
139
+ for t in tasks:
140
+ with contextlib.suppress(BaseException):
141
+ await t
142
+
143
+
144
+ async def run_process(
145
+ argv: list[str],
146
+ *,
147
+ env: dict[str, str] | None = None,
148
+ stdin: bytes | None = None,
149
+ timeout: float = 0,
150
+ cwd: str | None = None,
151
+ ) -> tuple[int, bytes, bytes]:
152
+ """Run ``argv`` to completion; returns ``(exit, stdout, stderr)``."""
153
+ proc = await asyncio.create_subprocess_exec(
154
+ *argv,
155
+ env=env if env is not None else host_env(),
156
+ cwd=cwd,
157
+ stdin=asyncio.subprocess.PIPE
158
+ if stdin is not None
159
+ else asyncio.subprocess.DEVNULL,
160
+ stdout=asyncio.subprocess.PIPE,
161
+ stderr=asyncio.subprocess.PIPE,
162
+ start_new_session=True,
163
+ )
164
+ try:
165
+ out, err = await asyncio.wait_for(
166
+ proc.communicate(stdin), timeout=timeout or None
167
+ )
168
+ except TimeoutError:
169
+ _kill_group(proc)
170
+ await proc.wait()
171
+ return -1, b"", b"timed out"
172
+ return proc.returncode or 0, out, err
@@ -0,0 +1,9 @@
1
+ """Wire constants shared with the platform side. Change in lockstep."""
2
+
3
+ # Loopback listener the platform reaches through the reverse tunnel.
4
+ RUNNER_HTTP_PORT = 17777
5
+ # Loopback ports the tunnel forwards to platform services.
6
+ FORWARD_SANDBOX_API_PORT = 18010
7
+ FORWARD_PROXY_PORT = 14100
8
+
9
+ CHISEL_VERSION = "1.11.3"
@@ -0,0 +1,304 @@
1
+ r"""Node execution entrypoint for sandbox containers.
2
+
3
+ Invoked as::
4
+
5
+ python /sandbox/node_runner.py \
6
+ --function claude_agent \
7
+ --params /io/params.json \
8
+ --state /io/state.json \
9
+ --output /io/result.json
10
+
11
+ Reads node parameters and graph state from JSON files,
12
+ dispatches to the built-in handler or resolves a
13
+ ``module:function`` reference, and writes a result JSON file.
14
+ The result is a state-delta dict consumed by the orchestrator.
15
+
16
+ Lives next to the sandbox Dockerfile (not in the SDK) because
17
+ it's container plumbing. The SDK is for recipe authors, who
18
+ never call this script. Handler logic (``run_claude_node``,
19
+ ``run_shell_node``) lives in ``levain_sdk.handlers`` and is
20
+ usable from other contexts.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import importlib
27
+ import inspect
28
+ import json
29
+ import logging
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Annotated, Any
33
+
34
+ # Make ``run_logging`` (sibling module) importable when this file
35
+ # is loaded as a script (``python /sandbox/node_runner.py`` — the
36
+ # container path) and when it's imported under a package name by
37
+ # tests (``from services.sandbox.node_runner import ...`` — pytest
38
+ # rootdir). The script path auto-prepends sys.path[0]; the test
39
+ # path doesn't, so we do it ourselves.
40
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
41
+
42
+ import run_logging
43
+ import typer
44
+ from levain_sdk.handlers import HANDLERS
45
+ from levain_sdk.mcp_registry import resolve_mcp_servers
46
+ from levain_sdk.messages import from_jsonable_state, to_jsonable
47
+
48
+ log = logging.getLogger("knead.node_runner")
49
+
50
+ app = typer.Typer(add_completion=False)
51
+
52
+
53
+ def _error_envelope(exc: BaseException) -> dict[str, Any]:
54
+ """Serialise a node-failure exception into the result-dict envelope.
55
+
56
+ The harness reads ``_error_status`` to classify wake retries
57
+ deterministically — a 4xx-not-429 from any provider is
58
+ terminal and must drop the wake on first attempt instead of
59
+ burning four more sandbox provisions. ``status_code`` is a
60
+ typed attribute on every ``litellm`` / ``openai`` /
61
+ ``anthropic`` exception class, so we walk the ``__cause__``
62
+ chain and grab the first integer we find — robust to wrappers
63
+ like the SDK's ``AgentError(str(litellm_exc))`` pattern.
64
+
65
+ ``_error_class`` carries the underlying exception class name
66
+ so callers can match deterministic-but-statusless types
67
+ (notably ``BudgetExceededError``, which is a plain
68
+ ``Exception`` with no ``status_code``).
69
+
70
+ ``_error`` keeps the original 500-char string for backward
71
+ compat with code paths that haven't been rewired to read the
72
+ structured fields yet.
73
+ """
74
+ return {
75
+ "_error": str(exc)[:500],
76
+ "_error_status": _extract_status_code(exc),
77
+ "_error_class": _root_cause_class(exc),
78
+ }
79
+
80
+
81
+ def _extract_status_code(exc: BaseException) -> int | None:
82
+ """Walk ``__cause__`` / ``__context__`` for an HTTP status code.
83
+
84
+ Stops on the first integer ``status_code`` attribute — typed
85
+ on ``openai.APIStatusError`` and its anthropic / litellm
86
+ subclasses, so any wrapped provider exception surfaces the
87
+ real status. Returns ``None`` when no link in the chain
88
+ carries one (e.g. plain ``RuntimeError`` from infra code, or
89
+ a stringified error coming back through claude_agent_sdk's
90
+ ``ResultMessage`` which throws away the typed exception).
91
+ """
92
+ seen: set[int] = set()
93
+ current: BaseException | None = exc
94
+ while current is not None and id(current) not in seen:
95
+ seen.add(id(current))
96
+ status = getattr(current, "status_code", None)
97
+ if isinstance(status, int):
98
+ return status
99
+ if current.__cause__ is not None:
100
+ current = current.__cause__
101
+ elif (
102
+ not current.__suppress_context__
103
+ and current.__context__ is not None
104
+ ):
105
+ current = current.__context__
106
+ else:
107
+ current = None
108
+ return None
109
+
110
+
111
+ def _root_cause_class(exc: BaseException) -> str:
112
+ """Return the deepest ``__cause__`` class name, or the immediate one.
113
+
114
+ The harness uses this as a fallback signal for typed
115
+ failures that don't carry a status code — chiefly
116
+ ``BudgetExceededError``, which LiteLLM raises as a plain
117
+ ``Exception`` with no HTTP envelope but is unambiguously
118
+ terminal.
119
+ """
120
+ seen: set[int] = set()
121
+ current: BaseException = exc
122
+ while True:
123
+ seen.add(id(current))
124
+ nxt = current.__cause__
125
+ if nxt is None and not current.__suppress_context__:
126
+ nxt = current.__context__
127
+ if nxt is None or id(nxt) in seen:
128
+ break
129
+ current = nxt
130
+ return type(current).__name__
131
+
132
+
133
+ def _resolve(function: str) -> Any:
134
+ """Resolve a node function name to a callable.
135
+
136
+ Built-in names (``claude_agent``, ``shell``) come from
137
+ ``HANDLERS``. Recipe-defined ``module:function`` refs
138
+ are imported via ``importlib``. ``ImportError`` and
139
+ ``AttributeError`` are re-raised so the caller can
140
+ distinguish "unknown built-in" from "broken import" vs
141
+ "missing function in module" — otherwise typos produce
142
+ a misleading "Unknown function" with no hint where the
143
+ actual failure is.
144
+ """
145
+ if function in HANDLERS:
146
+ return HANDLERS[function]
147
+ if ":" in function:
148
+ mod_path, _, fn_name = function.partition(":")
149
+ mod = importlib.import_module(mod_path)
150
+ try:
151
+ return getattr(mod, fn_name)
152
+ except AttributeError as exc:
153
+ raise AttributeError(
154
+ f"Module {mod_path!r} has no attribute {fn_name!r}"
155
+ ) from exc
156
+ return None
157
+
158
+
159
+ def _register_platform_mcp_servers() -> None:
160
+ """Register platform MCP servers via platform_sdk's import side-effect."""
161
+ try:
162
+ import platform_sdk # noqa: F401
163
+ except ModuleNotFoundError as exc:
164
+ # Only swallow platform_sdk-absent (customer sandbox); a broken
165
+ # install — missing transitive dep — must surface, not be masked.
166
+ if exc.name != "platform_sdk":
167
+ raise
168
+
169
+
170
+ async def execute_node(
171
+ function: str,
172
+ params_path: Path,
173
+ state_path: Path,
174
+ output_path: Path,
175
+ ) -> None:
176
+ """Read inputs, dispatch to handler, write result."""
177
+ try:
178
+ params = json.loads(params_path.read_text())
179
+ # Reducers don't run in the sandbox; rehydrate Message dicts.
180
+ state = from_jsonable_state(json.loads(state_path.read_text()))
181
+ except (OSError, json.JSONDecodeError) as exc:
182
+ result: dict[str, Any] = {
183
+ "_error": f"Failed to read IO files: {exc}",
184
+ }
185
+ output_path.write_text(json.dumps(result))
186
+ return
187
+
188
+ try:
189
+ handler = _resolve(function)
190
+ except (ImportError, AttributeError) as exc:
191
+ result = {
192
+ "_error": f"Cannot resolve {function!r}: {exc}",
193
+ }
194
+ output_path.write_text(json.dumps(result))
195
+ return
196
+
197
+ # Resolve ``@mcp_servers`` names → SDK configs once, in the
198
+ # sandbox process where ``LEVAIN_INTEGRATIONS_B64`` is in scope
199
+ # and stdio platform servers (``knead-review``) can be spawned
200
+ # in-process. Resolution feeds both call paths: built-in
201
+ # ``claude_agent`` reads ``params["mcp_servers"]`` directly,
202
+ # recipe nodes read ``config["configurable"]["_resolved_mcp_servers"]``
203
+ # via ``levain_sdk.claude.resolved_mcp_servers``.
204
+ requested_mcp = params.get("mcp_servers")
205
+ resolved_mcp: dict[str, Any] = {}
206
+ if isinstance(requested_mcp, list) and requested_mcp:
207
+ # Catch everything (not just ValueError): a platform MCP server's
208
+ # init() can raise other types — e.g. knead-review touching git on
209
+ # a not-yet-cloned /workspace. A clean error envelope keeps it from
210
+ # escaping node_runner as a bare "Pod exec exited with 1".
211
+ try:
212
+ _register_platform_mcp_servers()
213
+ resolved_mcp = resolve_mcp_servers(
214
+ [n for n in requested_mcp if isinstance(n, str)],
215
+ repo_root=str(state.get("cwd") or "/workspace"),
216
+ )
217
+ except Exception as exc:
218
+ output_path.write_text(json.dumps(_error_envelope(exc)))
219
+ return
220
+ params["mcp_servers"] = resolved_mcp
221
+ elif isinstance(requested_mcp, dict):
222
+ resolved_mcp = requested_mcp
223
+
224
+ if handler is None:
225
+ result = {
226
+ "_error": f"Unknown function: {function}",
227
+ }
228
+ elif function in HANDLERS:
229
+ # Built-in handler: (params, state) signature.
230
+ try:
231
+ result = await handler(params, state)
232
+ except Exception as exc:
233
+ log.error(
234
+ "Node execution failed: %s",
235
+ exc,
236
+ exc_info=True,
237
+ )
238
+ result = _error_envelope(exc)
239
+ else:
240
+ # Recipe function: (state, config) LangGraph signature.
241
+ # ``params["_configurable"]`` (if present) is the
242
+ # serialized subset of ``RunnableConfig["configurable"]``
243
+ # that the orchestrator forwarded into the container —
244
+ # recipe nodes read values like ``task`` or
245
+ # ``message_hint`` from it via
246
+ # ``config["configurable"]``.
247
+ configurable = dict(params.get("_configurable") or {})
248
+ if resolved_mcp:
249
+ configurable["_resolved_mcp_servers"] = resolved_mcp
250
+ config: dict[str, Any] = {"configurable": configurable}
251
+ try:
252
+ result = handler(state, config)
253
+ if inspect.isawaitable(result):
254
+ result = await result
255
+ except Exception as exc:
256
+ log.error(
257
+ "Node execution failed: %s",
258
+ exc,
259
+ exc_info=True,
260
+ )
261
+ result = _error_envelope(exc)
262
+
263
+ if not isinstance(result, dict):
264
+ result = {
265
+ "_error": (
266
+ f"Node returned {type(result).__name__}, expected dict"
267
+ ),
268
+ }
269
+
270
+ # Recipes return ``Message`` dataclasses (and other typed
271
+ # values like ``datetime``) in state-delta dicts; the default
272
+ # JSON encoder can't serialise those, so convert via
273
+ # ``to_jsonable`` before write. The harness side coerces dict
274
+ # shapes back into ``Message`` via ``add_messages``.
275
+ try:
276
+ payload = json.dumps(to_jsonable(result))
277
+ except (TypeError, ValueError) as exc:
278
+ payload = json.dumps(_error_envelope(exc))
279
+ output_path.write_text(payload)
280
+
281
+
282
+ @app.command()
283
+ def main(
284
+ function: Annotated[str, typer.Option("--function")],
285
+ params: Annotated[Path, typer.Option("--params")],
286
+ state: Annotated[Path, typer.Option("--state")],
287
+ output: Annotated[Path, typer.Option("--output")],
288
+ ) -> None:
289
+ """Execute a single graph node inside a sandbox."""
290
+ # JSON Lines on stdout so the harness consolidator can ingest
291
+ # every record this subprocess emits. Identity (run / session
292
+ # / agent ids) is read from ``LEVAIN_*`` env vars set by the
293
+ # harness's ``_env_prefix`` on the dispatch. DEBUG so the
294
+ # SDK's raw ``sdk_message`` dumps (``log.debug(...)`` in
295
+ # ``levain_sdk.claude``) reach the consolidator — completeness
296
+ # over economy here since one subprocess handles one node.
297
+ run_logging.configure(level=logging.DEBUG)
298
+ run_logging.emit_node_boot("knead.node_runner", function)
299
+ asyncio.run(execute_node(function, params, state, output))
300
+
301
+
302
+ if __name__ == "__main__":
303
+ # Allow running as a plain script (not just typer app)
304
+ app()