@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.
Files changed (44) hide show
  1. package/README.md +34 -5
  2. package/README.ru.md +5 -5
  3. package/README.zh-CN.md +5 -5
  4. package/package.json +1 -1
  5. package/src/bridge/handlers/await.ts +148 -0
  6. package/src/bridge/handlers/completion.ts +72 -0
  7. package/src/bridge/handlers/emitting.ts +104 -0
  8. package/src/bridge/handlers/finish.ts +45 -0
  9. package/src/bridge/handlers/index.ts +48 -0
  10. package/src/bridge/handlers/llm-query.ts +130 -0
  11. package/src/bridge/handlers/rlm-query.ts +227 -0
  12. package/src/bridge/handlers/task-registry.ts +202 -0
  13. package/src/bridge/handlers/types.ts +136 -0
  14. package/src/commands/rlm-config.ts +33 -14
  15. package/src/context/listing.ts +2 -2
  16. package/src/context/refresh.ts +141 -0
  17. package/src/core/engine.ts +16 -18
  18. package/src/core/types.ts +1 -3
  19. package/src/index.ts +54 -42
  20. package/src/mode/native-guards.ts +4 -4
  21. package/src/mode/subagent.ts +1 -1
  22. package/src/prompts/glossary.ts +71 -74
  23. package/src/prompts/native.ts +127 -85
  24. package/src/prompts/system.ts +29 -15
  25. package/src/sandbox/interrupts.ts +258 -68
  26. package/src/sandbox/protocol.ts +53 -30
  27. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  28. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  29. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  31. package/src/sandbox/py/guards.py +8 -5
  32. package/src/sandbox/py/retrieval.py +17 -8
  33. package/src/sandbox/py/tasks.py +1 -1
  34. package/src/sandbox/py/worker.py +106 -79
  35. package/src/sandbox/sandbox-manager.ts +26 -1
  36. package/src/sandbox/sandbox.ts +1 -1
  37. package/src/tool/background-tasks.ts +1 -1
  38. package/src/tool/repl-result.ts +2 -2
  39. package/src/tool/repl-tool.ts +13 -14
  40. package/src/ui/config-panel.ts +1 -1
  41. package/src/ui/intro.ts +1 -4
  42. package/src/ui/model-picker.ts +28 -2
  43. package/src/util/concurrency.ts +1 -1
  44. package/src/bridge/subcall-handlers.ts +0 -382
