@hicaru/pi-rlm 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bridge/llm-query.ts +59 -36
- package/src/bridge/rlm-query.ts +63 -77
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- 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 +48 -249
- 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 +35 -17
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/system.ts +143 -52
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +0 -7
- package/src/sandbox/sandbox-manager.ts +5 -5
- package/src/sandbox/sandbox.ts +25 -19
- package/src/sandbox/worker.py +354 -0
- 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/repl-tool.ts +105 -284
- package/src/tool/rlm-details.ts +0 -10
- package/src/tool/rlm-tool.ts +18 -31
- package/src/tool/subcall-render.ts +61 -9
- package/src/tool/subcall-store.ts +2 -2
- 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/mode/input-router.ts +0 -23
package/src/sandbox/worker.py
CHANGED
|
@@ -17,8 +17,11 @@ services the request in-process (it holds API keys).
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import argparse
|
|
20
|
+
import fnmatch
|
|
21
|
+
import heapq
|
|
20
22
|
import io
|
|
21
23
|
import json
|
|
24
|
+
import math
|
|
22
25
|
import os
|
|
23
26
|
import pickle
|
|
24
27
|
import re
|
|
@@ -102,12 +105,17 @@ RESERVED = frozenset(
|
|
|
102
105
|
{
|
|
103
106
|
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
104
107
|
"rlm_query", "rlm_query_batched",
|
|
108
|
+
"map_files", "llm_map_reduce",
|
|
109
|
+
"search", "grep_context", "outline",
|
|
105
110
|
"advance_phase", "save_artifact",
|
|
106
111
|
"ask_user_question", "todo",
|
|
107
112
|
"load_library",
|
|
108
113
|
"SHOW_VARS", "answer", "context",
|
|
109
114
|
}
|
|
110
115
|
)
|
|
116
|
+
# NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
|
|
117
|
+
# owned by the model, so they must appear in SHOW_VARS and be captured by snapshots — losing a
|
|
118
|
+
# memoized answer across a resume is exactly the failure the memo exists to prevent.
|
|
111
119
|
# Only the single name `context` is the packed world. Legacy context_N names are filtered out.
|
|
112
120
|
_CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
|
|
113
121
|
|
|
@@ -134,6 +142,145 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
134
142
|
return chunks
|
|
135
143
|
|
|
136
144
|
|
|
145
|
+
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
146
|
+
#
|
|
147
|
+
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
148
|
+
# context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
|
|
149
|
+
# the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
|
|
150
|
+
# These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
|
|
151
|
+
# spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
|
|
152
|
+
|
|
153
|
+
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
154
|
+
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
|
|
155
|
+
_SNIPPET_CHARS = 400
|
|
156
|
+
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
157
|
+
_BM25_K1 = 1.2
|
|
158
|
+
_BM25_B = 0.75
|
|
159
|
+
|
|
160
|
+
_TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
|
|
161
|
+
_CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
162
|
+
|
|
163
|
+
# Definition-ish lines across the languages this plugin is likely to meet. Deliberately
|
|
164
|
+
# lexical: an outline is an orientation aid, not a parse tree.
|
|
165
|
+
_OUTLINE_LINE = re.compile(
|
|
166
|
+
r"^\s*(?:"
|
|
167
|
+
r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
|
|
168
|
+
r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
|
|
169
|
+
r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
|
|
170
|
+
r"|def\s+\w+|class\s+\w+"
|
|
171
|
+
r"|func\s+\w+"
|
|
172
|
+
r"|#{1,4}\s+\S"
|
|
173
|
+
r")"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _tokenize(text: str) -> list[str]:
|
|
178
|
+
"""Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
|
|
179
|
+
out: list[str] = []
|
|
180
|
+
for raw in _TOKEN_SPLIT.split(text):
|
|
181
|
+
if not raw:
|
|
182
|
+
continue
|
|
183
|
+
lowered = raw.lower()
|
|
184
|
+
out.append(lowered)
|
|
185
|
+
if len(raw) > 3:
|
|
186
|
+
parts = _CAMEL_SPLIT.split(raw)
|
|
187
|
+
if len(parts) > 1:
|
|
188
|
+
for part in parts:
|
|
189
|
+
piece = part.lower()
|
|
190
|
+
if piece and piece != lowered:
|
|
191
|
+
out.append(piece)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _context_entries(context: Any) -> list[tuple[str, str]]:
|
|
196
|
+
"""(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
|
|
197
|
+
if isinstance(context, str):
|
|
198
|
+
return [("<context>", context)]
|
|
199
|
+
if not isinstance(context, list):
|
|
200
|
+
return []
|
|
201
|
+
out: list[tuple[str, str]] = []
|
|
202
|
+
for i, item in enumerate(context):
|
|
203
|
+
if isinstance(item, dict):
|
|
204
|
+
content = item.get("content", "")
|
|
205
|
+
out.append((
|
|
206
|
+
str(item.get("path", f"<context[{i}]>")),
|
|
207
|
+
content if isinstance(content, str) else str(content),
|
|
208
|
+
))
|
|
209
|
+
elif isinstance(item, str):
|
|
210
|
+
out.append((f"<context[{i}]>", item))
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _Bm25Index:
|
|
215
|
+
"""Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
|
|
216
|
+
|
|
217
|
+
__slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
218
|
+
|
|
219
|
+
def __init__(self, entries: list[tuple[str, str]]) -> None:
|
|
220
|
+
self.paths: list[str] = []
|
|
221
|
+
self.starts: list[int] = []
|
|
222
|
+
self.texts: list[str] = []
|
|
223
|
+
self.postings: dict[str, list[tuple[int, int]]] = {}
|
|
224
|
+
self.doc_len: list[int] = []
|
|
225
|
+
self.truncated = False
|
|
226
|
+
|
|
227
|
+
for path, content in entries:
|
|
228
|
+
if not content:
|
|
229
|
+
continue
|
|
230
|
+
lines = content.split("\n")
|
|
231
|
+
for start in range(0, len(lines), _INDEX_WINDOW_LINES):
|
|
232
|
+
if len(self.texts) >= _INDEX_MAX_WINDOWS:
|
|
233
|
+
self.truncated = True
|
|
234
|
+
break
|
|
235
|
+
window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
|
|
236
|
+
idx = len(self.texts)
|
|
237
|
+
self.paths.append(path)
|
|
238
|
+
self.starts.append(start + 1)
|
|
239
|
+
self.texts.append(window)
|
|
240
|
+
terms = _tokenize(window)
|
|
241
|
+
self.doc_len.append(len(terms))
|
|
242
|
+
freq: dict[str, int] = {}
|
|
243
|
+
for term in terms:
|
|
244
|
+
freq[term] = freq.get(term, 0) + 1
|
|
245
|
+
for term, tf in freq.items():
|
|
246
|
+
self.postings.setdefault(term, []).append((idx, tf))
|
|
247
|
+
if self.truncated:
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
total = len(self.doc_len)
|
|
251
|
+
self.avg_len = (sum(self.doc_len) / total) if total else 1.0
|
|
252
|
+
|
|
253
|
+
def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
|
|
254
|
+
total = len(self.texts)
|
|
255
|
+
if total == 0:
|
|
256
|
+
return []
|
|
257
|
+
scores: dict[int, float] = {}
|
|
258
|
+
for term in set(terms):
|
|
259
|
+
posting = self.postings.get(term)
|
|
260
|
+
if not posting:
|
|
261
|
+
continue
|
|
262
|
+
df = len(posting)
|
|
263
|
+
idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
264
|
+
for idx, tf in posting:
|
|
265
|
+
norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
|
|
266
|
+
scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
267
|
+
if path_glob:
|
|
268
|
+
scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
|
|
269
|
+
if not scores:
|
|
270
|
+
return []
|
|
271
|
+
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
272
|
+
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
273
|
+
for i, (idx, score) in enumerate(top):
|
|
274
|
+
text = self.texts[idx]
|
|
275
|
+
out[i] = {
|
|
276
|
+
"path": self.paths[idx],
|
|
277
|
+
"line": self.starts[idx],
|
|
278
|
+
"score": round(score, 3),
|
|
279
|
+
"snippet": text[:_SNIPPET_CHARS],
|
|
280
|
+
}
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
137
284
|
class _AnswerDict(dict):
|
|
138
285
|
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
139
286
|
|
|
@@ -174,6 +321,8 @@ class Worker:
|
|
|
174
321
|
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
175
322
|
self._context_payload: Any | None = None # pristine restore for the single `context` var
|
|
176
323
|
self._nudged: set[str] = set()
|
|
324
|
+
self._index: _Bm25Index | None = None
|
|
325
|
+
self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
|
|
177
326
|
self._restore_scaffold()
|
|
178
327
|
|
|
179
328
|
def _capture_answer(self, content: Any) -> None:
|
|
@@ -187,6 +336,17 @@ class Worker:
|
|
|
187
336
|
ns["llm_query_chunked"] = self._llm_query_chunked
|
|
188
337
|
ns["rlm_query"] = self._rlm_query
|
|
189
338
|
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
339
|
+
ns["map_files"] = self._map_files
|
|
340
|
+
ns["llm_map_reduce"] = self._llm_map_reduce
|
|
341
|
+
ns["search"] = self._search
|
|
342
|
+
ns["grep_context"] = self._grep_context
|
|
343
|
+
ns["outline"] = self._outline
|
|
344
|
+
# env_tips memo (paper App. C.3): "If a value isn't in `answers`, it doesn't exist."
|
|
345
|
+
# Re-created only when deleted — contents must survive every turn.
|
|
346
|
+
if not isinstance(ns.get("answers"), dict):
|
|
347
|
+
ns["answers"] = {}
|
|
348
|
+
if not isinstance(ns.get("plan"), dict):
|
|
349
|
+
ns["plan"] = {}
|
|
190
350
|
ns["advance_phase"] = self._advance_phase
|
|
191
351
|
ns["save_artifact"] = self._save_artifact
|
|
192
352
|
ns["ask_user_question"] = self._ask_user_question
|
|
@@ -303,6 +463,200 @@ class Worker:
|
|
|
303
463
|
results.extend(self._llm_query_batched(batch, model))
|
|
304
464
|
return results
|
|
305
465
|
|
|
466
|
+
# ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
|
|
467
|
+
|
|
468
|
+
def _entries(self) -> list[tuple[str, str]]:
|
|
469
|
+
return _context_entries(self.ns.get("context"))
|
|
470
|
+
|
|
471
|
+
def _get_index(self) -> _Bm25Index:
|
|
472
|
+
"""Build the BM25 index on first use; rebuild when `context` was replaced or resized.
|
|
473
|
+
|
|
474
|
+
Identity+length is a cheap stamp that catches the two ways context actually changes:
|
|
475
|
+
load_library() extending the list, and the model re-binding the name. In-place edits
|
|
476
|
+
that preserve length are not detected — documented, and rare in practice.
|
|
477
|
+
"""
|
|
478
|
+
ctx = self.ns.get("context")
|
|
479
|
+
stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
|
|
480
|
+
if self._index is None or self._index_stamp != stamp:
|
|
481
|
+
self._index = _Bm25Index(self._entries())
|
|
482
|
+
self._index_stamp = stamp
|
|
483
|
+
return self._index
|
|
484
|
+
|
|
485
|
+
def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
486
|
+
"""Rank `context` windows against a natural-language query (BM25).
|
|
487
|
+
|
|
488
|
+
Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
|
|
489
|
+
named files out of `context` and delegating them to llm_query / map_files.
|
|
490
|
+
"""
|
|
491
|
+
terms = _tokenize(str(query))
|
|
492
|
+
if not terms:
|
|
493
|
+
return []
|
|
494
|
+
try:
|
|
495
|
+
limit = max(1, min(int(k), 100))
|
|
496
|
+
except (TypeError, ValueError):
|
|
497
|
+
limit = 10
|
|
498
|
+
return self._get_index().query(terms, limit, path_glob)
|
|
499
|
+
|
|
500
|
+
def _grep_context(
|
|
501
|
+
self,
|
|
502
|
+
pattern: str,
|
|
503
|
+
k: int = 50,
|
|
504
|
+
path_glob: str | None = None,
|
|
505
|
+
before: int = 0,
|
|
506
|
+
after: int = 0,
|
|
507
|
+
) -> dict[str, Any]:
|
|
508
|
+
"""Regex over `context`, capped and shaped.
|
|
509
|
+
|
|
510
|
+
Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
|
|
511
|
+
`counts` is complete even when `hits` is capped, so a wide pattern reports its shape
|
|
512
|
+
instead of flooding stdout.
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
rx = re.compile(pattern)
|
|
516
|
+
except re.error as e:
|
|
517
|
+
return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
|
|
518
|
+
try:
|
|
519
|
+
limit = max(1, min(int(k), _GREP_HARD_CAP))
|
|
520
|
+
except (TypeError, ValueError):
|
|
521
|
+
limit = 50
|
|
522
|
+
pad_before = max(0, min(int(before or 0), 10))
|
|
523
|
+
pad_after = max(0, min(int(after or 0), 10))
|
|
524
|
+
|
|
525
|
+
hits: list[dict[str, Any]] = []
|
|
526
|
+
counts: dict[str, int] = {}
|
|
527
|
+
total = 0
|
|
528
|
+
for path, content in self._entries():
|
|
529
|
+
if path_glob and not fnmatch.fnmatch(path, path_glob):
|
|
530
|
+
continue
|
|
531
|
+
if not rx.search(content):
|
|
532
|
+
continue
|
|
533
|
+
lines = content.split("\n")
|
|
534
|
+
for i, line in enumerate(lines):
|
|
535
|
+
if not rx.search(line):
|
|
536
|
+
continue
|
|
537
|
+
total += 1
|
|
538
|
+
counts[path] = counts.get(path, 0) + 1
|
|
539
|
+
if len(hits) >= limit:
|
|
540
|
+
continue
|
|
541
|
+
lo = max(0, i - pad_before)
|
|
542
|
+
hi = min(len(lines), i + pad_after + 1)
|
|
543
|
+
hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
|
|
544
|
+
return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
|
|
545
|
+
|
|
546
|
+
def _outline(self, path: str) -> str:
|
|
547
|
+
"""Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
|
|
548
|
+
|
|
549
|
+
`path` matches exactly, then by suffix, then as a glob.
|
|
550
|
+
"""
|
|
551
|
+
target = str(path)
|
|
552
|
+
entries = self._entries()
|
|
553
|
+
content: str | None = None
|
|
554
|
+
for p, c in entries:
|
|
555
|
+
if p == target:
|
|
556
|
+
content = c
|
|
557
|
+
break
|
|
558
|
+
if content is None:
|
|
559
|
+
for p, c in entries:
|
|
560
|
+
if p.endswith(target) or fnmatch.fnmatch(p, target):
|
|
561
|
+
content = c
|
|
562
|
+
target = p
|
|
563
|
+
break
|
|
564
|
+
if content is None:
|
|
565
|
+
return f"Error: no context file matching {path!r} — use search() or list paths from context"
|
|
566
|
+
out: list[str] = [f"# {target}"]
|
|
567
|
+
for i, line in enumerate(content.split("\n")):
|
|
568
|
+
if _OUTLINE_LINE.match(line):
|
|
569
|
+
out.append(f"{i + 1}: {line.strip()[:160]}")
|
|
570
|
+
if len(out) == 1:
|
|
571
|
+
return f"# {target}\n(no definition-like lines found)"
|
|
572
|
+
return "\n".join(out)
|
|
573
|
+
|
|
574
|
+
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
575
|
+
|
|
576
|
+
def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
|
|
577
|
+
"""Ask `prompt` of every given file, batched, and return {path: answer}.
|
|
578
|
+
|
|
579
|
+
`files` accepts context entries (dicts), paths (strings), or a mix — the whole
|
|
580
|
+
chunk/batch/collect loop the system prompt used to spell out, as one call.
|
|
581
|
+
Oversized files are split and their per-chunk answers joined.
|
|
582
|
+
"""
|
|
583
|
+
prompt = str(prompt)
|
|
584
|
+
by_path: list[tuple[str, str]] = []
|
|
585
|
+
lookup: dict[str, str] | None = None
|
|
586
|
+
for item in files if isinstance(files, (list, tuple)) else [files]:
|
|
587
|
+
if isinstance(item, dict):
|
|
588
|
+
content = item.get("content", "")
|
|
589
|
+
by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
|
|
590
|
+
elif isinstance(item, str):
|
|
591
|
+
if lookup is None:
|
|
592
|
+
lookup = {p: c for p, c in self._entries()}
|
|
593
|
+
if item in lookup:
|
|
594
|
+
by_path.append((item, lookup[item]))
|
|
595
|
+
else:
|
|
596
|
+
by_path.append((item, ""))
|
|
597
|
+
if not by_path:
|
|
598
|
+
return {}
|
|
599
|
+
|
|
600
|
+
# Per-file prompt budget; anything larger is chunked and its answers concatenated.
|
|
601
|
+
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
|
|
602
|
+
if budget < 1_000:
|
|
603
|
+
return {p: "Error: prompt too long to leave room for file content" for p, _ in by_path}
|
|
604
|
+
|
|
605
|
+
requests: list[str] = []
|
|
606
|
+
spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
|
|
607
|
+
for path, content in by_path:
|
|
608
|
+
chunks = _chunk_text(content, budget) if len(content) > budget else [content]
|
|
609
|
+
spans.append((path, len(chunks)))
|
|
610
|
+
for j, chunk in enumerate(chunks):
|
|
611
|
+
header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
|
|
612
|
+
requests.append(f"{prompt}\n\n{header}\n{chunk}")
|
|
613
|
+
|
|
614
|
+
responses: list[str] = []
|
|
615
|
+
for i in range(0, len(requests), _MAX_CHUNK_BATCH):
|
|
616
|
+
responses.extend(self._llm_query_batched(requests[i:i + _MAX_CHUNK_BATCH], model))
|
|
617
|
+
|
|
618
|
+
out: dict[str, str] = {}
|
|
619
|
+
cursor = 0
|
|
620
|
+
for path, count in spans:
|
|
621
|
+
part = responses[cursor:cursor + count]
|
|
622
|
+
cursor += count
|
|
623
|
+
out[path] = part[0] if count == 1 and part else "\n\n".join(part)
|
|
624
|
+
return out
|
|
625
|
+
|
|
626
|
+
def _llm_map_reduce(
|
|
627
|
+
self,
|
|
628
|
+
items: Any,
|
|
629
|
+
map_prompt: str,
|
|
630
|
+
reduce_prompt: str,
|
|
631
|
+
model: str | None = None,
|
|
632
|
+
) -> str:
|
|
633
|
+
"""Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
|
|
634
|
+
|
|
635
|
+
The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
|
|
636
|
+
the buffers") as a single call, so the root never hand-rolls the loop.
|
|
637
|
+
"""
|
|
638
|
+
map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
|
|
639
|
+
seq = list(items) if isinstance(items, (list, tuple)) else [items]
|
|
640
|
+
if not seq:
|
|
641
|
+
return "Error: llm_map_reduce got no items"
|
|
642
|
+
texts = [
|
|
643
|
+
(str(it.get("content", "")) if isinstance(it, dict) else str(it))
|
|
644
|
+
for it in seq
|
|
645
|
+
]
|
|
646
|
+
labels = [
|
|
647
|
+
(str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
|
|
648
|
+
for i, it in enumerate(seq)
|
|
649
|
+
]
|
|
650
|
+
mapped: list[str] = []
|
|
651
|
+
for i in range(0, len(texts), _MAX_CHUNK_BATCH):
|
|
652
|
+
batch = [
|
|
653
|
+
f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
|
|
654
|
+
for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
|
|
655
|
+
]
|
|
656
|
+
mapped.extend(self._llm_query_batched(batch, model))
|
|
657
|
+
joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
|
|
658
|
+
return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
|
|
659
|
+
|
|
306
660
|
def _rlm_query(self, prompt: str, model: str | None = None) -> str:
|
|
307
661
|
r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
|
|
308
662
|
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
package/src/state/paths.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function generateRunId(
|
|
|
20
20
|
now: Date = new Date(),
|
|
21
21
|
suffix: string = randomBytes(RUN_ID_SUFFIX_BYTES).toString("hex"),
|
|
22
22
|
): string {
|
|
23
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
23
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
24
24
|
const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
25
25
|
return `${iso.slice(0, ISO_DATETIME_LENGTH).replaceAll(":", "-").replace("T", "_")}-${suffix}`;
|
|
26
26
|
}
|
package/src/state/reads.ts
CHANGED
|
@@ -12,8 +12,12 @@ import { runsDir, runDir, trailPath, contextPath } from "./paths.ts";
|
|
|
12
12
|
import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
|
|
13
13
|
import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Raw JSONL lines of a run's trail, in order. Missing or unreadable file → [].
|
|
17
|
+
* Shared by the fail-soft reader here and the hole-detecting reader in resume.ts, which
|
|
18
|
+
* differ only in how they treat a bad line.
|
|
19
|
+
*/
|
|
20
|
+
export async function readTrailLines(cwd: string, dir: string, runId: string): Promise<string[]> {
|
|
17
21
|
const path = trailPath(cwd, dir, runId);
|
|
18
22
|
if (!await pathExists(path)) return [];
|
|
19
23
|
const content = await failSoft(
|
|
@@ -21,10 +25,14 @@ export async function readRows(cwd: string, dir: string, runId: string): Promise
|
|
|
21
25
|
undefined as string | undefined,
|
|
22
26
|
);
|
|
23
27
|
const trimmed = content?.trim();
|
|
24
|
-
|
|
28
|
+
return trimmed ? trimmed.split("\n") : [];
|
|
29
|
+
}
|
|
25
30
|
|
|
31
|
+
/** Every well-formed row, in trail order. Malformed line → one warn, skipped. */
|
|
32
|
+
export async function readRows(cwd: string, dir: string, runId: string): Promise<Row[]> {
|
|
33
|
+
const lines = await readTrailLines(cwd, dir, runId);
|
|
26
34
|
const rows: Row[] = [];
|
|
27
|
-
for (const line of
|
|
35
|
+
for (const line of lines) {
|
|
28
36
|
try {
|
|
29
37
|
const row = JSON.parse(line) as unknown;
|
|
30
38
|
if (isRow(row)) rows.push(row);
|
package/src/state/resume.ts
CHANGED
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
* garbage from a crash is tolerated.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFile } from "node:fs/promises";
|
|
11
10
|
import { type ChatMsg } from "../bridge/model.ts";
|
|
12
11
|
import { appendUserMessage } from "../core/history.ts";
|
|
13
12
|
import { buildTurnPrompt } from "../prompts/user.ts";
|
|
14
|
-
import { readHeader } from "./reads.ts";
|
|
13
|
+
import { readHeader, readTrailLines } from "./reads.ts";
|
|
15
14
|
import {
|
|
16
15
|
isCompaction,
|
|
17
16
|
isHeader,
|
|
@@ -24,8 +23,8 @@ import {
|
|
|
24
23
|
type Row,
|
|
25
24
|
type RunHeader,
|
|
26
25
|
} from "./rows.ts";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
26
|
+
import { snapshotPath } from "./paths.ts";
|
|
27
|
+
import { pathExists } from "./internal.ts";
|
|
29
28
|
|
|
30
29
|
/** Artifact path + supersede flag reconstructed from phase rows. */
|
|
31
30
|
export interface PhaseReconArtifact {
|
|
@@ -63,15 +62,10 @@ export type ReconstructResult =
|
|
|
63
62
|
|
|
64
63
|
/** QB: single read + parse — detects mid-file holes without reading the trail twice. */
|
|
65
64
|
async function readRowsStrict(cwd: string, dir: string, runId: string): Promise<{ readonly rows: Row[]; readonly hole: boolean }> {
|
|
66
|
-
const
|
|
67
|
-
if (!await pathExists(path)) return { rows: [], hole: false };
|
|
68
|
-
const content = await failSoft(() => readFile(path, "utf-8"), undefined as string | undefined);
|
|
69
|
-
const trimmed = content?.trim();
|
|
70
|
-
if (!trimmed) return { rows: [], hole: false };
|
|
71
|
-
|
|
65
|
+
const lines = await readTrailLines(cwd, dir, runId);
|
|
72
66
|
const rows: Row[] = [];
|
|
73
67
|
let sawBad = false;
|
|
74
|
-
for (const line of
|
|
68
|
+
for (const line of lines) {
|
|
75
69
|
try {
|
|
76
70
|
const row = JSON.parse(line) as unknown;
|
|
77
71
|
if (!isRow(row)) {
|
package/src/text/parsing.ts
CHANGED
|
@@ -19,12 +19,6 @@ export function findReplBlocks(text: string): string[] {
|
|
|
19
19
|
return blocks;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/** True if the response contains at least one runnable ```repl``` block. */
|
|
23
|
-
export function hasReplBlock(text: string): boolean {
|
|
24
|
-
FENCE.lastIndex = 0;
|
|
25
|
-
return FENCE.test(text);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
22
|
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
|
|
29
23
|
export function truncateOutput(text: string, limit = 20_000): string {
|
|
30
24
|
if (text.length <= limit) return text;
|