@hicaru/pi-rlm 0.1.9 → 0.2.1
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/package.json +1 -1
- package/src/bridge/fallback-todo.ts +12 -1
- package/src/bridge/subcall-handlers.ts +336 -0
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/defaults.ts +4 -1
- package/src/config/settings.ts +33 -3
- package/src/context/repomix-context.ts +5 -10
- package/src/core/answer.ts +4 -3
- package/src/core/artifacts.ts +4 -3
- package/src/core/engine.ts +101 -267
- package/src/core/gates.ts +3 -3
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +2 -2
- package/src/core/types.ts +25 -27
- package/src/index.ts +63 -17
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/system.ts +164 -52
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +6 -7
- package/src/sandbox/sandbox-manager.ts +25 -11
- package/src/sandbox/sandbox.ts +93 -22
- package/src/sandbox/worker.py +798 -66
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +5 -11
- package/src/text/parsing.ts +0 -6
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +2 -0
- package/src/tool/repl-tool.ts +223 -318
- package/src/tool/rlm-details.ts +0 -10
- package/src/tool/rlm-events.ts +10 -2
- package/src/tool/rlm-tool.ts +18 -31
- package/src/tool/subcall-render.ts +75 -11
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +41 -21
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/util/concurrency.ts +87 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/llm-query.ts +0 -133
- package/src/bridge/rlm-query.ts +0 -122
- package/src/mode/input-router.ts +0 -23
package/src/sandbox/worker.py
CHANGED
|
@@ -12,13 +12,22 @@ Protocol (worker -> parent): {"id","ok",...result} # response to a r
|
|
|
12
12
|
When sandbox code calls llm_query/rlm_query/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
|
|
13
13
|
and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
|
|
14
14
|
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.
|
|
15
21
|
"""
|
|
16
22
|
|
|
17
23
|
from __future__ import annotations
|
|
18
24
|
|
|
19
25
|
import argparse
|
|
26
|
+
import fnmatch
|
|
27
|
+
import heapq
|
|
20
28
|
import io
|
|
21
29
|
import json
|
|
30
|
+
import math
|
|
22
31
|
import os
|
|
23
32
|
import pickle
|
|
24
33
|
import re
|
|
@@ -95,19 +104,53 @@ def _install_read_only_guards():
|
|
|
95
104
|
return guarded_io_open
|
|
96
105
|
|
|
97
106
|
|
|
107
|
+
# _builtin()'s getattr(..., None) fallback would silently inject None for a name this
|
|
108
|
+
# interpreter lacks, surfacing much later as "'NoneType' object is not callable" inside model
|
|
109
|
+
# code. Fail at startup instead. Note "None" is legitimately None, and the block-list below is
|
|
110
|
+
# deliberate — which is why this check runs BEFORE it.
|
|
111
|
+
_MISSING = sorted(name for name, value in _SAFE_BUILTINS.items() if value is None and name != "None")
|
|
112
|
+
if _MISSING:
|
|
113
|
+
raise RuntimeError(f"unsupported Python interpreter: missing builtins {_MISSING}")
|
|
114
|
+
|
|
115
|
+
def _blocked_builtin(name: str):
|
|
116
|
+
"""Bind a disabled builtin to a callable that explains itself.
|
|
117
|
+
|
|
118
|
+
Binding these to None made `eval(...)` fail with a bare "'NoneType' object is not callable",
|
|
119
|
+
which reads as a broken sandbox rather than a deliberate block: an audit session spent six
|
|
120
|
+
execs on it and filed a phantom "namespace corruption" bug. Saying so at the point of failure
|
|
121
|
+
fixes it for every model without spending native-prompt budget on a rule most runs never hit.
|
|
122
|
+
"""
|
|
123
|
+
def blocked(*_args, **_kwargs):
|
|
124
|
+
raise PermissionError(
|
|
125
|
+
f"{name}() is disabled in the RLM sandbox by design — it is not missing and the "
|
|
126
|
+
"namespace is not corrupt. Names are already bound, so reference them directly; "
|
|
127
|
+
"inspect `context` with search() / grep_context() / outline()."
|
|
128
|
+
)
|
|
129
|
+
blocked.__name__ = name
|
|
130
|
+
return blocked
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Blocked on purpose (NOT missing) — see _blocked_builtin. The _MISSING check above runs first,
|
|
134
|
+
# so a genuinely absent builtin is still a startup failure rather than a silent None.
|
|
98
135
|
for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
99
|
-
_SAFE_BUILTINS[_blocked] =
|
|
136
|
+
_SAFE_BUILTINS[_blocked] = _blocked_builtin(_blocked)
|
|
100
137
|
|
|
101
138
|
RESERVED = frozenset(
|
|
102
139
|
{
|
|
103
140
|
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
104
141
|
"rlm_query", "rlm_query_batched",
|
|
142
|
+
"spawn", "rlm_await", "rlm_await_all",
|
|
143
|
+
"map_files", "llm_map_reduce",
|
|
144
|
+
"search", "grep_context", "outline",
|
|
105
145
|
"advance_phase", "save_artifact",
|
|
106
146
|
"ask_user_question", "todo",
|
|
107
147
|
"load_library",
|
|
108
148
|
"SHOW_VARS", "answer", "context",
|
|
109
149
|
}
|
|
110
150
|
)
|
|
151
|
+
# NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
|
|
152
|
+
# owned by the model, so they must appear in SHOW_VARS and be captured by snapshots — losing a
|
|
153
|
+
# memoized answer across a resume is exactly the failure the memo exists to prevent.
|
|
111
154
|
# Only the single name `context` is the packed world. Legacy context_N names are filtered out.
|
|
112
155
|
_CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
|
|
113
156
|
|
|
@@ -134,6 +177,145 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
134
177
|
return chunks
|
|
135
178
|
|
|
136
179
|
|
|
180
|
+
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
181
|
+
#
|
|
182
|
+
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
183
|
+
# context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
|
|
184
|
+
# the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
|
|
185
|
+
# These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
|
|
186
|
+
# spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
|
|
187
|
+
|
|
188
|
+
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
189
|
+
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
|
|
190
|
+
_SNIPPET_CHARS = 400
|
|
191
|
+
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
192
|
+
_BM25_K1 = 1.2
|
|
193
|
+
_BM25_B = 0.75
|
|
194
|
+
|
|
195
|
+
_TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
|
|
196
|
+
_CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
197
|
+
|
|
198
|
+
# Definition-ish lines across the languages this plugin is likely to meet. Deliberately
|
|
199
|
+
# lexical: an outline is an orientation aid, not a parse tree.
|
|
200
|
+
_OUTLINE_LINE = re.compile(
|
|
201
|
+
r"^\s*(?:"
|
|
202
|
+
r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
|
|
203
|
+
r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
|
|
204
|
+
r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
|
|
205
|
+
r"|def\s+\w+|class\s+\w+"
|
|
206
|
+
r"|func\s+\w+"
|
|
207
|
+
r"|#{1,4}\s+\S"
|
|
208
|
+
r")"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _tokenize(text: str) -> list[str]:
|
|
213
|
+
"""Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
|
|
214
|
+
out: list[str] = []
|
|
215
|
+
for raw in _TOKEN_SPLIT.split(text):
|
|
216
|
+
if not raw:
|
|
217
|
+
continue
|
|
218
|
+
lowered = raw.lower()
|
|
219
|
+
out.append(lowered)
|
|
220
|
+
if len(raw) > 3:
|
|
221
|
+
parts = _CAMEL_SPLIT.split(raw)
|
|
222
|
+
if len(parts) > 1:
|
|
223
|
+
for part in parts:
|
|
224
|
+
piece = part.lower()
|
|
225
|
+
if piece and piece != lowered:
|
|
226
|
+
out.append(piece)
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _context_entries(context: Any) -> list[tuple[str, str]]:
|
|
231
|
+
"""(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
|
|
232
|
+
if isinstance(context, str):
|
|
233
|
+
return [("<context>", context)]
|
|
234
|
+
if not isinstance(context, list):
|
|
235
|
+
return []
|
|
236
|
+
out: list[tuple[str, str]] = []
|
|
237
|
+
for i, item in enumerate(context):
|
|
238
|
+
if isinstance(item, dict):
|
|
239
|
+
content = item.get("content", "")
|
|
240
|
+
out.append((
|
|
241
|
+
str(item.get("path", f"<context[{i}]>")),
|
|
242
|
+
content if isinstance(content, str) else str(content),
|
|
243
|
+
))
|
|
244
|
+
elif isinstance(item, str):
|
|
245
|
+
out.append((f"<context[{i}]>", item))
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _Bm25Index:
|
|
250
|
+
"""Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
|
|
251
|
+
|
|
252
|
+
__slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
253
|
+
|
|
254
|
+
def __init__(self, entries: list[tuple[str, str]]) -> None:
|
|
255
|
+
self.paths: list[str] = []
|
|
256
|
+
self.starts: list[int] = []
|
|
257
|
+
self.texts: list[str] = []
|
|
258
|
+
self.postings: dict[str, list[tuple[int, int]]] = {}
|
|
259
|
+
self.doc_len: list[int] = []
|
|
260
|
+
self.truncated = False
|
|
261
|
+
|
|
262
|
+
for path, content in entries:
|
|
263
|
+
if not content:
|
|
264
|
+
continue
|
|
265
|
+
lines = content.split("\n")
|
|
266
|
+
for start in range(0, len(lines), _INDEX_WINDOW_LINES):
|
|
267
|
+
if len(self.texts) >= _INDEX_MAX_WINDOWS:
|
|
268
|
+
self.truncated = True
|
|
269
|
+
break
|
|
270
|
+
window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
|
|
271
|
+
idx = len(self.texts)
|
|
272
|
+
self.paths.append(path)
|
|
273
|
+
self.starts.append(start + 1)
|
|
274
|
+
self.texts.append(window)
|
|
275
|
+
terms = _tokenize(window)
|
|
276
|
+
self.doc_len.append(len(terms))
|
|
277
|
+
freq: dict[str, int] = {}
|
|
278
|
+
for term in terms:
|
|
279
|
+
freq[term] = freq.get(term, 0) + 1
|
|
280
|
+
for term, tf in freq.items():
|
|
281
|
+
self.postings.setdefault(term, []).append((idx, tf))
|
|
282
|
+
if self.truncated:
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
total = len(self.doc_len)
|
|
286
|
+
self.avg_len = (sum(self.doc_len) / total) if total else 1.0
|
|
287
|
+
|
|
288
|
+
def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
|
|
289
|
+
total = len(self.texts)
|
|
290
|
+
if total == 0:
|
|
291
|
+
return []
|
|
292
|
+
scores: dict[int, float] = {}
|
|
293
|
+
for term in set(terms):
|
|
294
|
+
posting = self.postings.get(term)
|
|
295
|
+
if not posting:
|
|
296
|
+
continue
|
|
297
|
+
df = len(posting)
|
|
298
|
+
idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
299
|
+
for idx, tf in posting:
|
|
300
|
+
norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
|
|
301
|
+
scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
302
|
+
if path_glob:
|
|
303
|
+
scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
|
|
304
|
+
if not scores:
|
|
305
|
+
return []
|
|
306
|
+
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
307
|
+
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
308
|
+
for i, (idx, score) in enumerate(top):
|
|
309
|
+
text = self.texts[idx]
|
|
310
|
+
out[i] = {
|
|
311
|
+
"path": self.paths[idx],
|
|
312
|
+
"line": self.starts[idx],
|
|
313
|
+
"score": round(score, 3),
|
|
314
|
+
"snippet": text[:_SNIPPET_CHARS],
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
137
319
|
class _AnswerDict(dict):
|
|
138
320
|
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
139
321
|
|
|
@@ -154,14 +336,179 @@ def _send(obj: dict[str, Any]) -> None:
|
|
|
154
336
|
_REAL_STDOUT.flush()
|
|
155
337
|
|
|
156
338
|
|
|
339
|
+
class _StallTimeout(Exception):
|
|
340
|
+
"""No frame from the host while a sub-call was pending."""
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@contextmanager
|
|
344
|
+
def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
|
|
345
|
+
"""Swap the per-cell alarm for a stall alarm while blocked on the parent.
|
|
346
|
+
|
|
347
|
+
Sub-LLM latency is network time, not cell compute time, so it must not count against the
|
|
348
|
+
```repl``` block timeout — but an unbounded wait is exactly how a lost reply turns into a
|
|
349
|
+
dead session. The yielded `rearm()` restarts the stall clock on every frame, so a healthy
|
|
350
|
+
long-running child never trips it.
|
|
351
|
+
"""
|
|
352
|
+
use = hasattr(signal, "SIGALRM")
|
|
353
|
+
remaining = signal.getitimer(signal.ITIMER_REAL)[0] if (use and exec_timeout_s > 0) else 0.0
|
|
354
|
+
|
|
355
|
+
def _fire(signum, frame): # noqa: ARG001
|
|
356
|
+
raise _StallTimeout(
|
|
357
|
+
f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
|
|
358
|
+
"(the task may still be running; rlm_await it again in a later block)"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
old = signal.signal(signal.SIGALRM, _fire) if use else None
|
|
362
|
+
|
|
363
|
+
def rearm() -> None:
|
|
364
|
+
if use and stall_timeout_s > 0:
|
|
365
|
+
signal.setitimer(signal.ITIMER_REAL, stall_timeout_s)
|
|
366
|
+
|
|
367
|
+
rearm()
|
|
368
|
+
try:
|
|
369
|
+
yield rearm
|
|
370
|
+
finally:
|
|
371
|
+
if use:
|
|
372
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
373
|
+
if old is not None:
|
|
374
|
+
signal.signal(signal.SIGALRM, old)
|
|
375
|
+
if remaining > 0: # restore the cell's remaining budget
|
|
376
|
+
signal.setitimer(signal.ITIMER_REAL, remaining)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _surfaced_error(message: str) -> str:
|
|
380
|
+
"""The "Error: …" contract value, ALSO written to the cell's stderr.
|
|
381
|
+
|
|
382
|
+
A spawn/await misuse whose only trace is the returned value reads to the model as a random
|
|
383
|
+
string much later — which is exactly how `tasks.items()` blew up on a str.
|
|
384
|
+
"""
|
|
385
|
+
print(f"[rlm] {message}", file=sys.stderr)
|
|
386
|
+
return f"Error: {message}"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---- reply reducers: raw parent replies -> the value the scaffold fn returns ----------------
|
|
390
|
+
# One reducer per result shape, shared by the sync helpers and their spawned equivalents.
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _reduce_one(replies: list[dict[str, Any]]) -> str:
|
|
394
|
+
r = replies[0]
|
|
395
|
+
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _reduce_batch(n: int):
|
|
399
|
+
"""Reducer for a single *_query_batched reply of n prompts."""
|
|
400
|
+
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
401
|
+
r = replies[0]
|
|
402
|
+
if r.get("error"):
|
|
403
|
+
return [f"Error: {r['error']}"] * n
|
|
404
|
+
out = r.get("responses")
|
|
405
|
+
if not isinstance(out, list) or len(out) != n:
|
|
406
|
+
return ["Error: malformed batched response"] * n
|
|
407
|
+
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
408
|
+
return reduce
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _reduce_chunked(sizes: list[int]):
|
|
412
|
+
"""Concatenate several llm_query_batched replies back into one flat chunk list."""
|
|
413
|
+
per = [_reduce_batch(n) for n in sizes]
|
|
414
|
+
|
|
415
|
+
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
416
|
+
out: list[str] = []
|
|
417
|
+
for red, rep in zip(per, replies):
|
|
418
|
+
out.extend(red([rep]))
|
|
419
|
+
return out
|
|
420
|
+
return reduce
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
|
|
424
|
+
"""Flatten the batch replies (same as chunked), then regroup them per path.
|
|
425
|
+
|
|
426
|
+
A file larger than the per-prompt budget contributed several requests; its answers rejoin
|
|
427
|
+
in order, which is what makes map_files a {path: answer} dict rather than a flat list.
|
|
428
|
+
"""
|
|
429
|
+
flatten = _reduce_chunked(sizes)
|
|
430
|
+
|
|
431
|
+
def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
|
|
432
|
+
responses = flatten(replies)
|
|
433
|
+
out: dict[str, str] = {}
|
|
434
|
+
cursor = 0
|
|
435
|
+
for path, count in spans:
|
|
436
|
+
part = responses[cursor:cursor + count]
|
|
437
|
+
cursor += count
|
|
438
|
+
out[path] = part[0] if count == 1 and part else "\n\n".join(part)
|
|
439
|
+
return out
|
|
440
|
+
return reduce
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _spawnable(name: str):
|
|
444
|
+
"""Tag a sync scaffold fn with the request kind spawn() should route it to."""
|
|
445
|
+
def mark(fn):
|
|
446
|
+
fn._rlm_name = name
|
|
447
|
+
return fn
|
|
448
|
+
return mark
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class Task:
|
|
452
|
+
"""Handle for parent-side work in flight, returned by spawn().
|
|
453
|
+
|
|
454
|
+
Opaque to model code apart from `done` and repr. A Task may be awaited in a later
|
|
455
|
+
```repl``` block than the one that created it.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
__slots__ = ("kind", "label", "_worker", "_rids", "_reduce", "_value", "_settled")
|
|
459
|
+
|
|
460
|
+
def __init__(self, worker: "Worker", kind: str, rids, reduce, label: str = ""):
|
|
461
|
+
self.kind = kind
|
|
462
|
+
self.label = label
|
|
463
|
+
self._worker = worker
|
|
464
|
+
self._rids = tuple(rids)
|
|
465
|
+
self._reduce = reduce
|
|
466
|
+
self._value: Any = None
|
|
467
|
+
self._settled = False
|
|
468
|
+
|
|
469
|
+
@staticmethod
|
|
470
|
+
def resolved(worker: "Worker", kind: str, value: Any, label: str = "") -> "Task":
|
|
471
|
+
"""A Task that never hit the wire — validation errors and empty inputs."""
|
|
472
|
+
task = Task(worker, kind, (), lambda _replies: value, label)
|
|
473
|
+
task._value = value
|
|
474
|
+
task._settled = True
|
|
475
|
+
return task
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def done(self) -> bool:
|
|
479
|
+
"""True once every reply has landed — awaiting will not block."""
|
|
480
|
+
return self._settled or all(r in self._worker.inbox for r in self._rids)
|
|
481
|
+
|
|
482
|
+
def __repr__(self) -> str:
|
|
483
|
+
return f"<Task {self.kind} {'done' if self.done else 'running'} {self.label}>"
|
|
484
|
+
|
|
485
|
+
|
|
157
486
|
class Worker:
|
|
158
|
-
def __init__(
|
|
487
|
+
def __init__(
|
|
488
|
+
self,
|
|
489
|
+
depth: int,
|
|
490
|
+
exec_timeout_s: float,
|
|
491
|
+
max_prompt_chars: int,
|
|
492
|
+
read_only: bool = False,
|
|
493
|
+
await_timeout_s: float = 600.0,
|
|
494
|
+
):
|
|
159
495
|
self.depth = depth
|
|
160
496
|
self.exec_timeout_s = exec_timeout_s
|
|
161
497
|
self.max_prompt_chars = max_prompt_chars
|
|
162
498
|
self.read_only = read_only
|
|
499
|
+
self.await_timeout_s = await_timeout_s
|
|
163
500
|
self._rid = 0
|
|
164
501
|
self._final_answer: str | None = None
|
|
502
|
+
# Replies parked by rid until something awaits them. Unbounded by design: a task
|
|
503
|
+
# the model spawns and never awaits keeps its entry for the life of the process.
|
|
504
|
+
# Bounded in practice by session length; evicting would silently hang a later
|
|
505
|
+
# rlm_await, which is strictly worse than the memory.
|
|
506
|
+
self.inbox: dict[str, dict[str, Any]] = {}
|
|
507
|
+
self._inflight: set[str] = set()
|
|
508
|
+
# Requests (exec/snapshot/shutdown) that arrived mid-exec; main() replays them.
|
|
509
|
+
self._deferred: list[Any] = []
|
|
510
|
+
# True only while spawn() runs a builder — marks requests that may outlive this exec.
|
|
511
|
+
self._detached = False
|
|
165
512
|
self.ns: dict[str, Any] = {}
|
|
166
513
|
self._setup()
|
|
167
514
|
|
|
@@ -174,6 +521,8 @@ class Worker:
|
|
|
174
521
|
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
175
522
|
self._context_payload: Any | None = None # pristine restore for the single `context` var
|
|
176
523
|
self._nudged: set[str] = set()
|
|
524
|
+
self._index: _Bm25Index | None = None
|
|
525
|
+
self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
|
|
177
526
|
self._restore_scaffold()
|
|
178
527
|
|
|
179
528
|
def _capture_answer(self, content: Any) -> None:
|
|
@@ -187,6 +536,20 @@ class Worker:
|
|
|
187
536
|
ns["llm_query_chunked"] = self._llm_query_chunked
|
|
188
537
|
ns["rlm_query"] = self._rlm_query
|
|
189
538
|
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
539
|
+
ns["spawn"] = self._spawn
|
|
540
|
+
ns["rlm_await"] = self._await_task
|
|
541
|
+
ns["rlm_await_all"] = self._await_all
|
|
542
|
+
ns["map_files"] = self._map_files
|
|
543
|
+
ns["llm_map_reduce"] = self._llm_map_reduce
|
|
544
|
+
ns["search"] = self._search
|
|
545
|
+
ns["grep_context"] = self._grep_context
|
|
546
|
+
ns["outline"] = self._outline
|
|
547
|
+
# env_tips memo (paper App. C.3): "If a value isn't in `answers`, it doesn't exist."
|
|
548
|
+
# Re-created only when deleted — contents must survive every turn.
|
|
549
|
+
if not isinstance(ns.get("answers"), dict):
|
|
550
|
+
ns["answers"] = {}
|
|
551
|
+
if not isinstance(ns.get("plan"), dict):
|
|
552
|
+
ns["plan"] = {}
|
|
190
553
|
ns["advance_phase"] = self._advance_phase
|
|
191
554
|
ns["save_artifact"] = self._save_artifact
|
|
192
555
|
ns["ask_user_question"] = self._ask_user_question
|
|
@@ -231,54 +594,120 @@ class Worker:
|
|
|
231
594
|
|
|
232
595
|
# ---- sub-LLM bridge over stdio --------------------------------------------------------
|
|
233
596
|
|
|
234
|
-
def
|
|
597
|
+
def _post(self, kind: str, payload: dict[str, Any]) -> str:
|
|
598
|
+
"""Write one parent request and return its rid WITHOUT waiting for the reply."""
|
|
235
599
|
self._rid += 1
|
|
236
600
|
rid = f"q{self._rid}"
|
|
237
|
-
|
|
238
|
-
#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
601
|
+
# Register only after the write succeeds — a broken pipe must not leave an
|
|
602
|
+
# _inflight entry that nothing will ever settle.
|
|
603
|
+
_send({"type": kind, "rid": rid, "depth": self.depth,
|
|
604
|
+
"detached": self._detached, **payload})
|
|
605
|
+
self._inflight.add(rid)
|
|
606
|
+
return rid
|
|
607
|
+
|
|
608
|
+
def park_reply(self, msg: Any) -> bool:
|
|
609
|
+
"""File an llm_reply against its rid. True when the frame was a reply.
|
|
610
|
+
|
|
611
|
+
Public because main() needs it too: a spawned task can settle while the worker
|
|
612
|
+
sits idle between execs, and that reply must not fall through to "unknown type".
|
|
613
|
+
"""
|
|
614
|
+
if not isinstance(msg, dict) or msg.get("type") != "llm_reply":
|
|
615
|
+
return False
|
|
616
|
+
rid = msg.get("rid")
|
|
617
|
+
if isinstance(rid, str) and rid in self._inflight:
|
|
618
|
+
self._inflight.discard(rid)
|
|
619
|
+
self.inbox[rid] = msg
|
|
620
|
+
else:
|
|
621
|
+
# Late reply to an abandoned rid (e.g. a request from a discarded sandbox).
|
|
622
|
+
print(f"[rlm-sandbox] dropping reply for unknown rid: {rid!r}", file=_REAL_STDERR)
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
def take_deferred(self) -> Any | None:
|
|
626
|
+
"""Pop a request that arrived mid-exec, for main() to replay. None when empty."""
|
|
627
|
+
return self._deferred.pop(0) if self._deferred else None
|
|
628
|
+
|
|
629
|
+
def _pump(self) -> bool:
|
|
630
|
+
"""Read one frame from the parent into the inbox. False when the pipe closed."""
|
|
631
|
+
line = _REAL_STDIN.readline()
|
|
632
|
+
if not line:
|
|
633
|
+
return False
|
|
244
634
|
try:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
635
|
+
msg = json.loads(line)
|
|
636
|
+
except ValueError:
|
|
637
|
+
print(f"[rlm-sandbox] skipping non-JSON parent frame: {line[:200]}", file=_REAL_STDERR)
|
|
638
|
+
return True
|
|
639
|
+
if self.park_reply(msg):
|
|
640
|
+
return True
|
|
641
|
+
# A request (exec/snapshot/shutdown) arriving mid-exec: main() replays it.
|
|
642
|
+
self._deferred.append(msg)
|
|
643
|
+
return True
|
|
644
|
+
|
|
645
|
+
def _drain_until(self, rids) -> None:
|
|
646
|
+
"""Block until every rid in `rids` has its reply parked in the inbox.
|
|
647
|
+
|
|
648
|
+
Bounded: a host that goes silent raises inside the ```repl``` block instead of hanging
|
|
649
|
+
the session forever.
|
|
650
|
+
"""
|
|
651
|
+
if all(r in self.inbox for r in rids):
|
|
652
|
+
return
|
|
653
|
+
with _stall_alarm(self.exec_timeout_s, self.await_timeout_s) as rearm:
|
|
654
|
+
while not all(r in self.inbox for r in rids):
|
|
655
|
+
if not self._pump():
|
|
248
656
|
raise RuntimeError("parent closed the pipe during a sub-LLM request")
|
|
249
|
-
|
|
250
|
-
if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
|
|
251
|
-
return msg
|
|
252
|
-
# Stray/late message (e.g. a reply to an earlier timed-out request): skip it.
|
|
253
|
-
print(
|
|
254
|
-
f"[rlm-sandbox] ignoring unexpected message during sub-LLM request: {str(msg)[:200]}",
|
|
255
|
-
file=_REAL_STDERR,
|
|
256
|
-
)
|
|
257
|
-
finally:
|
|
258
|
-
if pause and remaining > 0:
|
|
259
|
-
signal.setitimer(signal.ITIMER_REAL, remaining)
|
|
657
|
+
rearm()
|
|
260
658
|
|
|
261
|
-
def
|
|
262
|
-
|
|
263
|
-
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
659
|
+
def _take(self, rids) -> list[dict[str, Any]]:
|
|
660
|
+
return [self.inbox.pop(r) for r in rids]
|
|
264
661
|
|
|
265
|
-
def
|
|
662
|
+
def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
663
|
+
"""Post one request and block for its reply — the synchronous single-shot path."""
|
|
664
|
+
rid = self._post(kind, payload)
|
|
665
|
+
self._drain_until((rid,))
|
|
666
|
+
return self._take((rid,))[0]
|
|
667
|
+
|
|
668
|
+
# ---- spawn / await ---------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def _start_prompt(self, kind: str, prompt, model) -> Task:
|
|
671
|
+
text = str(prompt)
|
|
672
|
+
# A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
|
|
673
|
+
# looking exactly like data. Refuse instead of spending a call on it.
|
|
674
|
+
if not text.strip():
|
|
675
|
+
return Task.resolved(self, kind, _surfaced_error(
|
|
676
|
+
f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
|
|
677
|
+
rid = self._post(kind, {"prompt": text, "model": model})
|
|
678
|
+
return Task(self, kind, (rid,), _reduce_one, text[:40])
|
|
679
|
+
|
|
680
|
+
def _start_prompts(self, kind: str, prompts, model) -> Task:
|
|
266
681
|
prompts = [str(p) for p in prompts]
|
|
267
682
|
if not prompts:
|
|
268
|
-
return []
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
return
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return
|
|
683
|
+
return Task.resolved(self, kind, [])
|
|
684
|
+
# Only the all-blank case: one blank prompt among twenty is the caller's business.
|
|
685
|
+
if not any(p.strip() for p in prompts):
|
|
686
|
+
return Task.resolved(self, kind, [
|
|
687
|
+
_surfaced_error(f"{kind}() got only empty prompts")
|
|
688
|
+
] * len(prompts))
|
|
689
|
+
rid = self._post(kind, {"prompts": prompts, "model": model})
|
|
690
|
+
return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
|
|
276
691
|
|
|
277
|
-
def
|
|
278
|
-
""
|
|
692
|
+
def _start_llm_query(self, prompt, model: str | None = None) -> Task:
|
|
693
|
+
return self._start_prompt("llm_query", prompt, model)
|
|
279
694
|
|
|
280
|
-
|
|
281
|
-
|
|
695
|
+
def _start_rlm_query(self, prompt, model: str | None = None) -> Task:
|
|
696
|
+
return self._start_prompt("rlm_query", prompt, model)
|
|
697
|
+
|
|
698
|
+
def _start_llm_query_batched(self, prompts, model: str | None = None) -> Task:
|
|
699
|
+
return self._start_prompts("llm_query_batched", prompts, model)
|
|
700
|
+
|
|
701
|
+
def _start_rlm_query_batched(self, prompts, model: str | None = None) -> Task:
|
|
702
|
+
return self._start_prompts("rlm_query_batched", prompts, model)
|
|
703
|
+
|
|
704
|
+
def _start_llm_query_chunked(self, text, prompt: str, model: str | None = None) -> Task:
|
|
705
|
+
"""Split oversized text into cap-sized chunks and post EVERY batch at once.
|
|
706
|
+
|
|
707
|
+
One answer per chunk, order preserved. No exceptions escape: errors come back as
|
|
708
|
+
"Error: ..." strings per chunk (same contract as llm_query_batched). Because all
|
|
709
|
+
batches go on the wire together, a large input costs one round-trip of latency
|
|
710
|
+
rather than one per 20 chunks.
|
|
282
711
|
|
|
283
712
|
NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
|
|
284
713
|
UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
|
|
@@ -286,26 +715,313 @@ class Worker:
|
|
|
286
715
|
"""
|
|
287
716
|
text, prompt = str(text), str(prompt)
|
|
288
717
|
if not text:
|
|
289
|
-
return []
|
|
718
|
+
return Task.resolved(self, "llm_query_chunked", [])
|
|
290
719
|
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
|
|
291
720
|
if budget < 1_000:
|
|
292
|
-
return
|
|
721
|
+
return Task.resolved(self, "llm_query_chunked", [
|
|
722
|
+
f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"
|
|
723
|
+
])
|
|
293
724
|
chunks = _chunk_text(text, budget)
|
|
294
725
|
total = len(chunks)
|
|
295
726
|
if total > _MAX_CHUNKS:
|
|
296
|
-
return
|
|
297
|
-
|
|
727
|
+
return Task.resolved(self, "llm_query_chunked", [
|
|
728
|
+
f"Error: {total} chunks would be needed — filter/slice the text in Python first"
|
|
729
|
+
])
|
|
730
|
+
rids: list[str] = []
|
|
731
|
+
sizes: list[int] = []
|
|
298
732
|
for i in range(0, total, _MAX_CHUNK_BATCH):
|
|
299
733
|
batch = [
|
|
300
734
|
f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
|
|
301
735
|
for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
|
|
302
736
|
]
|
|
303
|
-
|
|
304
|
-
|
|
737
|
+
rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
|
|
738
|
+
sizes.append(len(batch))
|
|
739
|
+
return Task(self, "llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
|
|
740
|
+
|
|
741
|
+
def _builder_for(self, name: str):
|
|
742
|
+
# llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
|
|
743
|
+
# depends on its own map results, so it cannot be one (rids, pure reduce) Task.
|
|
744
|
+
return {
|
|
745
|
+
"llm_query": self._start_llm_query,
|
|
746
|
+
"llm_query_batched": self._start_llm_query_batched,
|
|
747
|
+
"llm_query_chunked": self._start_llm_query_chunked,
|
|
748
|
+
"map_files": self._start_map_files,
|
|
749
|
+
"rlm_query": self._start_rlm_query,
|
|
750
|
+
"rlm_query_batched": self._start_rlm_query_batched,
|
|
751
|
+
}.get(name)
|
|
752
|
+
|
|
753
|
+
def _spawn(self, fn, *args, **kwargs) -> Task:
|
|
754
|
+
"""Start a sub-call without waiting for it. `fn` is the scaffold function itself.
|
|
755
|
+
|
|
756
|
+
Returns a Task for rlm_await / rlm_await_all, possibly in a later ```repl``` block.
|
|
757
|
+
Misuse returns an already-resolved error Task rather than raising, matching the
|
|
758
|
+
"Error: ..." contract of the synchronous helpers.
|
|
759
|
+
"""
|
|
760
|
+
name = getattr(fn, "_rlm_name", None)
|
|
761
|
+
builder = self._builder_for(name) if isinstance(name, str) else None
|
|
762
|
+
if builder is None:
|
|
763
|
+
return Task.resolved(self, "spawn", _surfaced_error(
|
|
764
|
+
"spawn() takes llm_query, llm_query_batched, llm_query_chunked, map_files, "
|
|
765
|
+
"rlm_query or rlm_query_batched — not llm_map_reduce, whose reduce step depends "
|
|
766
|
+
"on its own map results and so cannot be a single Task"))
|
|
767
|
+
# Mark every request this builder posts as detached: the parent routes them to its
|
|
768
|
+
# session-scoped registry, since they may outlive the exec that started them.
|
|
769
|
+
self._detached = True
|
|
770
|
+
try:
|
|
771
|
+
return builder(*args, **kwargs)
|
|
772
|
+
except TypeError as e:
|
|
773
|
+
return Task.resolved(self, "spawn", _surfaced_error(f"bad spawn arguments — {e}"))
|
|
774
|
+
finally:
|
|
775
|
+
self._detached = False
|
|
776
|
+
|
|
777
|
+
def _await_task(self, task) -> Any:
|
|
778
|
+
"""Block until `task` has its result. Idempotent — the value is memoized."""
|
|
779
|
+
if not isinstance(task, Task):
|
|
780
|
+
return _surfaced_error(
|
|
781
|
+
f"rlm_await expects a Task from spawn(), got {type(task).__name__}"
|
|
782
|
+
)
|
|
783
|
+
if not task._settled:
|
|
784
|
+
self._drain_until(task._rids)
|
|
785
|
+
task._value = task._reduce(self._take(task._rids))
|
|
786
|
+
task._settled = True
|
|
787
|
+
return task._value
|
|
788
|
+
|
|
789
|
+
def _await_all(self, tasks) -> list:
|
|
790
|
+
"""Block until every task has its result. Order matches the input."""
|
|
791
|
+
tasks = list(tasks)
|
|
792
|
+
# One union drain so the tasks overlap instead of settling one after another.
|
|
793
|
+
union: list[str] = []
|
|
794
|
+
seen: set[str] = set()
|
|
795
|
+
for t in tasks:
|
|
796
|
+
if not isinstance(t, Task) or t._settled:
|
|
797
|
+
continue
|
|
798
|
+
for rid in t._rids:
|
|
799
|
+
if rid not in seen:
|
|
800
|
+
seen.add(rid)
|
|
801
|
+
union.append(rid)
|
|
802
|
+
if union:
|
|
803
|
+
self._drain_until(union)
|
|
804
|
+
return [self._await_task(t) for t in tasks]
|
|
805
|
+
|
|
806
|
+
# ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
|
|
807
|
+
|
|
808
|
+
def _entries(self) -> list[tuple[str, str]]:
|
|
809
|
+
return _context_entries(self.ns.get("context"))
|
|
810
|
+
|
|
811
|
+
def _get_index(self) -> _Bm25Index:
|
|
812
|
+
"""Build the BM25 index on first use; rebuild when `context` was replaced or resized.
|
|
813
|
+
|
|
814
|
+
Identity+length is a cheap stamp that catches the two ways context actually changes:
|
|
815
|
+
load_library() extending the list, and the model re-binding the name. In-place edits
|
|
816
|
+
that preserve length are not detected — documented, and rare in practice.
|
|
817
|
+
"""
|
|
818
|
+
ctx = self.ns.get("context")
|
|
819
|
+
stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
|
|
820
|
+
if self._index is None or self._index_stamp != stamp:
|
|
821
|
+
self._index = _Bm25Index(self._entries())
|
|
822
|
+
self._index_stamp = stamp
|
|
823
|
+
return self._index
|
|
305
824
|
|
|
825
|
+
def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
826
|
+
"""Rank `context` windows against a natural-language query (BM25).
|
|
827
|
+
|
|
828
|
+
Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
|
|
829
|
+
named files out of `context` and delegating them to llm_query / map_files.
|
|
830
|
+
"""
|
|
831
|
+
terms = _tokenize(str(query))
|
|
832
|
+
if not terms:
|
|
833
|
+
return []
|
|
834
|
+
try:
|
|
835
|
+
limit = max(1, min(int(k), 100))
|
|
836
|
+
except (TypeError, ValueError):
|
|
837
|
+
limit = 10
|
|
838
|
+
return self._get_index().query(terms, limit, path_glob)
|
|
839
|
+
|
|
840
|
+
def _grep_context(
|
|
841
|
+
self,
|
|
842
|
+
pattern: str,
|
|
843
|
+
k: int = 50,
|
|
844
|
+
path_glob: str | None = None,
|
|
845
|
+
before: int = 0,
|
|
846
|
+
after: int = 0,
|
|
847
|
+
) -> dict[str, Any]:
|
|
848
|
+
"""Regex over `context`, capped and shaped.
|
|
849
|
+
|
|
850
|
+
Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
|
|
851
|
+
`counts` is complete even when `hits` is capped, so a wide pattern reports its shape
|
|
852
|
+
instead of flooding stdout.
|
|
853
|
+
"""
|
|
854
|
+
try:
|
|
855
|
+
rx = re.compile(pattern)
|
|
856
|
+
except re.error as e:
|
|
857
|
+
return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
|
|
858
|
+
try:
|
|
859
|
+
limit = max(1, min(int(k), _GREP_HARD_CAP))
|
|
860
|
+
except (TypeError, ValueError):
|
|
861
|
+
limit = 50
|
|
862
|
+
pad_before = max(0, min(int(before or 0), 10))
|
|
863
|
+
pad_after = max(0, min(int(after or 0), 10))
|
|
864
|
+
|
|
865
|
+
hits: list[dict[str, Any]] = []
|
|
866
|
+
counts: dict[str, int] = {}
|
|
867
|
+
total = 0
|
|
868
|
+
for path, content in self._entries():
|
|
869
|
+
if path_glob and not fnmatch.fnmatch(path, path_glob):
|
|
870
|
+
continue
|
|
871
|
+
if not rx.search(content):
|
|
872
|
+
continue
|
|
873
|
+
lines = content.split("\n")
|
|
874
|
+
for i, line in enumerate(lines):
|
|
875
|
+
if not rx.search(line):
|
|
876
|
+
continue
|
|
877
|
+
total += 1
|
|
878
|
+
counts[path] = counts.get(path, 0) + 1
|
|
879
|
+
if len(hits) >= limit:
|
|
880
|
+
continue
|
|
881
|
+
lo = max(0, i - pad_before)
|
|
882
|
+
hi = min(len(lines), i + pad_after + 1)
|
|
883
|
+
hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
|
|
884
|
+
return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
|
|
885
|
+
|
|
886
|
+
def _outline(self, path: str) -> str:
|
|
887
|
+
"""Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
|
|
888
|
+
|
|
889
|
+
`path` matches exactly, then by suffix, then as a glob.
|
|
890
|
+
"""
|
|
891
|
+
target = str(path)
|
|
892
|
+
entries = self._entries()
|
|
893
|
+
content: str | None = None
|
|
894
|
+
for p, c in entries:
|
|
895
|
+
if p == target:
|
|
896
|
+
content = c
|
|
897
|
+
break
|
|
898
|
+
if content is None:
|
|
899
|
+
for p, c in entries:
|
|
900
|
+
if p.endswith(target) or fnmatch.fnmatch(p, target):
|
|
901
|
+
content = c
|
|
902
|
+
target = p
|
|
903
|
+
break
|
|
904
|
+
if content is None:
|
|
905
|
+
return f"Error: no context file matching {path!r} — use search() or list paths from context"
|
|
906
|
+
out: list[str] = [f"# {target}"]
|
|
907
|
+
for i, line in enumerate(content.split("\n")):
|
|
908
|
+
if _OUTLINE_LINE.match(line):
|
|
909
|
+
out.append(f"{i + 1}: {line.strip()[:160]}")
|
|
910
|
+
if len(out) == 1:
|
|
911
|
+
return f"# {target}\n(no definition-like lines found)"
|
|
912
|
+
return "\n".join(out)
|
|
913
|
+
|
|
914
|
+
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
915
|
+
|
|
916
|
+
def _start_map_files(self, files: Any, prompt: str, model: str | None = None) -> Task:
|
|
917
|
+
"""Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
|
|
918
|
+
|
|
919
|
+
All batches go on the wire together, so a 100-file map costs one round-trip of latency
|
|
920
|
+
rather than one per 20 files.
|
|
921
|
+
"""
|
|
922
|
+
prompt = str(prompt)
|
|
923
|
+
by_path: list[tuple[str, str]] = []
|
|
924
|
+
lookup: dict[str, str] | None = None
|
|
925
|
+
for item in files if isinstance(files, (list, tuple)) else [files]:
|
|
926
|
+
if isinstance(item, dict):
|
|
927
|
+
content = item.get("content", "")
|
|
928
|
+
by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
|
|
929
|
+
elif isinstance(item, str):
|
|
930
|
+
if lookup is None:
|
|
931
|
+
lookup = {p: c for p, c in self._entries()}
|
|
932
|
+
if item in lookup:
|
|
933
|
+
by_path.append((item, lookup[item]))
|
|
934
|
+
else:
|
|
935
|
+
by_path.append((item, ""))
|
|
936
|
+
if not by_path:
|
|
937
|
+
return Task.resolved(self, "map_files", {})
|
|
938
|
+
|
|
939
|
+
# Per-file prompt budget; anything larger is chunked and its answers concatenated.
|
|
940
|
+
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
|
|
941
|
+
if budget < 1_000:
|
|
942
|
+
return Task.resolved(self, "map_files", {
|
|
943
|
+
p: "Error: prompt too long to leave room for file content" for p, _ in by_path
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
requests: list[str] = []
|
|
947
|
+
spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
|
|
948
|
+
for path, content in by_path:
|
|
949
|
+
chunks = _chunk_text(content, budget) if len(content) > budget else [content]
|
|
950
|
+
spans.append((path, len(chunks)))
|
|
951
|
+
for j, chunk in enumerate(chunks):
|
|
952
|
+
header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
|
|
953
|
+
requests.append(f"{prompt}\n\n{header}\n{chunk}")
|
|
954
|
+
|
|
955
|
+
rids: list[str] = []
|
|
956
|
+
sizes: list[int] = []
|
|
957
|
+
for i in range(0, len(requests), _MAX_CHUNK_BATCH):
|
|
958
|
+
batch = requests[i:i + _MAX_CHUNK_BATCH]
|
|
959
|
+
rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
|
|
960
|
+
sizes.append(len(batch))
|
|
961
|
+
return Task(self, "map_files", tuple(rids),
|
|
962
|
+
_reduce_map_files(sizes, spans), f"{len(by_path)} files")
|
|
963
|
+
|
|
964
|
+
@_spawnable("map_files")
|
|
965
|
+
def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
|
|
966
|
+
"""Ask `prompt` of every given file, batched, and return {path: answer}.
|
|
967
|
+
|
|
968
|
+
`files` accepts context entries (dicts), paths (strings), or a mix — the whole
|
|
969
|
+
chunk/batch/collect loop the system prompt used to spell out, as one call.
|
|
970
|
+
Oversized files are split and their per-chunk answers joined.
|
|
971
|
+
"""
|
|
972
|
+
return self._await_task(self._start_map_files(files, prompt, model))
|
|
973
|
+
|
|
974
|
+
def _llm_map_reduce(
|
|
975
|
+
self,
|
|
976
|
+
items: Any,
|
|
977
|
+
map_prompt: str,
|
|
978
|
+
reduce_prompt: str,
|
|
979
|
+
model: str | None = None,
|
|
980
|
+
) -> str:
|
|
981
|
+
"""Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
|
|
982
|
+
|
|
983
|
+
The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
|
|
984
|
+
the buffers") as a single call, so the root never hand-rolls the loop.
|
|
985
|
+
"""
|
|
986
|
+
map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
|
|
987
|
+
seq = list(items) if isinstance(items, (list, tuple)) else [items]
|
|
988
|
+
if not seq:
|
|
989
|
+
return "Error: llm_map_reduce got no items"
|
|
990
|
+
texts = [
|
|
991
|
+
(str(it.get("content", "")) if isinstance(it, dict) else str(it))
|
|
992
|
+
for it in seq
|
|
993
|
+
]
|
|
994
|
+
labels = [
|
|
995
|
+
(str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
|
|
996
|
+
for i, it in enumerate(seq)
|
|
997
|
+
]
|
|
998
|
+
mapped: list[str] = []
|
|
999
|
+
for i in range(0, len(texts), _MAX_CHUNK_BATCH):
|
|
1000
|
+
batch = [
|
|
1001
|
+
f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
|
|
1002
|
+
for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
|
|
1003
|
+
]
|
|
1004
|
+
mapped.extend(self._llm_query_batched(batch, model))
|
|
1005
|
+
joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
|
|
1006
|
+
return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
|
|
1007
|
+
|
|
1008
|
+
# ---- sync helpers: await(start(...)), so there is exactly one code path -----------------
|
|
1009
|
+
|
|
1010
|
+
@_spawnable("llm_query")
|
|
1011
|
+
def _llm_query(self, prompt: str, model: str | None = None) -> str:
|
|
1012
|
+
return self._await_task(self._start_llm_query(prompt, model))
|
|
1013
|
+
|
|
1014
|
+
@_spawnable("llm_query_batched")
|
|
1015
|
+
def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
1016
|
+
return self._await_task(self._start_llm_query_batched(prompts, model))
|
|
1017
|
+
|
|
1018
|
+
@_spawnable("llm_query_chunked")
|
|
1019
|
+
def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
|
|
1020
|
+
return self._await_task(self._start_llm_query_chunked(text, prompt, model))
|
|
1021
|
+
|
|
1022
|
+
@_spawnable("rlm_query")
|
|
306
1023
|
def _rlm_query(self, prompt: str, model: str | None = None) -> str:
|
|
307
|
-
|
|
308
|
-
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
1024
|
+
return self._await_task(self._start_rlm_query(prompt, model))
|
|
309
1025
|
|
|
310
1026
|
def _ask_user_question(self, questions: list[dict]) -> list[dict]:
|
|
311
1027
|
"""Present structured questions to the user; blocks until answered.
|
|
@@ -512,17 +1228,9 @@ class Worker:
|
|
|
512
1228
|
return response
|
|
513
1229
|
return response if isinstance(response, str) else "ok"
|
|
514
1230
|
|
|
1231
|
+
@_spawnable("rlm_query_batched")
|
|
515
1232
|
def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
516
|
-
prompts
|
|
517
|
-
if not prompts:
|
|
518
|
-
return []
|
|
519
|
-
r = self._rpc("rlm_query_batched", {"prompts": prompts, "model": model})
|
|
520
|
-
if r.get("error"):
|
|
521
|
-
return [f"Error: {r['error']}"] * len(prompts)
|
|
522
|
-
out = r.get("responses")
|
|
523
|
-
if not isinstance(out, list) or len(out) != len(prompts):
|
|
524
|
-
return ["Error: malformed batched response"] * len(prompts)
|
|
525
|
-
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
1233
|
+
return self._await_task(self._start_rlm_query_batched(prompts, model))
|
|
526
1234
|
|
|
527
1235
|
# ---- context + execution --------------------------------------------------------------
|
|
528
1236
|
|
|
@@ -643,6 +1351,13 @@ class Worker:
|
|
|
643
1351
|
for k, v in self.ns.items():
|
|
644
1352
|
if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
|
|
645
1353
|
continue
|
|
1354
|
+
# A Task holds a back-reference to this Worker, so dill would happily pickle the
|
|
1355
|
+
# whole process. Top-level guard only: a Task nested inside a list/dict still
|
|
1356
|
+
# falls to the generic `except` below and skips the variable — which is why this
|
|
1357
|
+
# guard is explicit rather than left to that fallback.
|
|
1358
|
+
if isinstance(v, Task):
|
|
1359
|
+
skipped.append(k)
|
|
1360
|
+
continue
|
|
646
1361
|
try:
|
|
647
1362
|
blob = s.dumps(v)
|
|
648
1363
|
if len(blob) > MAX_VAR_BYTES:
|
|
@@ -680,6 +1395,8 @@ def main() -> None:
|
|
|
680
1395
|
ap = argparse.ArgumentParser()
|
|
681
1396
|
ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
|
|
682
1397
|
ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
|
|
1398
|
+
ap.add_argument("--await-timeout", type=float,
|
|
1399
|
+
default=float(os.environ.get("RLM_AWAIT_TIMEOUT_S", "600")))
|
|
683
1400
|
ap.add_argument("--max-prompt-chars", type=int,
|
|
684
1401
|
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
685
1402
|
ap.add_argument("--read-only", action="store_true",
|
|
@@ -688,17 +1405,32 @@ def main() -> None:
|
|
|
688
1405
|
args = ap.parse_args()
|
|
689
1406
|
|
|
690
1407
|
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
691
|
-
max_prompt_chars=args.max_prompt_chars, read_only=args.read_only
|
|
1408
|
+
max_prompt_chars=args.max_prompt_chars, read_only=args.read_only,
|
|
1409
|
+
await_timeout_s=args.await_timeout)
|
|
692
1410
|
_send({"id": "_init", "ok": True})
|
|
693
1411
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1412
|
+
while True:
|
|
1413
|
+
# Requests that arrived mid-exec were parked by _pump; replay them before reading.
|
|
1414
|
+
req = worker.take_deferred()
|
|
1415
|
+
if req is None:
|
|
1416
|
+
line = _REAL_STDIN.readline()
|
|
1417
|
+
if not line:
|
|
1418
|
+
return
|
|
1419
|
+
raw = line.strip()
|
|
1420
|
+
if not raw:
|
|
1421
|
+
continue
|
|
1422
|
+
try:
|
|
1423
|
+
req = json.loads(raw)
|
|
1424
|
+
except json.JSONDecodeError as e:
|
|
1425
|
+
_send({"id": "?", "ok": False, "error": f"bad json: {e}"})
|
|
1426
|
+
continue
|
|
1427
|
+
# A task spawned in an earlier exec settling while the worker is idle. Park it for
|
|
1428
|
+
# a later rlm_await; without this it would fall through to "unknown type" and the
|
|
1429
|
+
# result would be lost.
|
|
1430
|
+
if worker.park_reply(req):
|
|
697
1431
|
continue
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
except json.JSONDecodeError as e:
|
|
701
|
-
_send({"id": "?", "ok": False, "error": f"bad json: {e}"})
|
|
1432
|
+
if not isinstance(req, dict):
|
|
1433
|
+
_send({"id": "?", "ok": False, "error": f"expected an object, got {type(req).__name__}"})
|
|
702
1434
|
continue
|
|
703
1435
|
rid, kind = req.get("id", "?"), req.get("type")
|
|
704
1436
|
try:
|