@hicaru/pi-rlm 0.3.0 → 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 (45) hide show
  1. package/README.md +52 -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 +95 -38
  20. package/src/mode/native-guards.ts +4 -4
  21. package/src/mode/subagent.ts +68 -0
  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 +15 -5
  32. package/src/sandbox/py/hostio.py +57 -0
  33. package/src/sandbox/py/retrieval.py +17 -8
  34. package/src/sandbox/py/tasks.py +1 -1
  35. package/src/sandbox/py/worker.py +109 -83
  36. package/src/sandbox/sandbox-manager.ts +26 -1
  37. package/src/sandbox/sandbox.ts +9 -2
  38. package/src/tool/background-tasks.ts +1 -1
  39. package/src/tool/repl-result.ts +2 -2
  40. package/src/tool/repl-tool.ts +13 -14
  41. package/src/ui/config-panel.ts +1 -1
  42. package/src/ui/intro.ts +1 -4
  43. package/src/ui/model-picker.ts +28 -2
  44. package/src/util/concurrency.ts +1 -1
  45. package/src/bridge/subcall-handlers.ts +0 -382
@@ -15,7 +15,7 @@ from typing import Any
15
15
 
16
16
 
17
17
  _CHUNK_HEADER_OVERHEAD = 64
18
- _MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
18
+ _MAX_CHUNK_BATCH = 20 # fan-out per llm_batch call (matches prompt guidance)
19
19
  _MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
20
20
  _NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
21
21
 
@@ -167,11 +167,14 @@ class _Bm25Index:
167
167
  out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
168
168
  for i, (idx, score) in enumerate(top):
169
169
  text = self.texts[idx]
170
+ snip = text[:_SNIPPET_CHARS]
171
+ # Both `snippet` and `text` so agents never KeyError mixing search vs grep shapes.
170
172
  out[i] = {
171
173
  "path": self.paths[idx],
172
174
  "line": self.starts[idx],
173
175
  "score": round(score, 3),
174
- "snippet": text[:_SNIPPET_CHARS],
176
+ "snippet": snip,
177
+ "text": snip,
175
178
  }
176
179
  return out
177
180
 
