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,153 @@
|
|
|
1
|
+
"""Sandbox-side logging: JSON Lines to stdout for harness ingestion.
|
|
2
|
+
|
|
3
|
+
The harness streams this subprocess's stdout into the per-run log
|
|
4
|
+
file consolidator. Records are JSON Lines (one object per line)
|
|
5
|
+
matching the harness's ``_JsonFormatter`` field shape so the
|
|
6
|
+
consolidator can deserialize without sniffing.
|
|
7
|
+
|
|
8
|
+
Run identity is provided as ``LEVAIN_*`` env vars set by
|
|
9
|
+
``_env_prefix`` on the dispatch — see
|
|
10
|
+
:mod:`levain.agent.harness.run_context`. These values are baked
|
|
11
|
+
into every record so the consolidator can correlate without
|
|
12
|
+
re-parsing the env on each line.
|
|
13
|
+
|
|
14
|
+
This module lives next to ``node_runner.py`` because it ships
|
|
15
|
+
inside the sandbox image and must not import from
|
|
16
|
+
``levain.agent`` (which is not on the sandbox's PYTHONPATH).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
# Mirror of ``levain.agent.harness.run_context.ENV_VAR_NAMES``.
|
|
28
|
+
# Duplicated here because the sandbox image doesn't ship the
|
|
29
|
+
# harness package; any change must touch both copies.
|
|
30
|
+
_ENV_TO_FIELD: dict[str, str] = {
|
|
31
|
+
"LEVAIN_RUN_ID": "run_id",
|
|
32
|
+
"LEVAIN_SESSION_ID": "session_id",
|
|
33
|
+
"LEVAIN_WORKSPACE_ID": "workspace_id",
|
|
34
|
+
"LEVAIN_AGENT_ID": "agent_id",
|
|
35
|
+
"LEVAIN_AGENT_VERSION_ID": "agent_version_id",
|
|
36
|
+
"LEVAIN_TURN_ID": "turn_id",
|
|
37
|
+
"LEVAIN_NODE": "node",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _snapshot_identity() -> dict[str, str]:
|
|
42
|
+
"""Read run-scoped identifiers from env at configure time.
|
|
43
|
+
|
|
44
|
+
A sandbox subprocess handles exactly one node dispatch, so
|
|
45
|
+
these fields are constants for the process lifetime. Empty
|
|
46
|
+
values are dropped to keep records terse when the harness
|
|
47
|
+
didn't supply a particular id (e.g. platform runs without an
|
|
48
|
+
agent).
|
|
49
|
+
"""
|
|
50
|
+
out: dict[str, str] = {}
|
|
51
|
+
for env_name, field in _ENV_TO_FIELD.items():
|
|
52
|
+
value = os.environ.get(env_name)
|
|
53
|
+
if value:
|
|
54
|
+
out[field] = value
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _JsonFormatter(logging.Formatter):
|
|
59
|
+
"""One JSON object per line — same shape as the harness side.
|
|
60
|
+
|
|
61
|
+
Bakes the env-supplied run identity into every record so the
|
|
62
|
+
consolidator can route lines into the right per-run file
|
|
63
|
+
without inspecting subprocess env separately.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, identity: dict[str, str]) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self._identity = identity
|
|
69
|
+
|
|
70
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
71
|
+
entry: dict[str, object] = {
|
|
72
|
+
"ts": record.created,
|
|
73
|
+
"level": record.levelname,
|
|
74
|
+
"logger": record.name,
|
|
75
|
+
"msg": record.getMessage(),
|
|
76
|
+
}
|
|
77
|
+
entry.update(self._identity)
|
|
78
|
+
for key in ("label", "cost_usd", "tokens", "duration_ms", "event"):
|
|
79
|
+
val = getattr(record, key, None)
|
|
80
|
+
if val is not None:
|
|
81
|
+
entry[key] = val
|
|
82
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
83
|
+
entry["exc"] = self.formatException(record.exc_info)
|
|
84
|
+
return json.dumps(entry, default=str)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_NOISY_DEBUG_LOGGERS: tuple[str, ...] = (
|
|
88
|
+
"httpx",
|
|
89
|
+
"httpcore",
|
|
90
|
+
"urllib3",
|
|
91
|
+
"anthropic",
|
|
92
|
+
"openai",
|
|
93
|
+
"asyncio",
|
|
94
|
+
"langchain",
|
|
95
|
+
"langgraph",
|
|
96
|
+
"litellm",
|
|
97
|
+
"boto3",
|
|
98
|
+
"botocore",
|
|
99
|
+
"s3transfer",
|
|
100
|
+
# Content-extraction libs used by the crawler — per-page DEBUG
|
|
101
|
+
# (element discarding, readability scoring) drowns the run log.
|
|
102
|
+
"trafilatura",
|
|
103
|
+
"htmldate",
|
|
104
|
+
"tzlocal",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def configure(level: int = logging.INFO) -> dict[str, str]:
|
|
109
|
+
"""Install the JSON formatter on the root logger.
|
|
110
|
+
|
|
111
|
+
Replaces any existing handlers so ``node_runner.py``'s prior
|
|
112
|
+
plain-text ``basicConfig`` doesn't double-print. Returns the
|
|
113
|
+
identity snapshot so the caller can include it in any
|
|
114
|
+
bootstrap log line.
|
|
115
|
+
|
|
116
|
+
When ``level`` is ``DEBUG`` (the sandbox default), known-noisy
|
|
117
|
+
third-party loggers are clamped to ``INFO`` so transport-layer
|
|
118
|
+
detail from the LLM SDKs doesn't drown out the curated SDK /
|
|
119
|
+
recipe records we actually want in the run log.
|
|
120
|
+
"""
|
|
121
|
+
identity = _snapshot_identity()
|
|
122
|
+
root = logging.getLogger()
|
|
123
|
+
root.setLevel(level)
|
|
124
|
+
# Drop any handlers a prior ``logging.basicConfig`` left
|
|
125
|
+
# behind so we don't emit both plaintext and JSON for the
|
|
126
|
+
# same record.
|
|
127
|
+
for h in root.handlers[:]:
|
|
128
|
+
root.removeHandler(h)
|
|
129
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
130
|
+
handler.setLevel(level)
|
|
131
|
+
handler.setFormatter(_JsonFormatter(identity))
|
|
132
|
+
root.addHandler(handler)
|
|
133
|
+
if level <= logging.DEBUG:
|
|
134
|
+
for name in _NOISY_DEBUG_LOGGERS:
|
|
135
|
+
logging.getLogger(name).setLevel(logging.INFO)
|
|
136
|
+
return identity
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def emit_node_boot(
|
|
140
|
+
logger_name: str,
|
|
141
|
+
function: str,
|
|
142
|
+
extra: dict[str, Any] | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Emit a single ``node_boot`` record summarizing the dispatch.
|
|
145
|
+
|
|
146
|
+
Lets the consolidator anchor the subprocess's activity even
|
|
147
|
+
when the recipe handler logs nothing useful on its own.
|
|
148
|
+
"""
|
|
149
|
+
log = logging.getLogger(logger_name)
|
|
150
|
+
event: dict[str, Any] = {"kind": "node_boot", "function": function}
|
|
151
|
+
if extra:
|
|
152
|
+
event.update(extra)
|
|
153
|
+
log.info("NODE %s starting", function, extra={"event": event})
|
levain_runtime/server.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Loopback HTTP server the platform drives through the reverse tunnel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
14
|
+
from starlette.routing import Mount, Route
|
|
15
|
+
|
|
16
|
+
from .config import RuntimeConfig
|
|
17
|
+
from .sessions import (
|
|
18
|
+
MissingTarget,
|
|
19
|
+
RuntimeBusy,
|
|
20
|
+
SessionManager,
|
|
21
|
+
UnknownSession,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_SPOOL_BYTES = 8 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _manager(request: Request) -> SessionManager:
|
|
28
|
+
manager: SessionManager = request.app.state.manager
|
|
29
|
+
return manager
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sid(request: Request) -> str:
|
|
33
|
+
raw = request.path_params["sid"]
|
|
34
|
+
try:
|
|
35
|
+
return str(uuid.UUID(raw))
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise ValueError("sid must be a UUID") from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _json_body(request: Request) -> dict[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
body = await request.json()
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise ValueError("body must be JSON") from exc
|
|
45
|
+
if not isinstance(body, dict):
|
|
46
|
+
raise ValueError("body must be a JSON object")
|
|
47
|
+
return body
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _env_of(body: dict[str, Any]) -> dict[str, str]:
|
|
51
|
+
env = body.get("env") or {}
|
|
52
|
+
if not isinstance(env, dict) or not all(
|
|
53
|
+
isinstance(k, str) and isinstance(v, str) for k, v in env.items()
|
|
54
|
+
):
|
|
55
|
+
raise ValueError("env must map strings to strings")
|
|
56
|
+
return env
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def health(request: Request) -> Response:
|
|
60
|
+
m = _manager(request)
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
{"ok": True, "mode": m.mode, "active_sessions": m.active_sessions()}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def session(request: Request) -> Response:
|
|
67
|
+
m = _manager(request)
|
|
68
|
+
sid = _sid(request)
|
|
69
|
+
if request.method == "GET":
|
|
70
|
+
return JSONResponse({"status": await m.status(sid)})
|
|
71
|
+
if request.method == "DELETE":
|
|
72
|
+
keep = request.query_params.get("keep_workspace", "0") == "1"
|
|
73
|
+
await m.delete(sid, keep_workspace=keep)
|
|
74
|
+
return Response(status_code=204)
|
|
75
|
+
body = await _json_body(request)
|
|
76
|
+
name, created = await m.provision(
|
|
77
|
+
sid,
|
|
78
|
+
workspace_id=str(body.get("workspace_id") or ""),
|
|
79
|
+
agent_id=body.get("agent_id") or None,
|
|
80
|
+
workspace_scope=body.get("workspace_scope") or None,
|
|
81
|
+
workspace_user=body.get("workspace_user") or None,
|
|
82
|
+
env=_env_of(body),
|
|
83
|
+
)
|
|
84
|
+
return JSONResponse({"name": name, "created": created})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def pause(request: Request) -> Response:
|
|
88
|
+
await _manager(request).pause(_sid(request))
|
|
89
|
+
return Response(status_code=204)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def resume(request: Request) -> Response:
|
|
93
|
+
await _manager(request).resume(_sid(request))
|
|
94
|
+
return Response(status_code=204)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def exec_(request: Request) -> Response:
|
|
98
|
+
m = _manager(request)
|
|
99
|
+
sid = _sid(request)
|
|
100
|
+
body = await _json_body(request)
|
|
101
|
+
cmd = body.get("cmd")
|
|
102
|
+
if not isinstance(cmd, str) or not cmd:
|
|
103
|
+
raise ValueError("cmd is required")
|
|
104
|
+
stdin = body.get("stdin")
|
|
105
|
+
if stdin is not None and not isinstance(stdin, str):
|
|
106
|
+
raise ValueError("stdin must be a string")
|
|
107
|
+
timeout = float(body.get("timeout") or 0)
|
|
108
|
+
stream = m.exec(sid, cmd, _env_of(body), stdin=stdin, timeout=timeout)
|
|
109
|
+
|
|
110
|
+
async def lines() -> AsyncIterator[bytes]:
|
|
111
|
+
async for item in stream:
|
|
112
|
+
yield (json.dumps(item) + "\n").encode()
|
|
113
|
+
|
|
114
|
+
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def capture(request: Request) -> Response:
|
|
118
|
+
m = _manager(request)
|
|
119
|
+
sid = _sid(request)
|
|
120
|
+
body = await _json_body(request)
|
|
121
|
+
cmd = body.get("cmd")
|
|
122
|
+
if not isinstance(cmd, str) or not cmd:
|
|
123
|
+
raise ValueError("cmd is required")
|
|
124
|
+
out, rc = await m.capture(
|
|
125
|
+
sid, cmd, _env_of(body), timeout=float(body.get("timeout") or 0)
|
|
126
|
+
)
|
|
127
|
+
return JSONResponse({"stdout": out, "exit": rc})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _path_param(request: Request) -> str:
|
|
131
|
+
path = request.query_params.get("path")
|
|
132
|
+
if not path:
|
|
133
|
+
raise ValueError("path is required")
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def files(request: Request) -> Response:
|
|
138
|
+
m = _manager(request)
|
|
139
|
+
sid = _sid(request)
|
|
140
|
+
path = _path_param(request)
|
|
141
|
+
if request.method == "GET":
|
|
142
|
+
data = await m.get_file(sid, path)
|
|
143
|
+
if data is None:
|
|
144
|
+
return JSONResponse({"detail": "not found"}, status_code=404)
|
|
145
|
+
return Response(data, media_type="application/octet-stream")
|
|
146
|
+
await m.put_file(sid, path, await request.body())
|
|
147
|
+
return Response(status_code=204)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def tree(request: Request) -> Response:
|
|
151
|
+
m = _manager(request)
|
|
152
|
+
sid = _sid(request)
|
|
153
|
+
path = _path_param(request)
|
|
154
|
+
with tempfile.SpooledTemporaryFile(max_size=_SPOOL_BYTES) as spooled:
|
|
155
|
+
async for chunk in request.stream():
|
|
156
|
+
spooled.write(chunk)
|
|
157
|
+
spooled.seek(0)
|
|
158
|
+
await m.put_tree(sid, path, spooled)
|
|
159
|
+
return Response(status_code=204)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def agent_workspace(request: Request) -> Response:
|
|
163
|
+
m = _manager(request)
|
|
164
|
+
user = request.query_params.get("user") or None
|
|
165
|
+
await m.reset_agent_workspace(request.path_params["agent_id"], user=user)
|
|
166
|
+
return Response(status_code=204)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def link(request: Request) -> Response:
|
|
170
|
+
m = _manager(request)
|
|
171
|
+
sid = _sid(request)
|
|
172
|
+
body = await _json_body(request)
|
|
173
|
+
path, target = body.get("path"), body.get("target")
|
|
174
|
+
if not isinstance(path, str) or not isinstance(target, str):
|
|
175
|
+
raise ValueError("path and target are required")
|
|
176
|
+
await m.link(sid, path, target)
|
|
177
|
+
return Response(status_code=204)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _error(status: int) -> Any:
|
|
181
|
+
async def handler(request: Request, exc: Exception) -> Response:
|
|
182
|
+
return JSONResponse({"detail": str(exc)}, status_code=status)
|
|
183
|
+
|
|
184
|
+
return handler
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def create_app(config: RuntimeConfig) -> Starlette:
|
|
188
|
+
app = Starlette(
|
|
189
|
+
routes=[
|
|
190
|
+
Mount(
|
|
191
|
+
"/v1",
|
|
192
|
+
routes=[
|
|
193
|
+
Route("/health", health, methods=["GET"]),
|
|
194
|
+
Route(
|
|
195
|
+
"/sessions/{sid}",
|
|
196
|
+
session,
|
|
197
|
+
methods=["POST", "GET", "DELETE"],
|
|
198
|
+
),
|
|
199
|
+
Route("/sessions/{sid}/pause", pause, methods=["POST"]),
|
|
200
|
+
Route("/sessions/{sid}/resume", resume, methods=["POST"]),
|
|
201
|
+
Route("/sessions/{sid}/exec", exec_, methods=["POST"]),
|
|
202
|
+
Route(
|
|
203
|
+
"/sessions/{sid}/capture", capture, methods=["POST"]
|
|
204
|
+
),
|
|
205
|
+
Route(
|
|
206
|
+
"/sessions/{sid}/files", files, methods=["PUT", "GET"]
|
|
207
|
+
),
|
|
208
|
+
Route("/sessions/{sid}/tree", tree, methods=["PUT"]),
|
|
209
|
+
Route("/sessions/{sid}/link", link, methods=["PUT"]),
|
|
210
|
+
Route(
|
|
211
|
+
"/agents/{agent_id}/workspace",
|
|
212
|
+
agent_workspace,
|
|
213
|
+
methods=["DELETE"],
|
|
214
|
+
),
|
|
215
|
+
],
|
|
216
|
+
)
|
|
217
|
+
],
|
|
218
|
+
exception_handlers={
|
|
219
|
+
ValueError: _error(400),
|
|
220
|
+
UnknownSession: _error(404),
|
|
221
|
+
MissingTarget: _error(404),
|
|
222
|
+
RuntimeBusy: _error(409),
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
app.state.manager = SessionManager(config)
|
|
226
|
+
return app
|