@hicaru/pi-rlm 0.2.0 → 0.2.1

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.
@@ -12,6 +12,12 @@ Protocol (worker -> parent): {"id","ok",...result} # response to a r
12
12
  When sandbox code calls llm_query/rlm_query/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
13
13
  and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
14
14
  services the request in-process (it holds API keys).
15
+
16
+ Requests and replies are decoupled: `_post` writes a request and returns its rid without
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
19
+ in flight at once (the parent already services interrupts concurrently), and a task may be
20
+ awaited in a LATER exec than the one that started it.
15
21
  """
16
22
 
17
23
  from __future__ import annotations
@@ -98,13 +104,42 @@ def _install_read_only_guards():
98
104
  return guarded_io_open
99
105
 
100
106
 
107
+ # _builtin()'s getattr(..., None) fallback would silently inject None for a name this
108
+ # interpreter lacks, surfacing much later as "'NoneType' object is not callable" inside model
109
+ # code. Fail at startup instead. Note "None" is legitimately None, and the block-list below is
110
+ # deliberate — which is why this check runs BEFORE it.
111
+ _MISSING = sorted(name for name, value in _SAFE_BUILTINS.items() if value is None and name != "None")
112
+ if _MISSING:
113
+ raise RuntimeError(f"unsupported Python interpreter: missing builtins {_MISSING}")
114
+
115
+ def _blocked_builtin(name: str):
116
+ """Bind a disabled builtin to a callable that explains itself.
117
+
118
+ Binding these to None made `eval(...)` fail with a bare "'NoneType' object is not callable",
119
+ which reads as a broken sandbox rather than a deliberate block: an audit session spent six
120
+ execs on it and filed a phantom "namespace corruption" bug. Saying so at the point of failure
121
+ fixes it for every model without spending native-prompt budget on a rule most runs never hit.
122
+ """
123
+ def blocked(*_args, **_kwargs):
124
+ raise PermissionError(
125
+ f"{name}() is disabled in the RLM sandbox by design — it is not missing and the "
126
+ "namespace is not corrupt. Names are already bound, so reference them directly; "
127
+ "inspect `context` with search() / grep_context() / outline()."
128
+ )
129
+ blocked.__name__ = name
130
+ return blocked
131
+
132
+
133
+ # Blocked on purpose (NOT missing) — see _blocked_builtin. The _MISSING check above runs first,
134
+ # so a genuinely absent builtin is still a startup failure rather than a silent None.
101
135
  for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
102
- _SAFE_BUILTINS[_blocked] = None
136
+ _SAFE_BUILTINS[_blocked] = _blocked_builtin(_blocked)
103
137
 
104
138
  RESERVED = frozenset(
105
139
  {
106
140
  "llm_query", "llm_query_batched", "llm_query_chunked",
107
141
  "rlm_query", "rlm_query_batched",
142
+ "spawn", "rlm_await", "rlm_await_all",
108
143
  "map_files", "llm_map_reduce",
109
144
  "search", "grep_context", "outline",
110
145
  "advance_phase", "save_artifact",
@@ -301,14 +336,179 @@ def _send(obj: dict[str, Any]) -> None:
301
336
  _REAL_STDOUT.flush()
302
337
 
303
338
 
339
+ class _StallTimeout(Exception):
340
+ """No frame from the host while a sub-call was pending."""
341
+
342
+
343
+ @contextmanager
344
+ def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
345
+ """Swap the per-cell alarm for a stall alarm while blocked on the parent.
346
+
347
+ Sub-LLM latency is network time, not cell compute time, so it must not count against the
348
+ ```repl``` block timeout — but an unbounded wait is exactly how a lost reply turns into a
349
+ dead session. The yielded `rearm()` restarts the stall clock on every frame, so a healthy
350
+ long-running child never trips it.
351
+ """
352
+ use = hasattr(signal, "SIGALRM")
353
+ remaining = signal.getitimer(signal.ITIMER_REAL)[0] if (use and exec_timeout_s > 0) else 0.0
354
+
355
+ def _fire(signum, frame): # noqa: ARG001
356
+ raise _StallTimeout(
357
+ f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
358
+ "(the task may still be running; rlm_await it again in a later block)"
359
+ )
360
+
361
+ old = signal.signal(signal.SIGALRM, _fire) if use else None
362
+
363
+ def rearm() -> None:
364
+ if use and stall_timeout_s > 0:
365
+ signal.setitimer(signal.ITIMER_REAL, stall_timeout_s)
366
+
367
+ rearm()
368
+ try:
369
+ yield rearm
370
+ finally:
371
+ if use:
372
+ signal.setitimer(signal.ITIMER_REAL, 0)
373
+ if old is not None:
374
+ signal.signal(signal.SIGALRM, old)
375
+ if remaining > 0: # restore the cell's remaining budget
376
+ signal.setitimer(signal.ITIMER_REAL, remaining)
377
+
378
+
379
+ def _surfaced_error(message: str) -> str:
380
+ """The "Error: …" contract value, ALSO written to the cell's stderr.
381
+
382
+ A spawn/await misuse whose only trace is the returned value reads to the model as a random
383
+ string much later — which is exactly how `tasks.items()` blew up on a str.
384
+ """
385
+ print(f"[rlm] {message}", file=sys.stderr)
386
+ return f"Error: {message}"
387
+
388
+
389
+ # ---- reply reducers: raw parent replies -> the value the scaffold fn returns ----------------
390
+ # One reducer per result shape, shared by the sync helpers and their spawned equivalents.
391
+
392
+
393
+ def _reduce_one(replies: list[dict[str, Any]]) -> str:
394
+ r = replies[0]
395
+ return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
396
+
397
+
398
+ def _reduce_batch(n: int):
399
+ """Reducer for a single *_query_batched reply of n prompts."""
400
+ def reduce(replies: list[dict[str, Any]]) -> list[str]:
401
+ r = replies[0]
402
+ if r.get("error"):
403
+ return [f"Error: {r['error']}"] * n
404
+ out = r.get("responses")
405
+ if not isinstance(out, list) or len(out) != n:
406
+ return ["Error: malformed batched response"] * n
407
+ return [s if isinstance(s, str) else f"Error: {s}" for s in out]
408
+ return reduce
409
+
410
+
411
+ def _reduce_chunked(sizes: list[int]):
412
+ """Concatenate several llm_query_batched replies back into one flat chunk list."""
413
+ per = [_reduce_batch(n) for n in sizes]
414
+
415
+ def reduce(replies: list[dict[str, Any]]) -> list[str]:
416
+ out: list[str] = []
417
+ for red, rep in zip(per, replies):
418
+ out.extend(red([rep]))
419
+ return out
420
+ return reduce
421
+
422
+
423
+ def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
424
+ """Flatten the batch replies (same as chunked), then regroup them per path.
425
+
426
+ A file larger than the per-prompt budget contributed several requests; its answers rejoin
427
+ in order, which is what makes map_files a {path: answer} dict rather than a flat list.
428
+ """
429
+ flatten = _reduce_chunked(sizes)
430
+
431
+ def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
432
+ responses = flatten(replies)
433
+ out: dict[str, str] = {}
434
+ cursor = 0
435
+ for path, count in spans:
436
+ part = responses[cursor:cursor + count]
437
+ cursor += count
438
+ out[path] = part[0] if count == 1 and part else "\n\n".join(part)
439
+ return out
440
+ return reduce
441
+
442
+
443
+ def _spawnable(name: str):
444
+ """Tag a sync scaffold fn with the request kind spawn() should route it to."""
445
+ def mark(fn):
446
+ fn._rlm_name = name
447
+ return fn
448
+ return mark
449
+
450
+
451
+ class Task:
452
+ """Handle for parent-side work in flight, returned by spawn().
453
+
454
+ Opaque to model code apart from `done` and repr. A Task may be awaited in a later
455
+ ```repl``` block than the one that created it.
456
+ """
457
+
458
+ __slots__ = ("kind", "label", "_worker", "_rids", "_reduce", "_value", "_settled")
459
+
460
+ def __init__(self, worker: "Worker", kind: str, rids, reduce, label: str = ""):
461
+ self.kind = kind
462
+ self.label = label
463
+ self._worker = worker
464
+ self._rids = tuple(rids)
465
+ self._reduce = reduce
466
+ self._value: Any = None
467
+ self._settled = False
468
+
469
+ @staticmethod
470
+ def resolved(worker: "Worker", kind: str, value: Any, label: str = "") -> "Task":
471
+ """A Task that never hit the wire — validation errors and empty inputs."""
472
+ task = Task(worker, kind, (), lambda _replies: value, label)
473
+ task._value = value
474
+ task._settled = True
475
+ return task
476
+
477
+ @property
478
+ def done(self) -> bool:
479
+ """True once every reply has landed — awaiting will not block."""
480
+ return self._settled or all(r in self._worker.inbox for r in self._rids)
481
+
482
+ def __repr__(self) -> str:
483
+ return f"<Task {self.kind} {'done' if self.done else 'running'} {self.label}>"
484
+
485
+
304
486
  class Worker:
305
- def __init__(self, depth: int, exec_timeout_s: float, max_prompt_chars: int, read_only: bool = False):
487
+ def __init__(
488
+ self,
489
+ depth: int,
490
+ exec_timeout_s: float,
491
+ max_prompt_chars: int,
492
+ read_only: bool = False,
493
+ await_timeout_s: float = 600.0,
494
+ ):
306
495
  self.depth = depth
307
496
  self.exec_timeout_s = exec_timeout_s
308
497
  self.max_prompt_chars = max_prompt_chars
309
498
  self.read_only = read_only
499
+ self.await_timeout_s = await_timeout_s
310
500
  self._rid = 0
311
501
  self._final_answer: str | None = None
502
+ # Replies parked by rid until something awaits them. Unbounded by design: a task
503
+ # the model spawns and never awaits keeps its entry for the life of the process.
504
+ # Bounded in practice by session length; evicting would silently hang a later
505
+ # rlm_await, which is strictly worse than the memory.
506
+ self.inbox: dict[str, dict[str, Any]] = {}
507
+ self._inflight: set[str] = set()
508
+ # Requests (exec/snapshot/shutdown) that arrived mid-exec; main() replays them.
509
+ self._deferred: list[Any] = []
510
+ # True only while spawn() runs a builder — marks requests that may outlive this exec.
511
+ self._detached = False
312
512
  self.ns: dict[str, Any] = {}
313
513
  self._setup()
314
514
 
@@ -336,6 +536,9 @@ class Worker:
336
536
  ns["llm_query_chunked"] = self._llm_query_chunked
337
537
  ns["rlm_query"] = self._rlm_query
338
538
  ns["rlm_query_batched"] = self._rlm_query_batched
539
+ ns["spawn"] = self._spawn
540
+ ns["rlm_await"] = self._await_task
541
+ ns["rlm_await_all"] = self._await_all
339
542
  ns["map_files"] = self._map_files
340
543
  ns["llm_map_reduce"] = self._llm_map_reduce
341
544
  ns["search"] = self._search
@@ -391,54 +594,120 @@ class Worker:
391
594
 
392
595
  # ---- sub-LLM bridge over stdio --------------------------------------------------------
393
596
 
394
- def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
597
+ def _post(self, kind: str, payload: dict[str, Any]) -> str:
598
+ """Write one parent request and return its rid WITHOUT waiting for the reply."""
395
599
  self._rid += 1
396
600
  rid = f"q{self._rid}"
397
- _send({"type": kind, "rid": rid, "depth": self.depth, **payload})
398
- # The per-cell SIGALRM is wall-clock; it must not count time blocked here waiting for
399
- # a sub-LLM reply (network/LLM latency, not local CPU). Pause it across the readline.
400
- pause = self.exec_timeout_s > 0 and hasattr(signal, "SIGALRM")
401
- if pause:
402
- remaining = signal.getitimer(signal.ITIMER_REAL)[0]
403
- signal.setitimer(signal.ITIMER_REAL, 0)
601
+ # Register only after the write succeeds — a broken pipe must not leave an
602
+ # _inflight entry that nothing will ever settle.
603
+ _send({"type": kind, "rid": rid, "depth": self.depth,
604
+ "detached": self._detached, **payload})
605
+ self._inflight.add(rid)
606
+ return rid
607
+
608
+ def park_reply(self, msg: Any) -> bool:
609
+ """File an llm_reply against its rid. True when the frame was a reply.
610
+
611
+ Public because main() needs it too: a spawned task can settle while the worker
612
+ sits idle between execs, and that reply must not fall through to "unknown type".
613
+ """
614
+ if not isinstance(msg, dict) or msg.get("type") != "llm_reply":
615
+ return False
616
+ rid = msg.get("rid")
617
+ if isinstance(rid, str) and rid in self._inflight:
618
+ self._inflight.discard(rid)
619
+ self.inbox[rid] = msg
620
+ else:
621
+ # Late reply to an abandoned rid (e.g. a request from a discarded sandbox).
622
+ print(f"[rlm-sandbox] dropping reply for unknown rid: {rid!r}", file=_REAL_STDERR)
623
+ return True
624
+
625
+ def take_deferred(self) -> Any | None:
626
+ """Pop a request that arrived mid-exec, for main() to replay. None when empty."""
627
+ return self._deferred.pop(0) if self._deferred else None
628
+
629
+ def _pump(self) -> bool:
630
+ """Read one frame from the parent into the inbox. False when the pipe closed."""
631
+ line = _REAL_STDIN.readline()
632
+ if not line:
633
+ return False
404
634
  try:
405
- while True:
406
- line = _REAL_STDIN.readline()
407
- if not line:
635
+ msg = json.loads(line)
636
+ except ValueError:
637
+ print(f"[rlm-sandbox] skipping non-JSON parent frame: {line[:200]}", file=_REAL_STDERR)
638
+ return True
639
+ if self.park_reply(msg):
640
+ return True
641
+ # A request (exec/snapshot/shutdown) arriving mid-exec: main() replays it.
642
+ self._deferred.append(msg)
643
+ return True
644
+
645
+ def _drain_until(self, rids) -> None:
646
+ """Block until every rid in `rids` has its reply parked in the inbox.
647
+
648
+ Bounded: a host that goes silent raises inside the ```repl``` block instead of hanging
649
+ the session forever.
650
+ """
651
+ if all(r in self.inbox for r in rids):
652
+ return
653
+ with _stall_alarm(self.exec_timeout_s, self.await_timeout_s) as rearm:
654
+ while not all(r in self.inbox for r in rids):
655
+ if not self._pump():
408
656
  raise RuntimeError("parent closed the pipe during a sub-LLM request")
409
- msg = json.loads(line)
410
- if msg.get("type") == "llm_reply" and msg.get("rid") == rid:
411
- return msg
412
- # Stray/late message (e.g. a reply to an earlier timed-out request): skip it.
413
- print(
414
- f"[rlm-sandbox] ignoring unexpected message during sub-LLM request: {str(msg)[:200]}",
415
- file=_REAL_STDERR,
416
- )
417
- finally:
418
- if pause and remaining > 0:
419
- signal.setitimer(signal.ITIMER_REAL, remaining)
657
+ rearm()
420
658
 
421
- def _llm_query(self, prompt: str, model: str | None = None) -> str:
422
- r = self._rpc("llm_query", {"prompt": str(prompt), "model": model})
423
- return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
659
+ def _take(self, rids) -> list[dict[str, Any]]:
660
+ return [self.inbox.pop(r) for r in rids]
424
661
 
425
- def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
662
+ def _rpc(self, kind: str, payload: dict[str, Any]) -> dict[str, Any]:
663
+ """Post one request and block for its reply — the synchronous single-shot path."""
664
+ rid = self._post(kind, payload)
665
+ self._drain_until((rid,))
666
+ return self._take((rid,))[0]
667
+
668
+ # ---- spawn / await ---------------------------------------------------------------------
669
+
670
+ def _start_prompt(self, kind: str, prompt, model) -> Task:
671
+ text = str(prompt)
672
+ # A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
673
+ # looking exactly like data. Refuse instead of spending a call on it.
674
+ if not text.strip():
675
+ return Task.resolved(self, kind, _surfaced_error(
676
+ f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
677
+ rid = self._post(kind, {"prompt": text, "model": model})
678
+ return Task(self, kind, (rid,), _reduce_one, text[:40])
679
+
680
+ def _start_prompts(self, kind: str, prompts, model) -> Task:
426
681
  prompts = [str(p) for p in prompts]
427
682
  if not prompts:
428
- return []
429
- r = self._rpc("llm_query_batched", {"prompts": prompts, "model": model})
430
- if r.get("error"):
431
- return [f"Error: {r['error']}"] * len(prompts)
432
- out = r.get("responses")
433
- if not isinstance(out, list) or len(out) != len(prompts):
434
- return ["Error: malformed batched response"] * len(prompts)
435
- return [s if isinstance(s, str) else f"Error: {s}" for s in out]
683
+ return Task.resolved(self, kind, [])
684
+ # Only the all-blank case: one blank prompt among twenty is the caller's business.
685
+ if not any(p.strip() for p in prompts):
686
+ return Task.resolved(self, kind, [
687
+ _surfaced_error(f"{kind}() got only empty prompts")
688
+ ] * len(prompts))
689
+ rid = self._post(kind, {"prompts": prompts, "model": model})
690
+ return Task(self, kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
436
691
 
437
- def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
438
- """Split oversized text into cap-sized chunks and fan out via llm_query_batched.
692
+ def _start_llm_query(self, prompt, model: str | None = None) -> Task:
693
+ return self._start_prompt("llm_query", prompt, model)
694
+
695
+ def _start_rlm_query(self, prompt, model: str | None = None) -> Task:
696
+ return self._start_prompt("rlm_query", prompt, model)
697
+
698
+ def _start_llm_query_batched(self, prompts, model: str | None = None) -> Task:
699
+ return self._start_prompts("llm_query_batched", prompts, model)
700
+
701
+ def _start_rlm_query_batched(self, prompts, model: str | None = None) -> Task:
702
+ return self._start_prompts("rlm_query_batched", prompts, model)
703
+
704
+ def _start_llm_query_chunked(self, text, prompt: str, model: str | None = None) -> Task:
705
+ """Split oversized text into cap-sized chunks and post EVERY batch at once.
439
706
 
440
- Returns one answer per chunk, order preserved. No exceptions escape: errors come
441
- back as "Error: ..." strings per chunk (same contract as llm_query_batched).
707
+ One answer per chunk, order preserved. No exceptions escape: errors come back as
708
+ "Error: ..." strings per chunk (same contract as llm_query_batched). Because all
709
+ batches go on the wire together, a large input costs one round-trip of latency
710
+ rather than one per 20 chunks.
442
711
 
443
712
  NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
444
713
  UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
@@ -446,22 +715,93 @@ class Worker:
446
715
  """
