@hicaru/pi-rlm 0.2.0 → 0.2.2
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/README.md +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +49 -10
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1078
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
|
@@ -0,0 +1,836 @@
|
|
|
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
|
+
"rlm_query_batched"|"load_library","rid",...}
|
|
11
|
+
# mid-exec helper request
|
|
12
|
+
When sandbox code calls llm_query/rlm_query/load_library, the worker writes a
|
|
13
|
+
request line and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
|
|
14
|
+
The parent services the request in-process (it holds API keys).
|
|
15
|
+
|
|
16
|
+
Requests and replies are decoupled: `_post` writes a request and returns its rid without
|
|
17
|
+
waiting, and replies are parked in `_inbox` keyed by rid until something asks for them. That
|
|
18
|
+
is what makes `spawn()` / `rlm_await()` / `rlm_await_all()` possible — many requests can be
|
|
19
|
+
in flight at once (the parent already services interrupts concurrently), and a task may be
|
|
20
|
+
awaited in a LATER exec than the one that started it.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import traceback
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
# Sibling modules: Python puts this file's directory on sys.path[0], so these resolve without
|
|
36
|
+
# any packaging step. They ship inside `src/` like everything else.
|
|
37
|
+
from guards import (
|
|
38
|
+
_SAFE_BUILTINS,
|
|
39
|
+
_CONTEXT_NAME,
|
|
40
|
+
_send,
|
|
41
|
+
_stall_alarm,
|
|
42
|
+
_surfaced_error,
|
|
43
|
+
RESERVED,
|
|
44
|
+
REAL_STDERR as _REAL_STDERR,
|
|
45
|
+
REAL_STDIN as _REAL_STDIN,
|
|
46
|
+
)
|
|
47
|
+
from retrieval import (
|
|
48
|
+
_Bm25Index,
|
|
49
|
+
_chunk_text,
|
|
50
|
+
_context_entries,
|
|
51
|
+
_CHUNK_HEADER_OVERHEAD,
|
|
52
|
+
_MAX_CHUNK_BATCH,
|
|
53
|
+
_MAX_CHUNKS,
|
|
54
|
+
_NUDGE_CHARS,
|
|
55
|
+
grep_context as _grep_context_impl,
|
|
56
|
+
outline as _outline_impl,
|
|
57
|
+
search as _search_impl,
|
|
58
|
+
)
|
|
59
|
+
from tasks import (
|
|
60
|
+
_clean_paths,
|
|
61
|
+
_reduce_batch,
|
|
62
|
+
_reduce_chunked,
|
|
63
|
+
_reduce_map_files,
|
|
64
|
+
_reduce_one,
|
|
65
|
+
_spawnable,
|
|
66
|
+
Task,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
class _AnswerDict(dict):
|
|
70
|
+
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, on_ready):
|
|
73
|
+
super().__init__()
|
|
74
|
+
super().__setitem__("content", "")
|
|
75
|
+
super().__setitem__("ready", False)
|
|
76
|
+
self._on_ready = on_ready
|
|
77
|
+
|
|
78
|
+
def __setitem__(self, key, value):
|
|
79
|
+
super().__setitem__(key, value)
|
|
80
|
+
if key == "ready" and value:
|
|
81
|
+
self._on_ready(self.get("content", ""))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Worker:
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
depth: int,
|
|
89
|
+
exec_timeout_s: float,
|
|
90
|
+
max_prompt_chars: int,
|
|
91
|
+
await_timeout_s: float = 600.0,
|
|
92
|
+
):
|
|
93
|
+
self.depth = depth
|
|
94
|
+
self.exec_timeout_s = exec_timeout_s
|
|
95
|
+
self.max_prompt_chars = max_prompt_chars
|
|
96
|
+
self.await_timeout_s = await_timeout_s
|
|
97
|
+
self._rid = 0
|
|
98
|
+
self._final_answer: str | None = None
|
|
99
|
+
# Replies parked by rid until something awaits them. Unbounded by design: a task
|
|
100
|
+
# the model spawns and never awaits keeps its entry for the life of the process.
|
|
101
|
+
# Bounded in practice by session length; evicting would silently hang a later
|
|
102
|
+
# rlm_await, which is strictly worse than the memory.
|
|
103
|
+
self.inbox: dict[str, dict[str, Any]] = {}
|
|
104
|
+
self._inflight: set[str] = set()
|
|
105
|
+
# Requests (exec/shutdown) that arrived mid-exec; main() replays them.
|
|
106
|
+
self._deferred: list[Any] = []
|
|
107
|
+
# True only while spawn() runs a builder — marks requests that may outlive this exec.
|
|
108
|
+
self._detached = False
|
|
109
|
+
self.ns: dict[str, Any] = {}
|
|
110
|
+
self._setup()
|
|
111
|
+
|
|
112
|
+
def _setup(self) -> None:
|
|
113
|
+
builtins = _SAFE_BUILTINS.copy()
|
|
114
|
+
builtins["open"] = open
|
|
115
|
+
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
116
|
+
self._context_payload: Any | None = None # pristine restore for the single `context` var
|
|
117
|
+
self._nudged: set[str] = set()
|
|
118
|
+
self._index: _Bm25Index | None = None
|
|
119
|
+
self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
|
|
120
|
+
self._restore_scaffold()
|
|
121
|
+
|
|
122
|
+
def _capture_answer(self, content: Any) -> None:
|
|
123
|
+
self._final_answer = str(content)
|
|
124
|
+
|
|
125
|
+
def _restore_scaffold(self) -> None:
|
|
126
|
+
# Re-inject any scaffolding the user code clobbered.
|
|
127
|
+
ns = self.ns
|
|
128
|
+
ns["llm_query"] = self._llm_query
|
|
129
|
+
ns["llm_query_batched"] = self._llm_query_batched
|
|
130
|
+
ns["llm_query_chunked"] = self._llm_query_chunked
|
|
131
|
+
ns["rlm_query"] = self._rlm_query
|
|
132
|
+
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
133
|
+
ns["spawn"] = self._spawn
|
|
134
|
+
ns["rlm_await"] = self._await_task
|
|
135
|
+
ns["rlm_await_all"] = self._await_all
|
|
136
|
+
ns["map_files"] = self._map_files
|
|
137
|
+
ns["llm_map_reduce"] = self._llm_map_reduce
|
|
138
|
+
ns["search"] = self._search
|
|
139
|
+
ns["grep_context"] = self._grep_context
|
|
140
|
+
ns["outline"] = self._outline
|
|
141
|
+
# env_tips memo (paper App. C.3): "If a value isn't in `answers`, it doesn't exist."
|
|
142
|
+
# Re-created only when deleted — contents must survive every turn.
|
|
143
|
+
if not isinstance(ns.get("answers"), dict):
|
|
144
|
+
ns["answers"] = {}
|
|
145
|
+
if not isinstance(ns.get("plan"), dict):
|
|
146
|
+
ns["plan"] = {}
|
|
147
|
+
ns["load_library"] = self._load_library
|
|
148
|
+
ns["SHOW_VARS"] = self._show_vars
|
|
149
|
+
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
150
|
+
cur = ns.get("answer")
|
|
151
|
+
ans = _AnswerDict(self._capture_answer)
|
|
152
|
+
if isinstance(cur, dict):
|
|
153
|
+
for k, v in cur.items():
|
|
154
|
+
dict.__setitem__(ans, k, v)
|
|
155
|
+
if cur.get("ready") and self._final_answer is None:
|
|
156
|
+
self._final_answer = str(cur.get("content", ""))
|
|
157
|
+
ns["answer"] = ans
|
|
158
|
+
# Single context variable (RLM paper: the context lives in the environment and
|
|
159
|
+
# the model may transform it in place). Re-inject only if the model deleted the
|
|
160
|
+
# name entirely; mutations and re-binds persist within the run.
|
|
161
|
+
if self._context_payload is not None:
|
|
162
|
+
ns.setdefault("context", self._context_payload)
|
|
163
|
+
# Scrub any legacy context_N names so the model never sees multi-slot APIs.
|
|
164
|
+
for k in list(ns.keys()):
|
|
165
|
+
if k != "context" and _CONTEXT_NAME.match(k):
|
|
166
|
+
del ns[k]
|
|
167
|
+
|
|
168
|
+
def _user_var_names(self) -> list[str]:
|
|
169
|
+
"""User-created variable names — filters builtins, scaffold, and `context`.
|
|
170
|
+
|
|
171
|
+
Shared by SHOW_VARS() and the exec result so both expose the same namespace view.
|
|
172
|
+
This is the cheap orientation hint that goes into history instead of full stdout.
|
|
173
|
+
"""
|
|
174
|
+
return [
|
|
175
|
+
k for k in self.ns
|
|
176
|
+
if not k.startswith("_")
|
|
177
|
+
and not _CONTEXT_NAME.match(k)
|
|
178
|
+
and k not in RESERVED
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
def _show_vars(self) -> str:
|
|
182
|
+
avail = {k: type(self.ns[k]).__name__ for k in self._user_var_names()}
|
|
183
|
+
return f"Available variables: {avail}" if avail else "No variables created yet."
|
|
184
|
+
|
|
185
|
+
# ---- sub-LLM bridge over stdio --------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _post(self, kind: str, payload: dict[str, Any]) -> str:
|
|
188
|
+
"""Write one parent request and return its rid WITHOUT waiting for the reply."""
|
|
189
|
+
self._rid += 1
|
|
190
|
+
rid = f"q{self._rid}"
|
|
191
|
+
# Register only after the write succeeds — a broken pipe must not leave an
|
|
192
|
+
# _inflight entry that nothing will ever settle.
|
|
193
|
+
_send({"type": kind, "rid": rid, "depth": self.depth,
|
|
194
|
+
"detached": self._detached, **payload})
|
|
195
|
+
self._inflight.add(rid)
|
|
196
|
+
return rid
|
|
197
|
+
|
|
198
|
+
def park_reply(self, msg: Any) -> bool:
|
|
199
|
+
"""File an llm_reply against its rid. True when the frame was a reply.
|
|
200
|
+
|
|
201
|
+
Public because main() needs it too: a spawned task can settle while the worker
|
|
202
|
+
sits idle between execs, and that reply must not fall through to "unknown type".
|
|
203
|
+
"""
|
|
204
|
+
if not isinstance(msg, dict) or msg.get("type") != "llm_reply":
|
|
205
|
+
return False
|
|
206
|
+
rid = msg.get("rid")
|
|
207
|
+
if isinstance(rid, str) and rid in self._inflight:
|
|
208
|
+
self._inflight.discard(rid)
|
|
209
|
+
self.inbox[rid] = msg
|
|
210
|
+
else:
|
|
211
|
+
# Late reply to an abandoned rid (e.g. a request from a discarded sandbox).
|
|
212
|
+
print(f"[rlm-sandbox] dropping reply for unknown rid: {rid!r}", file=_REAL_STDERR)
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
def take_deferred(self) -> Any | None:
|
|
216
|
+
"""Pop a request that arrived mid-exec, for main() to replay. None when empty."""
|
|
217
|
+
return self._deferred.pop(0) if self._deferred else None
|
|
218
|
+
|
|
219
|
+
def _pump(self) -> bool:
|
|
220
|
+
"""Read one frame from the parent into the inbox. False when the pipe closed."""
|
|
221
|
+
line = _REAL_STDIN.readline()
|
|
222
|
+
if not line:
|
|
223
|
+
return False
|
|
224
|
+
try:
|
|
225
|
+
msg = json.loads(line)
|
|
226
|
+
except ValueError:
|
|
227
|
+
print(f"[rlm-sandbox] skipping non-JSON parent frame: {line[:200]}", file=_REAL_STDERR)
|
|
228
|
+
return True
|
|
229
|
+
if self.park_reply(msg):
|
|
230
|
+
return True
|
|
231
|
+
# A request (exec/shutdown) arriving mid-exec: main() replays it.
|
|
232
|
+
self._deferred.append(msg)
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
def _drain_until(self, rids) -> None:
|
|
236
|
+
"""Block until every rid in `rids` has its reply parked in the inbox.
|
|
237
|
+
|
|
238
|
+
Bounded: a host that goes silent raises inside the ```repl``` block instead of hanging
|
|
239
|
+
the session forever.
|
|
240
|
+
"""
|
|
241
|
+
if all(r in self.inbox for r in rids):
|
|
242
|
+
return
|
|
243
|
+
with _stall_alarm(self.exec_timeout_s, self.await_timeout_s) as rearm:
|
|
244
|
+
while not all(r in self.inbox for r in rids):
|
|
245
|
+
if not self._pump():
|
|
246
|
+
raise RuntimeError("parent closed the pipe during a sub-LLM request")
|
|
247
|
+
rearm()
|
|
248
|
+
|
|
249
|
+
def _take(self, rids) -> list[dict[str, Any]]:
|
|
250
|
+
return [self.inbox.pop(r) for r in rids]
|
|
251
|
+
|
|
252
|
+
def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
253
|
+
"""Post one request and block for its reply — the synchronous single-shot path."""
|
|
254
|
+
rid = self._post(kind, payload)
|
|
255
|
+
self._drain_until((rid,))
|
|
256
|
+
return self._take((rid,))[0]
|
|
257
|
+
|
|
258
|
+
# ---- spawn / await ---------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _start_prompt(self, kind: str, prompt, model, paths=None) -> Task:
|
|
261
|
+
text = str(prompt)
|
|
262
|
+
# A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
|
|
263
|
+
# looking exactly like data. Refuse instead of spending a call on it.
|
|
264
|
+
if not text.strip():
|
|
265
|
+
return Task.resolved(self, kind, _surfaced_error(
|
|
266
|
+
f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
|
|
267
|
+
payload: dict[str, Any] = {"prompt": text, "model": model}
|
|
268
|
+
clean = _clean_paths(paths)
|
|
269
|
+
if clean is not None:
|
|
270
|
+
payload["paths"] = clean
|
|
271
|
+
rid = self._post(kind, payload)
|
|
272
|
+
return Task(self, kind, (rid,), _reduce_one, text[:40])
|
|
273
|
+
|
|
274
|
+
def _start_prompts(self, kind: str, prompts, model, paths=None) -> Task:
|
|
275
|
+
prompts = [str(p) for p in prompts]
|
|
276
|
+
if not prompts:
|
|
277
|
+
return Task.resolved(self, kind, [])
|
|
278
|
+
# Only the all-blank case: one blank prompt among twenty is the caller's business.
|
|
279
|
+
if not any(p.strip() for p in prompts):
|
|
280
|
+
return Task.resolved(self, kind, [
|
|
281
|
+
_surfaced_error(f"{kind}() got only empty prompts")
|
|
282
|
+
] * len(prompts))
|
|
283
|
+
payload: dict[str, Any] = {"prompts": prompts, "model": model}
|
|
284
|
+
# One prefix set for the whole batch: a per-prompt aligned list is an API nobody uses
|
|
285
|
+
# correctly, and every prompt in a batch is asking about the same slice anyway.
|
|
286
|
+
clean = _clean_paths(paths)
|
|
287
|
+
if clean is not None:
|
|
288
|
+
payload["paths"] = clean
|
|
289
|
+
rid = self._post(kind, payload)
|
|
290
|
+
return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
|
|
291
|
+
|
|
292
|
+
def _start_llm_query(self, prompt, model: str | None = None) -> Task:
|
|
293
|
+
return self._start_prompt("llm_query", prompt, model)
|
|
294
|
+
|
|
295
|
+
def _start_rlm_query(self, prompt, model: str | None = None, paths=None) -> Task:
|
|
296
|
+
return self._start_prompt("rlm_query", prompt, model, paths)
|
|
297
|
+
|
|
298
|
+
def _start_llm_query_batched(self, prompts, model: str | None = None) -> Task:
|
|
299
|
+
return self._start_prompts("llm_query_batched", prompts, model)
|
|
300
|
+
|
|
301
|
+
def _start_rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> Task:
|
|
302
|
+
return self._start_prompts("rlm_query_batched", prompts, model, paths)
|
|
303
|
+
|
|
304
|
+
def _start_llm_query_chunked(self, text, prompt: str, model: str | None = None) -> Task:
|
|
305
|
+
"""Split oversized text into cap-sized chunks and post EVERY batch at once.
|
|
306
|
+
|
|
307
|
+
One answer per chunk, order preserved. No exceptions escape: errors come back as
|
|
308
|
+
"Error: ..." strings per chunk (same contract as llm_query_batched). Because all
|
|
309
|
+
batches go on the wire together, a large input costs one round-trip of latency
|
|
310
|
+
rather than one per 20 chunks.
|
|
311
|
+
|
|
312
|
+
NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
|
|
313
|
+
UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
|
|
314
|
+
parent and get per-chunk rejected. Acceptable trade-off for typical code/log/profile text.
|
|
315
|
+
"""
|
|
316
|
+
text, prompt = str(text), str(prompt)
|
|
317
|
+
if not text:
|
|
318
|
+
return Task.resolved(self, "llm_query_chunked", [])
|
|
319
|
+
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
|
|
320
|
+
if budget < 1_000:
|
|
321
|
+
return Task.resolved(self, "llm_query_chunked", [
|
|
322
|
+
f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"
|
|
323
|
+
])
|
|
324
|
+
chunks = _chunk_text(text, budget)
|
|
325
|
+
total = len(chunks)
|
|
326
|
+
if total > _MAX_CHUNKS:
|
|
327
|
+
return Task.resolved(self, "llm_query_chunked", [
|
|
328
|
+
f"Error: {total} chunks would be needed — filter/slice the text in Python first"
|
|
329
|
+
])
|
|
330
|
+
rids: list[str] = []
|
|
331
|
+
sizes: list[int] = []
|
|
332
|
+
for i in range(0, total, _MAX_CHUNK_BATCH):
|
|
333
|
+
batch = [
|
|
334
|
+
f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
|
|
335
|
+
for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
|
|
336
|
+
]
|
|
337
|
+
rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
|
|
338
|
+
sizes.append(len(batch))
|
|
339
|
+
return Task(self, "llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
|
|
340
|
+
|
|
341
|
+
def _builder_for(self, name: str):
|
|
342
|
+
# llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
|
|
343
|
+
# depends on its own map results, so it cannot be one (rids, pure reduce) Task.
|
|
344
|
+
return {
|
|
345
|
+
"llm_query": self._start_llm_query,
|
|
346
|
+
"llm_query_batched": self._start_llm_query_batched,
|
|
347
|
+
"llm_query_chunked": self._start_llm_query_chunked,
|
|
348
|
+
"map_files": self._start_map_files,
|
|
349
|
+
"rlm_query": self._start_rlm_query,
|
|
350
|
+
"rlm_query_batched": self._start_rlm_query_batched,
|
|
351
|
+
}.get(name)
|
|
352
|
+
|
|
353
|
+
def _spawn(self, fn, *args, **kwargs) -> Task:
|
|
354
|
+
"""Start a sub-call without waiting for it. `fn` is the scaffold function itself.
|
|
355
|
+
|
|
356
|
+
Returns a Task for rlm_await / rlm_await_all, possibly in a later ```repl``` block.
|
|
357
|
+
Misuse returns an already-resolved error Task rather than raising, matching the
|
|
358
|
+
"Error: ..." contract of the synchronous helpers.
|
|
359
|
+
"""
|
|
360
|
+
name = getattr(fn, "_rlm_name", None)
|
|
361
|
+
builder = self._builder_for(name) if isinstance(name, str) else None
|
|
362
|
+
if builder is None:
|
|
363
|
+
return Task.resolved(self, "spawn", _surfaced_error(
|
|
364
|
+
"spawn() takes llm_query, llm_query_batched, llm_query_chunked, map_files, "
|
|
365
|
+
"rlm_query or rlm_query_batched — not llm_map_reduce, whose reduce step depends "
|
|
366
|
+
"on its own map results and so cannot be a single Task"))
|
|
367
|
+
# Mark every request this builder posts as detached: the parent routes them to its
|
|
368
|
+
# session-scoped registry, since they may outlive the exec that started them.
|
|
369
|
+
self._detached = True
|
|
370
|
+
try:
|
|
371
|
+
return builder(*args, **kwargs)
|
|
372
|
+
except TypeError as e:
|
|
373
|
+
return Task.resolved(self, "spawn", _surfaced_error(f"bad spawn arguments — {e}"))
|
|
374
|
+
finally:
|
|
375
|
+
self._detached = False
|
|
376
|
+
|
|
377
|
+
def _await_task(self, task) -> Any:
|
|
378
|
+
"""Block until `task` has its result. Idempotent — the value is memoized."""
|
|
379
|
+
if not isinstance(task, Task):
|
|
380
|
+
return _surfaced_error(
|
|
381
|
+
f"rlm_await expects a Task from spawn(), got {type(task).__name__}"
|
|
382
|
+
)
|
|
383
|
+
if not task._settled:
|
|
384
|
+
self._drain_until(task._rids)
|
|
385
|
+
task._value = task._reduce(self._take(task._rids))
|
|
386
|
+
task._settled = True
|
|
387
|
+
return task._value
|
|
388
|
+
|
|
389
|
+
def _await_all(self, tasks) -> list:
|
|
390
|
+
"""Block until every task has its result. Order matches the input."""
|
|
391
|
+
tasks = list(tasks)
|
|
392
|
+
# One union drain so the tasks overlap instead of settling one after another.
|
|
393
|
+
union: list[str] = []
|
|
394
|
+
seen: set[str] = set()
|
|
395
|
+
for t in tasks:
|
|
396
|
+
if not isinstance(t, Task) or t._settled:
|
|
397
|
+
continue
|
|
398
|
+
for rid in t._rids:
|
|
399
|
+
if rid not in seen:
|
|
400
|
+
seen.add(rid)
|
|
401
|
+
union.append(rid)
|
|
402
|
+
if union:
|
|
403
|
+
self._drain_until(union)
|
|
404
|
+
return [self._await_task(t) for t in tasks]
|
|
405
|
+
|
|
406
|
+
# ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
|
|
407
|
+
|
|
408
|
+
def _entries(self) -> list[tuple[str, str]]:
|
|
409
|
+
return _context_entries(self.ns.get("context"))
|
|
410
|
+
|
|
411
|
+
def _get_index(self) -> _Bm25Index:
|
|
412
|
+
"""Build the BM25 index on first use; rebuild when `context` was replaced or resized.
|
|
413
|
+
|
|
414
|
+
Identity+length is a cheap stamp that catches the two ways context actually changes:
|
|
415
|
+
load_library() extending the list, and the model re-binding the name. In-place edits
|
|
416
|
+
that preserve length are not detected — documented, and rare in practice.
|
|
417
|
+
"""
|
|
418
|
+
ctx = self.ns.get("context")
|
|
419
|
+
stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
|
|
420
|
+
if self._index is None or self._index_stamp != stamp:
|
|
421
|
+
self._index = _Bm25Index(self._entries())
|
|
422
|
+
self._index_stamp = stamp
|
|
423
|
+
return self._index
|
|
424
|
+
|
|
425
|
+
def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
426
|
+
"""Rank `context` windows against a natural-language query (BM25).
|
|
427
|
+
|
|
428
|
+
Returns [{path, line, score, snippet}] — pointers, not bodies.
|
|
429
|
+
"""
|
|
430
|
+
return _search_impl(self._entries(), self._get_index(), query, k, path_glob)
|
|
431
|
+
|
|
432
|
+
def _grep_context(
|
|
433
|
+
self,
|
|
434
|
+
pattern: str,
|
|
435
|
+
k: int = 50,
|
|
436
|
+
path_glob: str | None = None,
|
|
437
|
+
before: int = 0,
|
|
438
|
+
after: int = 0,
|
|
439
|
+
) -> dict[str, Any]:
|
|
440
|
+
"""Regex over `context`, capped and shaped. See retrieval.grep_context."""
|
|
441
|
+
return _grep_context_impl(self._entries(), pattern, k, path_glob, before, after)
|
|
442
|
+
|
|
443
|
+
def _outline(self, path: str) -> str:
|
|
444
|
+
"""Definition/heading skeleton of one context file. See retrieval.outline."""
|
|
445
|
+
return _outline_impl(self._entries(), path)
|
|
446
|
+
|
|
447
|
+
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
448
|
+
|
|
449
|
+
def _start_map_files(self, files: Any, prompt: str, model: str | None = None) -> Task:
|
|
450
|
+
"""Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
|
|
451
|
+
|
|
452
|
+
All batches go on the wire together, so a 100-file map costs one round-trip of latency
|
|
453
|
+
rather than one per 20 files.
|
|
454
|
+
"""
|
|
455
|
+
prompt = str(prompt)
|
|
456
|
+
by_path: list[tuple[str, str]] = []
|
|
457
|
+
lookup: dict[str, str] | None = None
|
|
458
|
+
for item in files if isinstance(files, (list, tuple)) else [files]:
|
|
459
|
+
if isinstance(item, dict):
|
|
460
|
+
content = item.get("content", "")
|
|
461
|
+
by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
|
|
462
|
+
elif isinstance(item, str):
|
|
463
|
+
if lookup is None:
|
|
464
|
+
lookup = {p: c for p, c in self._entries()}
|
|
465
|
+
if item in lookup:
|
|
466
|
+
by_path.append((item, lookup[item]))
|
|
467
|
+
else:
|
|
468
|
+
by_path.append((item, ""))
|
|
469
|
+
if not by_path:
|
|
470
|
+
return Task.resolved(self, "map_files", {})
|
|
471
|
+
|
|
472
|
+
# Per-file prompt budget; anything larger is chunked and its answers concatenated.
|
|
473
|
+
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
|
|
474
|
+
if budget < 1_000:
|
|
475
|
+
return Task.resolved(self, "map_files", {
|
|
476
|
+
p: "Error: prompt too long to leave room for file content" for p, _ in by_path
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
requests: list[str] = []
|
|
480
|
+
spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
|
|
481
|
+
for path, content in by_path:
|
|
482
|
+
chunks = _chunk_text(content, budget) if len(content) > budget else [content]
|
|
483
|
+
spans.append((path, len(chunks)))
|
|
484
|
+
for j, chunk in enumerate(chunks):
|
|
485
|
+
header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
|
|
486
|
+
requests.append(f"{prompt}\n\n{header}\n{chunk}")
|
|
487
|
+
|
|
488
|
+
rids: list[str] = []
|
|
489
|
+
sizes: list[int] = []
|
|
490
|
+
for i in range(0, len(requests), _MAX_CHUNK_BATCH):
|
|
491
|
+
batch = requests[i:i + _MAX_CHUNK_BATCH]
|
|
492
|
+
rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
|
|
493
|
+
sizes.append(len(batch))
|
|
494
|
+
return Task(self, "map_files", tuple(rids),
|
|
495
|
+
_reduce_map_files(sizes, spans), f"{len(by_path)} files")
|
|
496
|
+
|
|
497
|
+
@_spawnable("map_files")
|
|
498
|
+
def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
|
|
499
|
+
"""Ask `prompt` of every given file, batched, and return {path: answer}.
|
|
500
|
+
|
|
501
|
+
`files` accepts context entries (dicts), paths (strings), or a mix — the whole
|
|
502
|
+
chunk/batch/collect loop the system prompt used to spell out, as one call.
|
|
503
|
+
Oversized files are split and their per-chunk answers joined.
|
|
504
|
+
"""
|
|
505
|
+
return self._await_task(self._start_map_files(files, prompt, model))
|
|
506
|
+
|
|
507
|
+
def _llm_map_reduce(
|
|
508
|
+
self,
|
|
509
|
+
items: Any,
|
|
510
|
+
map_prompt: str,
|
|
511
|
+
reduce_prompt: str,
|
|
512
|
+
model: str | None = None,
|
|
513
|
+
) -> str:
|
|
514
|
+
"""Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
|
|
515
|
+
|
|
516
|
+
The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
|
|
517
|
+
the buffers") as a single call, so the root never hand-rolls the loop.
|
|
518
|
+
"""
|
|
519
|
+
map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
|
|
520
|
+
seq = list(items) if isinstance(items, (list, tuple)) else [items]
|
|
521
|
+
if not seq:
|
|
522
|
+
return "Error: llm_map_reduce got no items"
|
|
523
|
+
texts = [
|
|
524
|
+
(str(it.get("content", "")) if isinstance(it, dict) else str(it))
|
|
525
|
+
for it in seq
|
|
526
|
+
]
|
|
527
|
+
labels = [
|
|
528
|
+
(str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
|
|
529
|
+
for i, it in enumerate(seq)
|
|
530
|
+
]
|
|
531
|
+
mapped: list[str] = []
|
|
532
|
+
for i in range(0, len(texts), _MAX_CHUNK_BATCH):
|
|
533
|
+
batch = [
|
|
534
|
+
f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
|
|
535
|
+
for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
|
|
536
|
+
]
|
|
537
|
+
mapped.extend(self._llm_query_batched(batch, model))
|
|
538
|
+
joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
|
|
539
|
+
return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
|
|
540
|
+
|
|
541
|
+
# ---- sync helpers: await(start(...)), so there is exactly one code path -----------------
|
|
542
|
+
|
|
543
|
+
@_spawnable("llm_query")
|
|
544
|
+
def _llm_query(self, prompt: str, model: str | None = None) -> str:
|
|
545
|
+
return self._await_task(self._start_llm_query(prompt, model))
|
|
546
|
+
|
|
547
|
+
@_spawnable("llm_query_batched")
|
|
548
|
+
def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
549
|
+
return self._await_task(self._start_llm_query_batched(prompts, model))
|
|
550
|
+
|
|
551
|
+
@_spawnable("llm_query_chunked")
|
|
552
|
+
def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
|
|
553
|
+
return self._await_task(self._start_llm_query_chunked(text, prompt, model))
|
|
554
|
+
|
|
555
|
+
@_spawnable("rlm_query")
|
|
556
|
+
def _rlm_query(self, prompt: str, model: str | None = None, paths=None) -> str:
|
|
557
|
+
return self._await_task(self._start_rlm_query(prompt, model, paths))
|
|
558
|
+
|
|
559
|
+
def _load_library(self, source: str) -> dict[str, Any] | str:
|
|
560
|
+
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
561
|
+
|
|
562
|
+
Paths are namespaced under lib/<source_id>/ (host). Content is always in the
|
|
563
|
+
single `context` list — never a new context_N variable.
|
|
564
|
+
Host-side idempotency may return already_loaded without a payload path.
|
|
565
|
+
"""
|
|
566
|
+
r = self._rpc("load_library", {"source": str(source)})
|
|
567
|
+
if r.get("error"):
|
|
568
|
+
return f"Error: {r['error']}"
|
|
569
|
+
if r.get("already_loaded"):
|
|
570
|
+
source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "lib"
|
|
571
|
+
path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"lib/{source_id}/"
|
|
572
|
+
ctx = self.ns.get("context")
|
|
573
|
+
ctx_len = len(ctx) if isinstance(ctx, list) else 0
|
|
574
|
+
print(
|
|
575
|
+
f"[rlm] load_library: already loaded {source_id} "
|
|
576
|
+
f"(paths under {path_prefix}, context len={ctx_len})"
|
|
577
|
+
)
|
|
578
|
+
return {
|
|
579
|
+
"source": str(source),
|
|
580
|
+
"source_id": source_id,
|
|
581
|
+
"path_prefix": path_prefix,
|
|
582
|
+
"files": 0,
|
|
583
|
+
"chars": r.get("chars"),
|
|
584
|
+
"context_len": ctx_len,
|
|
585
|
+
"already_loaded": True,
|
|
586
|
+
}
|
|
587
|
+
path = r.get("path")
|
|
588
|
+
if not isinstance(path, str):
|
|
589
|
+
return "Error: malformed load_library reply (no path)"
|
|
590
|
+
try:
|
|
591
|
+
with io.open(path, "r") as f:
|
|
592
|
+
payload = json.load(f) if r.get("json") else f.read()
|
|
593
|
+
finally:
|
|
594
|
+
try:
|
|
595
|
+
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
596
|
+
except OSError:
|
|
597
|
+
pass
|
|
598
|
+
return self._append_library(str(source), payload, r)
|
|
599
|
+
|
|
600
|
+
def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
|
|
601
|
+
"""Append host-packed library files into `context` (idempotent by path prefix).
|
|
602
|
+
|
|
603
|
+
The two refusals below are pre-flighted host-side by LIST_CONTEXT_REQUIRED /
|
|
604
|
+
NO_FILES_PRODUCED in src/bridge/library.ts, so the host never commits a
|
|
605
|
+
loaded-prefix for an append that fails here. Reaching either one means host and worker
|
|
606
|
+
disagree about `context`; keep the wording identical to its twin.
|
|
607
|
+
"""
|
|
608
|
+
ctx = self.ns.get("context")
|
|
609
|
+
if not isinstance(ctx, list):
|
|
610
|
+
kind = type(ctx).__name__ if ctx is not None else "None"
|
|
611
|
+
return f"Error: load_library requires list context (file bundle); got {kind}"
|
|
612
|
+
|
|
613
|
+
source_id = meta.get("source_id")
|
|
614
|
+
if not isinstance(source_id, str) or not source_id:
|
|
615
|
+
source_id = "lib"
|
|
616
|
+
path_prefix = meta.get("path_prefix")
|
|
617
|
+
if not isinstance(path_prefix, str) or not path_prefix:
|
|
618
|
+
path_prefix = f"lib/{source_id}/"
|
|
619
|
+
|
|
620
|
+
# Idempotent: already present if any path uses this library prefix.
|
|
621
|
+
for item in ctx:
|
|
622
|
+
if isinstance(item, dict) and str(item.get("path", "")).startswith(path_prefix):
|
|
623
|
+
print(
|
|
624
|
+
f"[rlm] load_library: already loaded {source_id} "
|
|
625
|
+
f"(paths under {path_prefix}, context len={len(ctx)})"
|
|
626
|
+
)
|
|
627
|
+
return {
|
|
628
|
+
"source": source,
|
|
629
|
+
"source_id": source_id,
|
|
630
|
+
"path_prefix": path_prefix,
|
|
631
|
+
"files": 0,
|
|
632
|
+
"chars": meta.get("chars"),
|
|
633
|
+
"context_len": len(ctx),
|
|
634
|
+
"already_loaded": True,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
files = self._library_file_entries(payload, path_prefix)
|
|
638
|
+
if not files:
|
|
639
|
+
return "Error: load_library produced no files"
|
|
640
|
+
|
|
641
|
+
ctx.extend(files)
|
|
642
|
+
# Keep restore payload in sync with the live list.
|
|
643
|
+
self._context_payload = ctx
|
|
644
|
+
self.ns["context"] = ctx
|
|
645
|
+
|
|
646
|
+
print(
|
|
647
|
+
f"[rlm] load_library: +{len(files)} files into context "
|
|
648
|
+
f"(len={len(ctx)}); paths under {path_prefix}"
|
|
649
|
+
)
|
|
650
|
+
return {
|
|
651
|
+
"source": source,
|
|
652
|
+
"source_id": source_id,
|
|
653
|
+
"path_prefix": path_prefix,
|
|
654
|
+
"files": len(files),
|
|
655
|
+
"chars": meta.get("chars"),
|
|
656
|
+
"context_len": len(ctx),
|
|
657
|
+
"already_loaded": False,
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def _library_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
|
|
662
|
+
"""Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
|
|
663
|
+
if isinstance(payload, str):
|
|
664
|
+
return [{
|
|
665
|
+
"path": f"{path_prefix}content",
|
|
666
|
+
"content": payload,
|
|
667
|
+
"tokens": max(1, (len(payload) + 3) // 4),
|
|
668
|
+
}]
|
|
669
|
+
if not isinstance(payload, list):
|
|
670
|
+
return []
|
|
671
|
+
out: list[dict[str, Any]] = []
|
|
672
|
+
for item in payload:
|
|
673
|
+
if isinstance(item, dict) and "path" in item and "content" in item:
|
|
674
|
+
out.append(item)
|
|
675
|
+
return out
|
|
676
|
+
|
|
677
|
+
@_spawnable("rlm_query_batched")
|
|
678
|
+
def _rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> list[str]:
|
|
679
|
+
return self._await_task(self._start_rlm_query_batched(prompts, model, paths))
|
|
680
|
+
|
|
681
|
+
# ---- context + execution --------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
|
|
684
|
+
"""Load the packed world into the single REPL variable `context`.
|
|
685
|
+
|
|
686
|
+
`index` is accepted for protocol compatibility but ignored — there is only
|
|
687
|
+
one context slot. Libraries are merged on the host (or via load_library).
|
|
688
|
+
"""
|
|
689
|
+
with open(path, "r") as f:
|
|
690
|
+
payload = json.load(f) if is_json else f.read()
|
|
691
|
+
self._context_payload = payload
|
|
692
|
+
self.ns["context"] = payload
|
|
693
|
+
# Drop legacy multi-slot names if present.
|
|
694
|
+
for k in list(self.ns.keys()):
|
|
695
|
+
if k != "context" and _CONTEXT_NAME.match(k):
|
|
696
|
+
del self.ns[k]
|
|
697
|
+
return 0
|
|
698
|
+
|
|
699
|
+
@contextmanager
|
|
700
|
+
def _capture(self):
|
|
701
|
+
out, err = io.StringIO(), io.StringIO()
|
|
702
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
703
|
+
sys.stdout, sys.stderr = out, err
|
|
704
|
+
try:
|
|
705
|
+
yield out, err
|
|
706
|
+
finally:
|
|
707
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
708
|
+
|
|
709
|
+
def _exec(self, code: str, ns: dict[str, Any]) -> None:
|
|
710
|
+
t = self.exec_timeout_s
|
|
711
|
+
if t <= 0 or not hasattr(signal, "SIGALRM"):
|
|
712
|
+
exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
def _alarm(signum, frame): # noqa: ARG001
|
|
716
|
+
raise TimeoutError(f"```repl``` block exceeded {t:g}s timeout")
|
|
717
|
+
|
|
718
|
+
old = signal.signal(signal.SIGALRM, _alarm)
|
|
719
|
+
signal.setitimer(signal.ITIMER_REAL, t)
|
|
720
|
+
try:
|
|
721
|
+
exec(compile(code, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
722
|
+
finally:
|
|
723
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
724
|
+
signal.signal(signal.SIGALRM, old)
|
|
725
|
+
|
|
726
|
+
def _nudge_lines(self) -> list[str]:
|
|
727
|
+
"""One-time hint for newly created huge raw-text variables (single line).
|
|
728
|
+
|
|
729
|
+
Collapses to one line so it survives headless stdout elision (head 200 + tail 200).
|
|
730
|
+
"""
|
|
731
|
+
names: list[str] = []
|
|
732
|
+
for k in self._user_var_names():
|
|
733
|
+
v = self.ns.get(k)
|
|
734
|
+
if isinstance(v, (str, bytes)) and len(v) > _NUDGE_CHARS and k not in self._nudged:
|
|
735
|
+
self._nudged.add(k)
|
|
736
|
+
names.append(f"{k} ({len(v):,} chars)")
|
|
737
|
+
if not names:
|
|
738
|
+
return []
|
|
739
|
+
return [
|
|
740
|
+
f"[rlm] huge raw-text variable(s): {', '.join(names)} — do NOT analyze them yourself; "
|
|
741
|
+
'delegate with llm_query_chunked(name, "your question") or slice + llm_query_batched.'
|
|
742
|
+
]
|
|
743
|
+
|
|
744
|
+
def execute(self, code: str) -> dict[str, Any]:
|
|
745
|
+
start = time.perf_counter()
|
|
746
|
+
raised = False
|
|
747
|
+
with self._capture() as (out, err):
|
|
748
|
+
try:
|
|
749
|
+
self._restore_scaffold()
|
|
750
|
+
self._exec(code, self.ns)
|
|
751
|
+
self._restore_scaffold()
|
|
752
|
+
stdout, stderr = out.getvalue(), err.getvalue()
|
|
753
|
+
except BaseException as e: # noqa: BLE001
|
|
754
|
+
raised = True
|
|
755
|
+
self._restore_scaffold()
|
|
756
|
+
stdout = out.getvalue()
|
|
757
|
+
stderr = err.getvalue() + f"\n{type(e).__name__}: {e}\n" + traceback.format_exc()
|
|
758
|
+
final, self._final_answer = self._final_answer, None
|
|
759
|
+
answer = self.ns.get("answer")
|
|
760
|
+
answer_content = answer.get("content", "") if isinstance(answer, dict) else ""
|
|
761
|
+
# ready may have been flipped with empty content before content was assigned later
|
|
762
|
+
# in the same block; the dict's current content is the real submission.
|
|
763
|
+
if final is not None and not final.strip() and str(answer_content).strip():
|
|
764
|
+
final = str(answer_content)
|
|
765
|
+
nudges = self._nudge_lines()
|
|
766
|
+
if nudges:
|
|
767
|
+
parts = [stdout] if stdout else []
|
|
768
|
+
parts.extend(nudges)
|
|
769
|
+
stdout = "\n".join(parts) + "\n"
|
|
770
|
+
return {
|
|
771
|
+
"stdout": stdout,
|
|
772
|
+
"stderr": stderr,
|
|
773
|
+
"final_answer": final,
|
|
774
|
+
"answer_content": str(answer_content),
|
|
775
|
+
"raised": raised,
|
|
776
|
+
"execution_time": time.perf_counter() - start,
|
|
777
|
+
"var_names": self._user_var_names(),
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def main() -> None:
|
|
782
|
+
ap = argparse.ArgumentParser()
|
|
783
|
+
ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
|
|
784
|
+
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
785
|
+
ap.add_argument("--await-timeout", type=float,
|
|
786
|
+
default=float(os.environ.get("RLM_AWAIT_TIMEOUT_S", "600")))
|
|
787
|
+
ap.add_argument("--max-prompt-chars", type=int,
|
|
788
|
+
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
789
|
+
args = ap.parse_args()
|
|
790
|
+
|
|
791
|
+
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
792
|
+
max_prompt_chars=args.max_prompt_chars,
|
|
793
|
+
await_timeout_s=args.await_timeout)
|
|
794
|
+
_send({"id": "_init", "ok": True})
|
|
795
|
+
|
|
796
|
+
while True:
|
|
797
|
+
# Requests that arrived mid-exec were parked by _pump; replay them before reading.
|
|
798
|
+
req = worker.take_deferred()
|
|
799
|
+
if req is None:
|
|
800
|
+
line = _REAL_STDIN.readline()
|
|
801
|
+
if not line:
|
|
802
|
+
return
|
|
803
|
+
raw = line.strip()
|
|
804
|
+
if not raw:
|
|
805
|
+
continue
|
|
806
|
+
try:
|
|
807
|
+
req = json.loads(raw)
|
|
808
|
+
except json.JSONDecodeError as e:
|
|
809
|
+
_send({"id": "?", "ok": False, "error": f"bad json: {e}"})
|
|
810
|
+
continue
|
|
811
|
+
# A task spawned in an earlier exec settling while the worker is idle. Park it for
|
|
812
|
+
# a later rlm_await; without this it would fall through to "unknown type" and the
|
|
813
|
+
# result would be lost.
|
|
814
|
+
if worker.park_reply(req):
|
|
815
|
+
continue
|
|
816
|
+
if not isinstance(req, dict):
|
|
817
|
+
_send({"id": "?", "ok": False, "error": f"expected an object, got {type(req).__name__}"})
|
|
818
|
+
continue
|
|
819
|
+
rid, kind = req.get("id", "?"), req.get("type")
|
|
820
|
+
try:
|
|
821
|
+
if kind == "exec":
|
|
822
|
+
_send({"id": rid, "ok": True, **worker.execute(req.get("code", ""))})
|
|
823
|
+
elif kind == "load_context":
|
|
824
|
+
idx = worker.load_context(req.get("path"), req.get("index"), req.get("json"))
|
|
825
|
+
_send({"id": rid, "ok": True, "index": idx})
|
|
826
|
+
elif kind == "shutdown":
|
|
827
|
+
_send({"id": rid, "ok": True})
|
|
828
|
+
return
|
|
829
|
+
else:
|
|
830
|
+
_send({"id": rid, "ok": False, "error": f"unknown type: {kind!r}"})
|
|
831
|
+
except BaseException as e: # noqa: BLE001
|
|
832
|
+
_send({"id": rid, "ok": False, "error": f"{type(e).__name__}: {e}\n{traceback.format_exc()}"})
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
if __name__ == "__main__":
|
|
836
|
+
main()
|