@hicaru/pi-rlm 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/README.ru.md +200 -0
- package/README.zh-CN.md +224 -0
- package/package.json +54 -0
- package/src/bridge/fallback-todo.ts +137 -0
- package/src/bridge/interactive.ts +65 -0
- package/src/bridge/llm-query.ts +124 -0
- package/src/bridge/model.ts +97 -0
- package/src/bridge/pi-interactive.ts +86 -0
- package/src/bridge/rlm-query.ts +78 -0
- package/src/commands/rlm-config.ts +42 -0
- package/src/commands/rlm.ts +165 -0
- package/src/config/defaults.ts +38 -0
- package/src/config/settings.ts +185 -0
- package/src/context/repomix-context.ts +253 -0
- package/src/core/answer.ts +97 -0
- package/src/core/compaction.ts +64 -0
- package/src/core/engine.ts +408 -0
- package/src/core/history.ts +13 -0
- package/src/core/iteration.ts +45 -0
- package/src/core/limits.ts +90 -0
- package/src/core/pipeline.ts +100 -0
- package/src/core/resource-limits.ts +14 -0
- package/src/core/types.ts +131 -0
- package/src/index.ts +165 -0
- package/src/mode/input-router.ts +23 -0
- package/src/mode/rlm-mode.ts +149 -0
- package/src/patch/apply.ts +148 -0
- package/src/patch/index.ts +37 -0
- package/src/prompts/system.ts +278 -0
- package/src/prompts/user.ts +21 -0
- package/src/sandbox/protocol.ts +191 -0
- package/src/sandbox/sandbox-manager.ts +143 -0
- package/src/sandbox/sandbox.ts +362 -0
- package/src/sandbox/worker.py +457 -0
- package/src/state/events.ts +22 -0
- package/src/state/index.ts +23 -0
- package/src/state/internal.ts +46 -0
- package/src/state/paths.ts +42 -0
- package/src/state/reads.ts +96 -0
- package/src/state/resume.ts +154 -0
- package/src/state/rows.ts +117 -0
- package/src/state/writes.ts +56 -0
- package/src/telemetry/dispatcher.ts +116 -0
- package/src/telemetry/index.ts +14 -0
- package/src/telemetry/mlflow-config.ts +15 -0
- package/src/telemetry/mlflow-sink.ts +136 -0
- package/src/telemetry/mlflow.ts +99 -0
- package/src/telemetry/sink.ts +8 -0
- package/src/text/edits.ts +16 -0
- package/src/text/parsing.ts +35 -0
- package/src/text/preview.ts +18 -0
- package/src/text/tokens.ts +64 -0
- package/src/tool/apply-diff-tool.ts +125 -0
- package/src/tool/emitter-listener.ts +24 -0
- package/src/tool/repl-details.ts +23 -0
- package/src/tool/repl-tool.ts +528 -0
- package/src/tool/rlm-aggregator.ts +115 -0
- package/src/tool/rlm-details.ts +53 -0
- package/src/tool/rlm-events.ts +215 -0
- package/src/tool/rlm-tool.ts +199 -0
- package/src/tool/subcall-render.ts +129 -0
- package/src/tool/subcall-store.ts +90 -0
- package/src/tool/tool-utils.ts +73 -0
- package/src/ui/config-panel.ts +92 -0
- package/src/ui/intro.ts +23 -0
- package/src/ui/model-picker.ts +139 -0
- package/src/ui/status.ts +26 -0
- package/src/ui/theme.ts +47 -0
- package/src/util/concurrency.ts +15 -0
- package/src/util/errors.ts +27 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""RLM sandbox worker: a persistent Python REPL driven over a JSONL stdio protocol.
|
|
2
|
+
|
|
3
|
+
Executes model-authored Python with secrets stripped from the environment.
|
|
4
|
+
This is NOT a security sandbox: __import__ and open are available, so code can import networking modules
|
|
5
|
+
(socket, urllib, subprocess) and read/write local files. Trust the root model's code.
|
|
6
|
+
|
|
7
|
+
Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
|
|
8
|
+
Protocol (worker -> parent): {"id","ok",...result} # response to a request
|
|
9
|
+
{"type":"llm_query"|"llm_query_batched"|"rlm_query"|...
|
|
10
|
+
"advance_phase"|"ask_user_question"|"todo","rid",...}
|
|
11
|
+
# mid-exec helper request
|
|
12
|
+
When sandbox code calls llm_query/rlm_query/advance_phase/ask_user_question/todo, the worker writes a request line
|
|
13
|
+
and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
|
|
14
|
+
services the request in-process (it holds API keys).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import pickle
|
|
24
|
+
import signal
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import traceback
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# Capture the REAL stdout/stdin before exec() redirects sys.stdout into a buffer.
|
|
32
|
+
# All protocol writes must go to the real stdout even while user code's prints are captured.
|
|
33
|
+
_REAL_STDOUT = sys.stdout
|
|
34
|
+
_REAL_STDIN = sys.stdin
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _builtin(name: str):
|
|
38
|
+
return __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name, None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Restricted builtins: enough for real data work, minus the dangerous reflection escapes.
|
|
42
|
+
_SAFE_BUILTINS = {
|
|
43
|
+
name: _builtin(name)
|
|
44
|
+
for name in (
|
|
45
|
+
"abs", "all", "any", "ascii", "bin", "bool", "bytearray", "bytes", "callable",
|
|
46
|
+
"chr", "classmethod", "complex", "dict", "dir", "divmod", "enumerate", "filter",
|
|
47
|
+
"float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int",
|
|
48
|
+
"isinstance", "issubclass", "iter", "len", "list", "map", "max", "min", "next",
|
|
49
|
+
"object", "oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
|
|
50
|
+
"round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super",
|
|
51
|
+
"tuple", "type", "vars", "zip", "delattr", "memoryview", "__import__", "__build_class__",
|
|
52
|
+
"Exception", "BaseException", "ValueError", "TypeError", "KeyError", "IndexError",
|
|
53
|
+
"AttributeError", "FileNotFoundError", "OSError", "IOError", "RuntimeError",
|
|
54
|
+
"NameError", "ImportError", "StopIteration", "AssertionError", "NotImplementedError",
|
|
55
|
+
"ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
# `open` is allowed (data work needs files); eval/exec/compile/input/globals/locals are not.
|
|
59
|
+
_SAFE_BUILTINS["open"] = open
|
|
60
|
+
for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
61
|
+
_SAFE_BUILTINS[_blocked] = None
|
|
62
|
+
|
|
63
|
+
RESERVED = frozenset(
|
|
64
|
+
{
|
|
65
|
+
"llm_query", "llm_query_batched", "rlm_query", "rlm_query_batched",
|
|
66
|
+
"advance_phase",
|
|
67
|
+
"ask_user_question", "todo",
|
|
68
|
+
"SHOW_EDITS", "SHOW_DIFFS", "SHOW_VARS", "answer", "context",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _AnswerDict(dict):
|
|
74
|
+
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, on_ready):
|
|
77
|
+
super().__init__()
|
|
78
|
+
super().__setitem__("content", "")
|
|
79
|
+
super().__setitem__("ready", False)
|
|
80
|
+
self._on_ready = on_ready
|
|
81
|
+
|
|
82
|
+
def __setitem__(self, key, value):
|
|
83
|
+
super().__setitem__(key, value)
|
|
84
|
+
if key == "ready" and value:
|
|
85
|
+
self._on_ready(self.get("content", ""))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _send(obj: dict[str, Any]) -> None:
|
|
89
|
+
_REAL_STDOUT.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
90
|
+
_REAL_STDOUT.flush()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Worker:
|
|
94
|
+
def __init__(self, depth: int, exec_timeout_s: float):
|
|
95
|
+
self.depth = depth
|
|
96
|
+
self.exec_timeout_s = exec_timeout_s
|
|
97
|
+
self._rid = 0
|
|
98
|
+
self._final_answer: str | None = None
|
|
99
|
+
self._context_count = 0
|
|
100
|
+
self.ns: dict[str, Any] = {}
|
|
101
|
+
self._setup()
|
|
102
|
+
|
|
103
|
+
def _setup(self) -> None:
|
|
104
|
+
self.ns = {"__builtins__": _SAFE_BUILTINS.copy(), "__name__": "__main__"}
|
|
105
|
+
self._ctx_payloads: dict[int, Any] = {}
|
|
106
|
+
self._restore_scaffold()
|
|
107
|
+
|
|
108
|
+
def _capture_answer(self, content: Any) -> None:
|
|
109
|
+
self._final_answer = str(content)
|
|
110
|
+
|
|
111
|
+
def _restore_scaffold(self) -> None:
|
|
112
|
+
# Re-inject any scaffolding the user code clobbered.
|
|
113
|
+
ns = self.ns
|
|
114
|
+
ns["llm_query"] = self._llm_query
|
|
115
|
+
ns["llm_query_batched"] = self._llm_query_batched
|
|
116
|
+
ns["rlm_query"] = self._rlm_query
|
|
117
|
+
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
118
|
+
ns["advance_phase"] = self._advance_phase
|
|
119
|
+
ns["ask_user_question"] = self._ask_user_question
|
|
120
|
+
ns["todo"] = self._todo
|
|
121
|
+
ns["SHOW_EDITS"] = self._show_edits
|
|
122
|
+
ns["SHOW_DIFFS"] = self._show_diffs
|
|
123
|
+
ns["SHOW_VARS"] = self._show_vars
|
|
124
|
+
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
125
|
+
cur = ns.get("answer")
|
|
126
|
+
ans = _AnswerDict(self._capture_answer)
|
|
127
|
+
if isinstance(cur, dict):
|
|
128
|
+
for k, v in cur.items():
|
|
129
|
+
dict.__setitem__(ans, k, v)
|
|
130
|
+
if cur.get("ready") and self._final_answer is None:
|
|
131
|
+
self._final_answer = str(cur.get("content", ""))
|
|
132
|
+
ns["answer"] = ans
|
|
133
|
+
# Restore context slots from immutable originals so REPL mutations don't persist.
|
|
134
|
+
for idx, payload in self._ctx_payloads.items():
|
|
135
|
+
ns[f"context_{idx}"] = payload
|
|
136
|
+
if 0 in self._ctx_payloads:
|
|
137
|
+
ns["context"] = self._ctx_payloads[0]
|
|
138
|
+
|
|
139
|
+
def _user_var_names(self) -> list[str]:
|
|
140
|
+
"""User-created variable names — filters builtins, scaffold, and context slots.
|
|
141
|
+
|
|
142
|
+
Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
|
|
143
|
+
This is the cheap orientation hint that goes into history instead of full stdout.
|
|
144
|
+
"""
|
|
145
|
+
return [
|
|
146
|
+
k for k in self.ns
|
|
147
|
+
if not k.startswith("_")
|
|
148
|
+
and not k.startswith("context_")
|
|
149
|
+
and k not in RESERVED
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
def _show_vars(self) -> str:
|
|
153
|
+
avail = {k: type(self.ns[k]).__name__ for k in self._user_var_names()}
|
|
154
|
+
return f"Available variables: {avail}" if avail else "No variables created yet."
|
|
155
|
+
|
|
156
|
+
def _show_edits(self) -> str:
|
|
157
|
+
return "No edits — edit tools are not available in this run."
|
|
158
|
+
|
|
159
|
+
def _show_diffs(self) -> str:
|
|
160
|
+
return "No diffs — edit tools are not available in this run."
|
|
161
|
+
|
|
162
|
+
# ---- sub-LLM bridge over stdio --------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
165
|
+
self._rid += 1
|
|
166
|
+
rid = f"q{self._rid}"
|
|
167
|
+
_send({"type": kind, "rid": rid, "depth": self.depth, **payload})
|
|
168
|
+
# The per-cell SIGALRM is wall-clock; it must not count time blocked here waiting for
|
|
169
|
+
# a sub-LLM reply (network/LLM latency, not local CPU). Pause it across the readline.
|
|
170
|
+
pause = self.exec_timeout_s > 0 and hasattr(signal, "SIGALRM")
|
|
171
|
+
if pause:
|
|
172
|
+
remaining = signal.getitimer(signal.ITIMER_REAL)[0]
|
|
173
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
174
|
+
try:
|
|
175
|
+
while True:
|
|
176
|
+
line = _REAL_STDIN.readline()
|
|
177
|
+
if not line:
|
|
178
|
+
raise RuntimeError("parent closed the pipe during a sub-LLM request")
|
|
179
|
+
msg = json.loads(line)
|
|
180
|
+
if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
|
|
181
|
+
return msg
|
|
182
|
+
# The parent only ever sends our reply mid-exec; anything else is a protocol error.
|
|
183
|
+
raise RuntimeError(f"unexpected parent message during sub-LLM request: {msg!r}")
|
|
184
|
+
finally:
|
|
185
|
+
if pause and remaining > 0:
|
|
186
|
+
signal.setitimer(signal.ITIMER_REAL, remaining)
|
|
187
|
+
|
|
188
|
+
def _llm_query(self, prompt: str, model: str | None = None) -> str:
|
|
189
|
+
r = self._rpc("llm_query", {"prompt": str(prompt), "model": model})
|
|
190
|
+
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
191
|
+
|
|
192
|
+
def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
193
|
+
prompts = [str(p) for p in prompts]
|
|
194
|
+
if not prompts:
|
|
195
|
+
return []
|
|
196
|
+
r = self._rpc("llm_query_batched", {"prompts": prompts, "model": model})
|
|
197
|
+
if r.get("error"):
|
|
198
|
+
return [f"Error: {r['error']}"] * len(prompts)
|
|
199
|
+
out = r.get("responses")
|
|
200
|
+
if not isinstance(out, list) or len(out) != len(prompts):
|
|
201
|
+
return ["Error: malformed batched response"] * len(prompts)
|
|
202
|
+
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
203
|
+
|
|
204
|
+
def _rlm_query(self, prompt: str, model: str | None = None) -> str:
|
|
205
|
+
r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
|
|
206
|
+
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
207
|
+
|
|
208
|
+
def _ask_user_question(self, questions: list[dict]) -> list[dict]:
|
|
209
|
+
"""Present structured questions to the user; blocks until answered.
|
|
210
|
+
|
|
211
|
+
Returns a list of {question, selected, custom?} dicts.
|
|
212
|
+
Each dict has: question (str), selected (list[str]), custom (str|None).
|
|
213
|
+
Only valid at root depth; sub-RLM calls return an error answer.
|
|
214
|
+
"""
|
|
215
|
+
if self.depth > 0:
|
|
216
|
+
qlist = questions if isinstance(questions, list) else []
|
|
217
|
+
return [
|
|
218
|
+
{"question": str(q.get("question", "")) if isinstance(q, dict) else "",
|
|
219
|
+
"selected": [],
|
|
220
|
+
"custom": "Error: ask_user_question not available inside rlm_query sub-calls"}
|
|
221
|
+
for q in qlist
|
|
222
|
+
] or [{"question": "", "selected": [],
|
|
223
|
+
"custom": "Error: ask_user_question not available inside rlm_query sub-calls"}]
|
|
224
|
+
if not isinstance(questions, list) or not questions:
|
|
225
|
+
return [{"question": "", "selected": [], "custom": "Error: questions must be a non-empty list"}]
|
|
226
|
+
cleaned = []
|
|
227
|
+
for q in questions:
|
|
228
|
+
if not isinstance(q, dict) or "question" not in q or "options" not in q:
|
|
229
|
+
return [{"question": "", "selected": [], "custom": "Error: each question needs 'question', 'header', 'options'"}]
|
|
230
|
+
opts = q.get("options")
|
|
231
|
+
if not isinstance(opts, list):
|
|
232
|
+
return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: options must be a list"}]
|
|
233
|
+
cleaned_opts = []
|
|
234
|
+
for o in opts:
|
|
235
|
+
if not isinstance(o, dict) or "label" not in o:
|
|
236
|
+
return [{"question": str(q.get("question", "")), "selected": [], "custom": "Error: each option needs 'label'"}]
|
|
237
|
+
item = {"label": str(o["label"]), "description": str(o.get("description", ""))}
|
|
238
|
+
if "preview" in o:
|
|
239
|
+
item["preview"] = str(o["preview"])
|
|
240
|
+
cleaned_opts.append(item)
|
|
241
|
+
cleaned.append({
|
|
242
|
+
"question": str(q["question"]),
|
|
243
|
+
"header": str(q.get("header", "Q")),
|
|
244
|
+
"multiSelect": bool(q.get("multiSelect", False)),
|
|
245
|
+
"options": cleaned_opts,
|
|
246
|
+
})
|
|
247
|
+
r = self._rpc("ask_user_question", {"questions": cleaned})
|
|
248
|
+
if r.get("error"):
|
|
249
|
+
return [{"question": q["question"], "selected": [], "custom": f"Error: {r['error']}"} for q in cleaned]
|
|
250
|
+
answers = r.get("answers", [])
|
|
251
|
+
return answers if isinstance(answers, list) else []
|
|
252
|
+
|
|
253
|
+
def _todo(self, action: str, **kwargs) -> str:
|
|
254
|
+
"""Manage the run's task list.
|
|
255
|
+
|
|
256
|
+
action: "create" | "update" | "list" | "get" | "delete" | "clear"
|
|
257
|
+
kwargs: id, subject, description, status, activeForm, blockedBy, owner, filterStatus
|
|
258
|
+
Returns a human-readable string result.
|
|
259
|
+
"""
|
|
260
|
+
params = {k: v for k, v in kwargs.items() if v is not None}
|
|
261
|
+
r = self._rpc("todo", {"action": str(action), **params})
|
|
262
|
+
if r.get("error"):
|
|
263
|
+
return f"Error: {r['error']}"
|
|
264
|
+
return str(r.get("response", "ok"))
|
|
265
|
+
|
|
266
|
+
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
267
|
+
"""Transition the root RLM pipeline to a new phase.
|
|
268
|
+
|
|
269
|
+
Only callable at depth 0. The parent handler validates the transition
|
|
270
|
+
against the phase state machine (research → blueprint → implement → validate).
|
|
271
|
+
Returns a short confirmation, or an `Error: …` string the model can act on.
|
|
272
|
+
"""
|
|
273
|
+
if self.depth > 0:
|
|
274
|
+
return "Error: advance_phase is only available at the root RLM depth"
|
|
275
|
+
r = self._rpc("advance_phase", {"phase": str(phase), "summary": summary})
|
|
276
|
+
if r.get("error"):
|
|
277
|
+
return f"Error: {r['error']}"
|
|
278
|
+
response = r.get("response", "ok")
|
|
279
|
+
if isinstance(response, str) and response.startswith("Error:"):
|
|
280
|
+
return response
|
|
281
|
+
return response if isinstance(response, str) else "ok"
|
|
282
|
+
|
|
283
|
+
def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
284
|
+
prompts = [str(p) for p in prompts]
|
|
285
|
+
if not prompts:
|
|
286
|
+
return []
|
|
287
|
+
r = self._rpc("rlm_query_batched", {"prompts": prompts, "model": model})
|
|
288
|
+
if r.get("error"):
|
|
289
|
+
return [f"Error: {r['error']}"] * len(prompts)
|
|
290
|
+
out = r.get("responses")
|
|
291
|
+
if not isinstance(out, list) or len(out) != len(prompts):
|
|
292
|
+
return ["Error: malformed batched response"] * len(prompts)
|
|
293
|
+
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
294
|
+
|
|
295
|
+
# ---- context + execution --------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
|
|
298
|
+
if index is None:
|
|
299
|
+
index = self._context_count
|
|
300
|
+
with open(path, "r") as f:
|
|
301
|
+
payload = json.load(f) if is_json else f.read()
|
|
302
|
+
self._ctx_payloads[index] = payload
|
|
303
|
+
self.ns[f"context_{index}"] = payload
|
|
304
|
+
if index == 0:
|
|
305
|
+
self.ns["context"] = payload
|
|
306
|
+
self._context_count = max(self._context_count, index + 1)
|
|
307
|
+
return index
|
|
308
|
+
|
|
309
|
+
@contextmanager
|
|
310
|
+
def _capture(self):
|
|
311
|
+
out, err = io.StringIO(), io.StringIO()
|
|
312
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
313
|
+
sys.stdout, sys.stderr = out, err
|
|
314
|
+
try:
|
|
315
|
+
yield out, err
|
|
316
|
+
finally:
|
|
317
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
318
|
+
|
|
319
|
+
def _exec(self, code: str, ns: dict[str, Any]) -> None:
|
|
320
|
+
t = self.exec_timeout_s
|
|
321
|
+
if t <= 0 or not hasattr(signal, "SIGALRM"):
|
|
322
|
+
exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
def _alarm(signum, frame): # noqa: ARG001
|
|
326
|
+
raise TimeoutError(f"```repl``` block exceeded {t:g}s timeout")
|
|
327
|
+
|
|
328
|
+
old = signal.signal(signal.SIGALRM, _alarm)
|
|
329
|
+
signal.setitimer(signal.ITIMER_REAL, t)
|
|
330
|
+
try:
|
|
331
|
+
exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
332
|
+
finally:
|
|
333
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
334
|
+
signal.signal(signal.SIGALRM, old)
|
|
335
|
+
|
|
336
|
+
def execute(self, code: str) -> dict[str, Any]:
|
|
337
|
+
start = time.perf_counter()
|
|
338
|
+
raised = False
|
|
339
|
+
with self._capture() as (out, err):
|
|
340
|
+
try:
|
|
341
|
+
self._restore_scaffold()
|
|
342
|
+
self._exec(code, self.ns)
|
|
343
|
+
self._restore_scaffold()
|
|
344
|
+
stdout, stderr = out.getvalue(), err.getvalue()
|
|
345
|
+
except BaseException as e: # noqa: BLE001
|
|
346
|
+
raised = True
|
|
347
|
+
self._restore_scaffold()
|
|
348
|
+
stdout = out.getvalue()
|
|
349
|
+
stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
|
|
350
|
+
final, self._final_answer = self._final_answer, None
|
|
351
|
+
answer = self.ns.get("answer")
|
|
352
|
+
answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
|
|
353
|
+
return {
|
|
354
|
+
"stdout": stdout,
|
|
355
|
+
"stderr": stderr,
|
|
356
|
+
"final_answer": final,
|
|
357
|
+
"answer_content": str(answer_content),
|
|
358
|
+
"edits": [],
|
|
359
|
+
"diffs": [],
|
|
360
|
+
"raised": raised,
|
|
361
|
+
"execution_time": time.perf_counter() - start,
|
|
362
|
+
"var_names": self._user_var_names(),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
def _serializer(self):
|
|
366
|
+
try:
|
|
367
|
+
import dill as s
|
|
368
|
+
return s
|
|
369
|
+
except ImportError:
|
|
370
|
+
return pickle
|
|
371
|
+
|
|
372
|
+
def snapshot(self, path: str, nonce: str) -> dict:
|
|
373
|
+
"""Pickle user variables atomically to path. Stores session nonce for restore verification.
|
|
374
|
+
|
|
375
|
+
Writes to path.tmp then os.rename — atomic on POSIX, so no .tmp leak and no
|
|
376
|
+
TypeScript-side finalize step needed. On resume (fresh session = different nonce),
|
|
377
|
+
restore fails — caller falls back to history-only replay.
|
|
378
|
+
"""
|
|
379
|
+
s = self._serializer()
|
|
380
|
+
out, skipped = {}, []
|
|
381
|
+
MAX_VAR_BYTES = 50 * 1024 * 1024
|
|
382
|
+
for k, v in self.ns.items():
|
|
383
|
+
if k.startswith("_") or k.startswith("context") or k in RESERVED or k == "__builtins__":
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
blob = s.dumps(v)
|
|
387
|
+
if len(blob) > MAX_VAR_BYTES:
|
|
388
|
+
skipped.append(k)
|
|
389
|
+
continue
|
|
390
|
+
out[k] = v
|
|
391
|
+
except Exception:
|
|
392
|
+
skipped.append(k)
|
|
393
|
+
if skipped:
|
|
394
|
+
print(f"[rlm-sandbox] snapshot skipped {len(skipped)} unpicklable/oversized vars: {skipped}", file=sys.stderr)
|
|
395
|
+
tmp = path + ".tmp"
|
|
396
|
+
with open(tmp, "wb") as f:
|
|
397
|
+
s.dump({"nonce": nonce, "vars": out}, f)
|
|
398
|
+
os.rename(tmp, path) # atomic rename
|
|
399
|
+
return {"skipped": skipped}
|
|
400
|
+
|
|
401
|
+
def restore(self, path: str, nonce: str) -> dict:
|
|
402
|
+
"""Restore user variables from a pickle file. Verifies session nonce before deserializing.
|
|
403
|
+
|
|
404
|
+
SECURITY: pickle.load executes arbitrary code. The session nonce check ensures the
|
|
405
|
+
.pkl was written by THIS engine session. Cross-session resume falls back to
|
|
406
|
+
history-only replay (caller skips restore when sessionNonce is undefined).
|
|
407
|
+
"""
|
|
408
|
+
s = self._serializer()
|
|
409
|
+
with open(path, "rb") as f:
|
|
410
|
+
data = s.load(f)
|
|
411
|
+
if not isinstance(data, dict) or data.get("nonce") != nonce:
|
|
412
|
+
raise ValueError("snapshot nonce mismatch — not from this session")
|
|
413
|
+
self.ns.update(data.get("vars", {}))
|
|
414
|
+
self._restore_scaffold()
|
|
415
|
+
return {"restored": list(data.get("vars", {}).keys())}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def main() -> None:
|
|
419
|
+
ap = argparse.ArgumentParser()
|
|
420
|
+
ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
|
|
421
|
+
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
422
|
+
args = ap.parse_args()
|
|
423
|
+
|
|
424
|
+
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout)
|
|
425
|
+
_send({"id": "_init", "ok": True})
|
|
426
|
+
|
|
427
|
+
for raw in _REAL_STDIN:
|
|
428
|
+
raw = raw.strip()
|
|
429
|
+
if not raw:
|
|
430
|
+
continue
|
|
431
|
+
try:
|
|
432
|
+
req = json.loads(raw)
|
|
433
|
+
except json.JSONDecodeError as e:
|
|
434
|
+
_send({"id": "?", "ok": False, "error": f"bad json: {e}"})
|
|
435
|
+
continue
|
|
436
|
+
rid, kind = req.get("id", "?"), req.get("type")
|
|
437
|
+
try:
|
|
438
|
+
if kind == "exec":
|
|
439
|
+
_send({"id": rid, "ok": True, **worker.execute(req.get("code", ""))})
|
|
440
|
+
elif kind == "load_context":
|
|
441
|
+
idx = worker.load_context(req.get("path"), req.get("index"), req.get("json"))
|
|
442
|
+
_send({"id": rid, "ok": True, "index": idx})
|
|
443
|
+
elif kind == "shutdown":
|
|
444
|
+
_send({"id": rid, "ok": True})
|
|
445
|
+
return
|
|
446
|
+
elif kind == "snapshot":
|
|
447
|
+
_send({"id": rid, "ok": True, **worker.snapshot(req.get("path", ""), req.get("nonce", ""))})
|
|
448
|
+
elif kind == "restore":
|
|
449
|
+
_send({"id": rid, "ok": True, **worker.restore(req.get("path", ""), req.get("nonce", ""))})
|
|
450
|
+
else:
|
|
451
|
+
_send({"id": rid, "ok": False, "error": f"unknown type: {kind!r}"})
|
|
452
|
+
except BaseException as e: # noqa: BLE001
|
|
453
|
+
_send({"id": rid, "ok": False, "error": f"{type(e).__name__}: {e}\n{traceback.format_exc()}"})
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubcallStart — parameter object carried forward for telemetry compatibility.
|
|
3
|
+
*
|
|
4
|
+
* The SubcallObserver interface, treeObserver(), observerWith(), and NOOP_OBSERVER
|
|
5
|
+
* have been removed. The engine and bridges now call RlmToolBridge directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SubcallKind } from "../tool/rlm-details.ts";
|
|
9
|
+
|
|
10
|
+
export interface SubcallStart {
|
|
11
|
+
readonly kind: SubcallKind;
|
|
12
|
+
readonly depth: number;
|
|
13
|
+
readonly parentId?: string;
|
|
14
|
+
readonly model?: string;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly detail?: string;
|
|
17
|
+
readonly args?: string;
|
|
18
|
+
/** Run ID for the root node — lets MLflow correlate a resumed trace with the original. */
|
|
19
|
+
readonly runId?: string;
|
|
20
|
+
/** True when this is a resumed root node (not a fresh start). */
|
|
21
|
+
readonly resume?: boolean;
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel for the RLM run-state module.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports every public symbol so `core/engine.ts` and `mode/rlm-mode.ts`
|
|
5
|
+
* import from one door. Type-only re-exports use `export type`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { generateRunId, runsDir, runDir, trailPath, contextPath, snapshotPath } from "./paths.ts";
|
|
9
|
+
export type {
|
|
10
|
+
UsageRow,
|
|
11
|
+
RunHeader,
|
|
12
|
+
TurnRow,
|
|
13
|
+
CompactionRow,
|
|
14
|
+
TerminalRow,
|
|
15
|
+
TodoRow,
|
|
16
|
+
PhaseRow,
|
|
17
|
+
Row,
|
|
18
|
+
} from "./rows.ts";
|
|
19
|
+
export { STATE_SCHEMA_VERSION, isHeader, isTurn, isCompaction, isPhase, isTodo, isTerminal, isRow } from "./rows.ts";
|
|
20
|
+
export { appendRow, appendTodoRow, pruneRuns, writeContextSidecar } from "./writes.ts";
|
|
21
|
+
export { readRows, readHeader, readContextSidecar, listRunIds, resolveRunId } from "./reads.ts";
|
|
22
|
+
export { reconstructRlmState } from "./resume.ts";
|
|
23
|
+
export type { PhaseRecon, ReconstructResult } from "./resume.ts";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Internal helpers shared across the RLM run-state module. */
|
|
2
|
+
|
|
3
|
+
import { access, readdir } from "node:fs/promises";
|
|
4
|
+
import { errorMessage } from "../util/errors.ts";
|
|
5
|
+
|
|
6
|
+
export { errorMessage } from "../util/errors.ts";
|
|
7
|
+
|
|
8
|
+
export interface FailSoftOptions {
|
|
9
|
+
readonly label?: string;
|
|
10
|
+
readonly warn?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_FAIL_SOFT_OPTIONS = Object.freeze({});
|
|
14
|
+
|
|
15
|
+
export const warn = (e: unknown): void => console.warn(`[rlm-state] ${errorMessage(e)}`);
|
|
16
|
+
|
|
17
|
+
export async function failSoft<T>(
|
|
18
|
+
fn: () => Promise<T>,
|
|
19
|
+
fallback: T,
|
|
20
|
+
options: FailSoftOptions = DEFAULT_FAIL_SOFT_OPTIONS,
|
|
21
|
+
): Promise<T> {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (options.warn !== false) warn(options.label ? `${options.label}: ${errorMessage(e)}` : e);
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function listDirectoriesSorted(root: string): Promise<string[]> {
|
|
31
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
32
|
+
return entries
|
|
33
|
+
.filter((entry) => entry.isDirectory())
|
|
34
|
+
.map((entry) => entry.name)
|
|
35
|
+
.sort()
|
|
36
|
+
.reverse();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
await access(path);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure path/id helpers for the RLM run-state module.
|
|
3
|
+
*
|
|
4
|
+
* Run-IDs are filename-sortable ISO-like slugs with a random hex suffix
|
|
5
|
+
* for sub-second collision safety. All helpers are pure — no I/O.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { isAbsolute, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const RUN_ID_SUFFIX_BYTES = 2;
|
|
12
|
+
const ISO_DATETIME_LENGTH = 19;
|
|
13
|
+
|
|
14
|
+
/** `YYYY-MM-DD_HH-MM-SS-<4hex>` — filename-sortable, sub-second collision-safe.
|
|
15
|
+
*
|
|
16
|
+
* Prune ordering in writes.ts:pruneRuns depends on the ISO-slug format producing
|
|
17
|
+
* chronologically sortable strings. If the format changes, update pruning logic
|
|
18
|
+
* to maintain oldest-first deletion. */
|
|
19
|
+
export function generateRunId(
|
|
20
|
+
now: Date = new Date(),
|
|
21
|
+
suffix: string = randomBytes(RUN_ID_SUFFIX_BYTES).toString("hex"),
|
|
22
|
+
): string {
|
|
23
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
24
|
+
const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
25
|
+
return `${iso.slice(0, ISO_DATETIME_LENGTH).replaceAll(":", "-").replace("T", "_")}-${suffix}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const runsDir = (cwd: string, dir: string): string =>
|
|
29
|
+
isAbsolute(dir) ? dir : join(cwd, dir);
|
|
30
|
+
|
|
31
|
+
export const runDir = (cwd: string, dir: string, runId: string): string => join(runsDir(cwd, dir), runId);
|
|
32
|
+
|
|
33
|
+
export const trailPath = (cwd: string, dir: string, runId: string): string => join(runDir(cwd, dir, runId), "trail.jsonl");
|
|
34
|
+
|
|
35
|
+
export const contextPath = (cwd: string, dir: string, runId: string, json: boolean): string =>
|
|
36
|
+
join(runDir(cwd, dir, runId), json ? "context.json" : "context.txt");
|
|
37
|
+
|
|
38
|
+
/** R-C1: per-turn snapshot files — `sandbox-<turn>.pkl` so resume can fall back to a prior turn if the latest rename failed. */
|
|
39
|
+
export function snapshotPath(cwd: string, dir: string, runId: string, turn?: number): string {
|
|
40
|
+
const name = turn !== undefined ? `sandbox-${turn}.pkl` : "sandbox.pkl";
|
|
41
|
+
return join(runDir(cwd, dir, runId), name);
|
|
42
|
+
}
|