@@ -179,8 +182,8 @@ class _Bm25Index:
179
182
  def search(entries: list[tuple[str, str]], index: _Bm25Index, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
180
183
  """Rank `context` windows against a natural-language query (BM25).
181
184
 
182
- Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
183
- named files out of `context` and delegating them to llm_query / map_files.
185
+ Returns [{path, line, score, snippet, text}] — pointers, not bodies.
186
+ `text` is an alias of `snippet` (same as grep_context hits).
184
187
  """
185
188
  terms = _tokenize(str(query))
186
189
  if not terms:
@@ -201,9 +204,9 @@ def grep_context(
201
204
  ) -> dict[str, Any]:
202
205
  """Regex over `context`, capped and shaped.
203
206
 
204
- Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
205
- `counts` is complete even when `hits` is capped, so a wide pattern reports its shape
206
- instead of flooding stdout.
207
+ Returns {"hits": [{path, line, text, snippet}], "counts": {path: n}, "total": n, "truncated": bool}.
208
+ `snippet` is an alias of `text` (same as search hits) to avoid KeyError footguns.
209
+ `counts` is complete even when `hits` is capped.
207
210
  """
208
211
  try:
209
212
  rx = re.compile(pattern)
@@ -234,7 +237,13 @@ def grep_context(
234
237
  continue
235
238
  lo = max(0, i - pad_before)
236
239
  hi = min(len(lines), i + pad_after + 1)
237
- hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
240
+ body = "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]
241
+ hits.append({
242
+ "path": path,
243
+ "line": i + 1,
244
+ "text": body,
245
+ "snippet": body,
246
+ })
238
247
  return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
239
248
 
240
249
  def outline(entries: list[tuple[str, str]], path: str) -> str:
@@ -42,7 +42,7 @@ def _reduce_batch(n: int):
42
42
 
43
43
 
44
44
  def _reduce_chunked(sizes: list[int], drop_empty: bool = True):
45
- """Concatenate several llm_query_batched replies back into one flat chunk list.
45
+ """Concatenate several llm_batch replies back into one flat chunk list.
46
46
 
47
47
  drop_empty=True (llm_query_chunked): filter "" replies so results never degrade
48
48
  to blank entries; the flattened list may then be shorter than the chunk count,
@@ -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
  """
@@ -44,6 +44,7 @@ from guards import (
44
44
  REAL_STDERR as _REAL_STDERR,
45
45
  REAL_STDIN as _REAL_STDIN,
46
46
  )
47
+ from hostio import read_host_payload
47
48
  from retrieval import (
48
49
  _Bm25Index,
49
50
  _chunk_text,
@@ -99,12 +100,13 @@ class Worker:
99
100
  # Replies parked by rid until something awaits them. Unbounded by design: a task
100
101
  # the model spawns and never awaits keeps its entry for the life of the process.
101
102
  # Bounded in practice by session length; evicting would silently hang a later
102
- # rlm_await, which is strictly worse than the memory.
103
+ # await_task, which is strictly worse than the memory.
103
104
  self.inbox: dict[str, dict[str, Any]] = {}
104
105
  self._inflight: set[str] = set()
105
106
  # Requests (exec/shutdown) that arrived mid-exec; main() replays them.
106
107
  self._deferred: list[Any] = []
107
- # 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).
108
110
  self._detached = False
109
111
  self.ns: dict[str, Any] = {}
110
112
  self._setup()
@@ -126,13 +128,13 @@ class Worker:
126
128
  # Re-inject any scaffolding the user code clobbered.
127
129
  ns = self.ns
128
130
  ns["llm_query"] = self._llm_query
129
- ns["llm_query_batched"] = self._llm_query_batched
131
+ ns["llm_batch"] = self._llm_batch
130
132
  ns["llm_query_chunked"] = self._llm_query_chunked
131
133
  ns["rlm_query"] = self._rlm_query
132
- ns["rlm_query_batched"] = self._rlm_query_batched
134
+ ns["rlm_batch"] = self._rlm_batch
133
135
  ns["spawn"] = self._spawn
134
- ns["rlm_await"] = self._await_task
135
- 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
136
138
  ns["map_files"] = self._map_files
137
139
  ns["llm_map_reduce"] = self._llm_map_reduce
138
140
  ns["search"] = self._search
@@ -183,14 +185,19 @@ class Worker:
183
185
 
184
186
  # ---- sub-LLM bridge over stdio --------------------------------------------------------
185
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
+
186
192
  def _post(self, kind: str, payload: dict[str, Any]) -> str:
187
193
  """Write one parent request and return its rid WITHOUT waiting for the reply."""
188
194
  self._rid += 1
189
195
  rid = f"q{self._rid}"
190
196
  # Register only after the write succeeds — a broken pipe must not leave an
191
197
  # _inflight entry that nothing will ever settle.
198
+ detached = self._detached or kind in self._DETACHED_KINDS
192
199
  _send({"type": kind, "rid": rid, "depth": self.depth,
193
- "detached": self._detached, **payload})
200
+ "detached": detached, **payload})
194
201
  self._inflight.add(rid)
195
202
  return rid
196
203
 
@@ -256,21 +263,21 @@ class Worker:
256
263
 
257
264
  # ---- spawn / await ---------------------------------------------------------------------
258
265
 
259
- def _start_prompt(self, kind: str, prompt, model, paths=None) -> Task:
266
+ def _start_prompt(self, kind: str, prompt, paths=None) -> Task:
260
267
  text = str(prompt)
261
268
  # A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
262
269
  # looking exactly like data. Refuse instead of spending a call on it.
263
270
  if not text.strip():
264
271
  return Task.resolved(self, kind, _surfaced_error(
265
272
  f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
266
- payload: dict[str, Any] = {"prompt": text, "model": model}
273
+ payload: dict[str, Any] = {"prompt": text}
267
274
  clean = _clean_paths(paths)
268
275
  if clean is not None:
269
276
  payload["paths"] = clean
270
277
  rid = self._post(kind, payload)
271
278
  return Task(self, kind, (rid,), _reduce_one, text[:40])
272
279
 
273
- def _start_prompts(self, kind: str, prompts, model, paths=None) -> Task:
280
+ def _start_prompts(self, kind: str, prompts, paths=None) -> Task:
274
281
  prompts = [str(p) for p in prompts]
275
282
  if not prompts:
276
283
  return Task.resolved(self, kind, [])
@@ -279,7 +286,7 @@ class Worker:
279
286
  return Task.resolved(self, kind, [
280
287
  _surfaced_error(f"{kind}() got only empty prompts")
281
288
  ] * len(prompts))
282
- payload: dict[str, Any] = {"prompts": prompts, "model": model}
289
+ payload: dict[str, Any] = {"prompts": prompts}
283
290
  # One prefix set for the whole batch: a per-prompt aligned list is an API nobody uses
284
291
  # correctly, and every prompt in a batch is asking about the same slice anyway.
285
292
  clean = _clean_paths(paths)
@@ -288,23 +295,23 @@ class Worker:
288
295
  rid = self._post(kind, payload)
289
296
  return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
290
297
 
291
- def _start_llm_query(self, prompt, model: str | None = None) -> Task:
292
- return self._start_prompt("llm_query", prompt, model)
298
+ def _start_llm_query(self, prompt) -> Task:
299
+ return self._start_prompt("llm_query", prompt)
293
300
 
294
- def _start_rlm_query(self, prompt, model: str | None = None, paths=None) -> Task:
295
- 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)
296
303
 
297
- def _start_llm_query_batched(self, prompts, model: str | None = None) -> Task:
298
- 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)
299
306
 
300
- def _start_rlm_query_batched(self, prompts, model: str | None = None, paths=None) -> Task:
301
- 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)
302
309
 
303
- 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:
304
311
  """Split oversized text into cap-sized chunks and post EVERY batch at once.
305
312
 
306
313
  One answer per chunk, order preserved. No exceptions escape: errors come back as
307
- "Error: ..." strings per chunk (same contract as llm_query_batched). Because all
314
+ "Error: ..." strings per chunk (same contract as llm_batch). Because all
308
315
  batches go on the wire together, a large input costs one round-trip of latency
309
316
  rather than one per 20 chunks.
310
317
 
@@ -333,26 +340,25 @@ class Worker:
333
340
  f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
334
341
  for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
335
342
  ]
336
- rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
343
+ rids.append(self._post("llm_batch", {"prompts": batch}))
337
344
  sizes.append(len(batch))
338
345
  return Task(self, "llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
339
-
340
346
  def _builder_for(self, name: str):
341
347
  # llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
342
348
  # depends on its own map results, so it cannot be one (rids, pure reduce) Task.
343
349
  return {
344
350
  "llm_query": self._start_llm_query,
345
- "llm_query_batched": self._start_llm_query_batched,
351
+ "llm_batch": self._start_llm_batch,
346
352
  "llm_query_chunked": self._start_llm_query_chunked,
347
353
  "map_files": self._start_map_files,
348
354
  "rlm_query": self._start_rlm_query,
349
- "rlm_query_batched": self._start_rlm_query_batched,
355
+ "rlm_batch": self._start_rlm_batch,
350
356
  }.get(name)
351
357
 
352
358
  def _spawn(self, fn, *args, **kwargs) -> Task:
353
359
  """Start a sub-call without waiting for it. `fn` is the scaffold function itself.
354
360
 
355
- 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.
356
362
  Misuse returns an already-resolved error Task rather than raising, matching the
357
363
  "Error: ..." contract of the synchronous helpers.
358
364
  """
@@ -360,11 +366,10 @@ class Worker:
360
366
  builder = self._builder_for(name) if isinstance(name, str) else None
361
367
  if builder is None:
362
368
  return Task.resolved(self, "spawn", _surfaced_error(
363
- "spawn() takes llm_query, llm_query_batched, llm_query_chunked, map_files, "
364
- "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 "
365
371
  "on its own map results and so cannot be a single Task"))
366
- # Mark every request this builder posts as detached: the parent routes them to its
367
- # 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.
368
373
  self._detached = True
369
374
  try:
370
375
  return builder(*args, **kwargs)
@@ -373,34 +378,48 @@ class Worker:
373
378
  finally:
374
379
  self._detached = False
375
380
 
376
- def _await_task(self, task) -> Any:
377
- """Block until `task` has its result. Idempotent — the value is memoized."""
378
- if not isinstance(task, Task):
379
- return _surfaced_error(
380
- f"rlm_await expects a Task from spawn(), got {type(task).__name__}"
381
- )
381
+ def _await_one(self, task: Task) -> Any:
382
+ """Block until one Task has its result. Idempotent — the value is memoized."""
382
383
  if not task._settled:
383
384
  self._drain_until(task._rids)
384
385
  task._value = task._reduce(self._take(task._rids))
385
386
  task._settled = True
386
387
  return task._value
387
388
 
388
- def _await_all(self, tasks) -> list:
389
- """Block until every task has its result. Order matches the input."""
390
- tasks = list(tasks)
391
- # One union drain so the tasks overlap instead of settling one after another.
392
- union: list[str] = []
393
- seen: set[str] = set()
394
- for t in tasks:
395
- if not isinstance(t, Task) or t._settled:
396
- continue
397
- for rid in t._rids:
398
- if rid not in seen:
399
- seen.add(rid)
400
- union.append(rid)
401
- if union:
402
- self._drain_until(union)
403
- 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
+ )
404
423
 
405
424
  # ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
406
425
 
@@ -445,7 +464,7 @@ class Worker:
445
464
 
446
465
  # ---- one-line delegation (structural: orchestrating must be easier than solving) -------
447
466
 
448
- 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:
449
468
  """Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
450
469
 
451
470
  All batches go on the wire together, so a 100-file map costs one round-trip of latency
@@ -488,27 +507,27 @@ class Worker:
488
507
  sizes: list[int] = []
489
508
  for i in range(0, len(requests), _MAX_CHUNK_BATCH):
490
509
  batch = requests[i:i + _MAX_CHUNK_BATCH]
491
- rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
510
+ rids.append(self._post("llm_batch", {"prompts": batch}))
492
511
  sizes.append(len(batch))
493
512
  return Task(self, "map_files", tuple(rids),
494
513
  _reduce_map_files(sizes, spans), f"{len(by_path)} files")
495
514
 
496
515
  @_spawnable("map_files")
497
- def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
498
- """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].
499
518
 
