@hicaru/pi-rlm 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -5
- package/README.ru.md +5 -5
- package/README.zh-CN.md +5 -5
- package/package.json +1 -1
- package/src/bridge/handlers/await.ts +148 -0
- package/src/bridge/handlers/completion.ts +72 -0
- package/src/bridge/handlers/emitting.ts +104 -0
- package/src/bridge/handlers/finish.ts +45 -0
- package/src/bridge/handlers/index.ts +48 -0
- package/src/bridge/handlers/llm-query.ts +130 -0
- package/src/bridge/handlers/rlm-query.ts +227 -0
- package/src/bridge/handlers/task-registry.ts +202 -0
- package/src/bridge/handlers/types.ts +136 -0
- package/src/commands/rlm-config.ts +33 -14
- package/src/context/listing.ts +2 -2
- package/src/context/refresh.ts +141 -0
- package/src/core/engine.ts +16 -18
- package/src/core/types.ts +1 -3
- package/src/index.ts +54 -42
- package/src/mode/native-guards.ts +4 -4
- package/src/mode/subagent.ts +1 -1
- package/src/prompts/glossary.ts +71 -74
- package/src/prompts/native.ts +127 -85
- package/src/prompts/system.ts +29 -15
- package/src/sandbox/interrupts.ts +258 -68
- package/src/sandbox/protocol.ts +53 -30
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +8 -5
- package/src/sandbox/py/retrieval.py +17 -8
- package/src/sandbox/py/tasks.py +1 -1
- package/src/sandbox/py/worker.py +106 -79
- package/src/sandbox/sandbox-manager.ts +26 -1
- package/src/sandbox/sandbox.ts +1 -1
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-result.ts +2 -2
- package/src/tool/repl-tool.ts +13 -14
- package/src/ui/config-panel.ts +1 -1
- package/src/ui/intro.ts +1 -4
- package/src/ui/model-picker.ts +28 -2
- package/src/util/concurrency.ts +1 -1
- package/src/bridge/subcall-handlers.ts +0 -382
package/src/sandbox/py/worker.py
CHANGED
|
@@ -6,8 +6,8 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
|
|
|
6
6
|
|
|
7
7
|
Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
|
|
8
8
|
Protocol (worker -> parent): {"id","ok",...result} # response to a request
|
|
9
|
-
{"type":"llm_query"|"
|
|
10
|
-
"
|
|
9
|
+
{"type":"llm_query"|"llm_batch"|"rlm_query"|
|
|
10
|
+
"rlm_batch"|"add_context","rid",...}
|
|
11
11
|
# mid-exec helper request
|
|
12
12
|
When sandbox code calls llm_query/rlm_query/add_context, the worker writes a
|
|
13
13
|
request line and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
|
|
@@ -15,7 +15,7 @@ The parent services the request in-process (it holds API keys).
|
|
|
15
15
|
|
|
16
16
|
Requests and replies are decoupled: `_post` writes a request and returns its rid without
|
|
17
17
|
waiting, and replies are parked in `_inbox` keyed by rid until something asks for them. That
|
|
18
|
-
is what makes `spawn()` / `
|
|
18
|
+
is what makes `spawn()` / `await_task()` / `await_task()` possible — many requests can be
|
|
19
19
|
in flight at once (the parent already services interrupts concurrently), and a task may be
|
|
20
20
|
awaited in a LATER exec than the one that started it.
|
|
21
21
|
"""
|
|
@@ -100,12 +100,13 @@ class Worker:
|
|
|
100
100
|
# Replies parked by rid until something awaits them. Unbounded by design: a task
|
|
101
101
|
# the model spawns and never awaits keeps its entry for the life of the process.
|
|
102
102
|
# Bounded in practice by session length; evicting would silently hang a later
|
|
103
|
-
#
|
|
103
|
+
# await_task, which is strictly worse than the memory.
|
|
104
104
|
self.inbox: dict[str, dict[str, Any]] = {}
|
|
105
105
|
self._inflight: set[str] = set()
|
|
106
106
|
# Requests (exec/shutdown) that arrived mid-exec; main() replays them.
|
|
107
107
|
self._deferred: list[Any] = []
|
|
108
|
-
#
|
|
108
|
+
# Kept for spawn() compatibility; sub-LLM kinds always post detached=true so fan-out
|
|
109
|
+
# outlives the repl cell and shows ↯bg (see _post).
|
|
109
110
|
self._detached = False
|
|
110
111
|
self.ns: dict[str, Any] = {}
|
|
111
112
|
self._setup()
|
|
@@ -127,13 +128,13 @@ class Worker:
|
|
|
127
128
|
# Re-inject any scaffolding the user code clobbered.
|
|
128
129
|
ns = self.ns
|
|
129
130
|
ns["llm_query"] = self._llm_query
|
|
130
|
-
ns["
|
|
131
|
+
ns["llm_batch"] = self._llm_batch
|
|
131
132
|
ns["llm_query_chunked"] = self._llm_query_chunked
|
|
132
133
|
ns["rlm_query"] = self._rlm_query
|
|
133
|
-
ns["
|
|
134
|
+
ns["rlm_batch"] = self._rlm_batch
|
|
134
135
|
ns["spawn"] = self._spawn
|
|
135
|
-
|
|
136
|
-
ns["
|
|
136
|
+
# One collect API: await_task(Task) or await_task([Task, ...])
|
|
137
|
+
ns["await_task"] = self._await_task
|
|
137
138
|
ns["map_files"] = self._map_files
|
|
138
139
|
ns["llm_map_reduce"] = self._llm_map_reduce
|
|
139
140
|
ns["search"] = self._search
|
|
@@ -184,14 +185,19 @@ class Worker:
|
|
|
184
185
|
|
|
185
186
|
# ---- sub-LLM bridge over stdio --------------------------------------------------------
|
|
186
187
|
|
|
188
|
+
# Sub-LLM fan-out always runs detached (session BG registry + ↯bg), whether called
|
|
189
|
+
# as llm_batch(...) or via map_files internals — not only when wrapped in spawn().
|
|
190
|
+
_DETACHED_KINDS = frozenset({"llm_query", "llm_batch", "rlm_query", "rlm_batch"})
|
|
191
|
+
|
|
187
192
|
def _post(self, kind: str, payload: dict[str, Any]) -> str:
|
|
188
193
|
"""Write one parent request and return its rid WITHOUT waiting for the reply."""
|
|
189
194
|
self._rid += 1
|
|
190
195
|
rid = f"q{self._rid}"
|
|
191
196
|
# Register only after the write succeeds — a broken pipe must not leave an
|
|
192
197
|
# _inflight entry that nothing will ever settle.
|
|
198
|
+
detached = self._detached or kind in self._DETACHED_KINDS
|
|
193
199
|
_send({"type": kind, "rid": rid, "depth": self.depth,
|
|
194
|
-
"detached":
|
|
200
|
+
"detached": detached, **payload})
|
|
195
201
|
self._inflight.add(rid)
|
|
196
202
|
return rid
|
|
197
203
|
|
|
@@ -257,21 +263,21 @@ class Worker:
|
|
|
257
263
|
|
|
258
264
|
# ---- spawn / await ---------------------------------------------------------------------
|
|
259
265
|
|
|
260
|
-
def _start_prompt(self, kind: str, prompt,
|
|
266
|
+
def _start_prompt(self, kind: str, prompt, paths=None) -> Task:
|
|
261
267
|
text = str(prompt)
|
|
262
268
|
# A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
|
|
263
269
|
# looking exactly like data. Refuse instead of spending a call on it.
|
|
264
270
|
if not text.strip():
|
|
265
271
|
return Task.resolved(self, kind, _surfaced_error(
|
|
266
272
|
f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
|
|
267
|
-
payload: dict[str, Any] = {"prompt": text
|
|
273
|
+
payload: dict[str, Any] = {"prompt": text}
|
|
268
274
|
clean = _clean_paths(paths)
|
|
269
275
|
if clean is not None:
|
|
270
276
|
payload["paths"] = clean
|
|
271
277
|
rid = self._post(kind, payload)
|
|
272
278
|
return Task(self, kind, (rid,), _reduce_one, text[:40])
|
|
273
279
|
|
|
274
|
-
def _start_prompts(self, kind: str, prompts,
|
|
280
|
+
def _start_prompts(self, kind: str, prompts, paths=None) -> Task:
|
|
275
281
|
prompts = [str(p) for p in prompts]
|
|
276
282
|
if not prompts:
|
|
277
283
|
return Task.resolved(self, kind, [])
|
|
@@ -280,7 +286,7 @@ class Worker:
|
|
|
280
286
|
return Task.resolved(self, kind, [
|
|
281
287
|
_surfaced_error(f"{kind}() got only empty prompts")
|
|
282
288
|
] * len(prompts))
|
|
283
|
-
payload: dict[str, Any] = {"prompts": prompts
|
|
289
|
+
payload: dict[str, Any] = {"prompts": prompts}
|
|
284
290
|
# One prefix set for the whole batch: a per-prompt aligned list is an API nobody uses
|
|
285
291
|
# correctly, and every prompt in a batch is asking about the same slice anyway.
|
|
286
292
|
clean = _clean_paths(paths)
|
|
@@ -289,23 +295,23 @@ class Worker:
|
|
|
289
295
|
rid = self._post(kind, payload)
|
|
290
296
|
return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
|
|
291
297
|
|
|
292
|
-
def _start_llm_query(self, prompt
|
|
293
|
-
return self._start_prompt("llm_query", prompt
|
|
298
|
+
def _start_llm_query(self, prompt) -> Task:
|
|
299
|
+
return self._start_prompt("llm_query", prompt)
|
|
294
300
|
|
|
295
|
-
def _start_rlm_query(self, prompt,
|
|
296
|
-
return self._start_prompt("rlm_query", prompt,
|
|
301
|
+
def _start_rlm_query(self, prompt, paths=None) -> Task:
|
|
302
|
+
return self._start_prompt("rlm_query", prompt, paths)
|
|
297
303
|
|
|
298
|
-
def
|
|
299
|
-
return self._start_prompts("
|
|
304
|
+
def _start_llm_batch(self, prompts) -> Task:
|
|
305
|
+
return self._start_prompts("llm_batch", prompts)
|
|
300
306
|
|
|
301
|
-
def
|
|
302
|
-
return self._start_prompts("
|
|
307
|
+
def _start_rlm_batch(self, prompts, paths=None) -> Task:
|
|
308
|
+
return self._start_prompts("rlm_batch", prompts, paths)
|
|
303
309
|
|
|
304
|
-
def _start_llm_query_chunked(self, text, prompt: str
|
|
310
|
+
def _start_llm_query_chunked(self, text, prompt: str) -> Task:
|
|
305
311
|
"""Split oversized text into cap-sized chunks and post EVERY batch at once.
|
|
306
312
|
|
|
307
313
|
One answer per chunk, order preserved. No exceptions escape: errors come back as
|
|
308
|
-
"Error: ..." strings per chunk (same contract as
|
|
314
|
+
"Error: ..." strings per chunk (same contract as llm_batch). Because all
|
|
309
315
|
batches go on the wire together, a large input costs one round-trip of latency
|
|
310
316
|
rather than one per 20 chunks.
|
|
311
317
|
|
|
@@ -334,26 +340,25 @@ class Worker:
|
|
|
334
340
|
f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
|
|
335
341
|
for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
|
|
336
342
|
]
|
|
337
|
-
rids.append(self._post("
|
|
343
|
+
rids.append(self._post("llm_batch", {"prompts": batch}))
|
|
338
344
|
sizes.append(len(batch))
|
|
339
345
|
return Task(self, "llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
|
|
340
|
-
|
|
341
346
|
def _builder_for(self, name: str):
|
|
342
347
|
# llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
|
|
343
348
|
# depends on its own map results, so it cannot be one (rids, pure reduce) Task.
|
|
344
349
|
return {
|
|
345
350
|
"llm_query": self._start_llm_query,
|
|
346
|
-
"
|
|
351
|
+
"llm_batch": self._start_llm_batch,
|
|
347
352
|
"llm_query_chunked": self._start_llm_query_chunked,
|
|
348
353
|
"map_files": self._start_map_files,
|
|
349
354
|
"rlm_query": self._start_rlm_query,
|
|
350
|
-
"
|
|
355
|
+
"rlm_batch": self._start_rlm_batch,
|
|
351
356
|
}.get(name)
|
|
352
357
|
|
|
353
358
|
def _spawn(self, fn, *args, **kwargs) -> Task:
|
|
354
359
|
"""Start a sub-call without waiting for it. `fn` is the scaffold function itself.
|
|
355
360
|
|
|
356
|
-
Returns a Task for
|
|
361
|
+
Returns a Task for await_task, possibly in a later ```repl``` block.
|
|
357
362
|
Misuse returns an already-resolved error Task rather than raising, matching the
|
|
358
363
|
"Error: ..." contract of the synchronous helpers.
|
|
359
364
|
"""
|
|
@@ -361,11 +366,10 @@ class Worker:
|
|
|
361
366
|
builder = self._builder_for(name) if isinstance(name, str) else None
|
|
362
367
|
if builder is None:
|
|
363
368
|
return Task.resolved(self, "spawn", _surfaced_error(
|
|
364
|
-
"spawn() takes llm_query,
|
|
365
|
-
"rlm_query or
|
|
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 "
|
|
366
371
|
"on its own map results and so cannot be a single Task"))
|
|
367
|
-
#
|
|
368
|
-
# session-scoped registry, since they may outlive the exec that started them.
|
|
372
|
+
# Sub-LLM kinds already post detached via _post; keep the flag for clarity / future kinds.
|
|
369
373
|
self._detached = True
|
|
370
374
|
try:
|
|
371
375
|
return builder(*args, **kwargs)
|
|
@@ -374,34 +378,48 @@ class Worker:
|
|
|
374
378
|
finally:
|
|
375
379
|
self._detached = False
|
|
376
380
|
|
|
377
|
-
def
|
|
378
|
-
"""Block until
|
|
379
|
-
if not isinstance(task, Task):
|
|
380
|
-
return _surfaced_error(
|
|
381
|
-
f"rlm_await expects a Task from spawn(), got {type(task).__name__}"
|
|
382
|
-
)
|
|
381
|
+
def _await_one(self, task: Task) -> Any:
|
|
382
|
+
"""Block until one Task has its result. Idempotent — the value is memoized."""
|
|
383
383
|
if not task._settled:
|
|
384
384
|
self._drain_until(task._rids)
|
|
385
385
|
task._value = task._reduce(self._take(task._rids))
|
|
386
386
|
task._settled = True
|
|
387
387
|
return task._value
|
|
388
388
|
|
|
389
|
-
def
|
|
390
|
-
"""
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
)
|
|
405
423
|
|
|
406
424
|
# ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
|
|
407
425
|
|
|
@@ -446,7 +464,7 @@ class Worker:
|
|
|
446
464
|
|
|
447
465
|
# ---- one-line delegation (structural: orchestrating must be easier than solving) -------
|
|
448
466
|
|
|
449
|
-
def _start_map_files(self, files: Any, prompt: str
|
|
467
|
+
def _start_map_files(self, files: Any, prompt: str) -> Task:
|
|
450
468
|
"""Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
|
|
451
469
|
|
|
452
470
|
All batches go on the wire together, so a 100-file map costs one round-trip of latency
|
|
@@ -489,27 +507,27 @@ class Worker:
|
|
|
489
507
|
sizes: list[int] = []
|
|
490
508
|
for i in range(0, len(requests), _MAX_CHUNK_BATCH):
|
|
491
509
|
batch = requests[i:i + _MAX_CHUNK_BATCH]
|
|
492
|
-
rids.append(self._post("
|
|
510
|
+
rids.append(self._post("llm_batch", {"prompts": batch}))
|
|
493
511
|
sizes.append(len(batch))
|
|
494
512
|
return Task(self, "map_files", tuple(rids),
|
|
495
513
|
_reduce_map_files(sizes, spans), f"{len(by_path)} files")
|
|
496
514
|
|
|
497
515
|
@_spawnable("map_files")
|
|
498
|
-
def _map_files(self, files: Any, prompt: str
|
|
499
|
-
"""
|
|
516
|
+
def _map_files(self, files: Any, prompt: str) -> Task:
|
|
517
|
+
"""Always spawn. Collect with await_task(t) → dict[path, answer].
|
|
500
518
|
|
|
501
519
|
`files` accepts context entries (dicts), paths (strings), or a mix — the whole
|
|
502
520
|
chunk/batch/collect loop the system prompt used to spell out, as one call.
|
|
503
521
|
Oversized files are split and their per-chunk answers joined.
|
|
522
|
+
Posts are detached (↯bg) so fan-out outlives the repl cell.
|
|
504
523
|
"""
|
|
505
|
-
return self.
|
|
524
|
+
return self._start_map_files(files, prompt)
|
|
506
525
|
|
|
507
526
|
def _llm_map_reduce(
|
|
508
527
|
self,
|
|
509
528
|
items: Any,
|
|
510
529
|
map_prompt: str,
|
|
511
530
|
reduce_prompt: str,
|
|
512
|
-
model: str | None = None,
|
|
513
531
|
) -> str:
|
|
514
532
|
"""Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
|
|
515
533
|
|
|
@@ -534,28 +552,36 @@ class Worker:
|
|
|
534
552
|
f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
|
|
535
553
|
for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
|
|
536
554
|
]
|
|
537
|
-
|
|
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)])
|
|
538
558
|
joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
|
|
539
|
-
|
|
559
|
+
reduced = self._await_task(
|
|
560
|
+
self._start_llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}")
|
|
561
|
+
)
|
|
562
|
+
return str(reduced)
|
|
540
563
|
|
|
541
|
-
# ----
|
|
564
|
+
# ---- Core tools: ALWAYS spawn (return Task). Collect with await_task only. ------------
|
|
542
565
|
|
|
543
566
|
@_spawnable("llm_query")
|
|
544
|
-
def _llm_query(self, prompt: str
|
|
545
|
-
|
|
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)
|
|
546
570
|
|
|
547
|
-
@_spawnable("
|
|
548
|
-
def
|
|
549
|
-
|
|
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)
|
|
550
575
|
|
|
551
576
|
@_spawnable("llm_query_chunked")
|
|
552
|
-
def _llm_query_chunked(self, text, prompt: str
|
|
553
|
-
|
|
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)
|
|
554
580
|
|
|
555
581
|
@_spawnable("rlm_query")
|
|
556
|
-
def _rlm_query(self, prompt: str,
|
|
557
|
-
|
|
558
|
-
|
|
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)
|
|
559
585
|
def _add_context(self, source: str) -> dict[str, Any] | str:
|
|
560
586
|
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
561
587
|
|
|
@@ -694,9 +720,10 @@ class Worker:
|
|
|
694
720
|
out.append(item)
|
|
695
721
|
return out
|
|
696
722
|
|
|
697
|
-
@_spawnable("
|
|
698
|
-
def
|
|
699
|
-
|
|
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)
|
|
700
727
|
|
|
701
728
|
# ---- context + execution --------------------------------------------------------------
|
|
702
729
|
|
|
@@ -757,7 +784,7 @@ class Worker:
|
|
|
757
784
|
return []
|
|
758
785
|
return [
|
|
759
786
|
f"[rlm] huge raw-text variable(s): {', '.join(names)} — do NOT analyze them yourself; "
|
|
760
|
-
'delegate with llm_query_chunked(name, "your question") or slice +
|
|
787
|
+
'delegate with llm_query_chunked(name, "your question") or slice + llm_batch.'
|
|
761
788
|
]
|
|
762
789
|
|
|
763
790
|
def execute(self, code: str) -> dict[str, Any]:
|
|
@@ -828,7 +855,7 @@ def main() -> None:
|
|
|
828
855
|
_send({"id": "?", "ok": False, "error": f"bad json: {e}"})
|
|
829
856
|
continue
|
|
830
857
|
# A task spawned in an earlier exec settling while the worker is idle. Park it for
|
|
831
|
-
# a later
|
|
858
|
+
# a later await_task; without this it would fall through to "unknown type" and the
|
|
832
859
|
# result would be lost.
|
|
833
860
|
if worker.park_reply(req):
|
|
834
861
|
continue
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
import { PythonSandbox, type SubLlmHandlers } from "./sandbox.ts";
|
|
8
8
|
import type { ReplResult } from "./protocol.ts";
|
|
9
9
|
import { mergeIntoContext } from "../context/merge.ts";
|
|
10
|
+
import {
|
|
11
|
+
patchContextExecCode,
|
|
12
|
+
upsertContextFile,
|
|
13
|
+
} from "../context/refresh.ts";
|
|
10
14
|
|
|
11
15
|
/** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
|
|
12
16
|
export interface SandboxManagerConfig {
|
|
@@ -15,7 +19,7 @@ export interface SandboxManagerConfig {
|
|
|
15
19
|
readonly python: string;
|
|
16
20
|
readonly sandboxInitTimeoutMs: number;
|
|
17
21
|
readonly maxPromptChars: number;
|
|
18
|
-
/** Max seconds the worker waits for a host reply while parked in
|
|
22
|
+
/** Max seconds the worker waits for a host reply while parked in await_task. */
|
|
19
23
|
readonly awaitTimeoutS: number;
|
|
20
24
|
readonly signal?: AbortSignal;
|
|
21
25
|
readonly onSandboxDiscarded?: () => void;
|
|
@@ -50,6 +54,27 @@ export class SandboxManager {
|
|
|
50
54
|
this.contextPayload = mergeIntoContext(this.contextPayload, payload);
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* After native edit/write: replace file body in the host snapshot **and** the live worker.
|
|
59
|
+
* Rebuilds a new context list so BM25 index stamps invalidate.
|
|
60
|
+
*/
|
|
61
|
+
async refreshFileFromDisk(
|
|
62
|
+
filePath: string,
|
|
63
|
+
content: string,
|
|
64
|
+
cwd: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
this.contextPayload = upsertContextFile(this.contextPayload, filePath, content, cwd);
|
|
67
|
+
if (!this.sandbox || this.disposed) return;
|
|
68
|
+
// Avoid interleaving with an in-flight repl exec: queue like exec().
|
|
69
|
+
const code = patchContextExecCode(filePath, content, cwd);
|
|
70
|
+
try {
|
|
71
|
+
await this.execQueued(code);
|
|
72
|
+
} catch {
|
|
73
|
+
// Worker may be dead; next getOrCreate reloads contextPayload.
|
|
74
|
+
this.contextLoaded = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/**
|
|
54
79
|
* Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
|
|
55
80
|
* given handlers. Subsequent calls return the existing sandbox immediately.
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface SandboxOptions {
|
|
|
45
45
|
readonly maxPromptChars?: number;
|
|
46
46
|
/**
|
|
47
47
|
* Max seconds the worker will wait for a host reply while parked in `_drain_until`
|
|
48
|
-
* (
|
|
48
|
+
* (await_task / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
|
|
49
49
|
*/
|
|
50
50
|
readonly awaitTimeoutS?: number;
|
|
51
51
|
}
|
|
@@ -15,7 +15,7 @@ import { RlmEmitter } from "./rlm-events.ts";
|
|
|
15
15
|
import { SubcallStore, type SubcallTotals } from "./subcall-store.ts";
|
|
16
16
|
import type { RlmSubcall } from "./rlm-details.ts";
|
|
17
17
|
import { LimitGuard, type Limits } from "../core/limits.ts";
|
|
18
|
-
import type { Invocation } from "../bridge/
|
|
18
|
+
import type { Invocation } from "../bridge/handlers/index.ts";
|
|
19
19
|
import { trace, traceEnabled } from "../util/trace.ts";
|
|
20
20
|
|
|
21
21
|
/** What a drain hands to the turn that is reporting it. */
|
package/src/tool/repl-result.ts
CHANGED
|
@@ -46,10 +46,10 @@ export function buildReplResultText(
|
|
|
46
46
|
const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
47
47
|
const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
|
|
48
48
|
const pendingLine = backgroundPending > 0
|
|
49
|
-
? `\n\n[rlm] ${backgroundPending} background task(s) still running —
|
|
49
|
+
? `\n\n[rlm] ${backgroundPending} background task(s) still running — await_task(tasks) to collect.`
|
|
50
50
|
: "";
|
|
51
51
|
const failedLine = failedBg > 0
|
|
52
|
-
? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their
|
|
52
|
+
? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their await_task value is an "Error: …" string, not data.`
|
|
53
53
|
: "";
|
|
54
54
|
return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
|
|
55
55
|
}
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and collects sub-calls manually from emitter events. No RlmEventAggregator is used
|
|
7
7
|
* (ReplDetails ≠ RlmDetails structural mismatch).
|
|
8
8
|
*
|
|
9
|
-
* Sub-call handling
|
|
9
|
+
* Sub-call handling lives in bridge/handlers/ (createSubcallHandlers); this file only supplies the
|
|
10
10
|
* per-invocation Invocation those handlers resolve against, swapping it inside the
|
|
11
11
|
* serialized exec slot so a queued repl() cannot claim the running one's emitter.
|
|
12
12
|
*
|
|
@@ -27,7 +27,7 @@ import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
|
|
|
27
27
|
import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
28
28
|
import { SandboxManager } from "../sandbox/sandbox-manager.ts";
|
|
29
29
|
import type { SubcallOpts } from "../sandbox/sandbox.ts";
|
|
30
|
-
import { createSubcallHandlers, type Invocation } from "../bridge/
|
|
30
|
+
import { createSubcallHandlers, type Invocation } from "../bridge/handlers/index.ts";
|
|
31
31
|
import { BackgroundTasks } from "./background-tasks.ts";
|
|
32
32
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
33
33
|
import { RlmEmitter } from "./rlm-events.ts";
|
|
@@ -202,20 +202,19 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
202
202
|
name: "repl",
|
|
203
203
|
label: "REPL",
|
|
204
204
|
description:
|
|
205
|
-
"PRIMARY tool for
|
|
206
|
-
"Persistent Python sandbox
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"add_context for external dirs/files/git URLs and document conversion.",
|
|
205
|
+
"PRIMARY tool for bulk repository analysis (orchestrator). " +
|
|
206
|
+
"Persistent Python sandbox: free locate with search/grep_context/outline, then ALWAYS-SPAWN " +
|
|
207
|
+
"fan-out — llm_query/llm_batch/map_files/llm_query_chunked/rlm_query/rlm_batch return Task " +
|
|
208
|
+
"immediately (↯bg); collect with await_task. Prefer rlm_batch for ≥2 independent multi-step " +
|
|
209
|
+
"module studies; map_files/llm_batch for one-shot extracts. Fire independent Tasks first, " +
|
|
210
|
+
"free work, then await — never treat Task as the answer. Stdout hard-capped ~4K (no file dumps). " +
|
|
211
|
+
"`answers`/`plan` persist. add_context for external dirs/files/git/docs.",
|
|
213
212
|
promptSnippet:
|
|
214
|
-
"repl:
|
|
215
|
-
"search/grep_context/outline to locate, map_files/llm_query* to read.",
|
|
213
|
+
"repl: free search/outline; fire rlm_batch|map_files|llm_batch as Task (BG); await_task for results.",
|
|
216
214
|
promptGuidelines: [
|
|
217
|
-
"
|
|
218
|
-
"
|
|
215
|
+
"Multi-area analysis: rlm_batch([...]) or map_files(paths, q); free search; await_task — not serial native read.",
|
|
216
|
+
"Always-spawn tools return Task; only await_task has content. Fire-all independent work before await.",
|
|
217
|
+
"llm_query/llm_batch have no disk — never 'Read path/to/file.ts'; use map_files or rlm_* (see context).",
|
|
219
218
|
],
|
|
220
219
|
parameters: ReplToolParams,
|
|
221
220
|
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -39,7 +39,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
39
39
|
item("maxDepth", "Max recursion depth", String(config.maxDepth), CHOICES.maxDepth, "rlm_query past this depth degrades to plain llm_query (1 = no recursion)."),
|
|
40
40
|
item("maxIterations", "Max iterations", String(config.maxIterations), CHOICES.maxIterations, "Maximum root REPL turns before RLM asks the model for a final answer."),
|
|
41
41
|
item("execTimeoutS", "REPL block timeout (s)", String(config.execTimeoutS), CHOICES.execTimeoutS, "Wall-clock limit for one model-authored Python REPL block."),
|
|
42
|
-
item("maxConcurrentSubcalls", "Max concurrent sub-calls", String(config.maxConcurrentSubcalls), CHOICES.maxConcurrentSubcalls, "Concurrency pool size for
|
|
42
|
+
item("maxConcurrentSubcalls", "Max concurrent sub-calls", String(config.maxConcurrentSubcalls), CHOICES.maxConcurrentSubcalls, "Concurrency pool size for llm_batch and rlm_batch."),
|
|
43
43
|
item("maxConcurrentChildren", "Max concurrent children", String(config.maxConcurrentChildren), CHOICES.maxConcurrentChildren, "Concurrent rlm_query child engines per depth. Each is a Python process holding its own copy of the inherited context."),
|
|
44
44
|
item("maxTimeoutMs", "Wall-clock ceiling (min)", config.maxTimeoutMs != null ? String(Math.round(config.maxTimeoutMs / 60_000)) : "none", CHOICES.maxTimeoutMs, "Total runtime cap for the whole recursive tree; none disables the cap."),
|
|
45
45
|
item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
|
package/src/ui/intro.ts
CHANGED
|
@@ -12,10 +12,7 @@ export const RLM_GUIDE = `# RLM mode
|
|
|
12
12
|
|
|
13
13
|
- \`/rlm\` — toggle RLM mode (shortcut: Ctrl+Shift+R). Turning it OFF also stops a running query.
|
|
14
14
|
- \`/rlm-config\` — choose models, reasoning, and run limits
|
|
15
|
-
- \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
|
|
16
|
-
|
|
17
|
-
When RLM mode is ON, \`read\`/\`grep\` are disabled and the agent reads the repository through the
|
|
18
|
-
\`repl\` tool, delegating bulk analysis to sub-LLMs. The footer/status line shows the current state.`;
|
|
15
|
+
- \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)`;
|
|
19
16
|
|
|
20
17
|
export function postRlmGuide(pi: ExtensionAPI, controller: RlmController): void {
|
|
21
18
|
const content = RLM_GUIDE.replace("{state}", formatRlmStateLine(controller));
|
package/src/ui/model-picker.ts
CHANGED
|
@@ -15,7 +15,8 @@ export interface ModelSelection {
|
|
|
15
15
|
const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
16
16
|
type SelectableThinkingLevel = (typeof LEVELS)[number];
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** Sentinel SelectList value for "always use cheapest available". */
|
|
19
|
+
export const CHEAPEST_VALUE = "__rlm_cheapest__";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Models Pi itself would offer for this session, cheapest-first.
|
|
@@ -53,6 +54,24 @@ function items(models: readonly Model<Api>[], includeCheapest: boolean): SelectI
|
|
|
53
54
|
];
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Index to pre-select in the model list (with cheapest row at 0 when included).
|
|
59
|
+
* Without this, the list always opens on "cheapest (auto)" and Enter silently unpins.
|
|
60
|
+
*/
|
|
61
|
+
export function initialModelPickerIndex(
|
|
62
|
+
models: readonly Model<Api>[],
|
|
63
|
+
current?: Model<Api>,
|
|
64
|
+
currentRef?: string,
|
|
65
|
+
includeCheapest = true,
|
|
66
|
+
): number {
|
|
67
|
+
const offset = includeCheapest ? 1 : 0;
|
|
68
|
+
const ref = current ? `${current.provider}/${current.id}` : currentRef;
|
|
69
|
+
if (!ref) return 0;
|
|
70
|
+
const idx = models.findIndex((m) => `${m.provider}/${m.id}` === ref);
|
|
71
|
+
if (idx < 0) return 0;
|
|
72
|
+
return idx + offset;
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
function supportedThinkingLevels(model: Model<Api>): SelectableThinkingLevel[] {
|
|
57
76
|
if (!model.reasoning) return [];
|
|
58
77
|
const map = model.thinkingLevelMap;
|
|
@@ -106,6 +125,7 @@ export async function selectModel(
|
|
|
106
125
|
models: readonly Model<Api>[],
|
|
107
126
|
current?: Model<Api>,
|
|
108
127
|
currentThinking?: ThinkingLevel,
|
|
128
|
+
currentRef?: string,
|
|
109
129
|
): Promise<ModelSelection | null | undefined> {
|
|
110
130
|
if (models.length === 0) {
|
|
111
131
|
ctx.ui.notify("RLM: no models available (add a provider key in Pi, or widen --models / enabledModels)", "warning");
|
|
@@ -114,7 +134,11 @@ export async function selectModel(
|
|
|
114
134
|
if (ctx.mode !== "tui") {
|
|
115
135
|
const fallback = models[0];
|
|
116
136
|
if (!fallback) return undefined;
|
|
117
|
-
|
|
137
|
+
// Prefer an explicit pin (resolved model or saved ref) over "first = cheapest".
|
|
138
|
+
const fromRef = currentRef
|
|
139
|
+
? models.find((m) => `${m.provider}/${m.id}` === currentRef)
|
|
140
|
+
: undefined;
|
|
141
|
+
const model = current ?? fromRef ?? fallback;
|
|
118
142
|
return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
|
|
119
143
|
}
|
|
120
144
|
|
|
@@ -134,6 +158,8 @@ export async function selectModel(
|
|
|
134
158
|
scrollInfo: (t) => theme.fg("dim", t),
|
|
135
159
|
noMatch: (t) => theme.fg("warning", t),
|
|
136
160
|
});
|
|
161
|
+
const initial = initialModelPickerIndex(models, current, currentRef, true);
|
|
162
|
+
if (initial > 0) list.setSelectedIndex(initial);
|
|
137
163
|
const isFilterText = (s: string): boolean => {
|
|
138
164
|
const sanitized = s.replace(/ /g, "");
|
|
139
165
|
return sanitized.length > 0 && Array.from(sanitized).every((char) => char >= " " && char !== "\x7f");
|
package/src/util/concurrency.ts
CHANGED
|
@@ -73,7 +73,7 @@ export class DepthGates {
|
|
|
73
73
|
|
|
74
74
|
/** Session-wide sub-call admission. Construct once; pass explicitly — never default one in. */
|
|
75
75
|
export interface SubcallGates {
|
|
76
|
-
/** llm_query /
|
|
76
|
+
/** llm_query / llm_batch completions — terminal, so one shared gate. */
|
|
77
77
|
readonly leaf: Semaphore;
|
|
78
78
|
/** Recursive child engines — one gate per depth, see DepthGates. */
|
|
79
79
|
readonly rlm: DepthGates;
|