447
716
  text, prompt = str(text), str(prompt)
448
717
  if not text:
449
- return []
718
+ return Task.resolved(self, "llm_query_chunked", [])
450
719
  budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
451
720
  if budget < 1_000:
452
- return [f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) shorten the instruction"]
721
+ return Task.resolved(self, "llm_query_chunked", [
722
+ f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"
723
+ ])
453
724
  chunks = _chunk_text(text, budget)
454
725
  total = len(chunks)
455
726
  if total > _MAX_CHUNKS:
456
- return [f"Error: {total} chunks would be needed — filter/slice the text in Python first"]
457
- results: list[str] = []
727
+ return Task.resolved(self, "llm_query_chunked", [
728
+ f"Error: {total} chunks would be needed — filter/slice the text in Python first"
729
+ ])
730
+ rids: list[str] = []
731
+ sizes: list[int] = []
458
732
  for i in range(0, total, _MAX_CHUNK_BATCH):
459
733
  batch = [
460
734
  f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
461
735
  for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
462
736
  ]
463
- results.extend(self._llm_query_batched(batch, model))
464
- return results
737
+ rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
738
+ sizes.append(len(batch))
739
+ return Task(self, "llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
740
+
741
+ def _builder_for(self, name: str):
742
+ # llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
743
+ # depends on its own map results, so it cannot be one (rids, pure reduce) Task.
744
+ return {
745
+ "llm_query": self._start_llm_query,
746
+ "llm_query_batched": self._start_llm_query_batched,
747
+ "llm_query_chunked": self._start_llm_query_chunked,
748
+ "map_files": self._start_map_files,
749
+ "rlm_query": self._start_rlm_query,
750
+ "rlm_query_batched": self._start_rlm_query_batched,
751
+ }.get(name)
752
+
753
+ def _spawn(self, fn, *args, **kwargs) -> Task:
754
+ """Start a sub-call without waiting for it. `fn` is the scaffold function itself.
755
+
756
+ Returns a Task for rlm_await / rlm_await_all, possibly in a later ```repl``` block.
757
+ Misuse returns an already-resolved error Task rather than raising, matching the
758
+ "Error: ..." contract of the synchronous helpers.
759
+ """
760
+ name = getattr(fn, "_rlm_name", None)
761
+ builder = self._builder_for(name) if isinstance(name, str) else None
762
+ if builder is None:
763
+ return Task.resolved(self, "spawn", _surfaced_error(
764
+ "spawn() takes llm_query, llm_query_batched, llm_query_chunked, map_files, "
765
+ "rlm_query or rlm_query_batched — not llm_map_reduce, whose reduce step depends "
766
+ "on its own map results and so cannot be a single Task"))
767
+ # Mark every request this builder posts as detached: the parent routes them to its
768
+ # session-scoped registry, since they may outlive the exec that started them.
769
+ self._detached = True
770
+ try:
771
+ return builder(*args, **kwargs)
772
+ except TypeError as e:
773
+ return Task.resolved(self, "spawn", _surfaced_error(f"bad spawn arguments — {e}"))
774
+ finally:
775
+ self._detached = False
776
+
777
+ def _await_task(self, task) -> Any:
778
+ """Block until `task` has its result. Idempotent — the value is memoized."""
779
+ if not isinstance(task, Task):
780
+ return _surfaced_error(
781
+ f"rlm_await expects a Task from spawn(), got {type(task).__name__}"
782
+ )
783
+ if not task._settled:
784
+ self._drain_until(task._rids)
785
+ task._value = task._reduce(self._take(task._rids))
786
+ task._settled = True
787
+ return task._value
788
+
789
+ def _await_all(self, tasks) -> list:
790
+ """Block until every task has its result. Order matches the input."""
791
+ tasks = list(tasks)
792
+ # One union drain so the tasks overlap instead of settling one after another.
793
+ union: list[str] = []
794
+ seen: set[str] = set()
795
+ for t in tasks:
796
+ if not isinstance(t, Task) or t._settled:
797
+ continue
798
+ for rid in t._rids:
799
+ if rid not in seen:
800
+ seen.add(rid)
801
+ union.append(rid)
802
+ if union:
803
+ self._drain_until(union)
804
+ return [self._await_task(t) for t in tasks]
465
805
 
466
806
  # ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
467
807
 
@@ -573,12 +913,11 @@ class Worker:
573
913
 
574
914
  # ---- one-line delegation (structural: orchestrating must be easier than solving) -------
575
915
 
576
- def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
577
- """Ask `prompt` of every given file, batched, and return {path: answer}.
916
+ def _start_map_files(self, files: Any, prompt: str, model: str | None = None) -> Task:
917
+ """Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
578
918
 
579
- `files` accepts context entries (dicts), paths (strings), or a mix the whole
580
- chunk/batch/collect loop the system prompt used to spell out, as one call.
581
- Oversized files are split and their per-chunk answers joined.
919
+ All batches go on the wire together, so a 100-file map costs one round-trip of latency
920
+ rather than one per 20 files.
582
921
  """
583
922
  prompt = str(prompt)
584
923
  by_path: list[tuple[str, str]] = []
@@ -595,12 +934,14 @@ class Worker:
595
934
  else:
596
935
  by_path.append((item, ""))
597
936
  if not by_path:
598
- return {}
937
+ return Task.resolved(self, "map_files", {})
599
938
 
600
939
  # Per-file prompt budget; anything larger is chunked and its answers concatenated.
601
940
  budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
602
941
  if budget < 1_000:
603
- return {p: "Error: prompt too long to leave room for file content" for p, _ in by_path}
942
+ return Task.resolved(self, "map_files", {
943
+ p: "Error: prompt too long to leave room for file content" for p, _ in by_path
944
+ })
604
945
 
605
946
  requests: list[str] = []
606
947
  spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
@@ -611,17 +952,24 @@ class Worker:
611
952
  header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
612
953
  requests.append(f"{prompt}\n\n{header}\n{chunk}")
613
954
 
614
- responses: list[str] = []
955
+ rids: list[str] = []
956
+ sizes: list[int] = []
615
957
  for i in range(0, len(requests), _MAX_CHUNK_BATCH):
616
- responses.extend(self._llm_query_batched(requests[i:i + _MAX_CHUNK_BATCH], model))
958
+ batch = requests[i:i + _MAX_CHUNK_BATCH]
959
+ rids.append(self._post("llm_query_batched", {"prompts": batch, "model": model}))
960
+ sizes.append(len(batch))
961
+ return Task(self, "map_files", tuple(rids),
962
+ _reduce_map_files(sizes, spans), f"{len(by_path)} files")
617
963
 
618
- out: dict[str, str] = {}
619
- cursor = 0
620
- for path, count in spans:
621
- part = responses[cursor:cursor + count]
622
- cursor += count
623
- out[path] = part[0] if count == 1 and part else "\n\n".join(part)
624
- return out
964
+ @_spawnable("map_files")
965
+ def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
966
+ """Ask `prompt` of every given file, batched, and return {path: answer}.
967
+
968
+ `files` accepts context entries (dicts), paths (strings), or a mix — the whole
969
+ chunk/batch/collect loop the system prompt used to spell out, as one call.
970
+ Oversized files are split and their per-chunk answers joined.
971
+ """
972
+ return self._await_task(self._start_map_files(files, prompt, model))
625
973
 
626
974
  def _llm_map_reduce(
627
975
  self,
@@ -657,9 +1005,23 @@ class Worker:
657
1005
  joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
658
1006
  return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
659
1007
 
1008
+ # ---- sync helpers: await(start(...)), so there is exactly one code path -----------------
1009
+
1010
+ @_spawnable("llm_query")
1011
+ def _llm_query(self, prompt: str, model: str | None = None) -> str:
1012
+ return self._await_task(self._start_llm_query(prompt, model))
1013
+
1014
+ @_spawnable("llm_query_batched")
1015
+ def _llm_query_batched(self, prompts, model: str | None = None) -> list[str]:
1016
+ return self._await_task(self._start_llm_query_batched(prompts, model))
1017
+
1018
+ @_spawnable("llm_query_chunked")
1019
+ def _llm_query_chunked(self, text, prompt: str, model: str | None = None) -> list[str]:
1020
+ return self._await_task(self._start_llm_query_chunked(text, prompt, model))
1021
+
1022
+ @_spawnable("rlm_query")
660
1023
  def _rlm_query(self, prompt: str, model: str | None = None) -> str:
661
- r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
662
- return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
1024
+ return self._await_task(self._start_rlm_query(prompt, model))
663
1025
 
664
1026
  def _ask_user_question(self, questions: list[dict]) -> list[dict]:
665
1027
  """Present structured questions to the user; blocks until answered.
@@ -866,17 +1228,9 @@ class Worker:
866
1228
  return response
867
1229
  return response if isinstance(response, str) else "ok"
868
1230
 
1231
+ @_spawnable("rlm_query_batched")
869
1232
  def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
870
- prompts = [str(p) for p in prompts]
871
- if not prompts:
872
- return []
873
- r = self._rpc("rlm_query_batched", {"prompts": prompts, "model": model})
874
- if r.get("error"):
875
- return [f"Error: {r['error']}"] * len(prompts)
876
- out = r.get("responses")
877
- if not isinstance(out, list) or len(out) != len(prompts):
878
- return ["Error: malformed batched response"] * len(prompts)
879
- return [s if isinstance(s, str) else f"Error: {s}" for s in out]
1233
+ return self._await_task(self._start_rlm_query_batched(prompts, model))
880
1234
 
881
1235
  # ---- context + execution --------------------------------------------------------------
882
1236
 
@@ -997,6 +1351,13 @@ class Worker:
997
1351
  for k, v in self.ns.items():
998
1352
  if k.startswith("_") or _CONTEXT_NAME.match(k) or k in RESERVED or k == "__builtins__":
999
1353
  continue
1354
+ # A Task holds a back-reference to this Worker, so dill would happily pickle the
1355
+ # whole process. Top-level guard only: a Task nested inside a list/dict still
1356
+ # falls to the generic `except` below and skips the variable — which is why this
1357
+ # guard is explicit rather than left to that fallback.
1358
+ if isinstance(v, Task):
1359
+ skipped.append(k)
1360
+ continue
1000
1361
  try:
1001
1362
  blob = s.dumps(v)
1002
1363
  if len(blob) > MAX_VAR_BYTES:
@@ -1034,6 +1395,8 @@ def main() -> None:
1034
1395
  ap = argparse.ArgumentParser()
1035
1396
  ap.add_argument("--depth", type=int, default=int(os.environ.get("RLM_DEPTH", "1")))
1036
1397
  ap.add_argument("--timeout", type=float, default=float(os.environ.get("RLM_EXEC_TIMEOUT_S", "600")))
1398
+ ap.add_argument("--await-timeout", type=float,
1399
+ default=float(os.environ.get("RLM_AWAIT_TIMEOUT_S", "600")))
1037
1400
  ap.add_argument("--max-prompt-chars", type=int,
1038
1401
  default=int(os.environ.get("RLM_MAX_PROMPT_CHARS", "400000")))
1039
1402
  ap.add_argument("--read-only", action="store_true",
@@ -1042,17 +1405,32 @@ def main() -> None:
1042
1405
  args = ap.parse_args()
1043
1406
 
1044
1407
  worker = Worker(depth=args.depth, exec_timeout_s=args.timeout,
1045
- max_prompt_chars=args.max_prompt_chars, read_only=args.read_only)
1408
+ max_prompt_chars=args.max_prompt_chars, read_only=args.read_only,
1409
+ await_timeout_s=args.await_timeout)
1046
1410
  _send({"id": "_init", "ok": True})
1047
1411
 
1048
- for raw in _REAL_STDIN:
1049
- raw = raw.strip()
1050
- if not raw:
1412
+ while True:
1413
+ # Requests that arrived mid-exec were parked by _pump; replay them before reading.
1414
+ req = worker.take_deferred()
1415
+ if req is None:
1416
+ line = _REAL_STDIN.readline()
1417
+ if not line:
1418
+ return
1419
+ raw = line.strip()
1420
+ if not raw:
1421
+ continue
1422
+ try:
1423
+ req = json.loads(raw)
1424
+ except json.JSONDecodeError as e:
1425
+ _send({"id": "?", "ok": False, "error": f"bad json: {e}"})
1426
+ continue
1427
+ # A task spawned in an earlier exec settling while the worker is idle. Park it for
1428
+ # a later rlm_await; without this it would fall through to "unknown type" and the
1429
+ # result would be lost.
1430
+ if worker.park_reply(req):
1051
1431
  continue
1052
- try:
1053
- req = json.loads(raw)
1054
- except json.JSONDecodeError as e:
1055
- _send({"id": "?", "ok": False, "error": f"bad json: {e}"})
1432
+ if not isinstance(req, dict):
1433
+ _send({"id": "?", "ok": False, "error": f"expected an object, got {type(req).__name__}"})
1056
1434
  continue
1057
1435
  rid, kind = req.get("id", "?"), req.get("type")
1058
1436
  try: