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.
- levain_runtime/__init__.py +5 -0
- levain_runtime/__main__.py +3 -0
- levain_runtime/chisel.py +153 -0
- levain_runtime/cli.py +365 -0
- levain_runtime/config.py +117 -0
- levain_runtime/heartbeat.py +47 -0
- levain_runtime/platform_api.py +83 -0
- levain_runtime/process.py +172 -0
- levain_runtime/protocol.py +9 -0
- levain_runtime/sandbox/node_runner.py.vendored +304 -0
- levain_runtime/sandbox/run_logging.py.vendored +153 -0
- levain_runtime/server.py +226 -0
- levain_runtime/sessions.py +721 -0
- levain_runtime-0.1.2.dist-info/METADATA +81 -0
- levain_runtime-0.1.2.dist-info/RECORD +18 -0
- levain_runtime-0.1.2.dist-info/WHEEL +4 -0
- levain_runtime-0.1.2.dist-info/entry_points.txt +2 -0
- levain_runtime-0.1.2.dist-info/licenses/LICENSE +202 -0
|
@@ -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()
|