@hicaru/pi-rlm 0.3.6 → 0.3.8
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/handlers/llm-query.ts +73 -17
- package/src/bridge/handlers/rlm-query.ts +130 -8
- package/src/bridge/handlers/types.ts +12 -0
- package/src/config/defaults.ts +17 -0
- package/src/config/settings.ts +45 -2
- package/src/core/answer.ts +7 -10
- package/src/core/budget.ts +182 -0
- package/src/core/compaction.ts +46 -0
- package/src/core/engine.ts +171 -5
- package/src/core/ledger.ts +451 -0
- package/src/core/memory.ts +577 -0
- package/src/core/model-registry.ts +88 -0
- package/src/core/types.ts +44 -3
- package/src/index.ts +60 -9
- package/src/mode/rlm-mode.ts +47 -9
- package/src/prompts/glossary.ts +139 -57
- package/src/prompts/native.ts +12 -7
- package/src/prompts/system.ts +22 -7
- package/src/prompts/user.ts +6 -3
- package/src/sandbox/interrupts.ts +24 -0
- package/src/sandbox/protocol.ts +69 -5
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +11 -6
- package/src/sandbox/py/scaffold.py +615 -0
- package/src/sandbox/py/worker.py +53 -506
- package/src/sandbox/sandbox.ts +21 -3
- package/src/text/repl-output.ts +15 -0
- package/src/tool/repl-result.ts +54 -10
- package/src/tool/repl-tool.ts +43 -9
- package/src/ui/status.ts +4 -1
- package/src/util/concurrency.ts +47 -0
package/src/sandbox/py/worker.py
CHANGED
|
@@ -39,33 +39,15 @@ from guards import (
|
|
|
39
39
|
_CONTEXT_NAME,
|
|
40
40
|
_send,
|
|
41
41
|
_stall_alarm,
|
|
42
|
-
|
|
42
|
+
_stall_message,
|
|
43
|
+
_StallTimeout,
|
|
43
44
|
RESERVED,
|
|
44
45
|
REAL_STDERR as _REAL_STDERR,
|
|
45
46
|
REAL_STDIN as _REAL_STDIN,
|
|
46
47
|
)
|
|
47
48
|
from hostio import read_host_payload
|
|
48
|
-
from retrieval import
|
|
49
|
-
|
|
50
|
-
_chunk_text,
|
|
51
|
-
_context_entries,
|
|
52
|
-
_CHUNK_HEADER_OVERHEAD,
|
|
53
|
-
_MAX_CHUNK_BATCH,
|
|
54
|
-
_MAX_CHUNKS,
|
|
55
|
-
_NUDGE_CHARS,
|
|
56
|
-
grep_context as _grep_context_impl,
|
|
57
|
-
outline as _outline_impl,
|
|
58
|
-
search as _search_impl,
|
|
59
|
-
)
|
|
60
|
-
from tasks import (
|
|
61
|
-
_clean_paths,
|
|
62
|
-
_reduce_batch,
|
|
63
|
-
_reduce_chunked,
|
|
64
|
-
_reduce_map_files,
|
|
65
|
-
_reduce_one,
|
|
66
|
-
_spawnable,
|
|
67
|
-
Task,
|
|
68
|
-
)
|
|
49
|
+
from retrieval import _Bm25Index, _NUDGE_CHARS
|
|
50
|
+
from tasks import Task
|
|
69
51
|
|
|
70
52
|
class _AnswerDict(dict):
|
|
71
53
|
"""`answer` dict; flipping `ready` True captures the final answer for the parent."""
|
|
@@ -83,18 +65,25 @@ class _AnswerDict(dict):
|
|
|
83
65
|
|
|
84
66
|
|
|
85
67
|
|
|
86
|
-
|
|
68
|
+
from scaffold import WorkerScaffold
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Worker(WorkerScaffold):
|
|
87
72
|
def __init__(
|
|
88
73
|
self,
|
|
89
74
|
depth: int,
|
|
90
75
|
exec_timeout_s: float,
|
|
91
76
|
max_prompt_chars: int,
|
|
92
77
|
await_timeout_s: float = 600.0,
|
|
78
|
+
surface: str = "root",
|
|
93
79
|
):
|
|
94
80
|
self.depth = depth
|
|
95
81
|
self.exec_timeout_s = exec_timeout_s
|
|
96
82
|
self.max_prompt_chars = max_prompt_chars
|
|
97
83
|
self.await_timeout_s = await_timeout_s
|
|
84
|
+
# v5 role separation: "child" installs the delegation-only scaffold (no retrieval —
|
|
85
|
+
# a child's world arrives as text via getChildContext; retrieval belongs to the root).
|
|
86
|
+
self.surface = surface
|
|
98
87
|
self._rid = 0
|
|
99
88
|
self._final_answer: str | None = None
|
|
100
89
|
# Replies parked by rid until something awaits them. Unbounded by design: a task
|
|
@@ -103,6 +92,9 @@ class Worker:
|
|
|
103
92
|
# await_task, which is strictly worse than the memory.
|
|
104
93
|
self.inbox: dict[str, dict[str, Any]] = {}
|
|
105
94
|
self._inflight: set[str] = set()
|
|
95
|
+
# Every Task this worker created. Survives the model deleting the bound name, so
|
|
96
|
+
# await_task() / list_tasks() can still collect a spawn the model forgot to store.
|
|
97
|
+
self._handles: list[Task] = []
|
|
106
98
|
# Requests (exec/shutdown) that arrived mid-exec; main() replays them.
|
|
107
99
|
self._deferred: list[Any] = []
|
|
108
100
|
# Kept for spawn() compatibility; sub-LLM kinds always post detached=true so fan-out
|
|
@@ -135,18 +127,23 @@ class Worker:
|
|
|
135
127
|
ns["spawn"] = self._spawn
|
|
136
128
|
# One collect API: await_task(Task) or await_task([Task, ...])
|
|
137
129
|
ns["await_task"] = self._await_task
|
|
130
|
+
ns["list_tasks"] = self._list_tasks
|
|
138
131
|
ns["map_files"] = self._map_files
|
|
139
132
|
ns["llm_map_reduce"] = self._llm_map_reduce
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
133
|
+
if self.surface != "child":
|
|
134
|
+
ns["search"] = self._search
|
|
135
|
+
ns["grep_context"] = self._grep_context
|
|
136
|
+
ns["outline"] = self._outline
|
|
137
|
+
# env_tips memo: collected *results* live in `answers`; Task handles live in REPL vars.
|
|
144
138
|
# Re-created only when deleted — contents must survive every turn.
|
|
145
139
|
if not isinstance(ns.get("answers"), dict):
|
|
146
140
|
ns["answers"] = {}
|
|
147
141
|
if not isinstance(ns.get("plan"), dict):
|
|
148
142
|
ns["plan"] = {}
|
|
149
|
-
|
|
143
|
+
if self.surface != "child":
|
|
144
|
+
ns["add_context"] = self._add_context
|
|
145
|
+
ns["list_claims"] = self._list_claims
|
|
146
|
+
ns["memory"] = self._memory_api()
|
|
150
147
|
ns["SHOW_VARS"] = self._show_vars
|
|
151
148
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
152
149
|
cur = ns.get("answer")
|
|
@@ -180,7 +177,10 @@ class Worker:
|
|
|
180
177
|
]
|
|
181
178
|
|
|
182
179
|
def _show_vars(self) -> str:
|
|
183
|
-
avail
|
|
180
|
+
avail: dict[str, str] = {}
|
|
181
|
+
for k in self._user_var_names():
|
|
182
|
+
v = self.ns[k]
|
|
183
|
+
avail[k] = repr(v) if isinstance(v, Task) else type(v).__name__
|
|
184
184
|
return f"Available variables: {avail}" if avail else "No variables created yet."
|
|
185
185
|
|
|
186
186
|
# ---- sub-LLM bridge over stdio --------------------------------------------------------
|
|
@@ -234,23 +234,29 @@ class Worker:
|
|
|
234
234
|
return True
|
|
235
235
|
if self.park_reply(msg):
|
|
236
236
|
return True
|
|
237
|
+
if isinstance(msg, dict) and msg.get("type") == "heartbeat":
|
|
238
|
+
return True
|
|
237
239
|
# A request (exec/shutdown) arriving mid-exec: main() replays it.
|
|
238
240
|
self._deferred.append(msg)
|
|
239
241
|
return True
|
|
240
242
|
|
|
241
|
-
def _drain_until(self, rids) ->
|
|
243
|
+
def _drain_until(self, rids) -> bool:
|
|
242
244
|
"""Block until every rid in `rids` has its reply parked in the inbox.
|
|
243
245
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
Returns False if the host stayed silent for `await_timeout_s` — the Task is
|
|
247
|
+
not settled; a later await_task can collect it. A closed pipe still raises.
|
|
246
248
|
"""
|
|
247
249
|
if all(r in self.inbox for r in rids):
|
|
248
|
-
return
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
return True
|
|
251
|
+
try:
|
|
252
|
+
with _stall_alarm(self.exec_timeout_s, self.await_timeout_s) as rearm:
|
|
253
|
+
while not all(r in self.inbox for r in rids):
|
|
254
|
+
if not self._pump():
|
|
255
|
+
raise RuntimeError("parent closed the pipe during a sub-LLM request")
|
|
256
|
+
rearm()
|
|
257
|
+
except _StallTimeout:
|
|
258
|
+
return False
|
|
259
|
+
return True
|
|
254
260
|
|
|
255
261
|
def _take(self, rids) -> list[dict[str, Any]]:
|
|
256
262
|
return [self.inbox.pop(r) for r in rids]
|
|
@@ -258,475 +264,10 @@ class Worker:
|
|
|
258
264
|
def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
259
265
|
"""Post one request and block for its reply — the synchronous single-shot path."""
|
|
260
266
|
rid = self._post(kind, payload)
|
|
261
|
-
self._drain_until((rid,))
|
|
267
|
+
if not self._drain_until((rid,)):
|
|
268
|
+
return {"error": _stall_message(self.await_timeout_s)}
|
|
262
269
|
return self._take((rid,))[0]
|
|
263
270
|
|
|
264
|
-
# ---- spawn / await ---------------------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
def _start_prompt(self, kind: str, prompt, paths=None) -> Task:
|
|
267
|
-
text = str(prompt)
|
|
268
|
-
# A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
|
|
269
|
-
# looking exactly like data. Refuse instead of spending a call on it.
|
|
270
|
-
if not text.strip():
|
|
271
|
-
return Task.resolved(self, kind, _surfaced_error(
|
|
272
|
-
f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
|
|
273
|
-
payload: dict[str, Any] = {"prompt": text}
|
|
274
|
-
clean = _clean_paths(paths)
|
|
275
|
-
if clean is not None:
|
|
276
|
-
payload["paths"] = clean
|
|
277
|
-
rid = self._post(kind, payload)
|
|
278
|
-
return Task(self, kind, (rid,), _reduce_one, text[:40])
|
|
279
|
-
|
|
280
|
-
def _start_prompts(self, kind: str, prompts, paths=None) -> Task:
|
|
281
|
-
prompts = [str(p) for p in prompts]
|
|
282
|
-
if not prompts:
|
|
283
|
-
return Task.resolved(self, kind, [])
|
|
284
|
-
# Only the all-blank case: one blank prompt among twenty is the caller's business.
|
|
285
|
-
if not any(p.strip() for p in prompts):
|
|
286
|
-
return Task.resolved(self, kind, [
|
|
287
|
-
_surfaced_error(f"{kind}() got only empty prompts")
|
|
288
|
-
] * len(prompts))
|
|
289
|
-
payload: dict[str, Any] = {"prompts": prompts}
|
|
290
|
-
# One prefix set for the whole batch: a per-prompt aligned list is an API nobody uses
|
|
291
|
-
# correctly, and every prompt in a batch is asking about the same slice anyway.
|
|
292
|
-
clean = _clean_paths(paths)
|
|
293
|
-
if clean is not None:
|
|
294
|
-
payload["paths"] = clean
|
|
295
|
-
rid = self._post(kind, payload)
|
|
296
|
-
return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
|
|
297
|
-
|
|
298
|
-
def _start_llm_query(self, prompt) -> Task:
|
|
299
|
-
return self._start_prompt("llm_query", prompt)
|
|
300
|
-
|
|
301
|
-
def _start_rlm_query(self, prompt, paths=None) -> Task:
|
|
302
|
-
return self._start_prompt("rlm_query", prompt, paths)
|
|
303
|
-
|
|
304
|
-
def _start_llm_batch(self, prompts) -> Task:
|
|
305
|
-
return self._start_prompts("llm_batch", prompts)
|
|
306
|
-
|
|
307
|
-
def _start_rlm_batch(self, prompts, paths=None) -> Task:
|
|
308
|
-
return self._start_prompts("rlm_batch", prompts, paths)
|
|
309
|
-
|
|
310
|
-
def _start_llm_query_chunked(self, text, prompt: str) -> Task:
|
|
311
|
-
"""Split oversized text into cap-sized chunks and post EVERY batch at once.
|
|
312
|
-
|
|
313
|
-
One answer per chunk, order preserved. No exceptions escape: errors come back as
|
|
314
|
-
"Error: ..." strings per chunk (same contract as llm_batch). Because all
|
|
315
|
-
batches go on the wire together, a large input costs one round-trip of latency
|
|
316
|
-
rather than one per 20 chunks.
|
|
317
|
-
|
|
318
|
-
NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
|
|
319
|
-
UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
|
|
320
|
-
parent and get per-chunk rejected. Acceptable trade-off for typical code/log/profile text.
|
|
321
|
-
"""
|
|
322
|
-
text, prompt = str(text), str(prompt)
|
|
323
|
-
if not text:
|
|
324
|
-
return Task.resolved(self, "llm_query_chunked", [])
|
|
325
|
-
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
|
|
326
|
-
if budget < 1_000:
|
|
327
|
-
return Task.resolved(self, "llm_query_chunked", [
|
|
328
|
-
f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"
|
|
329
|
-
])
|
|
330
|
-
chunks = _chunk_text(text, budget)
|
|
331
|
-
total = len(chunks)
|
|
332
|
-
if total > _MAX_CHUNKS:
|
|
333
|
-
return Task.resolved(self, "llm_query_chunked", [
|
|
334
|
-
f"Error: {total} chunks would be needed — filter/slice the text in Python first"
|
|
335
|
-
])
|
|
336
|
-
rids: list[str] = []
|
|
337
|
-
sizes: list[int] = []
|
|
338
|
-
for i in range(0, total, _MAX_CHUNK_BATCH):
|
|
339
|
-
batch = [
|
|
340
|
-
f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
|
|
341
|
-
for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
|
|
342
|
-
]
|
|
343
|
-
rids.append(self._post("llm_batch", {"prompts": batch}))
|
|
344
|
-
sizes.append(len(batch))
|
|
345
|
-
return Task(self, "llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
|
|
346
|
-
def _builder_for(self, name: str):
|
|
347
|
-
# llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
|
|
348
|
-
# depends on its own map results, so it cannot be one (rids, pure reduce) Task.
|
|
349
|
-
return {
|
|
350
|
-
"llm_query": self._start_llm_query,
|
|
351
|
-
"llm_batch": self._start_llm_batch,
|
|
352
|
-
"llm_query_chunked": self._start_llm_query_chunked,
|
|
353
|
-
"map_files": self._start_map_files,
|
|
354
|
-
"rlm_query": self._start_rlm_query,
|
|
355
|
-
"rlm_batch": self._start_rlm_batch,
|
|
356
|
-
}.get(name)
|
|
357
|
-
|
|
358
|
-
def _spawn(self, fn, *args, **kwargs) -> Task:
|
|
359
|
-
"""Start a sub-call without waiting for it. `fn` is the scaffold function itself.
|
|
360
|
-
|
|
361
|
-
Returns a Task for await_task, possibly in a later ```repl``` block.
|
|
362
|
-
Misuse returns an already-resolved error Task rather than raising, matching the
|
|
363
|
-
"Error: ..." contract of the synchronous helpers.
|
|
364
|
-
"""
|
|
365
|
-
name = getattr(fn, "_rlm_name", None)
|
|
366
|
-
builder = self._builder_for(name) if isinstance(name, str) else None
|
|
367
|
-
if builder is None:
|
|
368
|
-
return Task.resolved(self, "spawn", _surfaced_error(
|
|
369
|
-
"spawn() takes llm_query, llm_batch, llm_query_chunked, map_files, "
|
|
370
|
-
"rlm_query or rlm_batch — not llm_map_reduce, whose reduce step depends "
|
|
371
|
-
"on its own map results and so cannot be a single Task"))
|
|
372
|
-
# Sub-LLM kinds already post detached via _post; keep the flag for clarity / future kinds.
|
|
373
|
-
self._detached = True
|
|
374
|
-
try:
|
|
375
|
-
return builder(*args, **kwargs)
|
|
376
|
-
except TypeError as e:
|
|
377
|
-
return Task.resolved(self, "spawn", _surfaced_error(f"bad spawn arguments — {e}"))
|
|
378
|
-
finally:
|
|
379
|
-
self._detached = False
|
|
380
|
-
|
|
381
|
-
def _await_one(self, task: Task) -> Any:
|
|
382
|
-
"""Block until one Task has its result. Idempotent — the value is memoized."""
|
|
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_task(self, task_or_tasks) -> Any:
|
|
390
|
-
"""Collect result(s). Accepts a single Task or a list/tuple of Tasks.
|
|
391
|
-
|
|
392
|
-
Canonical name for the model: await_task(...). (bare `await` is a Python keyword.)
|
|
393
|
-
"""
|
|
394
|
-
if isinstance(task_or_tasks, Task):
|
|
395
|
-
return self._await_one(task_or_tasks)
|
|
396
|
-
if isinstance(task_or_tasks, (list, tuple)):
|
|
397
|
-
tasks = list(task_or_tasks)
|
|
398
|
-
union: list[str] = []
|
|
399
|
-
seen: set[str] = set()
|
|
400
|
-
for t in tasks:
|
|
401
|
-
if not isinstance(t, Task) or t._settled:
|
|
402
|
-
continue
|
|
403
|
-
for rid in t._rids:
|
|
404
|
-
if rid not in seen:
|
|
405
|
-
seen.add(rid)
|
|
406
|
-
union.append(rid)
|
|
407
|
-
if union:
|
|
408
|
-
self._drain_until(union)
|
|
409
|
-
out: list[Any] = []
|
|
410
|
-
for t in tasks:
|
|
411
|
-
if isinstance(t, Task):
|
|
412
|
-
out.append(self._await_one(t))
|
|
413
|
-
else:
|
|
414
|
-
out.append(
|
|
415
|
-
_surfaced_error(
|
|
416
|
-
f"await_task expects Task items, got {type(t).__name__}"
|
|
417
|
-
)
|
|
418
|
-
)
|
|
419
|
-
return out
|
|
420
|
-
return _surfaced_error(
|
|
421
|
-
f"await_task expects a Task or list of Tasks, got {type(task_or_tasks).__name__}"
|
|
422
|
-
)
|
|
423
|
-
|
|
424
|
-
# ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
|
|
425
|
-
|
|
426
|
-
def _entries(self) -> list[tuple[str, str]]:
|
|
427
|
-
return _context_entries(self.ns.get("context"))
|
|
428
|
-
|
|
429
|
-
def _get_index(self) -> _Bm25Index:
|
|
430
|
-
"""Build the BM25 index on first use; rebuild when `context` was replaced or resized.
|
|
431
|
-
|
|
432
|
-
Identity+length is a cheap stamp that catches the two ways context actually changes:
|
|
433
|
-
add_context() extending the list, and the model re-binding the name. In-place edits
|
|
434
|
-
that preserve length are not detected — documented, and rare in practice.
|
|
435
|
-
"""
|
|
436
|
-
ctx = self.ns.get("context")
|
|
437
|
-
stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
|
|
438
|
-
if self._index is None or self._index_stamp != stamp:
|
|
439
|
-
self._index = _Bm25Index(self._entries())
|
|
440
|
-
self._index_stamp = stamp
|
|
441
|
-
return self._index
|
|
442
|
-
|
|
443
|
-
def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
444
|
-
"""Rank `context` windows against a natural-language query (BM25).
|
|
445
|
-
|
|
446
|
-
Returns [{path, line, score, snippet}] — pointers, not bodies.
|
|
447
|
-
"""
|
|
448
|
-
return _search_impl(self._entries(), self._get_index(), query, k, path_glob)
|
|
449
|
-
|
|
450
|
-
def _grep_context(
|
|
451
|
-
self,
|
|
452
|
-
pattern: str,
|
|
453
|
-
k: int = 50,
|
|
454
|
-
path_glob: str | None = None,
|
|
455
|
-
before: int = 0,
|
|
456
|
-
after: int = 0,
|
|
457
|
-
) -> dict[str, Any]:
|
|
458
|
-
"""Regex over `context`, capped and shaped. See retrieval.grep_context."""
|
|
459
|
-
return _grep_context_impl(self._entries(), pattern, k, path_glob, before, after)
|
|
460
|
-
|
|
461
|
-
def _outline(self, path: str) -> str:
|
|
462
|
-
"""Definition/heading skeleton of one context file. See retrieval.outline."""
|
|
463
|
-
return _outline_impl(self._entries(), path)
|
|
464
|
-
|
|
465
|
-
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
466
|
-
|
|
467
|
-
def _start_map_files(self, files: Any, prompt: str) -> Task:
|
|
468
|
-
"""Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
|
|
469
|
-
|
|
470
|
-
All batches go on the wire together, so a 100-file map costs one round-trip of latency
|
|
471
|
-
rather than one per 20 files.
|
|
472
|
-
"""
|
|
473
|
-
prompt = str(prompt)
|
|
474
|
-
by_path: list[tuple[str, str]] = []
|
|
475
|
-
lookup: dict[str, str] | None = None
|
|
476
|
-
for item in files if isinstance(files, (list, tuple)) else [files]:
|
|
477
|
-
if isinstance(item, dict):
|
|
478
|
-
content = item.get("content", "")
|
|
479
|
-
by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
|
|
480
|
-
elif isinstance(item, str):
|
|
481
|
-
if lookup is None:
|
|
482
|
-
lookup = {p: c for p, c in self._entries()}
|
|
483
|
-
if item in lookup:
|
|
484
|
-
by_path.append((item, lookup[item]))
|
|
485
|
-
else:
|
|
486
|
-
by_path.append((item, ""))
|
|
487
|
-
if not by_path:
|
|
488
|
-
return Task.resolved(self, "map_files", {})
|
|
489
|
-
|
|
490
|
-
# Per-file prompt budget; anything larger is chunked and its answers concatenated.
|
|
491
|
-
budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
|
|
492
|
-
if budget < 1_000:
|
|
493
|
-
return Task.resolved(self, "map_files", {
|
|
494
|
-
p: "Error: prompt too long to leave room for file content" for p, _ in by_path
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
requests: list[str] = []
|
|
498
|
-
spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
|
|
499
|
-
for path, content in by_path:
|
|
500
|
-
chunks = _chunk_text(content, budget) if len(content) > budget else [content]
|
|
501
|
-
spans.append((path, len(chunks)))
|
|
502
|
-
for j, chunk in enumerate(chunks):
|
|
503
|
-
header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
|
|
504
|
-
requests.append(f"{prompt}\n\n{header}\n{chunk}")
|
|
505
|
-
|
|
506
|
-
rids: list[str] = []
|
|
507
|
-
sizes: list[int] = []
|
|
508
|
-
for i in range(0, len(requests), _MAX_CHUNK_BATCH):
|
|
509
|
-
batch = requests[i:i + _MAX_CHUNK_BATCH]
|
|
510
|
-
rids.append(self._post("llm_batch", {"prompts": batch}))
|
|
511
|
-
sizes.append(len(batch))
|
|
512
|
-
return Task(self, "map_files", tuple(rids),
|
|
513
|
-
_reduce_map_files(sizes, spans), f"{len(by_path)} files")
|
|
514
|
-
|
|
515
|
-
@_spawnable("map_files")
|
|
516
|
-
def _map_files(self, files: Any, prompt: str) -> Task:
|
|
517
|
-
"""Always spawn. Collect with await_task(t) → dict[path, answer].
|
|
518
|
-
|
|
519
|
-
`files` accepts context entries (dicts), paths (strings), or a mix — the whole
|
|
520
|
-
chunk/batch/collect loop the system prompt used to spell out, as one call.
|
|
521
|
-
Oversized files are split and their per-chunk answers joined.
|
|
522
|
-
Posts are detached (↯bg) so fan-out outlives the repl cell.
|
|
523
|
-
"""
|
|
524
|
-
return self._start_map_files(files, prompt)
|
|
525
|
-
|
|
526
|
-
def _llm_map_reduce(
|
|
527
|
-
self,
|
|
528
|
-
items: Any,
|
|
529
|
-
map_prompt: str,
|
|
530
|
-
reduce_prompt: str,
|
|
531
|
-
) -> str:
|
|
532
|
-
"""Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
|
|
533
|
-
|
|
534
|
-
The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
|
|
535
|
-
the buffers") as a single call, so the root never hand-rolls the loop.
|
|
536
|
-
"""
|
|
537
|
-
map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
|
|
538
|
-
seq = list(items) if isinstance(items, (list, tuple)) else [items]
|
|
539
|
-
if not seq:
|
|
540
|
-
return "Error: llm_map_reduce got no items"
|
|
541
|
-
texts = [
|
|
542
|
-
(str(it.get("content", "")) if isinstance(it, dict) else str(it))
|
|
543
|
-
for it in seq
|
|
544
|
-
]
|
|
545
|
-
labels = [
|
|
546
|
-
(str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
|
|
547
|
-
for i, it in enumerate(seq)
|
|
548
|
-
]
|
|
549
|
-
mapped: list[str] = []
|
|
550
|
-
for i in range(0, len(texts), _MAX_CHUNK_BATCH):
|
|
551
|
-
batch = [
|
|
552
|
-
f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
|
|
553
|
-
for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
|
|
554
|
-
]
|
|
555
|
-
# Core tools always return Task — helpers must await explicitly.
|
|
556
|
-
part = self._await_task(self._start_llm_batch(batch))
|
|
557
|
-
mapped.extend(part if isinstance(part, list) else [str(part)])
|
|
558
|
-
joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
|
|
559
|
-
reduced = self._await_task(
|
|
560
|
-
self._start_llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}")
|
|
561
|
-
)
|
|
562
|
-
return str(reduced)
|
|
563
|
-
|
|
564
|
-
# ---- Core tools: ALWAYS spawn (return Task). Collect with await_task only. ------------
|
|
565
|
-
|
|
566
|
-
@_spawnable("llm_query")
|
|
567
|
-
def _llm_query(self, prompt: str) -> Task:
|
|
568
|
-
"""Always spawn. Collect with await_task(t). Never auto-awaits."""
|
|
569
|
-
return self._start_llm_query(prompt)
|
|
570
|
-
|
|
571
|
-
@_spawnable("llm_batch")
|
|
572
|
-
def _llm_batch(self, prompts) -> Task:
|
|
573
|
-
"""Always spawn. Collect with await_task(t) → ordered list[str]."""
|
|
574
|
-
return self._start_llm_batch(prompts)
|
|
575
|
-
|
|
576
|
-
@_spawnable("llm_query_chunked")
|
|
577
|
-
def _llm_query_chunked(self, text, prompt: str) -> Task:
|
|
578
|
-
"""Always spawn. Collect with await_task(t) → list[str] (one answer per chunk)."""
|
|
579
|
-
return self._start_llm_query_chunked(text, prompt)
|
|
580
|
-
|
|
581
|
-
@_spawnable("rlm_query")
|
|
582
|
-
def _rlm_query(self, prompt: str, paths=None) -> Task:
|
|
583
|
-
"""Always spawn. Collect with await_task(t)."""
|
|
584
|
-
return self._start_rlm_query(prompt, paths)
|
|
585
|
-
def _add_context(self, source: str) -> dict[str, Any] | str:
|
|
586
|
-
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
587
|
-
|
|
588
|
-
Paths are namespaced under ctx/<source_id>/ (host). Content is always in the
|
|
589
|
-
single `context` list — never a new context_N variable.
|
|
590
|
-
Host-side idempotency may return already_loaded without a payload path.
|
|
591
|
-
Documents (PDF/DOCX/…) are converted to Markdown on the host.
|
|
592
|
-
"""
|
|
593
|
-
r = self._rpc("add_context", {"source": str(source)})
|
|
594
|
-
if r.get("error"):
|
|
595
|
-
return f"Error: {r['error']}"
|
|
596
|
-
if r.get("already_loaded"):
|
|
597
|
-
source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "ctx"
|
|
598
|
-
path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"ctx/{source_id}/"
|
|
599
|
-
ctx = self.ns.get("context")
|
|
600
|
-
ctx_len = len(ctx) if isinstance(ctx, list) else 0
|
|
601
|
-
print(
|
|
602
|
-
f"[rlm] add_context: already loaded {source_id} "
|
|
603
|
-
f"(paths under {path_prefix}, context len={ctx_len})"
|
|
604
|
-
)
|
|
605
|
-
return {
|
|
606
|
-
"source": str(source),
|
|
607
|
-
"source_id": source_id,
|
|
608
|
-
"path_prefix": path_prefix,
|
|
609
|
-
"files": 0,
|
|
610
|
-
"chars": r.get("chars"),
|
|
611
|
-
"context_len": ctx_len,
|
|
612
|
-
"already_loaded": True,
|
|
613
|
-
"documents": 0,
|
|
614
|
-
"converted": 0,
|
|
615
|
-
"skipped": [],
|
|
616
|
-
}
|
|
617
|
-
path = r.get("path")
|
|
618
|
-
if not isinstance(path, str):
|
|
619
|
-
return "Error: malformed add_context reply (no path)"
|
|
620
|
-
try:
|
|
621
|
-
payload = read_host_payload(path, bool(r.get("json")))
|
|
622
|
-
finally:
|
|
623
|
-
try:
|
|
624
|
-
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
625
|
-
except OSError:
|
|
626
|
-
pass
|
|
627
|
-
return self._append_context(str(source), payload, r)
|
|
628
|
-
|
|
629
|
-
def _append_context(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
|
|
630
|
-
"""Append host-packed files into `context` (idempotent by path prefix).
|
|
631
|
-
|
|
632
|
-
The two refusals below are pre-flighted host-side by LIST_CONTEXT_REQUIRED /
|
|
633
|
-
NO_FILES_PRODUCED in src/bridge/add-context.ts, so the host never commits a
|
|
634
|
-
loaded-prefix for an append that fails here. Reaching either one means host and worker
|
|
635
|
-
disagree about `context`; keep the wording identical to its twin.
|
|
636
|
-
"""
|
|
637
|
-
ctx = self.ns.get("context")
|
|
638
|
-
if not isinstance(ctx, list):
|
|
639
|
-
kind = type(ctx).__name__ if ctx is not None else "None"
|
|
640
|
-
return f"Error: add_context requires list context (file bundle); got {kind}"
|
|
641
|
-
|
|
642
|
-
source_id = meta.get("source_id")
|
|
643
|
-
if not isinstance(source_id, str) or not source_id:
|
|
644
|
-
source_id = "ctx"
|
|
645
|
-
path_prefix = meta.get("path_prefix")
|
|
646
|
-
if not isinstance(path_prefix, str):
|
|
647
|
-
path_prefix = f"ctx/{source_id}/"
|
|
648
|
-
# Empty path_prefix is valid (cwd seed) but add_context always sends a non-empty ctx/ prefix.
|
|
649
|
-
# Guard startsWith on empty prefix: "anything".startswith("") is always True.
|
|
650
|
-
check_prefix = path_prefix if path_prefix != "" else None
|
|
651
|
-
|
|
652
|
-
# Idempotent: already present if any path uses this prefix.
|
|
653
|
-
if check_prefix is not None:
|
|
654
|
-
for item in ctx:
|
|
655
|
-
if isinstance(item, dict) and str(item.get("path", "")).startswith(check_prefix):
|
|
656
|
-
print(
|
|
657
|
-
f"[rlm] add_context: already loaded {source_id} "
|
|
658
|
-
f"(paths under {path_prefix}, context len={len(ctx)})"
|
|
659
|
-
)
|
|
660
|
-
return {
|
|
661
|
-
"source": source,
|
|
662
|
-
"source_id": source_id,
|
|
663
|
-
"path_prefix": path_prefix,
|
|
664
|
-
"files": 0,
|
|
665
|
-
"chars": meta.get("chars"),
|
|
666
|
-
"context_len": len(ctx),
|
|
667
|
-
"already_loaded": True,
|
|
668
|
-
"documents": 0,
|
|
669
|
-
"converted": 0,
|
|
670
|
-
"skipped": [],
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
files = self._context_file_entries(payload, path_prefix)
|
|
674
|
-
if not files:
|
|
675
|
-
return "Error: add_context produced no files"
|
|
676
|
-
|
|
677
|
-
ctx.extend(files)
|
|
678
|
-
# Keep restore payload in sync with the live list.
|
|
679
|
-
self._context_payload = ctx
|
|
680
|
-
self.ns["context"] = ctx
|
|
681
|
-
|
|
682
|
-
documents = meta.get("documents") if isinstance(meta.get("documents"), int) else 0
|
|
683
|
-
converted = meta.get("converted") if isinstance(meta.get("converted"), int) else 0
|
|
684
|
-
skipped = meta.get("skipped") if isinstance(meta.get("skipped"), list) else []
|
|
685
|
-
skip_n = len(skipped)
|
|
686
|
-
extra = ""
|
|
687
|
-
if documents or converted or skip_n:
|
|
688
|
-
extra = f"; documents={documents}, converted={converted}, skipped={skip_n}"
|
|
689
|
-
print(
|
|
690
|
-
f"[rlm] add_context: +{len(files)} files into context "
|
|
691
|
-
f"(len={len(ctx)}); paths under {path_prefix}{extra}"
|
|
692
|
-
)
|
|
693
|
-
return {
|
|
694
|
-
"source": source,
|
|
695
|
-
"source_id": source_id,
|
|
696
|
-
"path_prefix": path_prefix,
|
|
697
|
-
"files": len(files),
|
|
698
|
-
"chars": meta.get("chars"),
|
|
699
|
-
"context_len": len(ctx),
|
|
700
|
-
"already_loaded": False,
|
|
701
|
-
"documents": documents,
|
|
702
|
-
"converted": converted,
|
|
703
|
-
"skipped": skipped,
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
@staticmethod
|
|
707
|
-
def _context_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
|
|
708
|
-
"""Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
|
|
709
|
-
if isinstance(payload, str):
|
|
710
|
-
return [{
|
|
711
|
-
"path": f"{path_prefix}content" if path_prefix else "content",
|
|
712
|
-
"content": payload,
|
|
713
|
-
"tokens": max(1, (len(payload) + 3) // 4),
|
|
714
|
-
}]
|
|
715
|
-
if not isinstance(payload, list):
|
|
716
|
-
return []
|
|
717
|
-
out: list[dict[str, Any]] = []
|
|
718
|
-
for item in payload:
|
|
719
|
-
if isinstance(item, dict) and "path" in item and "content" in item:
|
|
720
|
-
out.append(item)
|
|
721
|
-
return out
|
|
722
|
-
|
|
723
|
-
@_spawnable("rlm_batch")
|
|
724
|
-
def _rlm_batch(self, prompts, paths=None) -> Task:
|
|
725
|
-
"""Always spawn. Collect with await_task(t) → ordered list of reports."""
|
|
726
|
-
return self._start_rlm_batch(prompts, paths)
|
|
727
|
-
|
|
728
|
-
# ---- context + execution --------------------------------------------------------------
|
|
729
|
-
|
|
730
271
|
def load_context(self, path: str, index: int | None = None, is_json: bool = False) -> int:
|
|
731
272
|
"""Load the packed world into the single REPL variable `context`.
|
|
732
273
|
|
|
@@ -821,6 +362,7 @@ class Worker:
|
|
|
821
362
|
"raised": raised,
|
|
822
363
|
"execution_time": time.perf_counter() - start,
|
|
823
364
|
"var_names": self._user_var_names(),
|
|
365
|
+
"pending_tasks": self._pending_task_infos(),
|
|
824
366
|
}
|
|
825
367
|
|
|
826
368
|
|
|
@@ -832,11 +374,14 @@ def main() -> None:
|
|
|
832
374
|
default=float(os.environ.get("RLM_AWAIT_TIMEOUT_S", "600")))
|
|
833
375
|
ap.add_argument("--max-prompt-chars", type=int,
|
|
834
376
|
default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
|
|
377
|
+
ap.add_argument("--surface", default=os.environ.get("RLM_SURFACE", "root"),
|
|
378
|
+
choices=["root", "child"])
|
|
835
379
|
args = ap.parse_args()
|
|
836
380
|
|
|
837
381
|
worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
|
|
838
382
|
max_prompt_chars=args.max_prompt_chars,
|
|
839
|
-
await_timeout_s=args.await_timeout
|
|
383
|
+
await_timeout_s=args.await_timeout,
|
|
384
|
+
surface=args.surface)
|
|
840
385
|
_send({"id": "_init", "ok": True})
|
|
841
386
|
|
|
842
387
|
while True:
|
|
@@ -859,6 +404,8 @@ def main() -> None:
|
|
|
859
404
|
# result would be lost.
|
|
860
405
|
if worker.park_reply(req):
|
|
861
406
|
continue
|
|
407
|
+
if isinstance(req, dict) and req.get("type") == "heartbeat":
|
|
408
|
+
continue
|
|
862
409
|
if not isinstance(req, dict):
|
|
863
410
|
_send({"id": "?", "ok": False, "error": f"expected an object, got {type(req).__name__}"})
|
|
864
411
|
continue
|