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,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})
@@ -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