@@ -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"|"llm_query_batched"|"rlm_query"|
10
- "rlm_query_batched"|"add_context","rid",...}
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()` / `rlm_await()` / `rlm_await_all()` possible — many requests can be
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
- # rlm_await, which is strictly worse than the memory.
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
- # True only while spawn() runs a builder marks requests that may outlive this exec.
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["llm_query_batched"] = self._llm_query_batched
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["rlm_query_batched"] = self._rlm_query_batched
134
+ ns["rlm_batch"] = self._rlm_batch
134
135
  ns["spawn"] = self._spawn
135
- ns["rlm_await"] = self._await_task
136
- ns["rlm_await_all"] = self._await_all
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": self._detached, **payload})
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, model, paths=None) -> Task:
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, "model": model}
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, model, paths=None) -> Task:
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, "model": model}
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, model: str | None = None) -> Task:
293
- return self._start_prompt("llm_query", prompt, model)
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, model: str | None = None, paths=None) -> Task:
296
- return self._start_prompt("rlm_query", prompt, model, paths)
301
+ def _start_rlm_query(self, prompt, paths=None) -> Task:
302
+ return self._start_prompt("rlm_query", prompt, paths)
297
303
 
298
- def _start_llm_query_batched(self, prompts, model: str | None = None) -> Task:
299
- return self._start_prompts("llm_query_batched", prompts, model)
304
+ def _start_llm_batch(self, prompts) -> Task:
305
+ return self._start_prompts("llm_batch", prompts)
300
306
 
301
- def _start_rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> Task:
302
- return self._start_prompts("rlm_query_batched", prompts, model, paths)
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, model: str | None = None) -> Task:
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 llm_query_batched). Because all
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("llm_query_batched", {"prompts": batch, "model": model}))
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
- "llm_query_batched": self._start_llm_query_batched,
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
- "rlm_query_batched": self._start_rlm_query_batched,
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 rlm_await / rlm_await_all, possibly in a later ```repl``` block.
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, llm_query_batched, llm_query_chunked, map_files, "
365
- "rlm_query or rlm_query_batched — not llm_map_reduce, whose reduce step depends "
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
- # Mark every request this builder posts as detached: the parent routes them to its
368
- # session-scoped registry, since they may outlive the exec that started them.
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 _await_task(self, task) -> Any:
378
- """Block until `task` has its result. Idempotent — the value is memoized."""
379
- if not isinstance(task, Task):
380
- return _surfaced_error(
381
- f"rlm_await expects a Task from spawn(), got {type(task).__name__}"
382
- )
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 _await_all(self, tasks) -> list:
390
- """Block until every task has its result. Order matches the input."""
391
- tasks = list(tasks)
392
- # One union drain so the tasks overlap instead of settling one after another.
393
- union: list[str] = []
394
- seen: set[str] = set()
395
- for t in tasks:
396
- if not isinstance(t, Task) or t._settled:
397
- continue
398
- for rid in t._rids:
399
- if rid not in seen:
400
- seen.add(rid)
401
- union.append(rid)
402
- if union:
403
- self._drain_until(union)
404
- return [self._await_task(t) for t in tasks]
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, model: str | None = None) -> Task:
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("llm_query_batched", {"prompts": batch, "model": model}))
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, model: str | None = None) -> dict[str, str]:
499
- """Ask `prompt` of every given file, batched, and return {path: answer}.
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._await_task(self._start_map_files(files, prompt, model))
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
- mapped.extend(self._llm_query_batched(batch, model))
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
- return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
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
- # ---- sync helpers: await(start(...)), so there is exactly one code path -----------------
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, model: str | None = None) -> str:
545
- return self._await_task(self._start_llm_query(prompt, model))
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("llm_query_batched")
548
- def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
549
- return self._await_task(self._start_llm_query_batched(prompts, model))
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, model: str | None = None) -> list[str]:
553
- return self._await_task(self._start_llm_query_chunked(text, prompt, model))
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, model: str | None = None, paths=None) -> str:
557
- return self._await_task(self._start_rlm_query(prompt, model, paths))
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("rlm_query_batched")
698
- def _rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> list[str]:
699
- return self._await_task(self._start_rlm_query_batched(prompts, model, paths))
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 + llm_query_batched.'
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 rlm_await; without this it would fall through to "unknown type" and the
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 rlm_await. */
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.
@@ -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
- * (rlm_await / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
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/subcall-handlers.ts";
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. */
@@ -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 — rlm_await_all(tasks) to collect.`
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 rlm_await value is an "Error: …" string, not data.`
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
  }
@@ -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 itself lives in bridge/subcall-handlers.ts; this file only supplies the
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/subcall-handlers.ts";
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 ALL repository reading and analysis (read/grep are disabled in RLM mode). " +
206
- "Persistent Python sandbox with loaded files in `context` (starts empty; cwd seeds on first " +
207
- "call). Locate first with the free primitives search(query) / grep_context(pattern) / " +
208
- "outline(path), then delegate the semantic reading to map_files / llm_query / " +
209
- "llm_query_batched / llm_query_chunked (rlm_query for iterative sub-tasks) stdout " +
210
- "returned to you is hard-capped at 4K chars, so printing file bodies is useless. " +
211
- "Variables, imports, and the `answers`/`plan` memo persist across calls. Also supports " +
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: run Python in a persistent sandbox holding loaded files in `context`; " +
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
- "In RLM mode, read the repository through `repl` only`read`/`grep` and bash readers are blocked.",
218
- "Inside `repl`, locate with search()/grep_context()/outline() before delegating bulk reading to map_files()/llm_query_batched().",
215
+ "Multi-area analysis: rlm_batch([...]) or map_files(paths, q); free search; await_tasknot 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
 
@@ -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 llm_query_batched and rlm_query_batched."),
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));
@@ -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
- const CHEAPEST_VALUE = "__rlm_cheapest__";
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
- const model = current ?? fallback;
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");
@@ -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 / llm_query_batched completions — terminal, so one shared gate. */
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;