500
519
  `files` accepts context entries (dicts), paths (strings), or a mix — the whole
501
520
  chunk/batch/collect loop the system prompt used to spell out, as one call.
502
521
  Oversized files are split and their per-chunk answers joined.
522
+ Posts are detached (↯bg) so fan-out outlives the repl cell.
503
523
  """
504
- return self._await_task(self._start_map_files(files, prompt, model))
524
+ return self._start_map_files(files, prompt)
505
525
 
506
526
  def _llm_map_reduce(
507
527
  self,
508
528
  items: Any,
509
529
  map_prompt: str,
510
530
  reduce_prompt: str,
511
- model: str | None = None,
512
531
  ) -> str:
513
532
  """Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
514
533
 
@@ -533,28 +552,36 @@ class Worker:
533
552
  f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
534
553
  for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
535
554
  ]
536
- 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)])
537
558
  joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
538
- 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)
539
563
 
540
- # ---- sync helpers: await(start(...)), so there is exactly one code path -----------------
564
+ # ---- Core tools: ALWAYS spawn (return Task). Collect with await_task only. ------------
541
565
 
542
566
  @_spawnable("llm_query")
543
- def _llm_query(self, prompt: str, model: str | None = None) -> str:
544
- 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)
545
570
 
546
- @_spawnable("llm_query_batched")
547
- def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
548
- 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)
549
575
 
