@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.
- package/README.md +52 -5
- package/README.ru.md +5 -5
- package/README.zh-CN.md +5 -5
- package/package.json +1 -1
- package/src/bridge/handlers/await.ts +148 -0
- package/src/bridge/handlers/completion.ts +72 -0
- package/src/bridge/handlers/emitting.ts +104 -0
- package/src/bridge/handlers/finish.ts +45 -0
- package/src/bridge/handlers/index.ts +48 -0
- package/src/bridge/handlers/llm-query.ts +130 -0
- package/src/bridge/handlers/rlm-query.ts +227 -0
- package/src/bridge/handlers/task-registry.ts +202 -0
- package/src/bridge/handlers/types.ts +136 -0
- package/src/commands/rlm-config.ts +33 -14
- package/src/context/listing.ts +2 -2
- package/src/context/refresh.ts +141 -0
- package/src/core/engine.ts +16 -18
- package/src/core/types.ts +1 -3
- package/src/index.ts +95 -38
- package/src/mode/native-guards.ts +4 -4
- package/src/mode/subagent.ts +68 -0
- package/src/prompts/glossary.ts +71 -74
- package/src/prompts/native.ts +127 -85
- package/src/prompts/system.ts +29 -15
- package/src/sandbox/interrupts.ts +258 -68
- package/src/sandbox/protocol.ts +53 -30
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +15 -5
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/retrieval.py +17 -8
- package/src/sandbox/py/tasks.py +1 -1
- package/src/sandbox/py/worker.py +109 -83
- package/src/sandbox/sandbox-manager.ts +26 -1
- package/src/sandbox/sandbox.ts +9 -2
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-result.ts +2 -2
- package/src/tool/repl-tool.ts +13 -14
- package/src/ui/config-panel.ts +1 -1
- package/src/ui/intro.ts +1 -4
- package/src/ui/model-picker.ts +28 -2
- package/src/util/concurrency.ts +1 -1
- package/src/bridge/subcall-handlers.ts +0 -382
|
@@ -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
|
|
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":
|
|
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.
|
|
183
|
-
|
|
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
|
-
`
|
|
206
|
-
|
|
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
|
-
|
|
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:
|
package/src/sandbox/py/tasks.py
CHANGED
|
@@ -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
|
|
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,
|
package/src/sandbox/py/worker.py
CHANGED
|
@@ -6,8 +6,8 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
|
|
|
6
6
|
|
|
7
7
|
Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
|
|
8
8
|
Protocol (worker -> parent): {"id","ok",...result} # response to a request
|
|
9
|
-
{"type":"llm_query"|"
|
|
10
|
-
"
|
|
9
|
+
{"type":"llm_query"|"llm_batch"|"rlm_query"|
|
|
10
|
+
"rlm_batch"|"add_context","rid",...}
|
|
11
11
|
# mid-exec helper request
|
|
12
12
|
When sandbox code calls llm_query/rlm_query/add_context, the worker writes a
|
|
13
13
|
request line and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
|
|
@@ -15,7 +15,7 @@ The parent services the request in-process (it holds API keys).
|
|
|
15
15
|
|
|
16
16
|
Requests and replies are decoupled: `_post` writes a request and returns its rid without
|
|
17
17
|
waiting, and replies are parked in `_inbox` keyed by rid until something asks for them. That
|
|
18
|
-
is what makes `spawn()` / `
|
|
18
|
+
is what makes `spawn()` / `await_task()` / `await_task()` possible — many requests can be
|
|
19
19
|
in flight at once (the parent already services interrupts concurrently), and a task may be
|
|
20
20
|
awaited in a LATER exec than the one that started it.
|
|
21
21
|
"""
|
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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["
|
|
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["
|
|
134
|
+
ns["rlm_batch"] = self._rlm_batch
|
|
133
135
|
ns["spawn"] = self._spawn
|
|
134
|
-
|
|
135
|
-
ns["
|
|
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":
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
292
|
-
return self._start_prompt("llm_query", prompt
|
|
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,
|
|
295
|
-
return self._start_prompt("rlm_query", prompt,
|
|
301
|
+
def _start_rlm_query(self, prompt, paths=None) -> Task:
|
|
302
|
+
return self._start_prompt("rlm_query", prompt, paths)
|
|
296
303
|
|
|
297
|
-
def
|
|
298
|
-
return self._start_prompts("
|
|
304
|
+
def _start_llm_batch(self, prompts) -> Task:
|
|
305
|
+
return self._start_prompts("llm_batch", prompts)
|
|
299
306
|
|
|
300
|
-
def
|
|
301
|
-
return self._start_prompts("
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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,
|
|
364
|
-
"rlm_query or
|
|
369
|
+
"spawn() takes llm_query, llm_batch, llm_query_chunked, map_files, "
|
|
370
|
+
"rlm_query or rlm_batch — not llm_map_reduce, whose reduce step depends "
|
|
365
371
|
"on its own map results and so cannot be a single Task"))
|
|
366
|
-
#
|
|
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
|
|
377
|
-
"""Block until
|
|
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
|
|
389
|
-
"""
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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("
|
|
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
|
|
498
|
-
"""
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# ----
|
|
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
|
|
544
|
-
|
|
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("
|
|
547
|
-
def
|
|
548
|
-
|
|
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
|
|
552
|
-
|
|
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,
|
|
556
|
-
|
|
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
|
-
|
|
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("
|
|
698
|
-
def
|
|
699
|
-
|
|
723
|
+
@_spawnable("rlm_batch")
|
|
724
|
+
def _rlm_batch(self, prompts, paths=None) -> Task:
|
|
725
|
+
"""Always spawn. Collect with await_task(t) → ordered list of reports."""
|
|
726
|
+
return self._start_rlm_batch(prompts, paths)
|
|
700
727
|
|
|
701
728
|
# ---- context + execution --------------------------------------------------------------
|
|
702
729
|
|
|
@@ -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
|
-
|
|
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 +
|
|
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
|
|
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
|
|
22
|
+
/** Max seconds the worker waits for a host reply while parked in await_task. */
|
|
19
23
|
readonly awaitTimeoutS: number;
|
|
20
24
|
readonly signal?: AbortSignal;
|
|
21
25
|
readonly onSandboxDiscarded?: () => void;
|
|
@@ -50,6 +54,27 @@ export class SandboxManager {
|
|
|
50
54
|
this.contextPayload = mergeIntoContext(this.contextPayload, payload);
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* After native edit/write: replace file body in the host snapshot **and** the live worker.
|
|
59
|
+
* Rebuilds a new context list so BM25 index stamps invalidate.
|
|
60
|
+
*/
|
|
61
|
+
async refreshFileFromDisk(
|
|
62
|
+
filePath: string,
|
|
63
|
+
content: string,
|
|
64
|
+
cwd: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
this.contextPayload = upsertContextFile(this.contextPayload, filePath, content, cwd);
|
|
67
|
+
if (!this.sandbox || this.disposed) return;
|
|
68
|
+
// Avoid interleaving with an in-flight repl exec: queue like exec().
|
|
69
|
+
const code = patchContextExecCode(filePath, content, cwd);
|
|
70
|
+
try {
|
|
71
|
+
await this.execQueued(code);
|
|
72
|
+
} catch {
|
|
73
|
+
// Worker may be dead; next getOrCreate reloads contextPayload.
|
|
74
|
+
this.contextLoaded = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/**
|
|
54
79
|
* Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
|
|
55
80
|
* given handlers. Subsequent calls return the existing sandbox immediately.
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface SandboxOptions {
|
|
|
45
45
|
readonly maxPromptChars?: number;
|
|
46
46
|
/**
|
|
47
47
|
* Max seconds the worker will wait for a host reply while parked in `_drain_until`
|
|
48
|
-
* (
|
|
48
|
+
* (await_task / sync sub-call). Defaults to the worker's own RLM_AWAIT_TIMEOUT_S (600).
|
|
49
49
|
*/
|
|
50
50
|
readonly awaitTimeoutS?: number;
|
|
51
51
|
}
|
|
@@ -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
|
-
|
|
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/
|
|
18
|
+
import type { Invocation } from "../bridge/handlers/index.ts";
|
|
19
19
|
import { trace, traceEnabled } from "../util/trace.ts";
|
|
20
20
|
|
|
21
21
|
/** What a drain hands to the turn that is reporting it. */
|
package/src/tool/repl-result.ts
CHANGED
|
@@ -46,10 +46,10 @@ export function buildReplResultText(
|
|
|
46
46
|
const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
47
47
|
const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
|
|
48
48
|
const pendingLine = backgroundPending > 0
|
|
49
|
-
? `\n\n[rlm] ${backgroundPending} background task(s) still running —
|
|
49
|
+
? `\n\n[rlm] ${backgroundPending} background task(s) still running — await_task(tasks) to collect.`
|
|
50
50
|
: "";
|
|
51
51
|
const failedLine = failedBg > 0
|
|
52
|
-
? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their
|
|
52
|
+
? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their await_task value is an "Error: …" string, not data.`
|
|
53
53
|
: "";
|
|
54
54
|
return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
|
|
55
55
|
}
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and collects sub-calls manually from emitter events. No RlmEventAggregator is used
|
|
7
7
|
* (ReplDetails ≠ RlmDetails structural mismatch).
|
|
8
8
|
*
|
|
9
|
-
* Sub-call handling
|
|
9
|
+
* Sub-call handling lives in bridge/handlers/ (createSubcallHandlers); this file only supplies the
|
|
10
10
|
* per-invocation Invocation those handlers resolve against, swapping it inside the
|
|
11
11
|
* serialized exec slot so a queued repl() cannot claim the running one's emitter.
|
|
12
12
|
*
|
|
@@ -27,7 +27,7 @@ import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
|
|
|
27
27
|
import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
28
28
|
import { SandboxManager } from "../sandbox/sandbox-manager.ts";
|
|
29
29
|
import type { SubcallOpts } from "../sandbox/sandbox.ts";
|
|
30
|
-
import { createSubcallHandlers, type Invocation } from "../bridge/
|
|
30
|
+
import { createSubcallHandlers, type Invocation } from "../bridge/handlers/index.ts";
|
|
31
31
|
import { BackgroundTasks } from "./background-tasks.ts";
|
|
32
32
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
33
33
|
import { RlmEmitter } from "./rlm-events.ts";
|
|
@@ -202,20 +202,19 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
202
202
|
name: "repl",
|
|
203
203
|
label: "REPL",
|
|
204
204
|
description:
|
|
205
|
-
"PRIMARY tool for
|
|
206
|
-
"Persistent Python sandbox
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"add_context for external dirs/files/git URLs and document conversion.",
|
|
205
|
+
"PRIMARY tool for bulk repository analysis (orchestrator). " +
|
|
206
|
+
"Persistent Python sandbox: free locate with search/grep_context/outline, then ALWAYS-SPAWN " +
|
|
207
|
+
"fan-out — llm_query/llm_batch/map_files/llm_query_chunked/rlm_query/rlm_batch return Task " +
|
|
208
|
+
"immediately (↯bg); collect with await_task. Prefer rlm_batch for ≥2 independent multi-step " +
|
|
209
|
+
"module studies; map_files/llm_batch for one-shot extracts. Fire independent Tasks first, " +
|
|
210
|
+
"free work, then await — never treat Task as the answer. Stdout hard-capped ~4K (no file dumps). " +
|
|
211
|
+
"`answers`/`plan` persist. add_context for external dirs/files/git/docs.",
|
|
213
212
|
promptSnippet:
|
|
214
|
-
"repl:
|
|
215
|
-
"search/grep_context/outline to locate, map_files/llm_query* to read.",
|
|
213
|
+
"repl: free search/outline; fire rlm_batch|map_files|llm_batch as Task (BG); await_task for results.",
|
|
216
214
|
promptGuidelines: [
|
|
217
|
-
"
|
|
218
|
-
"
|
|
215
|
+
"Multi-area analysis: rlm_batch([...]) or map_files(paths, q); free search; await_task — not serial native read.",
|
|
216
|
+
"Always-spawn tools return Task; only await_task has content. Fire-all independent work before await.",
|
|
217
|
+
"llm_query/llm_batch have no disk — never 'Read path/to/file.ts'; use map_files or rlm_* (see context).",
|
|
219
218
|
],
|
|
220
219
|
parameters: ReplToolParams,
|
|
221
220
|
|