550
576
  @_spawnable("llm_query_chunked")
551
- def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
552
- 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)
553
580
 
554
581
  @_spawnable("rlm_query")
555
- def _rlm_query(self, prompt: str, model: str | None = None, paths=None) -> str:
556
- return self._await_task(self._start_rlm_query(prompt, model, paths))
557
-
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)
558
585
  def _add_context(self, source: str) -> dict[str, Any] | str:
559
586
  """Pack an external dir/file/git-URL on the host and append it into `context`.
560
587
 
@@ -591,8 +618,7 @@ class Worker:
591
618
  if not isinstance(path, str):
592
619
  return "Error: malformed add_context reply (no path)"
593
620
  try:
594
- with io.open(path, "r") as f:
595
- payload = json.load(f) if r.get("json") else f.read()
621
+ payload = read_host_payload(path, bool(r.get("json")))
596
622
  finally:
597
623
  try:
598
624
  os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
@@ -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
 
@@ -706,8 +733,7 @@ class Worker:
706
733
  `index` is accepted for protocol compatibility but ignored — there is only
707
734
  one context slot. Sources are merged on the host (or via add_context).
708
735
  """
709
- with open(path, "r") as f:
710
- payload = json.load(f) if is_json else f.read()
736
+ payload = read_host_payload(path, bool(is_json))
711
737
  self._context_payload = payload
712
738
  self.ns["context"] = payload
713
739
  # Drop legacy multi-slot names if present.
@@ -758,7 +784,7 @@ class Worker:
758
784
  return []
759
785
  return [
760
786
  f"[rlm] huge raw-text variable(s): {', '.join(names)} — do NOT analyze them yourself; "
761
- '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.'
762
788
  ]
763
789
 
764
790
  def execute(self, code: str) -> dict[str, Any]:
@@ -829,7 +855,7 @@ def main() -> None:
829
855
  _send({"id": "?", "ok": False, "error": f"bad json: {e}"})
830
856
  continue
831
857
  # A task spawned in an earlier exec settling while the worker is idle. Park it for
832
- # 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
833
859
  # result would be lost.
834
860
  if worker.park_reply(req):
835
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
  }
@@ -111,6 +111,11 @@ export class PythonSandbox {
111
111
  this.initTimeoutMs = opts.initTimeoutMs ?? 30_000;
112
112
  const python = opts.python ?? "python3";
113
113
  const workerArgs = [
114
+ // -X utf8=1: the scaffold states its own encoding explicitly (py/hostio.py), but MODEL
115
+ // code gets a real open() — guards.py exposes it deliberately — and on Windows that
116
+ // would default to cp1252 (issue #7). UTF-8 mode covers the whole interpreter; the
117
+ // scaffold's explicit encoding= still wins where PYTHONIOENCODING would override this.
118
+ "-X", "utf8=1",
114
119
  "-u", WORKER_PATH,
115
120
  "--depth", String(opts.depth ?? 1),
116
121
  "--timeout", String(opts.execTimeoutS ?? 600),
@@ -124,7 +129,9 @@ export class PythonSandbox {
124
129
  this.proc = spawn(
125
130
  python,
126
131
  workerArgs,
127
- { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv() },
132
+ // windowsHide: without it each sandbox flashes a console window on Windows (pi sets
133
+ // this on every spawn — bash.ts / shell.ts). Same Windows surface as issue #7.
134
+ { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv(), windowsHide: true },
128
135
  ) as ChildProcessWithoutNullStreams;
129
136
 
130
137
  this.proc.stdout.setEncoding("utf8");
@@ -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